From 00f6e1910de463f2843c0e6f50186c3872a48755 Mon Sep 17 00:00:00 2001 From: Sheng Zhao Date: Tue, 11 Aug 2026 17:17:55 +0800 Subject: [PATCH] fix(portability): a bare $var before a non-ASCII byte breaks the installer on macOS's bash 3.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 自带 /bin/bash 是 3.2.57(许可证原因,Apple 不升级)。bash 3.2 解析变量名时 **不认多字节字符**: name=ok; set -u; echo "x $name:y" # 全角冒号 = EF BC 9A bash 3.2 → line 3: name: unbound variable ← 0xEF 被并入变量名 bash 5 → x ok:y `set -u` 之下是硬失败。实测后果:**adopt.sh 在 macOS 上 rc=1 ⇒ adopter 装不上这套 harness**;仓内 12 个测试在 setUpClass 就炸。 根因不是缺 bash4 特性(`declare -A` / `mapfile` / `${x,,}` 实测命中均为 0),所以修法是 加花括号,不是加版本门。 **它当时伪装成了别的东西**:测试用 subprocess(text=True) 去解码那个坏字节,失败呈现为 `UnicodeDecodeError` —— 看起来像测试框架的编码问题,真实缺陷是安装器炸了。这是「取证命令 的三种伪装」里第三种(数据在通往解析器的路上被改写)的又一实例。 回归门 tests/test_shell_multibyte_portability.py: - 形态式判据,不枚举变量名也不枚举字符:凡 `$identifier` 紧跟非 ASCII 字节即拒; - 开火构造 + 「加了花括号就不该命中」的反向构造; - **因果层**:在 bash 3.x 上实证「裸形式 unbound variable、花括号形式 rc=0」;bash>=4 上 跳过并说明理由(前提在那里本就不成立,强行断言只会得到假绿); - 覆盖自检:必须扫到 adopt.sh 与 harness_worktree_link.sh,否则「一个文件都没扫到」会假绿。 **门一上线就抓到我漏的 2 处**:我最初统计用手列 glob(overlay/** scripts/**)得出 13 处, 漏了 skills/;门走整棵树,得出 **15 处**。⇒ 又一次「枚举式弱于形态式」的实例,而这次弱的 是我的统计口径。 读数:红 8F+12E → **6F+0E**。12 个 error 全消;另 2 个 failure 是变异层护栏,基线转绿后 自动通过(护栏双向都在正常工作)。剩 6 处是 macOS `/var`→`/private/var` 符号链接族,另账。 Co-Authored-By: Claude Opus 5 --- adopt.sh | 12 +-- overlay/scripts/harness_worktree_link.sh | 10 +- skills/arborist-sync/sync.sh | 4 +- tests/test_shell_multibyte_portability.py | 123 ++++++++++++++++++++++ 4 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 tests/test_shell_multibyte_portability.py diff --git a/adopt.sh b/adopt.sh index 235cf69..830b80d 100755 --- a/adopt.sh +++ b/adopt.sh @@ -359,7 +359,7 @@ hvcs_install_credential_gate() { local dst="$ROOT/.harness-vcs/hooks/pre-commit" local marker="ARBORIST-CREDENTIAL-GATE:v1" if [ ! -f "$src" ]; then - echo " ✗✗ 缺少凭据门模板($src)—— 侧史无 pre-commit 保护" + echo " ✗✗ 缺少凭据门模板(${src})—— 侧史无 pre-commit 保护" return 1 fi mkdir -p "$ROOT/.harness-vcs/hooks" @@ -425,7 +425,7 @@ offer_tool() { # $1=命令名 $2=装法 $3=fallback local tpl="$SRC/arborist-templates/tools/$name.json" dst="$HOME/.arborist/tools/$name.json" if command -v "$name" >/dev/null 2>&1; then if [ -e "$dst" ]; then - echo " · $name:已安装、已登记($dst)" + echo " · ${name}:已安装、已登记(${dst})" elif [ -t 0 ] && [ -f "$tpl" ]; then read -r -p " · $name 已安装,登记进 ~/.arborist/tools/?[y/N] " ans || true case "$ans" in @@ -433,19 +433,19 @@ offer_tool() { # $1=命令名 $2=装法 $3=fallback *) echo " 跳过登记;缺席时兜底:$fb";; esac elif [ -f "$tpl" ]; then - echo " · $name:已安装、未登记(非交互)。手动登记:cp $tpl $dst 并填实况" + echo " · ${name}:已安装、未登记(非交互)。手动登记:cp $tpl $dst 并填实况" else - echo " · $name:已安装(无模板;要登记按 guide §2 手写 tool.json)" + echo " · ${name}:已安装(无模板;要登记按 guide §2 手写 tool.json)" fi else if [ -t 0 ]; then read -r -p " · $name 未安装,需要吗?(只给装法,不代装)[y/N] " ans || true case "$ans" in - y|Y) echo " 装法:$how;装好后重跑 adopt.sh 或按 guide §2 登记";; + y|Y) echo " 装法:${how};装好后重跑 adopt.sh 或按 guide §2 登记";; *) echo " 好——兜底:$fb";; esac else - echo " · $name:未安装(非交互)。装法:$how;兜底:$fb" + echo " · ${name}:未安装(非交互)。装法:${how};兜底:$fb" fi fi } diff --git a/overlay/scripts/harness_worktree_link.sh b/overlay/scripts/harness_worktree_link.sh index 5b45066..71e7646 100755 --- a/overlay/scripts/harness_worktree_link.sh +++ b/overlay/scripts/harness_worktree_link.sh @@ -59,28 +59,28 @@ link_one() { # 主树里源不存在 → 没什么可链,跳过(不同项目 harness 目录集可能有出入)。 if [ ! -e "$src" ]; then - echo " · $name:主树无此目录,跳过" + echo " · ${name}:主树无此目录,跳过" return 0 fi if [ -L "$dst" ]; then # 已是 symlink:解析后与源同 → no-op;否则重指。 if [ "$(readlink -f "$dst" 2>/dev/null || true)" = "$(readlink -f "$src")" ]; then - echo " · $name:已正确链接,no-op" + echo " · ${name}:已正确链接,no-op" noop=$((noop+1)) else rm "$dst" ln -s "$src" "$dst" - echo " ↻ $name:旧链接重指 → $src" + echo " ↻ ${name}:旧链接重指 → $src" relinked=$((relinked+1)) fi elif [ -e "$dst" ]; then # worktree 里是真目录/真文件:不覆盖,告警跳过(避免吞掉 worktree 本地内容)。 - echo " ⚠ $name:worktree 内已存在真实目录/文件,未覆盖(如需 link 请人工确认后移除)" >&2 + echo " ⚠ ${name}:worktree 内已存在真实目录/文件,未覆盖(如需 link 请人工确认后移除)" >&2 skipped=$((skipped+1)) else ln -s "$src" "$dst" - echo " ✓ $name:新建链接 → $src" + echo " ✓ ${name}:新建链接 → $src" created=$((created+1)) fi } diff --git a/skills/arborist-sync/sync.sh b/skills/arborist-sync/sync.sh index 27378f0..82e90cc 100755 --- a/skills/arborist-sync/sync.sh +++ b/skills/arborist-sync/sync.sh @@ -20,7 +20,7 @@ case "$cmd" in mkdir -p "$STAGING/$(dirname "$rel")"; cp "$INSTANCE_ROOT/$rel" "$STAGING/$rel" sed -i -e "s#$INSTANCE_ABS##g" -e "s/\\b$PROJECT\\b//g" -e "s#$HOME##g" "$STAGING/$rel" done - echo "generalized -> $STAGING(下一步:Agent 跑 audit + diff vs $ARBORIST_ROOT/overlay)" ;; + echo "generalized -> ${STAGING}(下一步:Agent 跑 audit + diff vs $ARBORIST_ROOT/overlay)" ;; specialize) _need ARBORIST_ROOT INSTANCE_ROOT INSTANCE_ABS PROJECT rm -rf "$STAGING"; mkdir -p "$STAGING" @@ -28,7 +28,7 @@ case "$cmd" in mkdir -p "$STAGING/$(dirname "$rel")"; cp "$ARBORIST_ROOT/overlay/$rel" "$STAGING/$rel" sed -i -e "s##$INSTANCE_ABS#g" -e "s//$PROJECT/g" -e "s##$HOME#g" "$STAGING/$rel" done - echo "specialized -> $STAGING(下一步:Agent diff vs $INSTANCE_ROOT/.trellis/spec + 冲突调解)" ;; + echo "specialized -> ${STAGING}(下一步:Agent diff vs $INSTANCE_ROOT/.trellis/spec + 冲突调解)" ;; audit) dir="${1:?给目录}"; hits=0 echo "== 绝对 home 路径 =="; grep -rnE "/home/|/Users/" "$dir" && hits=1 || echo " clean" diff --git a/tests/test_shell_multibyte_portability.py b/tests/test_shell_multibyte_portability.py new file mode 100644 index 0000000..ce023a2 --- /dev/null +++ b/tests/test_shell_multibyte_portability.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""shell 脚本里 `$var` 不得紧跟非 ASCII 字符 —— bash 3.2 会把首字节吞进变量名。 + +## 这条规则的来历(一次实测,以及它伪装成了什么) + +macOS 自带 `/bin/bash` 是 **3.2.57**(许可证原因,Apple 不升级)。bash 3.2 在解析变量名时 +**不认多字节字符**,于是: + + name=ok; set -u; echo "x $name:y" # 全角冒号 := U+FF1A = EF BC 9A + bash 3.2 → line 3: name: unbound variable ← 0xEF 被并入了变量名 + bash 5 → x ok:y + +`set -u` 之下这是硬失败。实测后果:`adopt.sh` 在 macOS 上 **rc=1**,于是 +**adopter 装不上这套 harness**;仓内 12 个测试在 setUpClass 就炸。 + +**它当时伪装成了别的东西**:测试用 `subprocess(..., text=True)` 去解码 bash 吐的那个坏字节, +于是失败呈现为 `UnicodeDecodeError` —— 看起来像测试框架的编码问题,而真实缺陷是安装器炸了。 +这是「取证命令的三种伪装」里的第三种(**数据在通往解析器的路上被改写**)的又一个实例。 + +## 判据是形态式的 + +不枚举变量名、不枚举字符:**凡 `$identifier` 后面紧跟一个非 ASCII 字节即拒**。修法是加花括号 +(`${identifier}`),不是加 bash 版本门 —— 因为根因不是缺 bash4 特性(实测 `declare -A` / +`mapfile` / `${x,,}` 命中均为 0),而是解析多字节字符。 + +## 为什么这道门必须存在,而不是"改完就行" + +写中文注释与中文输出的仓,**每写一行 `echo "…$var:…"` 都在重新引入它**,而 CI 若跑在 +bash 5 上永远不会报。⇒ 这是典型的「正确做法与错误做法一样省事、但错误只在别人机器上炸」, +必须由门守,靠人记不住。 +""" + +from __future__ import annotations + +import re +import subprocess +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +# `$identifier` 紧跟一个非 ASCII 字节。按字节做,避免 Python 的 str 把多字节看成一个字符。 +OFFENDER = re.compile(rb"\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]") + +SKIP_DIRS = {".git", ".harness-vcs", ".codegraph", "node_modules", "__pycache__"} + + +def shell_files() -> list[Path]: + found: list[Path] = [] + for path in ROOT.rglob("*"): + if not path.is_file(): + continue + if any(part in SKIP_DIRS for part in path.parts): + continue + if path.suffix == ".sh" or path.name in {"hgit"}: + found.append(path) + return sorted(found) + + +class ShippedShellIsMultibyteSafeTest(unittest.TestCase): + def test_no_shell_script_puts_a_bare_variable_before_a_non_ascii_byte(self) -> None: + offenders: list[str] = [] + for path in shell_files(): + data = path.read_bytes() + for match in OFFENDER.finditer(data): + line = data[: match.start()].count(b"\n") + 1 + snippet = match.group(0).decode("utf-8", "replace") + offenders.append(f"{path.relative_to(ROOT)}:{line}: {snippet!r} —— 改成 ${{…}}") + self.assertEqual([], offenders, "\n".join(offenders)) + + def test_the_scan_actually_covers_the_installer(self) -> None: + """否则上一条会因为「一个文件都没扫到」而假绿。""" + names = {path.name for path in shell_files()} + self.assertIn("adopt.sh", names) + self.assertIn("harness_worktree_link.sh", names) + self.assertGreaterEqual(len(shell_files()), 3) + + +class TheCheckWouldFireTest(unittest.TestCase): + """开火构造:门必须能抓到坏形式,否则它与没有门不可区分。""" + + def test_a_bare_variable_before_a_fullwidth_colon_is_caught(self) -> None: + sample = 'echo "x $name:y"'.encode("utf-8") + self.assertTrue(OFFENDER.search(sample)) + + def test_the_braced_form_is_not_caught(self) -> None: + sample = 'echo "x ${name}:y"'.encode("utf-8") + self.assertIsNone(OFFENDER.search(sample)) + + def test_a_variable_before_ascii_is_not_caught(self) -> None: + self.assertIsNone(OFFENDER.search(b'echo "x $name: y"')) + + def test_a_variable_at_end_of_line_is_not_caught(self) -> None: + self.assertIsNone(OFFENDER.search(b'echo "$name"\n')) + + +class TheCausalPremiseTest(unittest.TestCase): + """证明这条规则的**前提**是真的,而不是照搬来的迷信。 + + 只在本机 bash 确实是 3.x 时才跑;bash>=4 上跳过并说明理由 —— 因为在 bash 5 上 + 这个前提本来就不成立,强行断言只会得到一条假绿。 + """ + + def test_bash_3_really_mis_parses_it(self) -> None: + probe = subprocess.run(["bash", "--version"], capture_output=True, text=True) + if probe.returncode != 0: + self.skipTest("本机没有 bash") + first = probe.stdout.splitlines()[0] if probe.stdout else "" + if "version 3." not in first: + self.skipTest(f"本机 bash 不是 3.x({first.strip()})—— 前提在此不成立,跳过而不是假绿") + + script = 'name=ok\nset -u\necho "x $name:y"\n' + result = subprocess.run(["bash", "-c", script], capture_output=True) + self.assertNotEqual(0, result.returncode, "bash 3.x 竟然接受了裸变量+全角字符?前提需重查") + self.assertIn(b"unbound variable", result.stderr) + + braced = 'name=ok\nset -u\necho "x ${name}:y"\n' + ok = subprocess.run(["bash", "-c", braced], capture_output=True) + self.assertEqual(0, ok.returncode, ok.stderr) + + +if __name__ == "__main__": + unittest.main()