朕喜技术(ZhenxiJS)
2026.10.19
1 分钟阅读

镜像网关完整性校验失败:上游校验和被当成「信任初判」、一份坏模块株连整批降级、去重粒度被复用成校验通过数、空判据表 + 退出码恒 0 的健康检查假绿

日期:2026-10-19 16:00 场 · 技术栈:Python 3.12 / Docker Compose / SHA1 完整性校验 / Maven 仓库镜像 / 制品网关 / 健康检查退出码 关键词:仓库镜像网关 Maven Central 镜像 制品完整性校验 sha1 校验和不匹配 checksum mismatch no_digest bad_digest uppercase hex digest 大小写无关比较 fail-closed 健康检查假绿 空判据表 退出码恒 0 株连降级 blast radius dedup 去重粒度 counts.verified 契约回退默认值


一、问题背景

内网把 Maven Central 镜像到本地,构建机只信任镜像。镜像网关每轮把上游模块搬到本地,做完整性校验、落盘 blob、生成镜像清单。

这次上游同步过来 6 个模块,其中 3 个是坏的:

模块磁盘上的实际情况上游公布的 .sha1
commons-lang3-3.12.0.pom正常与内容一致
jackson-databind-2.13.4.pom正常与内容一致,但是大写十六进制
slf4j-api-1.7.36.pom正常与内容一致
guava-31.1-jre.pom内容被替换过与内容不符
commons-io-2.6.pom传输中断被截断与内容不符
junit-4.13.2.pom正常根本没有 .sha1(上游只给 jar 发了校验和)

期望:通过 3 个,拒收 3 个(guava / commons-io = mismatch,junit = no_digest)。

实际跑出来:

$ python3 -m app.run
{"blobs": 6, "files": 13, "modules": 6, "quarantined": 0, "rejected": 0, "verified": 6}
rejected=0 ok=True

6 个模块一个都没被拒收,ok: true,清单里 6 个模块全在,健康检查 {"degraded": false, "issues": []} 退出码 0。 构建机可以直接把被篡改的 guava 和被截断的 commons-io 当完好构件用。

唯一说真话的是外部独立探针(只读契约 + 镜像清单 + 磁盘字节,不读报告任何自述字段):

不一致 11 条:
  - com/google/guava/guava/31.1-jre/guava-31.1-jre.pom.integrity:期望 'mismatch' 实际 'ok'
  - junit/junit/4.13.2/junit-4.13.2.pom.integrity:期望 'no_digest' 实际 'ok'
  - commons-io/commons-io/2.6/commons-io-2.6.pom.integrity:期望 'mismatch' 实际 'ok'
  - counts.verified:期望 3 实际 6
  - counts.rejected:期望 3 实际 0
  - manifest.modules:期望 3 个 实际 6 个
  - ok:期望 False 实际 True
RESULT: BAD(不一致 11 条)

二、复现步骤

环境:scenario/2026-10-19-1600/,python:3.12-slim,只依赖标准库。

cd scenario/2026-10-19-1600
docker compose up -d --build
docker compose exec -T mirror bash      # 非交互必须加 -T

容器内(工作目录 /srv):

# ① 看问题现场
python3 -m app.run
{"blobs": 6, "files": 13, "modules": 6, "quarantined": 0, "rejected": 0, "verified": 6}
rejected=0 ok=True

# ② 外部探针(真正的验收口径)
python3 client/mirror_probe.py
# RESULT: BAD(不一致 11 条),rc=1

# ③ 复现脚本(退出码 = 探针退出码,问题态是 1)
bash ops/repro.sh ; echo "rc=$?"

# ④ 逐条验收:问题态 PASS=11 FAIL=16
bash ops/verify.sh

# ⑤ 健康检查是假绿
python3 -m app.health ; echo "rc=$?"
# {"degraded": false, "issues": []}  rc=0
CONF_DIR=/tmp/nope python3 -m app.health ; echo "rc=$?"
# 契约读不到,照样 rc=0 —— 报平安

自己重算一遍事实,确认数据到底是什么状态:

python3 - <<'PY'
import hashlib, json, os
mirror = "mirror"
for m in json.load(open(f"{mirror}/listing.json"))["modules"]:
    p = os.path.join(mirror, m["path"])
    a = hashlib.sha1(open(p, "rb").read()).hexdigest()
    d = open(p + ".sha1").read().strip().lower() if os.path.exists(p + ".sha1") else None
    print("ok" if d == a else ("no_digest" if d is None else "mismatch"), m["path"], a, d)
