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

94 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 目标
四项能力一起落地,但存储层是共用基础,所以按"先换地基、再上功能"的顺序做:
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 行),会分阶段提交式推进,每个阶段都跑一次全量回归。