Files
svfs/.zcode/plans/plan-sess_15727471-cbbb-40df-b543-cee569b07bf8.md
T
2026-09-14 00:58:53 +08:00

8.9 KiB
Raw Blame History

目标

四项能力一起落地,但存储层是共用基础,所以按"先换地基、再上功能"的顺序做:

  1. 内容寻址与去重 — content 从 files 表搬到 blobs(hash PRIMARY KEY),相同内容只存一份
  2. 快照 / 时间旅行 — path UNIQUE → UNIQUE(snapshot_id, path),一个库存多个版本,SQL 查任意版本
  3. 防篡改 — 每文件 sha256 + 快照级摘要 + HMAC-SHA256 签名,只读打开即可验证
  4. 增量打包 — 按 mtime+size(可选 rehash)比对,只写变化的条目

按你的选择:v2 库给迁移工具、签名用标准库 HMAC、快照默认单快照零感知、增量默认 mtime+size。

新 schema(v3)

filesystem_metadata(id, name, created_time, schema_version)   -- 去掉 total_*(移入 snapshots)

blobs(hash TEXT PRIMARY KEY,        -- sha256(原始字节) 十六进制;PK 即"只存一份"
      size, stored_size,            -- 原始大小 / 实际存储占用(含频率表)
      content, freq_blob, compress_algo)

snapshots(id, name, created_time, parent_id, total_files, total_size, digest)

files(id, snapshot_id NOT NULL, path, name, parent_path, is_directory, file_size,
      created_time, modified_time, permissions,
      content_hash,                 -- 目录为 NULL
      UNIQUE(snapshot_id, path))

signatures(id, snapshot_id, algorithm, key_id, signature, signed_time)

索引:UNIQUE(snapshot_id,path) 自带;另加 (snapshot_id,parent_path)、(content_hash)

file_size / modified_time / permissions 留在 files——FastAPI 插件的 ETag 与 Last-Modified 依赖它们,不能搬进 blobs。

关键设计点

读路径:read_file 用一条 JOIN 取回内容,并校验 blobs.size == files.file_size(便宜的结构性校验,不一致立刻报错,而不是返回坏数据)。

写入路径:算 sha256 → INSERT OR IGNORE INTO blobs → INSERT INTO files(...content_hash)。只有 blob 是新的才压缩——所以重打一棵没变的树时零压缩开销,这正是增量打包能变快的原因。

get_file_info 不再返回 content/freq_blob/compress_algo,改为返回 content_hash,并新增 get_blob_info(path) 供内省。这是有意的 API 变更:盘点发现今天 exists() / is_dir() / list_directory() 都在用 SELECT * 把整个 BLOB 拉进内存(core.py:416/436/445/914、folder_unpacker.py:82),拆表正好顺手修掉。会同步改 16 处测试断言;contrib/fastapi_static.py 只用到 is_directory/permissions/file_size/modified_time/path,不受影响。

blob 回收:gc_blobs() 删除不再被任何快照引用的 blob(NOT IN (SELECT content_hash FROM files ...)),在 remove / delete_snapshot / clean 的同一事务里执行。多快照下只要还有任一快照引用就保留。

完整性:compute_digest() 对快照内按 path 排序的规范串取 sha256(字段固定为 path、类型、大小、mtime、权限、content_hash),存进 snapshots.digest;verify(check_content=False) 复核摘要 + 检查 blob 是否缺失/大小是否对得上,check_content=True 时再逐文件重算 sha256;sign(key) / verify_signature(key) 用 HMAC-SHA256 + hmac.compare_digest,签的是摘要字符串。全部可在只读连接上跑(会写用例证明)。

快照零感知:_ensure_active_snapshot() 只在首次写入时懒建一个 id=1 的默认快照;只读连接上绝不创建(否则会打破 FastAPI 插件那条"服务过程不改动资源包"的 md5 用例)。clean() 保持"整库清空"语义不变(CLI 的覆盖警告与既有测试都依赖它),另加 clear_snapshot()。

recount() 改成按当前快照统计并回写 snapshots.total_files/total_size;get_stats() 返回合并后的 dict,保留 name/created_time/total_files/total_size 四个旧键,新增 snapshot_id/snapshot_name/digest/schema_version。

增量打包:pack_folder(source, incremental=False, rehash=False)。默认 incremental=False 保持现有的"清空 + 全量重打"语义(不动既有行为与测试)。incremental=True 时不 clean,读出当前快照的 {path: (size, mtime)},只对新增/变更的文件调 add_file,删掉磁盘上已不存在的路径,最后 recount + 重算摘要,并汇报"新增/更新/未变/删除"四个计数。mtime 比较留 1 秒容差(跨平台时间戳精度差异);rehash=True 强制按 sha256 全量比对。