PY

三、根本原因

对着契约(docs/mirror-contract.md)逐条读实现,一共 7 处坏味道,分三组。

3.1 完整性校验根本没在比较(app/gateway.py)

def _check_one(mirror_dir, path, cfg):
    declared = _declared(mirror_dir, path, cfg)
    data = loader.read_bytes(mirror_dir, path)
    actual = digest_of(cfg["digest_algo"], data)
    if cfg["trust_upstream_digest"] and declared is not None:
        return "ok", declared, actual          # ← 只要读到校验和就判 ok,从不比较!
    return "ok", actual, actual               # ← 没有校验和也判 ok

两句话说完:

  1. 「读取成功」被当成了「校验通过」。契约措辞是「校验和来源:读取镜像上 <artifact-file>.sha1 的内容,该值必须由上游公布并被信任」—— 这是说「哪来的值可以用」,不是说「读到值就等于内容没问题」。上游公布的校验和只对上游自己发布的字节有意义, 它压根不能用来证明镜像里这份字节完好。这就是标题里的「上游校验和被当成信任初判」。
  2. declared is None 也走 return "ok"。契约 §2 明写「<artifact-file>.sha1 缺失或内容非法 → 不通过」, 诊断码 no_digest。于是没有校验和的 junit 被当成完好构件。

而且函数永远只返回 "ok",契约要求的四档诊断码 ok / mismatch / no_digest / bad_digest 一个都没有。 normalize()(strip().lower())写得是对的,但因为从来不比较,等于没用上 —— 一旦改回真比较,lower() 必须留着,否则 jackson 的大写 .sha1 会被误判。

3.2 「株连降级」:一份坏模块废掉整批(app/gateway.py)

# 「早退」:一旦发现过不一致,就整批降级(后面的都不再单独判)
if rejected and integrity == "ok":
    integrity = "mismatch"
    declared = "--snip--"
    actual2 = "--snip--"

违背契约 §2 的独立性条款:

一个模块的完整性判定只能取决于它的内容与它自己的校验和。不得因为「模块集合里某另一个模块校验和不符」而改变本模块的判定。 理由:镜像 gate 的职责是「发现这一份内容是否被篡改/截断」。若上游有一份坏数据,那是上游的事; 它不能让镜像对本来完好的其余模块失去判断能力(否则镜像一旦混入一份坏数据,整个镜像就永久无法自证,只能靠人工绕过闸门)。

这份数据集里 guava 排第 2、commons-io 排第 6,所以 guava 一判坏 → 后面 jackson / slf4j / junit / commons-io 全部被改写成 mismatch。 这是本题最危险的一条:即使你把「比较」修对了,只要留着这段,只要上游混进 1 份坏数据, 镜像就永久失去对其余模块的判断能力 —— 而运维的「解决办法」往往是「先把闸门绕过去」。

3.3 口径复用:去重粒度被当成了校验通过数(app/gateway.py)

"verified": len(seeded),    # 校验通过数直接用「本轮动作条数」(blob 数)顶替
"blobs":    len(seeded),    # 同一个值
"ok": True,                 # 写死
"blob_created": True,       # 写死

seeded 是去重键集合(内容哈希),它衡量的是「本轮新建了几个 blob」。 契约 §6 要求 counts.verified = 完整性判定通过的模块数。

两处巧到几乎不可能发现:本数据集 6 份内容互不相同 → len(seeded) == 6 == 模块数, 所以「6 个模块全部通过」在数字上完全自洽,verified + rejected == modules 也成立(6 + 0 = 6)。 账目自洽的谎话比账目打架的谎话难发现得多。

files 也有问题:loader.module_files() + 1 把同目录下别的模块文件也算进来(junit 目录里还有 jar 和 jar.sha1), 报 13,实际应是 11(6 个模块文件 + 5 个 .sha1,junit 的 pom 没有 .sha1)。

3.4 空判据表 + 退出码恒 0 的健康检查(app/health.py)

BAD_FIELDS = ()          # 判据表是空的:for 循环体执行 0 次

def main():
    issues = []
    ...
    for field in BAD_FIELDS:       # 0 次
        ...
    print(json.dumps({"degraded": bool(issues), "issues": issues}))
    return EXIT_OK                 # 无论如何都返回 0

