三个跨平台的 shell 编码陷阱:零宽字符、locale 与 bash 版本差异

一、环境与结论先行

环境:macOS(自带 bash 3.2.57)、CI 多为 Linux + LC_ALL=C、部分 /bin/sh 是 dash。同一段脚本在三者间行为可能不同。

先把三类陷阱的结论放前面:

  1. 零宽字符 :从网页 / AI 对话 / PDF 复制会夹带 U+200B 等不可见字符,命令"看着对跑不对";
  2. locale :CI 默认 C locale,让 sort / ls / 大小写转换按字节序工作,和本地 UTF-8 结果不一致;
  3. bash 版本 / sh≠bash :macOS bash 3.2 缺 mapfile / ${x,,} / declare -A;CI 的 /bin/sh 常是 dash,无 [[ ]]、<( )、数组。

后面逐条给成因、实测与修法,并在结尾给复现清单。


二、陷阱一:零宽字符,复制即中毒

字符间可能夹着 U+200B(零宽空格)、U+200C / U+200D、U+FEFF(BOM)。宽度为零,肉眼不可见。

bash 复制代码
​ls -la ~/notes     # l 与 s 之间塞了 U+200B,shell 当成三个令牌

表现:command not found、变量莫名清空、diff 看着一样却不相等。

显形与清除(macOS 自带 grep 没有 -P,用下面任一):

bash 复制代码
# 检测
perl -ne 'print if /\x{200B}/' s.sh
grep -n $'\xe2\x80\x8b' s.sh
hexdump -C s.sh | grep 'e2 80 8b'

![fig2.png](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ec840aae48fc444faca932c80a2f8559~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgYWxvbmdsb25n:q75.awebp?rk3s=f64ab15b&x-expires=1792676122&x-signature=lFBqtBnElKJy8qIysq5I6Iej1lQ%3D)
# 清除(就地)
perl -CSD -i -pe 's/[\x{200B}-\x{200D}\x{FEFF}]//g' s.sh

修法:编辑器开"显示不可见字符";跨系统粘贴走纯文本中转;仓库加提交前扫描。


三、陷阱二:locale,同样的 sort 排出了不同顺序

输入 apple / Banana / cherry / Äpfel,仅切换 LC_ALL:

区域设置 首行 排序依据 完整输出
en_US.UTF-8 Äpfel 语言学排序(忽略大小写 / 变音) Äpfel / apple / Banana / cherry
LC_ALL=C Banana 字节序(A-Z 在前,0x41<0x61,Ä 的 0xC3 最后) Banana / apple / cherry / Äpfel

依赖"排序后取第一项"的脚本,在两种环境拿到的是不同的那一个文件 。同样受影响的还有:ls 排列、[A-Z]/[a-z] 字符类范围、大小写转换 ${x,,}(C 下只处理 ASCII)。

修法:脚本开头钉死 export LC_ALL=C.UTF-8,并写进 CI 配置,让本地与线上一致。


四、陷阱三:bash 版本 / sh ≠ bash

两个常被忽略的事实:

  • macOS 自带 /bin/bash = 3.2.57 ,而 mapfile/readarray、${x,,}/${x^^}、declare -A 都是 bash 4.0+。本机实测:mapfile → command not found,${x,,} → bad substitution。
  • CI 的 /bin/sh 常是 dash :无 [[ ]]、<( )、数组、local -n。

兼容矩阵(一眼看清谁有谁没有):

最典型翻车:头部 #!/bin/bash,却用 sh script.sh 跑------dash 把 [[ ]]、<( ) 报成语法错;或本地 bash 5 写、服务器 bash 3.2,declare -A 静默失效(检查项空跑却报成功)。

修法:

bash 复制代码
# 版本探测,fail-fast
[ "${BASH_VERSINFO[0]}" -ge 4 ] || { echo "need bash 4+"; exit 1; }
command -v bash >/dev/null && bash --version | head -1

# 要么写 POSIX sh(只用 case / while / test),要么声明 bash 并校验版本

五、验证方法论:检查脚本自己也必须被检查

用来防患的检查本身也会骗你:零宽字符检测正则写错一个字符 → 永远匹配不到 → 什么都没拦;set -u 下脚本中途崩 → 前半段打勾、后半段跳过 → 全绿假象。

给每类陷阱配最小自检:

  • 零宽字符:提交前 perl -ne 'print if /\x{200B}/' 跑一遍,空输出才算过;
  • locale:CI 显式 export LC_ALL=C.UTF-8,不靠默认;
  • 版本:开头探测 BASH_VERSINFO,不支持就立刻退,别带病运行。

总纲:检查脚本自己,也必须被检查。


六、复现清单

文件 行数 职责
precheck-publish.sh 271 本文门禁范本:bash 3.2 兼容写法(字符串列表 + case,不用关联数组)

关键写法(bash 3.2 兼容):

bash 复制代码
# 不用 declare -A;用字符串列表 + case
FILES=(
  "precheck-publish.sh|/path/to/precheck-publish.sh"
)
# 取真实行数
real=$(wc -l < "${entry##*|}" 2>/dev/null | tr -d ' ')

# 版本探测 fail-fast
[ "${BASH_VERSINFO[0]}" -ge 4 ] || { echo "need bash 4+"; exit 1; }

# 固定 locale
export LC_ALL=C.UTF-8

# 零宽字符提交前扫描
perl -ne 'print "ZW at $.: $_" if /\x{200B}/' "$0"
相关推荐
金融Tech趋势派14 分钟前
企业微信客户管理工具哪个好?按企业规模、行业与合规要求的三层判断标准
人工智能·企业微信
组工部管理能手李哥16 分钟前
一次政务知识库体验:AI回答之外,我更在意那行引用来源
大数据·人工智能·大模型·政务
硅谷秋水33 分钟前
AgenticRoboRL:将“编程智能体”后训练引入智能体化机器人领域
人工智能·机器学习·语言模型·机器人
武天旭36 分钟前
AI安全--AI大模型的毒性偏见与安全对齐实践
人工智能·python·深度学习
wordbaby1 小时前
构建高效的智能体(Building Effective Agents)— Anthropic 官方全文精译与图解
人工智能
xsd202411181 小时前
AI 短剧怎么做?
人工智能
m0_715674431 小时前
政务泛监测体系能力排行:AI 重塑监测边界,治理的颗粒度革命
大数据·网络·人工智能·安全·网络安全·政务
羲云网络1 小时前
AI API 门户上线不难,难的是让用户完成第一次调用
人工智能·产品运营·api·开发者·acedatacloud
视觉小萌新2 小时前
AI Class 3 · Chapter 2-3:函数、异常、文件读写+JSON / JSONL 读写(小白版)
人工智能·python·json
Zhao-Jichao2 小时前
AI Prompting for Everyone 课程笔记 - P21 总结 Conclusion
人工智能·笔记