改动清单

文件 内容
src/sqlite_vfs/core.py v3 DDL;所有 files 查询加 snapshot_id 过滤;SELECT * 全部改显式列名;读写走 blobs;新增 create_snapshot/use_snapshot/list_snapshots/delete_snapshot/clear_snapshot、gc_blobs、compute_digest、verify、sign、verify_signature、get_blob_info;REQUIRED_COLUMNS 与 VFSFormatError 消息同步
src/sqlite_vfs/migration.py(新增) migrate(path):v2→v3,先备份到 .bak,在临时副本上重建,原子替换;解压每个 blob 算 sha256、顺带去重。可重入,已是 v3 直接返回
src/sqlite_vfs/folder_packer.py incremental / rehash 两条路径;不再依赖 update_vfs_metadata 写总数(改由 recount() 落到快照)
src/sqlite_vfs/folder_unpacker.py 3 处裸 SQL 加快照过滤;构造函数收 snapshot_id
src/sqlite_vfs/cli/packer.py --incremental、--rehash、--snapshot NAME(建新快照并从磁盘增量更新)
src/sqlite_vfs/cli/unpacker.py --snapshot ID|latest、--list-snapshots
src/sqlite_vfs/cli/verify.py(新增) vfsverify <db>:列快照、打印摘要、--check-content、--sign/--key
src/sqlite_vfs/cli/migrate.py(新增) vfsmigrate <db>:调用 migration
pyproject.toml 两个新 console script
src/sqlite_vfs/contrib/fastapi_static.py 新增 snapshot_id=None 参数(默认取最新快照)
tests/helpers.py seed_rows 改成写 blobs+content_hash+snapshot_id;stored_files 改 JOIN;all_paths/path_parents/count_files 加快照过滤;新增 v2 库构造夹具(迁移用)

测试计划

新增 5 个测试文件(约 60 个用例):

  • test_dedup.py — 相同内容只占一行 blob;跨快照共享;移动文件不复制内容;重打同一棵树库体积不增长;GC 只回收无人引用的 blob
  • test_snapshots.py — 建/列/切/删;同一 path 在不同快照里内容不同(时间旅行);copy_entries 从父快照派生(只复制元数据行);快照间列举/导出互不串味;删快照后 blob 被回收
  • test_integrity.py — 摘要跨重开稳定;verify() 通过;篡改 blob 内容 → 报错;篡改元数据 → 摘要不符;check_content=True 能抓到哈希与内容不符;HMAC 签名与验签、错密钥失败、签名后可只读验证
  • test_incremental.py — 改一个/加一个/删一个后的增量结果;未变文件的 content_hash 与 blob 都不重写;rehash=True 能抓出"同大小同秒"的修改;增量进新快照;摘要同步更新
  • test_migration.py — 构造 v2 库 → 迁移 → 全部内容可读且逐字节一致;backup 生成;重复执行安全;已是 v3 直接返回;迁移后两处相同内容合并成一个 blob

同时修约 25 处既有断言(get_file_info 的 content 类键、COUNT(*) FROM files 的全局唯一性假设、CLI/插件的 compress_algo 查询改 JOIN blobs、README 迁移配方用例改为 v2→v3)。

回归门槛:现有 148 个用例除被有意变更的断言外全部保持通过,双环境(有/无 FastAPI)都跑一遍。

文档

README 增加「去重与内容寻址」「快照与时间旅行」「完整性校验与签名」「增量打包」四节,更新 files 表字段说明与迁移配方;BUG_REPORT.md 增加第十一节记录这次存储层改造(含格式变更与 API 变更的影响面)。

需要你知道的取舍

  • 格式变更不可避免:UNIQUE(snapshot_id, path) 无法用 ALTER TABLE 改约束,必须重建表;所以给了迁移工具而不是自动升级(延续你之前"不静默改用户数据"的选择)。迁移会生成 .bak。
  • get_file_info 不再带 content 是破坏性 API 变更。好处是元数据查询不再搬 BLOB;代价是 16 处断言要改。若你希望保留 content 键(哪怕代价是继续搬 BLOB),告诉我,我改回去。
  • HMAC 是对称的:验证方必须持有同一密钥,适合"自己验自己"。真正的非对称信任链要外部工具对导出的摘要签名。
  • verify(check_content=True) 是 O(总字节数),只在需要时调。
  • 增量用 mtime+size 有理论漏检(同一秒内同大小修改),所以提供 rehash=True 作为可靠模式。
  • 这次改动量不小(源码约 700–900 行、测试约 700 行),会分阶段提交式推进,每个阶段都跑一次全量回归。