Files
svfs/.zcode/plans/plan-sess_15727471-cbbb-40df-b543-cee569b07bf8.md
T

94 lines
8.9 KiB
Markdown
Raw Normal View History

2026-09-14 00:58:53 +08:00
## 目标
四项能力一起落地,但存储层是共用基础,所以按"先换地基、再上功能"的顺序做:
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)
```sql
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 行),会分阶段提交式推进,每个阶段都跑一次全量回归。