From 15df6210454163c15cdda8e801677056d63ef9c0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 10:26:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E6=9D=83=E9=99=90=E4=BD=8D?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E5=88=A4=E6=8D=AE=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=94=BF=E7=AD=96=EF=BC=88=E6=BA=90=E6=96=87=E4=BB=B6=E4=B8=8D?= =?UTF-8?q?=E4=B8=A5=E4=BA=8E=200644=EF=BC=89+=20=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=EF=BC=88=E5=89=AF=E6=9C=AC=3D=E4=BB=93=E5=BA=93?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 评审 2026-09-15 §三 给了决定:**开,但拆两条**。理由我采纳并写进注释: 合成的结果会是"看起来覆盖了、其实只覆盖一半"。 ## 为什么要这两条(实测实例,不是设想) 写文件的工具**不理会 umask**(umask 022,它建的仍是 0600),而 `deploy/redeploy-plugin.sh` 用 `cp -a "$SRC/."` 打快照 ⇒ **0600 会进生产**。 实测:快照里 `src/paths.mjs`/`src/turn-cwd.mjs` 是 0600、仓库 0644, 而 **`cmp` 五个 same、① 报"逐字节一致"** —— 两头都不报警: `collectFiles` 只把**内容**做 sha256,`check-deploy-drift` 只在脚本上判可执行位。 ## 两条的分工(别合成一条) - `deploy/check-file-modes.sh` = **政策**:源文件不得比 0644 更严。 **不看快照** ⇒ 能抓"两边都 0600",而一致性那条永远抓不到(两边一致 ⇒ 恒绿)。 - `check-deploy-drift.mjs` 新增 **①b**(`collectModes`/`diffModes`)= **一致性**: 部署副本的权限 = 仓库那一份。抓不到"两边都错"。 ## 落地 **政策侧**:新脚本判「已跟踪文件里 group/other 任一读位缺失」。 实测判出 **53 个**(含 `plugins/pi-mail-bridge/package.json`、`src/naming.mjs`、 `client/harmony/.../*.ets`、`docs/GUI-PLAN-HARMONY.md` 等)—— 全部 `chmod 644` 修掉, 现在 exit 0。**区分力实测**:把 `deploy/check-shared-libs.sh` 临时改 0600 ⇒ 判据红并点名它, 恢复后绿;0755 的可执行脚本**不**被判红(有读位,不是"更严")。 ★ 也修正了我先前的一处过报:那 53 个里有凭据类命名的文件吗 —— **0 个**(先查了才批量改)。 **一致性侧**:①b 一上线就抓到**真实的**、**先于本次改动**存在的漂移: `plugins/*-mail-bridge/lib/permission-grants.{js,d.ts}`、`rename-proposal.{js,d.ts}` 在三个宿主的快照里是 **600**、仓库是 **644**(pi 宿主 1 处、dsh 4 处、opencode 2 处)。 ⇒ 这条缝**一直存在**,只是此前没有任何判据看着它。 **处理**:不单独 redeploy 去"洗"权限(那要重启 pi 宿主,为权限位重启服务不值得), **下一次正常部署顺带修好** —— 这是记账,不是新欠账。 ①b 现在是**失败**态(真实不一致),所以 `drift` 整表 exit 1;这与"① 全过"并存是对的, 两条量的是不同的东西。 ## 自检 给 `collectModes`/`diffModes` 加了 4 条自检(`--self-check`),四条一起写, 因为"能发现差异"单独一条会被一个**恒判"都不同"**的坏实现骗过: ① 权限相同不得误报;② 内容一致但 0600 vs 0644 必须被发现(连数值一起断言); ③ 权限差异不污染 ① 的内容判据;④ 仅一侧存在的文件不算权限漂移(归 ① 的文件集判据)。 ★ 其中两条我**第一版写错了**并当场修掉,都记在注释里: - 用了 `lib/x.mjs` 做样本,而上面 `mk(b,'DIFFERENT')` 已把它改成内容不同 ⇒ ③ 红在**内容**上,而它想验的是"权限不污染内容";改用一对独立的内容相同文件。 - 夹具真实创建的是 `lib/x.mjs` 而不是我以为的 `lib/same.mjs`(ENOENT 才发现)。 验证:`--self-check` 全绿;pi 桥 509/509;`check-shared-libs` exit 0; `check-file-modes.sh` exit 0;`install.sh --check` exit 0。 --- deploy/check-deploy-drift.mjs | 109 +++++++++++++++++++++++++++++++++- deploy/check-file-modes.sh | 66 ++++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) create mode 100755 deploy/check-file-modes.sh diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index 8a78960..43a1676 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -45,7 +45,7 @@ */ import { createHash } from 'node:crypto'; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { join, relative, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; @@ -136,6 +136,52 @@ export function collectFiles(root) { return out; } +/** + * 收集一棵树里每个文件的**权限位**(低 9 位)。 + * + * ★ 为什么必须单独收(pi 评审 2026-09-15 §三 的实测实例): + * `collectFiles` 只把**内容**做 sha256,所以"内容一致、权限不同"在它眼里完全同形 —— + * 实测就是这么发生的:快照里 `src/paths.mjs`/`src/turn-cwd.mjs` 是 **0600**、仓库是 **0644**, + * 而 ① 报的是"逐字节一致"(`cmp` 也是这个结论,它同样只看内容)。 + * + * 与 `deploy/check-file-modes.sh` 的**分工**(两者别合成一条): + * · 本函数 = **一致性**:部署副本的权限 = 仓库那一份。它**抓不到"两边都错"**。 + * · `check-file-modes.sh` = **政策**:源文件不得比 0644 更严。它不看快照。 + * 合成的结果会是"看起来覆盖了、其实只覆盖一半" —— 正是这一路在消的形状。 + */ +export function collectModes(root) { + const out = new Map(); + const walk = dir => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.has(entry.name)) continue; + walk(full); + } else if (entry.isFile()) { + if (EXCLUDE_FILES.has(entry.name)) continue; + if (EXCLUDE_SUFFIX.some(s => entry.name.endsWith(s))) continue; + const rel = relative(root, full); + if (rel.split('/').includes('.cache')) continue; + // 只取低 9 位(权限),丢掉文件类型位与 setuid/setgid/sticky + out.set(rel, statSync(full).mode & 0o777); + } + } + }; + walk(root); + return out; +} + +/** 两棵树里"两边都在、但权限位不同"的文件(相对路径 + 两侧权限)。 */ +export function diffModes(repoModes, snapModes) { + const out = []; + for (const [rel, repoMode] of repoModes) { + if (!snapModes.has(rel)) continue; + const snapMode = snapModes.get(rel); + if (repoMode !== snapMode) out.push({ path: rel, repoMode, snapMode }); + } + return out.sort((a, b) => a.path.localeCompare(b.path)); +} + /** 比较两棵树,返回分类后的差异。导出是为了让自检能直接调它。 */ export function diffTrees(repoFiles, snapFiles) { const onlyRepo = [...repoFiles.keys()].filter(k => !snapFiles.has(k)).sort(); @@ -388,6 +434,25 @@ function checkHost(spec) { ); } + // ①b 权限位一致性:部署副本的权限 = 仓库那一份(pi 评审 2026-09-15 §三) + // + // ★ 为什么单开一条而不是塞进 ①:① 的判据是"**内容**逐字节一致"(`cmp`/sha256), + // 它**原理上**量不到权限位 —— 实测就是"内容一致、权限 0600 vs 0644"被报成"逐字节一致"。 + // 塞进 ① 会让它的名字继续替它作证(说"一致",其实只测了一半)。 + // ★ 也不能拿它替代 `check-file-modes.sh`:这条在"两边都 0600"时恒绿。 + const modeDiffs = diffModes(collectModes(repoDir), collectModes(linkPath)); + if (modeDiffs.length === 0) { + pass('①b 部署副本权限与仓库一致', '逐文件权限位相同'); + } else { + fail( + '①b 部署副本权限与仓库一致', + `${modeDiffs.length} 处权限不同:${modeDiffs + .slice(0, 5) + .map(m => `${m.path}(仓库 ${m.repoMode.toString(8)} / 快照 ${m.snapMode.toString(8)})`) + .join(';')}${modeDiffs.length > 5 ? ` …等 ${modeDiffs.length} 处` : ''}` + ); + } + // ② 加载路径指向快照 —— **按宿主真实的加载方式** if (load === 'own-process') { const execStart = unitExecStart(unit); @@ -558,6 +623,48 @@ export function selfCheck() { const extra = diffSummary(diffTrees(collectFiles(a), collectFiles(b))); out.push({ name: '快照多出运行文件必须被发现', ok: extra.runtimeDrift === 1 }); + // ①b 权限位:**内容相同、权限不同**必须被发现 —— 这正是 ① 原理上量不到的那一角。 + // ★ 三条一起写,因为"能发现差异"单独一条会放过一个坏实现: + // 一个恒判"所有文件权限都不同"的实现也能让第一条变红,但它会对**相同的树**误报。 + // ★ 用**一对内容相同**的新文件,不能用 `lib/x.mjs` —— 上面 `mk(b,'DIFFERENT')` + // 已经把它改成内容不同了,于是"① 报一致"那条会红在**内容**上, + // 而它想验的是"权限差异不污染内容判据"。我第一版就是这么写错的。 + const mA = join(a, 'lib', 'modeonly.mjs'); + const mB = join(b, 'lib', 'modeonly.mjs'); + writeFileSync(mA, 'identical'); + writeFileSync(mB, 'identical'); + chmodSync(mA, 0o644); + chmodSync(mB, 0o644); + out.push({ + name: '①b 权限相同不得误报', + ok: diffModes(collectModes(a), collectModes(b)).length === 0 + }); + + chmodSync(mB, 0o600); + const modeDiff = diffModes(collectModes(a), collectModes(b)); + out.push({ + name: '①b 内容一致但权限不同(0600 vs 0644)必须被发现', + ok: modeDiff.length === 1 && modeDiff[0].path === 'lib/modeonly.mjs' && + modeDiff[0].repoMode === 0o644 && modeDiff[0].snapMode === 0o600 + }); + // ① 与 ①b 必须**各报各的**:这一对文件内容相同 ⇒ ① 对它们无话可说。 + out.push({ + name: '①b 权限差异不污染 ① 的内容判据(同一对文件内容仍判一致)', + ok: collectFiles(a).get('lib/modeonly.mjs') === collectFiles(b).get('lib/modeonly.mjs') + }); + chmodSync(mB, 0o644); + rmSync(mA); + rmSync(mB); + + // 仅一侧存在的文件不产生"权限差异"(那是文件集差异,归 ① 管,别在这里重复报) + writeFileSync(join(b, 'lib', 'only-snap.mjs'), 'x'); + chmodSync(join(b, 'lib', 'only-snap.mjs'), 0o600); + out.push({ + name: '①b 仅一侧存在的文件不算权限漂移(归 ① 的文件集判据)', + ok: diffModes(collectModes(a), collectModes(b)).length === 0 + }); + rmSync(join(b, 'lib', 'only-snap.mjs')); + mkdirSync(join(a, 'test'), { recursive: true }); writeFileSync(join(a, 'test', 't.mjs'), 'only-in-repo'); const withTest = diffSummary(diffTrees(collectFiles(a), collectFiles(b))); diff --git a/deploy/check-file-modes.sh b/deploy/check-file-modes.sh new file mode 100755 index 0000000..9a4be3b --- /dev/null +++ b/deploy/check-file-modes.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# 源文件的权限位**政策**:不许比 0644 更严。 +# +# # 为什么要这条(一个实测出来的实例) +# +# 这台机器上写文件的工具**不理会 umask**(umask 是 022,但它建的仍是 0600)。 +# 后果不是"本地难看",而是它会**进生产**:`deploy/redeploy-plugin.sh` 用 +# `cp -a "$SRC/." "$STAGING/"` 打快照,`-a` 保留权限位 —— +# 于是快照里 `src/paths.mjs`/`src/turn-cwd.mjs` 是 **0600**,而仓库是 0644。 +# +# 两头都不报警: +# · `cmp` 只比**内容** ⇒ 报"逐字节相同"(实测就是这样); +# · `check-deploy-drift.mjs` 只比**内容**、且只在脚本上判**可执行位**(`mode & 0o111`)。 +# ⇒ "线上与仓库不一致"里,**只有权限位这一类**完全没有判据看着。 +# +# # 与漂移侧那条的分工(pi 评审 2026-09-15 §三 明确要求分开) +# +# · **本脚本 = 政策**:源文件不得比 0644 更严。它**不**看快照, +# 所以"两边都是 0600"这种情况它能抓到,而漂移侧永远抓不到(两边一致 ⇒ 恒绿)。 +# · **漂移侧 = 一致性**:部署副本的权限位 = 仓库那一份。 +# 它抓不到"两边都错"。 +# 两条别合成一条:合成的结果是"看起来覆盖了、其实只覆盖一半"。 +# +# # 为什么"更严"也算错 +# +# 这些文件是**源码**,要被构建/测试/部署以不同身份读(`install.sh` 会切身份做可写性判定, +# 判据里还有以 nobody 跑的分支)。0600 在"跑的人恰好是属主"时不炸,一旦换身份就是 +# EACCES —— 而那串报错看起来是**代码问题**。这条链一路上在消的就是这一类。 +set -uo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO" + +# 允许的最严权限:0644。比它更严(组/其他读位缺失)即判红。 +# 例外:**可执行**脚本的 0755 不是"更严",它多了执行位、读位齐全,属正常。 +fail=0 +worst="" +count=0 +while IFS= read -r f; do + [ -n "$f" ] || continue + # 只判 git 已跟踪的文件(未跟踪的构建产物不在这条政策范围内) + git ls-files --error-unmatch "$f" >/dev/null 2>&1 || continue + [ -f "$f" ] || continue + mode="$(stat -c '%a' "$f" 2>/dev/null)" || continue + # 取八进制后三位 + m="${mode: -3}" + o="${m: -1}" # other 位 + g="${m:1:1}" # group 位 + # other 与 group 都必须有读位(4 及以上);否则这条政策判红 + if [ "$(( o & 4 ))" -eq 0 ] || [ "$(( g & 4 ))" -eq 0 ]; then + count=$((count + 1)) + [ -z "$worst" ] && worst="$f" + printf ' [FAIL] 权限过严:%s 是 %s(政策要求不严于 0644)\n' "$f" "$mode" >&2 + fi +done < <(git ls-files) + +if [ "$count" -gt 0 ]; then + printf '\n 共 %d 个源文件比 0644 更严(首个:%s)。\n' "$count" "$worst" >&2 + printf ' 药方:chmod 644 <文件>。原因:写文件的工具不理会 umask,而 `cp -a` 会把这个\n' >&2 + printf ' 权限位带进生产快照,且 `cmp`/漂移判据都看不见它。\n' >&2 + printf ' 这是**政策问题**(源文件应当可被构建/测试/部署以不同身份读),不是代码问题。\n' >&2 + exit 1 +fi + +printf ' 源文件权限政策:通过(没有任何已跟踪文件比 0644 更严)\n' +exit 0