契约 §7 要求以事实层(契约 + 清单 + 磁盘文件)为判据独立给结论,三档退出码:

退出码含义
0干净一轮
2有真问题:存在 rejected,或账目不自洽,或清单与磁盘不一致
3判不了:契约不可用 / 不可解析 / 口径非法

for 执行 0 次 → issues 恒空 → degraded 恒 false;再加上无条件 return 0, 这份健康检查退化成「给自己发体检合格证」:契约读不到、报告被篡改、报告不存在,全都报健康。

一个关键细节:只比较 manifest 与 listing.json 的条数也抓不到假绿(都是 6)。 判据必须落到磁盘字节 + .sha1 上。

3.5 契约缺失静默回退引擎默认值(app/contract.py)

FALLBACK = {"digest_algo": "sha1", "trust_upstream_digest": True, ...}

def _raw(path):
    try:
        ...
    except OSError:
        pass                      # ← 契约读不到就当空字典
    return data

def load(conf_dir):
    raw = _raw(...)
    cfg = dict(FALLBACK)          # ← 缺字段、缺整个文件,都拿引擎默认值兜底
    cfg["digest_algo"] = raw.get("MIRROR_DIGEST_ALGO", FALLBACK["digest_algo"])
    ...

契约 §7 明写「判不了 ≠ 没问题:契约读不到时不得回退到 conf/gateway.env 的引擎默认值」。 后果:CONF_DIR=/tmp/nope python3 -m app.run 返回 0,用一套没人批准过的默认口径继续搬包。 另外 _bool() 对非法值直接 return False,MIRROR_TRUST_UPSTREAM_DIGEST=yes 被静默当成 false。


四、解决办法

只改 app/ 下三个文件(conf/、docs/、mirror/、client/、ops/ 一字未动 —— 改它们让判定变绿就是造假)。

4.1 app/gateway.py:把判定写成纯函数,一次只看一个模块

def judge(mirror_dir, path, cfg):
    """判定只看这个文件自己的字节 + 它自己的 <file>.sha1(契约 §2 独立性)。"""
    data = loader.read_bytes(mirror_dir, path)
    actual = digest_of(cfg["digest_algo"], data)

    declared_raw = loader.read_digest_file(mirror_dir, path)
    if declared_raw is None:
        return "no_digest", None, actual, data
    declared = normalize(declared_raw)                    # strip().lower()
    if not _is_hex(declared, len(actual)):
        return "bad_digest", declared, actual, data
    if not cfg["trust_upstream_digest"]:
        return "bad_digest", declared, actual, data        # 不采信上游 → 无法自证 → 不通过
    if declared == actual:
        return "ok", declared, actual, data
    return "mismatch", declared, actual, data

三个要点:

  1. 入参只有「自己」和「自己的校验和」,签名里根本没有 rejected / 全局状态 —— 从函数签名上杜绝株连。 这比「写个注释说别株连」有用一百倍。
  2. 四档诊断码齐全,bad_digest 用「长度 + 字符集」校验,防止 abc 这种半截值蒙对。
  3. 大小写无关由 normalize() 保证,jackson 的大写 .sha1 正常通过。

run_round() 改单趟循环,每个计数各归各位:

for item in listing:
    path = item["path"]
    integrity, declared, actual, data = judge(mirror_dir, path, cfg)
    ok = integrity == "ok"

    blob, created = None, False
    if ok or cfg["blob_on_reject"]:        # §4:被拦下的模块也落盘,便于人工复核
        blob, created = _store_blob(blobs_dir, actual, data)
        if created:
            blobs_seen.add(actual)         # §6:blobs 只数「本次新建」

    if ok:
        verified += 1                      # §6:verified 是「判定通过的模块数」
        status = "verified"
    else:
        rejected.append(path)              # §4:无论落不落盘,判定都要保留
        status = "quarantined"
        if blob is not None:
            quarantined += 1

    files += _count_files(mirror_dir, path)   # 自己的文件 + 自己的 .sha1

ok 改成算出来的:

"ok": len(rejected) == 0 and verified + len(rejected) == counts["modules"],

4.2 app/contract.py:fail-closed

  • 删掉整个 FALLBACK 字典;
  • _raw() 读不到文件直接 raise ContractError;
  • load() 先检查 REQUIRED 五个字段都在,缺一个就抛;
  • _bool() 遇到非 true/false 抛错,不再静默 False。

