feat: convert 新增 --verify 對帳模式 + 修復 Windows CRLF 使 hash 失準

- convert.py --verify:純唯讀 re-hash 全部登記檔並掃未登記檔,
  報 missing/mismatch/unregistered,不一致退出碼 1(供刪除/竄改後稽核)
- 修 write_text 在 Windows 轉 \n→\r\n 使磁碟 bytes 與登記 SHA-256 不符
  (converted md 與 web 快照原始檔),改 write_bytes 寫入所登記的那份 bytes
- selfcheck 補 clean/missing/mismatch/unregistered 四情境(全程唯讀斷言)
- AGENTS.md §14 raw 完整性與修復流程 + §1.4 hash==磁碟 bytes 不變式

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LittleYellow
2026-07-23 08:21:39 +08:00
parent cb16e8688f
commit f9a0cd2369
3 changed files with 149 additions and 4 deletions

View File

@@ -158,6 +158,45 @@ def main():
assert (root / "raw/manifest.json").read_text(encoding="utf-8") == before
print("PASS: 冪等(同 hash 跳過)")
# --verify 對帳純唯讀clean 通過、三類意外都抓到、且完全不改 manifest。
# root 已有 docx/pdf/pptx 抽出的 assets不入帳本——clean 案例即證明 assets 不被誤報。
def run_verify(rt):
mm = json.loads((rt / "raw/manifest.json").read_text(encoding="utf-8"))
err, code = io.StringIO(), 0
with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()):
try:
convert.verify(rt, mm)
except SystemExit as e:
code = e.code
return code, err.getvalue()
before = (root / "raw/manifest.json").read_text(encoding="utf-8")
code, _ = run_verify(root)
assert code == 0, "clean 帳本應通過對帳(含未入帳本的 assets"
rroot = tmp / "root_del" # missing刪掉一個 converted .md
shutil.copytree(root, rroot)
gone = next(p for p in (rroot / "raw/converted").glob("*.md"))
gone.unlink()
code, log = run_verify(rroot)
assert code == 1 and "missing" in log and gone.name in log, log
rroot = tmp / "root_mut" # mismatch就地改動一個 original 的 bytes
shutil.copytree(root, rroot)
tampered = next(p for p in (rroot / "raw/originals").iterdir() if p.is_file())
tampered.write_bytes(tampered.read_bytes() + b"tampered")
code, log = run_verify(rroot)
assert code == 1 and "mismatch" in log, log
rroot = tmp / "root_unreg" # unregisteredraw/originals 塞入未登記檔
shutil.copytree(root, rroot)
(rroot / "raw/originals/未登記 檔.docx").write_bytes(b"stray")
code, log = run_verify(rroot)
assert code == 1 and "unregistered" in log and "未登記 檔.docx" in log, log
assert (root / "raw/manifest.json").read_text(encoding="utf-8") == before # 全程唯讀
print("PASS: --verify 對帳clean/missing/mismatch/unregistered純唯讀")
# dry-run全新沙盒不落地
root2 = tmp / "root2"
for sub in ("raw/originals", "raw/converted"):