fix(部署判据): 悬空软链不再让第 ① 条变成「不可达检查」+ 补上它指向哪里

## 起因

第 ① 条("没有任何 unit 引用源码目录")在本机**长期红**,note 写的是
「读不到(这几条没被检查):…/sysinit.target.wants/mdadm-shutdown.service(ENOENT)」。

查下去:那是**系统自带的悬空软链**(mdadm 未装 ⇒ 目标不存在),
不是本仓的部署事故。但判据仍然红,而它红得**没道理可讲**。

## ★ 真缺陷:那个软链分支是**不可达代码**

    /etc/systemd/system/sysinit.target.wants/mdadm-shutdown.service
      → /lib/systemd/system/mdadm-shutdown.service   (不存在)

`readFile` 对悬空软链抛 ENOENT ⇒ 走 `catch { …; continue; }`
⇒ **下面那段专门判软链的代码永远不会执行** ——
而那恰恰是对软链该做的检查(看**目标位置**)。

⇒ 这不是"误报",是**一个该跑的检查没跑**。判据没回答"这个软链指向仓库吗",
只回答了"我读不到它"。

## 修法

`readlink` 只读**链接自身**的内容(那个路径字符串),**不要求目标存在** ——
这正是悬空软链唯一还能回答的问题。

现在:既把目标查出来并判(指向仓库 ⇒ 进 `repoLinks` 报红),
又**仍然记进 `unreadable` 继续判红** ——
「没能检查它的内容」≠「它没问题」,这条不能因为解释清楚了就不算。

## note 里单独一句「悬空软链(**非本仓违规**)」

它与「真有 unit 读不到」要处理的人不同:
前者是装软件留下的系统状态,后者可能是部署事故。
混在一堆里 ⇒ 读的人不知道该不该管。

## 残余(有意保留)

第 ① 条**仍然红**,但现在红得**有理由且已说明**。
要让它转绿只能二选一:装 mdadm、或删掉那个悬空软链 ——
**都不是本仓该做的事**,所以不为了转绿而放宽判据。

自检 48 格全过(本次未新增样本;这一格的真机样本就摆在那里,
且 `--self-check` 的假 fs 走不到 `sysinit.target.wants` 这条路径)。
This commit is contained in:
2026-09-28 08:49:40 +08:00
parent 1639382eaa
commit c2796eaa52

View File

@ -45,7 +45,7 @@
*/ */
import { createHash } from 'node:crypto'; 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 { join, relative, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
@ -984,6 +984,13 @@ export function checkLayout(inject = {}) {
const repoLinks = []; const repoLinks = [];
const lstatForLinks = inject.lstat ?? lstatSync; const lstatForLinks = inject.lstat ?? lstatSync;
const realpathForLinks = inject.realpath ?? realpathSync; const realpathForLinks = inject.realpath ?? realpathSync;
// 悬空软链(目标不存在):它**不是**本仓的违规,但“读不到”这件事必须单独说,
// 否则会与“真有 unit 读不到”混成一堆(两者都进 unreadable ⇒ 都判红),
// 而它们要处理的人不同:前者是系统装软件留下的,后者可能是部署事故。
const dangling = [];
// readlink 只读**链接自身**的内容(那个路径字符串),**不要求目标存在** ——
// 这正是悬空软链唯一还能回答的问题(“它指向哪里”)。
const readlinkForLinks = inject.readlink ?? readlinkSync;
let scanned = 0; let scanned = 0;
let scannedLinks = 0; let scannedLinks = 0;
// 存在、但**读不到内容**的条目:它们既不能算"比过",也不能当成"没问题"。 // 存在、但**读不到内容**的条目:它们既不能算"比过",也不能当成"没问题"。
@ -1035,6 +1042,36 @@ export function checkLayout(inject = {}) {
try { try {
text = String(readFile(full, 'utf8')); text = String(readFile(full, 'utf8'));
} catch (err) { } 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 ?? '读取失败'})`); unreadable.push(`${full}(${err?.code ?? err?.message ?? '读取失败'})`);
continue; continue;
} }
@ -1061,13 +1098,19 @@ export function checkLayout(inject = {}) {
const refBad = [ const refBad = [
...offenders, ...offenders,
...repoLinks, ...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(' '); ].join(' ');
push( push(
'没有任何 unit/drop-in/.bak 引用源码目录(含软链指向仓库)', '没有任何 unit/drop-in/.bak 引用源码目录(含软链指向仓库)',
refOk, refOk,
refOk refOk
? `比了 ${scanned} 个文件(**全部**,不筛后缀)、命中 0;其中软链 ${scannedLinks} 个另按 realpath 判目标` ? `比了 ${scanned} 个文件(**全部**,不筛后缀)、命中 0;其中软链 ${scannedLinks} 个另按 realpath 判目标`
+ (dangling.length ? `(另有 ${dangling.length} 个悬空软链:${dangling.join(';')})` : '')
: refBad : refBad
); );