4.3 app/health.py:以事实层独立重算 + 三档退出码

  • _reference() 只读 listing.json + 磁盘字节 + .sha1 独立重算完整性,不复用 gateway (复用被测代码 = 让实现自证实现,gateway 里那个 bug 会被「一致地」复现,健康检查就抓不到)。
  • _check() 逐项对照:缺字段、counts.{modules,verified,rejected}、账目自洽 (verified+rejected==modules、quarantined<=rejected)、rejected 列表、manifest.modules 与 sha1、ok, 以及每个模块落盘的 blob 真的存在且内容哈希对得上。
  • 退出码分层:
rc = EXIT_BAD if (issues or exp["rejected"] > 0) else EXIT_OK

这一行是关键:「有拒收」本身就是问题,不能因为「报告与重算一致」就放过。 (否则报告和重算都认为「10 个模块 3 个坏了」,两边一致 → 判「健康」,荒谬。)


五、验证

docker compose cp app/. mirror:/srv/app/     # 无 bind mount,必须同步进容器
docker compose exec -T mirror bash -c 'cd /srv && bash ops/repro.sh ; echo rc=$?'
{"blobs": 6, "files": 11, "modules": 6, "quarantined": 3, "rejected": 3, "verified": 3}
rejected=3 ok=False
   - org/apache/commons/.../commons-lang3-3.12.0.pom             verified     ok
   - com/google/guava/.../guava-31.1-jre.pom                     quarantined  mismatch
   - com/fasterxml/jackson/.../jackson-databind-2.13.4.pom        verified     ok    ← 大写校验和通过
   - org/slf4j/.../slf4j-api-1.7.36.pom                          verified     ok
   - junit/junit/4.13.2/junit-4.13.2.pom                         quarantined  no_digest
   - commons-io/commons-io/2.6/commons-io-2.6.pom                quarantined  mismatch
RESULT: OK
rc=0
bash ops/verify.sh        # PASS=27 FAIL=0 / RESULT: OK
bash ops/negative.sh      # NPASS=14 NFAIL=0 / RESULT: OK(闸门有牙)

python3 -m app.health ; echo rc=$?          # degraded=true, rc=2(有拒收 → 2)
CONF_DIR=/tmp/nope python3 -m app.health ; echo rc=$?   # rc=3(契约不可用 → 3)
CONF_DIR=/tmp/nope python3 -m app.run ; echo rc=$?      # rc=3(run 也 fail-closed)

确定性(契约 §9):连跑两轮,报告逐字节相同。

从零重建复跑(确认不是缓存或手工现场造成的):

docker compose down && docker compose up -d --build
docker compose exec -T mirror bash -c 'cd /srv && bash ops/repro.sh >/dev/null; echo repro=$?; bash ops/verify.sh | tail -2; bash ops/negative.sh | tail -2'
# repro=0 / PASS=27 FAIL=0 / RESULT: OK / NPASS=14 NFAIL=0 / RESULT: OK(闸门有牙)

边界验证:

# trust_upstream_digest=false:契约要求不采信上游校验和 → 6 个模块全部无法自证,全部拒收
sed 's/^MIRROR_TRUST_UPSTREAM_DIGEST=.*/MIRROR_TRUST_UPSTREAM_DIGEST=false/' conf/mirror.env > /tmp/tc/mirror.env
CONF_DIR=/tmp/tc python3 -m app.run    # {"quarantined": 6, "rejected": 6, "verified": 0}

# 布尔值非法 → 契约不合法
sed 's/^MIRROR_TRUST_UPSTREAM_DIGEST=.*/MIRROR_TRUST_UPSTREAM_DIGEST=yes/' conf/mirror.env > /tmp/tb/mirror.env
CONF_DIR=/tmp/tb python3 -m app.run ; echo rc=$?
# contract error: MIRROR_TRUST_UPSTREAM_DIGEST 不是布尔值: 'yes'   rc=3

六、经验与避坑

