2026-09-27 · 文章 GPL-3.0 / 工具 Apache-2.0 · GitHub 仓库 Lennonhaha/fibemate-tools
工具文章容易写成决定清单:我们决定用 pkijs,我们决定加 --follow。决定是结果。结果谁都能抄,过程抄不走。这篇只写过程——六次失败,每次都写「试了什么、为什么错、怎么发现」。密码工程里,失败的原因比成功的方法活得久。——题记
时间账本要证明自己会失败。scripts/smoke-freetsa-verify.mjs 里有一条负例断言:拿一个错误的证书 pin 集去验 TSR,必须验签失败、必须 fail-closed。「错误的 pin 集」用的是 pinned-certs.pem。
2026-09-23 给账本加外部 FreeTSA 锚定块(PR #30),顺手动了夹具:为第 3 个块把 FreeTSA 的证书也放进了 pinned-certs.pem。没人改负例。
负例从此变成这样:拿 FreeTSA 的 pin 集去验 FreeTSA 签发的 TSR——验签当然通过。断言期待的失败没来,测试全绿。失败路径被静默移除,CI 上一点动静都没有。
修正(PR #31)花了三层:
pinned-certs.pem,只留项目自己的 Test TSA 证书。pin 集这才真正「错」。node:crypto 的 X509Certificate.subject。www/README.md 里已不成立的「not yet committed」说明。可迁移的结论只有一句:负例的失败路径会随夹具演化静默消失。夹具不是测试,夹具是会腐烂的测试。
CI 卡住的时候,PR #15 做了一个交换:把 ledger-ci.yml 里的 Typecheck 步骤删掉,先让流水线绿。
同一天,TypeScript 就把账送来了。makeBlock() 返回 Record<string, unknown>,所以 genesis.hash_now 的类型是 unknown。把它传给 makeBlock 的 hash_prev: string 参数,TS2322。同一个文件里 L181 对同一模式已经写了 as string,L235 漏了——一个字的债。
修复(PR #16)是给 L235 补 as string,然后把 Typecheck 步骤装回 CI。两个 PR 的合并时间相隔 11 分钟(01:16Z → 01:27Z)。删门禁的代价不是「几小时后」,是立即。
这次失败的价值不在那一行。在于它证明了:删门禁换来的 CI 绿是借来的。编译器抓住的错误,测试抓不住——npm test 一直是绿的,TS2322 不影响运行时行为,它只在类型层面存在。类型层面的债没有运行时症状,唯一的报警器就是那个被拆掉的步骤。
更隐蔽的一处:同一个账本工具里,VerifyTSR 存在过两个不兼容的定义。
core.ts 的是三参数同步签名 (tsrRef, ts, tsrDigest) => boolean;tsr.ts 的是单参数异步 (digest) => Promise<boolean>。CLI 层用一个临时桥接函数把两边缝起来,TODO 里挂着「长期统一」的标记,编号 CLI-DESIGN-1。
桥接期的问题是结构性的:每个调用点都要自己记住「现在在跟哪套合同说话」。缝的时候没错,但缝的数量会涨。
PR #22 把合同统一为 (digest: string) => Promise<boolean>,verifyChain 改为 async,cli.ts 直接调 makeVerifyTSR,genTime 交叉核对单独走。改动只有十几行,前置的讨论比改动长。
结论:两套合同并存时,桥接函数不是解决方案,是缓刑。统一要趁调用点还少的时候做。
时间机器的输入是 git。git 的语义三次和预期不一致。
第一次:单 commit 仓里 -S 返回空。 CI 环境没有真实仓库,最初没法跑测试。改成自建临时仓(提交 bafa255):建一个 git 仓,commit 1 引入 foo,commit 2 引入 bar。第一版临时仓只建了单个 commit——git log -S bar 返回空,因为符号没有「被引入」这个事件,它一直在。这是 git 的语义:pickaxe 追踪的是出现次数的变化。测试仓必须至少两个 commit。
第二次:重命名断史。 git log <file> 只走文件名。文件改过名,历史就断成两截,改名前的一切消失。PR d3bee69(2026-09-24)给 collect_file_history 加 --follow,并把解析拆成独立的 parse_git_log。文档字符串里写清了边界:--follow 追踪重命名,但它是路径历史,不是符号追踪——要回答「这行代码哪来的」,仍然要用 -S。
第三次:环境变量名传错,静默跑错仓库。 时间机器有两个环境变量命名空间:ctime/core.py:23 读 CTM_REPO,test/local_test.py:20 读 CTM_TEST_REPO,默认值都是当前目录。跑测试的时候传了 REPO=...——三个名字一个都不匹配,Python 不报错,默认值接管,测试对着 fibemate-tools 自己的目录跑,FAIL,而且是解释不了为什么 FAIL 的 FAIL。
根子在文档:当时仓库根 README 写的是 REPO=/path/to/repo python test/local_test.py,代码读的是 CTM_TEST_REPO——文档给的名字就是错的。更早一版 README 的默认值写的是开发机绝对路径。修正把那一行改成 CTM_TEST_REPO=/path/to/repo ...(README.md:53)。这次修正没有独立 PR,随恢复提交 dc25c20 一起进来。
三个都是 git 或 shell 的默认行为在替你做决定。失败的时候先查默认值接管了什么。
两个工具各答一半问题。
时间账本回答「以谁为准」:一个声明在某时刻存在,由谁的密钥签名、锚在哪个账本上、验签走哪条信任链。时间机器回答「什么时候变的」:一个符号、一个文件,在 git 历史里的引入和消失点。
咬合处的合同是 PR #22 定的:两处对 TSR 验证的判定统一成一个签名。盘点(CBOM 工具)给出资产清单,账本给清单盖章,时间机器追溯清单里每一项的来历。三条线各自的发布文章都写过;合起来看,这条线是完整的时间语义:是什么(盘点)、何时为真(存证)、如何演变(考古)。
三条,照旧不软:
smoke-freetsa-verify.mjs 定位是手动检查,不在流水线里——它依赖真实网络锚定服务,CI 里不可复现。--follow 的边界是 git 的边界。 它追踪重命名的能力依赖 diff 相似度启发,重写历史、大改内容后再改名,照样断。Lennonhaha/fibemate-tools → crypto-time-ledger/Lennonhaha/fibemate-tools → crypto-time-machine/git log <hash> 与 gh pr view <n> 可查fibemate-tools PR #31(c4f668f);合同统一:PR #22(68d59d9);--follow:PR #33(d3bee69);外部锚定:PR #30;TS2322 与 typecheck 恢复:PR #16(724949d);CI 临时仓:提交 bafa255(无 PR);README 变量名修正:随 dc25c20(无独立 PR)原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。