diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index a393671..b453f56 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -45,7 +45,7 @@ */ import { createHash } from 'node:crypto'; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, 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'; @@ -984,6 +984,13 @@ export function checkLayout(inject = {}) { const repoLinks = []; const lstatForLinks = inject.lstat ?? lstatSync; const realpathForLinks = inject.realpath ?? realpathSync; + // 悬空软链(目标不存在):它**不是**本仓的违规,但“读不到”这件事必须单独说, + // 否则会与“真有 unit 读不到”混成一堆(两者都进 unreadable ⇒ 都判红), + // 而它们要处理的人不同:前者是系统装软件留下的,后者可能是部署事故。 + const dangling = []; + // readlink 只读**链接自身**的内容(那个路径字符串),**不要求目标存在** —— + // 这正是悬空软链唯一还能回答的问题(“它指向哪里”)。 + const readlinkForLinks = inject.readlink ?? readlinkSync; let scanned = 0; let scannedLinks = 0; // 存在、但**读不到内容**的条目:它们既不能算"比过",也不能当成"没问题"。 @@ -1035,6 +1042,36 @@ export function checkLayout(inject = {}) { try { text = String(readFile(full, 'utf8')); } catch (err) { + // ★★ 软链要在这里**换一条路**(2026-09-28 修):悬空软链上 `readFile` 抛 + // ENOENT,直接 `continue` 就把下面那个软链分支**变成了不可达代码** —— + // 而那分支恰恰是对软链该做的检查(看**目标位置**)。 + // + // 实测:本机 `/etc/systemd/system/sysinit.target.wants/mdadm-shutdown.service` + // → `/lib/systemd/system/mdadm-shutdown.service`(mdadm 未装 ⇒ 目标不存在)。 + // 后果是判据第 1 条报「读不到(这几条没被检查)」而**长期红**, + // 而它本该告诉我们的恰恰是「这个软链指向哪里」。 + // + // 为什么 `readlink` 能回答而 `readFile` 不能:readlink 只读**链接自身** + // 的内容(那个路径字符串),不要求目标存在 —— + // **这正是软链能被判定的那条信息**。 + // + // ⚠️ 与「读不到就当没事」相反:判据仍**不知道目标 unit 的内容**, + // 所以下面照样把目标记进 unreadable(红),但**额外**回答了 + // 「这个软链指向仓库吗」——那才是第 1 条真正要问的问题。 + let isLink = false; + try { isLink = lstatForLinks(full).isSymbolicLink(); } catch { /* 不是软链 */ } + if (isLink) { + scannedLinks++; + let target = ''; + try { target = String(realpathForLinks(full)); } + catch { + // realpath 对悬空软链也失败 ⇒ 退回读链接字面量(不跟随) + try { target = readlinkForLinks(full); } catch { target = ''; } + } + const resolved = target.startsWith('/') ? target : `${dir}/${target}`; + if (resolved.startsWith(`${REPO}/`)) repoLinks.push(`${full} → ${resolved}`); + else dangling.push(`${full} → ${target || '(读不出目标)'}`); + } unreadable.push(`${full}(${err?.code ?? err?.message ?? '读取失败'})`); continue; } @@ -1061,13 +1098,19 @@ export function checkLayout(inject = {}) { const refBad = [ ...offenders, ...repoLinks, - ...(unreadable.length ? [`读不到(**这几条没被检查**,不是"它们没问题"):${unreadable.join(';')}`] : []) + ...(unreadable.length ? [`读不到(**这几条没被检查**,不是"它们没问题"):${unreadable.join(';')}`] : []), + // 悬空软链**单独一句**:它不是本仓的违规(是系统装软件留下的), + // 但“目标不存在”这件事必须说出来 —— 不说的话,那条红会长期没人知道该不该管。 + // 已知实例:`sysinit.target.wants/mdadm-shutdown.service → /lib/systemd/system/mdadm-shutdown.service` + // (mdadm 未装 ⇒ 目标不存在)。这与真有 unit 读不到要处理的人不同。 + ...(dangling.length ? [`悬空软链(目标不存在,**非本仓违规**,但它指向哪里已查清):${dangling.join(';')}`] : []) ].join(' '); push( '没有任何 unit/drop-in/.bak 引用源码目录(含软链指向仓库)', refOk, refOk ? `比了 ${scanned} 个文件(**全部**,不筛后缀)、命中 0;其中软链 ${scannedLinks} 个另按 realpath 判目标` + + (dangling.length ? `(另有 ${dangling.length} 个悬空软链:${dangling.join(';')})` : '') : refBad );