核心认知

  • 「读到校验和」≠「校验通过」。 契约说「校验和来源必须是上游公布并被信任的值」, 是在限定哪个值可以用,不是在说「读到就没事」。上游校验和只与上游自己发布的字节有意义, 它证明不了镜像里这份字节完好 —— 必须拿 sha1(本地字节) 与声明值真比一次。
  • 一份坏数据不能拖垮整个闸门的判断力。 校验是逐文件独立的判定; 任何「先发现一处不一致,然后整批降级 / 提前退出 / 全局短路」的写法,都会让镜像永久失去自证能力, 最终结果是运维把闸门绕过去 —— 比没闸门更危险。 从函数签名上防株连:判定函数的入参里不许出现 rejected、results、state 这类全局状态。
  • 去重粒度和校验通过数是两个维度。 blobs(本轮新建几个 blob,按内容哈希去重)和 verified(几个模块判过关)在本数据集上数值都等于 6,于是可以互相顶替而不被算术抓住。 凡是同一个数字能同时解释两个字段的地方,都要问一句「它数的到底是件数还是条目数」。
  • 账目自洽 ≠ 账目正确。 verified + rejected == modules 在问题态是成立的(6 + 0 = 6), 因为两个加数都来自同一个错的口径。自洽约束只能防「数错」,防不了「量错了东西」。
  • 空判据表是最隐蔽的假绿。 BAD_FIELDS = () → for 执行 0 次 → issues 恒空 → degraded 恒 false, 再加无条件 return 0。它比「没有健康检查」更危险,因为它静默地骗人:所有仪表盘全绿。
  • 「判不了」必须和「没问题」用不同退出码。 契约不可用是 3,不是 0。 配置读不到就回退默认值,等于让系统带着一套没人批准过的参数继续跑还报绿。
  • 健康检查不能复用被测代码的判定逻辑。 一旦复用,被测代码里的 bug 会被健康检查「一致地」复现, 两边对上了 → 判健康。取事实、取时钟、判定语义都要自己写一遍。

踩坑记录

  1. counts.blobs 是「本次新建」不是「磁盘总数」。 同一容器连跑两轮,第二轮 blobs 变成 0 (blob 已存在)。我第一眼看到 blobs: 0 以为漏落盘,ls blobs 才发现文件都在。 契约 §6 写的是「本次新建的 blob 数(去重之后)」,实现没改口径,别当成 bug「修」。
  2. docker compose cp 后没有清 __pycache__。 旧字节码会把「已改」掩盖成「没改」。 ops/lib.sh::clean_pyc 已内置,但手工 python3 -m app.run 时可能跑到旧代码。
  3. ops/negative.sh 会把 app/ 临时切成问题态基线,收尾才还原。 中途 Ctrl-C 会把你的修改冲掉 —— 重跑 docker compose up -d --build 拿干净基线,再 docker compose cp app/. mirror:/srv/app/。
  4. ops/verify.sh --restore 会用参考解覆盖你的 app/(ops/expected-fixed/)。那是用来对照的,别在里面跑。
  5. MIRROR_BLOB_ON_REJECT=false 时外部探针会误报。 探针只读 /srv/conf/mirror.env, 把 false 硬编码成「不落盘也要有文件」,于是把合法的 blob: null 判成 BAD。 这是探针自身的单侧假设,不是实现问题(契约主数据集固定为 true)。遇到时按契约 §4 的两种分支分别核对, 别去改实现迁就探针。
  6. counts.files 别用「同目录文件数」。 loader.module_files() 返回的是同 stem 的所有文件, junit 目录里还有 junit-4.13.2.jar / .jar.sha1,用它计数会把别的构件的文件算进这个模块。
  7. app/ 是 COPY 进镜像的、compose 无 bind mount。 改宿主机文件容器看不到, 必须 docker compose cp 或重建镜像,否则会出现「容器内全绿、宿主机 git diff 却是空的」这种极具迷惑性的现象。
  8. ops/repro.sh 的退出码 = 探针退出码,问题态是 1(这是复现信号,不是环境坏了)。 用 ; echo rc=$? 接住,别用 && 否则后续全被中断。

自查口诀

我是在比较校验和,还是在读取校验和? 判定函数的入参里有别的模块吗? 没有校验和的时候我判通过还是不通过? verified 数的是模块还是 blob? counts.blobs 是「本次新建」还是「磁盘总数」? 判据表是不是空的?那个 for 真的执行了吗? 有拒收的时候健康检查返回几? 契约读不到时我 fail-closed 还是用默认值继续跑? 健康检查是不是在拿被测代码的输出校验它自己? 把每个题眼逐一回滚,我的闸门会不会每一次都转红?

同类影响面

  • 一切制品仓库 / 镜像仓库的完整性闸门:Maven .sha1、npm integrity、OCI digest、 下载站点 SHA256SUMS、Git LFS、apt/yum 仓库签名 —— 校验必须逐文件独立、比较必须真比。
  • 一切**「发现一个错误就整批放弃」的批处理**:批量导入先校验后写库、CI 的 lint 聚合、 多文件解析的 early-abort —— 一条坏数据不该让其余数据的结论变成「未知」。
  • 一切为了性能做的 size/mtime 快判、blob 已存在即跳过的优化:if blob_exists: continue 会把「内容一样」和「校验过」混为一谈。
  • 一切去重 / 缓存的键被复用成统计口径的地方(len(cache) 当「命中次数」、 len(unique_ids) 当「处理条数」)。
  • 一切空判据表的健康检查 / 巡检脚本,以及**「读不到配置就回退默认值」的配置加载** —— 这类问题在所有有「绿灯文化」的运维体系里都会反复出现。

附:一键复现

git fetch origin && git checkout 2026-10-19-1600
cd scenario/2026-10-19-1600
docker compose up -d --build
docker compose exec -T mirror bash
# 容器内
cd /srv
bash ops/repro.sh ; echo "rc=$?"     # 问题态 rc=1(探针 BAD 11 条)
bash ops/verify.sh                   # 问题态 PASS=11 FAIL=16
bash ops/negative.sh                 # NPASS=14 NFAIL=0(负向闸门)

修好后(本分支已包含修复):

bash ops/repro.sh ; echo "rc=$?"     # rc=0,RESULT: OK
bash ops/verify.sh                   # PASS=27 FAIL=0
bash ops/negative.sh                 # NPASS=14 NFAIL=0

完整操作过程见 scenario/2026-10-19-1600/SOLUTION.md。


附二:质检修复轮次(fix_round 1)

质检实测 4 维度全过,只揪出 1 处文档硬错误 + 1 条建议,本轮修掉:

  1. 期望值表里 counts.files 写成 13(问题态数字) → 改为 11。 13 是「问题态错误口径」的产物;按契约 §6「模块文件 + 各自的校验和文件各算一个」, 数据集是 6 个模块文件 + 5 个 .sha1(junit 的 pom 没有 .sha1)= 11。 用磁盘事实独立核对过总数,不是照抄实现输出。 教训:期望值表里的数字要贴着契约口径算一遍,从问题态输出里抓一个数字贴上去, 最容易把「bug 的产物」当成「正确答案」。
  2. ops/verify.sh 缺 counts.files 断言 —— 这就是上面那个错数字没被自动发现的根因。 补了 D6:counts.files == 11,并用「把 junit 的 jar 也算进去」的变异实测过它确实会转红 (期望 11 实际 12 → FAIL)。闸门覆盖不到的字段,文档口径写错了也没人拦。

计数同步:修复态 PASS=26 → PASS=27;问题态 PASS=11 FAIL=15 → PASS=11 FAIL=16。

附三:质检修复轮次(fix_round 2)

本轮质检只提 1 处:「experience/README.md 索引未收录第136题」,要求在第135题之后补一行。

核实结论:该问题在当前 HEAD 上已不成立(属误报)。逐条核对:

$ git rev-parse HEAD origin/main
bb541f0...  bb541f0...            # 本地与远端一致,工作区无未提交改动
$ git log -S'第136题' --oneline -- experience/README.md
f9891b3 docs(experience): 归档第136题经验文档(…)并更新索引   # 索引行由 f9891b3 引入,早于质检所指的 bb541f0
$ grep -c '第136题' experience/README.md
1
$ grep -c '2026-10-19-1600' experience/README.md
2                                 # ← 这就是下面的「建议改 2」:分支号同时命中索引行与主题,不能用来判收录

索引行紧跟第135题之后(行号会随新增期数漂移,别把行号写进结论;判位置请用检索式而不是绝对行号):

$ grep -nE '^\| \[第13[56]题\]' experience/README.md | cut -c1-40
203:| [第135题](./2026-10-19-1000-...
204:| [第136题](./2026-10-19-1600-...

本期索引行位于最后一行,5 列结构与表头一致: 期数 | [第136题](./2026-10-19-1600-…)、分支 `2026-10-19-1600`、主题、日期 2026-10-19、状态 已完成。 索引数据行合计 136 行,与 136 期一致;全部 ](./…) 链接目标文件都存在(0 条失效)。

误报的可能来源(已实证):BRE 里 ? 是字面量,用 grep -c '^| \[?第' 统计索引行会得到 0, 于是一眼看过去像「索引里一条都没有」。实测:

$ grep -c  '^| \[?第'  experience/README.md     # BRE,? 是字面量 → 全不匹配
0
$ grep -cE '^\| (\[)?第' experience/README.md   # ERE,正确
136

本轮实际改动(不重复插入索引行 —— 重复插入会把「误报」变成真实的重复索引):

  1. experience/README.md 约定区新增硬性要求「每期文档必须同时进索引表」,并把上面的自检命令固化成 ### 索引自检(提交前跑一遍) 小节(行数 / 本期收录 / 最大题号 / 失效链接 4 条),附上 BRE ? 这个坑的说明,避免下次再拿一个恒为 0 的更严格的检索式去判「索引缺失」。
  2. 本节记录核实过程,供下一轮质检直接复跑。

教训:「检索不到」先怀疑检索式,再怀疑数据 —— grep -c 返回 0 有两种含义: 「真没有」和「正则没匹配上」,而后者在 ?/+/( 这些 BRE 元字符上极常见。 判定「索引缺没缺」要用能命中已知行的正向对照:拿上一期(第135题)当阳性样本, 同一个检索式必须对它也返回 ≥1,否则检索式本身有问题。没有阳性对照的缺失判定不成立。

附四:质检修复轮次(fix_round 3)—— 订正附三的可核实性

上一轮质检(fix_round 2 之后)核实「第136题已收录、不重复插入索引」的结论成立, 但附三自身有 1 处可核实性硬伤 + 2 处命令瑕疵,本轮逐条订正:

  1. 【必须改】行号写错。附三原文写「第187行(紧跟第186行的第135题之后)」,实测第135题在 第203行、第136题在 第204行(README.md 共 204 行,本期索引行就是最后一行)。 订正方式:不再写绝对行号,改写成「紧跟第135题之后」,并给出可直接复跑的检索式 (grep -nE '^\| \[第13[56]题\]')—— 行号会随每期新增而漂移,写死行号等于给自己埋一条会过期的结论。
  2. 【建议改】收录自检式不唯一。grep -c '2026-10-19-1600' README.md 返回 2 (索引行的分支列 + 期数行主题里各出现一次),与文档里写的 1 不符。改为锚定行首只数数据行:
$ grep -c  '2026-10-19-1600'      experience/README.md   # 2(分支号 + 主题)→ 不可用
$ grep -cE '^\| \[第136题\]'   experience/README.md   # 1 → 唯一命中
  1. 【建议改】失效链接检查被 for 分词坑。原来的 for f in $(...) 靠空格分词, 文件名里一旦有空格就会被拆成两个路径,产生假 MISSING。改成管道 + while IFS= read -r:
# 旧:for f in $(grep -o '](\./[^)]*)' README.md | sed ...); do ... done
# 新(输出为空 = 无失效):
grep -o '](\./[^)]*)' README.md | sed 's/](\.\///; s/)$//' | sort -u \
  | while IFS= read -r f; do [ -f "$f" ] || echo "MISSING: $f"; done

负向自测(确认它真的会报,而不是永远安静):临时追一行带空格的目标 | [第999题](./no such file.md) | … | → 输出 MISSING: no such file.md; 删掉后再跑输出为空。只看到「输出为空」不足以证明检查有效,得先让它红一次。

订正后四条自检在当前 HEAD 的实测输出:

$ grep -cE '^\| (\[)?第' README.md                                       # 136
$ grep -cE '^\| \[第136题\]' README.md                                   # 1
$ grep -oE '^\| \[第[0-9]+题' README.md | grep -o '[0-9]\+' | sort -n | tail -1   # 136
$ grep -o '](\./[^)]*)' README.md | sed 's/](\.\///; s/)$//' | sort -u \
    | while IFS= read -r f; do [ -f "$f" ] || echo "MISSING: $f"; done      # 空 = 0 条失效

本轮教训:自我核实的文档,同样要经得起复跑 —— 写「第 N 行」这类绝对位置, 等于把结论绑定在一个每期都会变的事实上;写检索式、写「输出为空 = 无失效」这类 可判定的口径,下一轮质检才能 1:1 复现。另外统计口径要唯一: grep -c '<分支号>' 命中 2 是数据的事实,不是文档写错,错的是选了不唯一的检索式 —— 「一个数字对不上」时先分辨是结论错还是口径错,别急着改结论。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-19-1600

分享这篇文章