Files
svfs/BUG_REPORT.md
T
chakcy 1620d3eb5a docs: 补 README 能力章节、缺陷报告与两份设计说明
- README:新增「数据能力」四节(内容寻址去重 / 快照与时间旅行 / 完整性校验与签名 /
  增量打包)与「发布到对象存储」一节,附实测对比表;重写「要不要用 --compress」
  (结论从"别用"反转为"默认就该开")、「迁移旧格式」;修正项目结构与字段表
- BUG_REPORT.md:13 节,含 P0/P1 逐条分析、P2/P3 清单、五批后续追加的记录,
  以及过程中被推翻的两处结论(第九节的压缩建议、P3-1 的 uv sync 判断)
- docs/encryption-design.md:加密的设计说明——去重与随机 nonce 的冲突、
  标准库没有密码学原语、以及现有 freq_blob 可作为每块附加数据的槽位
- docs/evidence-*.txt:各阶段的测试运行证据(修复前 / 修复后 / 双环境)
2026-09-14 00:11:53 +08:00

723 lines
50 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.
# SQLite VFS 缺陷分析与测试报告
分析对象:工作区中未提交的哈夫曼压缩集成改动(`M src/sqlite_vfs/core.py`、`?? src/sqlite_vfs/huffman.py`)
分析日期:2026-09-13
结论:**打包/解包主链路当时 100% 不可用,而且所有故障都被吞掉,命令行依然打印"打包完成"。**
---
## 一、结论与结果
| | 修复前 | 修复后 |
|---|---|---|
| 测试套件 | 77 个用例 | 343 个用例(含后续五批追加) |
| 结果 | **42 failures / 9 errors** | **OK** |
| 耗时 | 175.7 s | 14.6 s |
| 打包结果 | 库里 0 个文件,界面报告"N 个文件" | 22 个文件全部入库 |
| 解包结果 | 导出 0 个文件 + 磁盘上生成几百层 `sub/sub/sub/...` 垃圾目录 | `diff -r` 零差异 |
| 失败可见性 | 全部 exit code 0 | 失败 exit code 1 + 中文错误信息 |
固定的 81 个用例基线见第五节;第九~十三节共追加 262 个用例。
最重要的一个发现:**建表用的列名是 `compresse_algo`,而 INSERT 与 `read_file` 用的是 `compress_algo`**。
这一个拼写不一致就让写入必然失败、读取必然失败,而异常被 `except Exception` 吞掉,
所以用户看到的是"打包完成 / 文件数量: 22",而数据库里其实一条文件记录都没有。
修复范围:4 个 P0 + 12 个 P1。P2/P3 按要求只记录不修改(见第六节,其中两条因第九/十节的
结论而顺带处理)。未提交 git,未改动 `dist/`、`Scripts/`、`resources/`,
仓库自带的 `appcation.svfs` 全程未被修改(md5 前后均为 `cc790c5cffab3155f42f66ed51a71743`)。
---
## 二、证据
### 2.1 修复前的测试结果(`docs/evidence-before.txt`)
```
Ran 77 tests in 175.700s
FAILED (failures=42, errors=9)
```
失败信息与根因一一对应,例如:
```
FAIL: test_add_file_actually_stores_a_row
AssertionError: False is not true : add_file 没有把文件写进数据库
FAIL: test_root_lists_top_level_only
AssertionError: Lists differ:
['doc', 'docs', 'top.txt'] ← 期望
!= ['doc', 'doc/a.txt', 'doc/sub', 'doc/sub/b.txt', ...] ← 实际返回了整张表
FAIL: test_add_file_survives_close_without_further_commit
AssertionError: False is not true : add_file 之后直接 close(),数据丢失——事务没有提交
FAIL: test_ignore_timestamps_full_export_does_not_recurse
AssertionError: 'RecursionError' unexpectedly found in output
```
耗时 175 秒这个数字本身也是证据:解包时对子目录的无限自我递归在磁盘上创建了几百层
`sub/sub/sub/...` 目录(本机 `LongPathsEnabled=1`,所以一直递归到 Python 的递归上限才停下)。
### 2.2 P0-1 根因的最小复现(`docs/evidence-root-cause.txt`)
```python
# 修复前的建表语句(注意 compresse_algo 的拼写)
CREATE TABLE files (path TEXT, content BLOB, freq_blob BLOB,
compresse_algo TEXT DEFAULT 'none')
# 而 INSERT / SELECT 用的是 compress_algo
```
```
修复前 INSERT -> OperationalError: table files has no column named compress_algo
→ 该异常被 core.py 的 except 吞掉,只打印一行"添加文件失败",库里没有任何记录
修复前 SELECT -> OperationalError: no such column: compress_algo
→ read_file 因此必然失败,所有导出操作都变成"导出文件失败"
修复后 INSERT -> ok, SELECT -> [('none',)]
```
### 2.3 修复后的测试结果(`docs/evidence-after.txt`)
```
Ran 81 tests in 5.179s
OK
```
### 2.4 真实目录端到端冒烟验证
```
$ python -m sqlite_vfs.cli.packer src /tmp/smoke_p.svfs # 不压缩
文件数量: 22 总大小: 0.17 MB 耗时: 0.15 秒
$ python -m sqlite_vfs.cli.packer src /tmp/smoke_c.svfs --compress # 压缩
文件数量: 22 总大小: 0.17 MB 耗时: 0.39 秒
数据库体积: 不压缩 233,472 字节 → 压缩 192,512 字节(省 17.5%)
$ python -m sqlite_vfs.cli.unpacker /tmp/smoke_c.svfs /tmp/smoke_out --list-root
根目录内容:
DIR: __pycache__ (路径: __pycache__)
DIR: sqlite_vfs (路径: sqlite_vfs)
$ python -m sqlite_vfs.cli.unpacker /tmp/smoke_c.svfs /tmp/smoke_out
文件系统导出完成! 共导出 22 个文件/目录
$ diff -r src /tmp/smoke_out
★ 完全一致,无任何差异
$ python -m sqlite_vfs.cli.unpacker /tmp/smoke_c.svfs /tmp/smoke_its --ignore-timestamps
★ diff -r 同样一致(修复前这里必然 RecursionError)
$ python -m sqlite_vfs.cli.packer src /tmp/smoke_ex.svfs --exclude "__pycache__" "*.pyc"
总大小: 0.05 MB → __pycache__ 被真正剪枝排除
```
各失败路径的退出码与提示(修复前全部是 exit 0,或以 traceback 崩溃):
| 场景 | 输出 | exit |
|---|---|---|
| 旧格式库 `appcation.svfs` | `错误: appcation.svfs 不是当前版本能够读取的数据库(files 表缺少列: compress_algo, freq_blob)。该库很可能是旧版本创建的;请用旧版本导出内容后重新打包,或换一个新的输出路径。` | 1 |
| 源目录不存在 | `错误: 源文件夹不存在: ./no_such_dir` | 1 |
| 输出目录不存在 | `错误: 输出目录不存在: ...\no_such_dir` | 1 |
| 非 SQLite 文件 | `错误: 无法打开数据库 ...: file is not a database` | 1 |
| 导出不存在的路径 | `错误: 虚拟文件系统中不存在该路径: nope` | 1 |
---
## 三、P0 缺陷(致命:链路完全不可用 / 静默数据丢失,均已修复)
### P0-1 建表列名与 INSERT / SELECT 列名不一致,打包与解包双向失效
- **位置**:原 `core.py:99`(建表 `compresse_algo`)、`:175`(INSERT `compress_algo`)、`:278`(`read_file` 读 `compress_algo`)
- **根因**:同一个字段在两处用了不同拼写。SQLite 不会做任何模糊匹配,列名必须完全一致。
- **影响**:
- `add_file` 的 INSERT 抛 `OperationalError`,被 `core.py:195` 的 `except Exception` 吞掉,只打印一行"添加文件失败"。**数据库里永远没有任何文件内容。**
- `folder_packer` 不受该异常影响,继续累加自己的计数器,于是 `update_vfs_metadata` 写入的是"走过多少个文件",
最终打印"文件数量: 22",而库里 0 条记录 —— 用户以为备份成功。
- `read_file` 取不存在的列,所有导出(`export_file` / `export_file_safe` / `export_directory` / `export_filesystem`)全部失败,
且失败信息同样是"导出文件失败",看不出真正原因。
- **修复**:把建表列名改为与代码一致的 `compress_algo`(`core.py:155`)。
选择改建表而非改另外两处的原因:INSERT 与 `read_file` 拼写一致且正确;且当前代码根本插不进数据,
磁盘上不存在任何依赖错误拼写且有内容的库。
- **状态**:已修复。回归用例:`test_core.SchemaContractTest.test_expected_columns_exist`、
`test_add_file_actually_stores_a_row`、`test_roundtrip.RoundTripTest.test_every_source_file_is_stored`。
### P0-2 `list_directory` 用 `path` 而不是 `parent_path` 过滤,导致导出无限递归 + 数据丢失
- **位置**:原 `core.py:210-230`
- **根因**:
- 非根分支写的是 `WHERE path = ?`,返回的是**目录自己那一行**,而不是它的子项;
- 根分支 `WHERE` 条件为空,返回**整张表**(把深层条目也当成顶层条目)。
- **影响**:`export_directory` 遍历 `list_directory(p)` 拿到的是 `p` 自己,
于是 `export_directory(p, 更深路径)` 无限自我递归。实测结果是磁盘上生成几百层
`sub/sub/sub/...` 垃圾目录,**子目录里的文件一个都导不出来**,最后以 `RecursionError` 或
路径过长错误收场。这是"解包出来是空的"的直接原因,也是测试耗时 175 秒的原因。
顶层文件之所以还能导出一部分,是因为 `_has_root_directory()` 为假、走了 `_export_root_contents` 这条旁路。
- **修复**:
- `list_directory` 先做路径归一化,根目录用 `parent_path IS NULL OR = '' OR = '/'`,其余用 `WHERE parent_path = ?`(`core.py:323-353`);
- `export_directory` / `_export_root_contents` / `export_filesystem` 补 `ignore_timestamps` 参数并统一走 `_export_one`(`core.py:745`、`:799`、`:852`)。
- **状态**:已修复。回归用例:`test_core.DirectoryListingTest`(5 个)、
`test_export_subdirectory_does_not_recurse_forever`、`test_cli.test_list_root_only_shows_top_level`。
### P0-3 `--ignore-timestamps` 把 `export_filesystem` 赋值成引用自身的 lambda,必然 RecursionError
- **位置**:原 `cli/unpacker.py:63-69`
- **根因**:
```python
unpacker.sqlite_vfs.export_filesystem = (
lambda export_root: unpacker.sqlite_vfs.export_filesystem(export_root)
)
```
把实例属性覆盖成 lambda,而 lambda 体内取的还是这个实例属性,于是自己调自己。
实例属性一旦赋值还会永久污染该实例。
- **影响**:`vfsunpack db out --ignore-timestamps`(不带 `--path`)100% 崩溃。
- **修复**:删掉这段 monkey-patch。改为把 `ignore_timestamps` 作为正式参数贯穿
`unpack_filesystem` → `export_filesystem` → `export_directory` → `_export_one` → `export_file_safe`,
该选项这才真正具备"时间戳设置失败也照样导出内容"的语义。
- **状态**:已修复。回归用例:`test_cli.CliUnpackTest.test_ignore_timestamps_full_export_does_not_recurse`、
`test_roundtrip.test_roundtrip_with_ignore_timestamps`。
### P0-4 `add_file` 缺少 `conn.commit()`
- **位置**:原 `core.py:147-196`(整个方法内没有任何 commit)
- **根因**:sqlite3 默认是显式事务,INSERT 后不 commit,数据只在连接内的未提交事务里。
- **影响**:`vfs.add_file(...)` 之后直接 `close()`,`close()` 不提交、直接回滚 → **数据静默丢失**。
打包流程里之所以没暴露,纯粹是因为后续 `update_vfs_metadata` 恰好调了一次 commit —— 属于巧合而非设计。
- **修复**:`add_file` / `add_directory` 写入后立即 `commit()`,异常时 `rollback()`,并返回成功与否(`core.py:235`、`:199`)。
- **取舍**:这样打包变成"每个文件一次事务提交",大目录树会变慢。正确性优先;
若要恢复批量提交,应显式开启事务(见第八节建议),而不是依赖"忘了 commit"。
- **状态**:已修复。回归用例:`test_core.PersistenceTest.test_add_file_survives_close_without_further_commit`。
---
## 四、P1 缺陷(正确性 / 静默损坏 / 可用性,均已修复)
### P1-1 `LIKE path || '%'` 前缀误伤,且 `%` `_` 未转义
- **位置**:原 `core.py:429-435`(`_remove_directory`)、`:389-403`(`mv_dir` 子树改名)
- **根因**:`LIKE 'doc%'` 会把 `docs`、`doc2` 一并匹配;路径里的 `%` 和 `_` 是 SQL 通配符,未做转义。
- **影响**:`remove("doc")` 会连 `docs` 一起删掉;`a_b.txt` 的删除会波及 `axb.txt`;`mv_dir` 同理。
这是"删一个目录,连带删掉名字相似的兄弟目录"这类最难排查的数据损坏。
- **修复**:统一改成"精确相等 **或** 以 `src + '/'` 开头",并用 `ESCAPE '\'` 转义 `%` `_` `\`
(新增 `_escape_like`,`core.py:189`;使用点 `core.py:565`、`:612`)。
- **状态**:已修复。回归用例:`test_remove_directory_does_not_touch_prefix_sibling`、
`test_remove_file_with_underscore_is_not_a_wildcard`、`test_remove_file_with_percent_is_not_a_wildcard`、
`test_mv_dir_does_not_touch_prefix_sibling`。
### P1-2 `mv_dir` 的第二条 UPDATE 会二次命中刚改名的目录自身
- **位置**:原 `core.py:388-403`
- **根因**:第一条 UPDATE 已把 `doc` 改成 `doc_new`,第二条 UPDATE 的条件是 `path LIKE 'doc%'`,
而 `doc_new` 正好以 `doc` 开头 → 再次被改名,`doc_new` + `SUBSTR('doc_new', 4)` = `doc_new_new`。
- **影响**:任何"新名字以旧名字开头"的目录移动都会把路径改坏。
- **修复**:前缀匹配加上 `/` 边界(`path LIKE 'doc/%'`,`core.py:565`),并在入口显式拒绝"移动到自己的子目录"
(`core.py:510`)。
- **状态**:已修复。回归用例:`test_mv_dir_rename_is_not_applied_twice`、`test_mv_dir_into_itself_raises`。
### P1-3 `mv_dir("/", dest)` 根目录分支的三处错误
- **位置**:原 `core.py:343-368`
- **根因与影响**:
1. `parent_path = dest || '/' || parent_path`,对顶层条目(`parent_path` 为空串)得到 `dest/`,
多了个尾斜杠 → 之后按 `parent_path = 'dest'` 查询不到这些条目;
2. 两条 UPDATE 分别用 `is_directory = 1` 和 `is_directory = 0` 过滤,逻辑不一致、易漏;
3. **从不创建 `dest` 目录行** → 移完之后目标目录在库里根本不存在,整个子树变成不可达。
- **修复**:先 `_insert_directory_row` 建目标目录行(并在 UPDATE 中用 `path != ?` 排除它,避免被二次前缀),
再用一条 UPDATE 统一处理所有条目,`parent_path` 为空/NULL/`/` 时置为 `dest`、否则前置 `dest/`;
同时拒绝"把根目录内容移进 `a/b` 这种嵌套目标"这种自引用写法(`core.py:497-576`)。
- **状态**:已修复。回归用例:`test_mv_dir_root_creates_destination_and_fixes_parent_paths`。
### P1-4 唯一约束冲突无处理,事务悬挂
- **位置**:原 `mv_file`/`mk_dir`/`add_file`/`add_directory`
- **根因**:`path` 有 UNIQUE 约束,但没有任何一处检查目标是否存在。
- **影响**:重复 `mk_dir` 抛裸 `IntegrityError`,且事务不 commit / rollback 就留着;
重复 `add_file` 静默失败,调用方无法感知。
- **修复**:`mk_dir` / `mv_file` / `mv_dir` 显式检查来源与目标,抛出带中文说明的
`FileExistsError` / `FileNotFoundError` / `NotADirectoryError`;所有写操作统一 `commit`/`rollback`;
`add_file` 返回布尔值,重复路径返回 `False` 且不影响已有内容和后续操作(`core.py:439-576`)。
- **状态**:已修复。回归用例:`test_core.MkDirTest`、`AddFileConflictTest`、
`test_mv_dir_to_existing_destination_raises_and_keeps_source`、`test_mv_dir_to_missing_parent_raises`。
### P1-5 异常被吞 + 统计数字造假
- **位置**:原 `core.py:141-142`、`:195-196`;`folder_packer.py:64-67`
- **根因**:`add_file`/`add_directory` 捕获所有异常只打印;`pack_folder` 无论成功与否都
`self.total_files += 1`,最后把"遍历计数"写进元数据。另外 `os.path.getsize` 在 try 之外。
- **影响**:打包结果自相矛盾(报告 22 个、库里 0 个);源目录里一个坏软链或权限不足的文件
会让整个打包抛未捕获异常中断,留下半成品库。
- **修复**:`pack_folder` 只统计成功项,并在遍历结束后调用 `recount()`
直接从库里 `SELECT COUNT(*), SUM(file_size)` 取真实数字写回元数据(新增 `core.py:294`);
文件大小不再由 packer 单独 `getsize`,交给 `add_file` 自己的 try 处理;
同时打印"已排除 N 个 / 警告: M 个条目未能写入"。
- **状态**:已修复。回归用例:`test_roundtrip.test_metadata_counts_match_database`、
`test_cli.test_pack_reports_file_count_matching_database`。
### P1-6 `os.makedirs(os.path.dirname(local_path))` 在纯文件名时抛 FileNotFoundError
- **位置**:原 `core.py:480`、`:671`
- **根因**:`os.path.dirname("out.txt")` 是空串,`makedirs('')` 抛 `FileNotFoundError`。
- **影响**:`export_file("a.txt", "out.txt")` 这类"导出到当前目录"的合法用法直接失败。
- **修复**:抽出 `_ensure_parent_dir`,仅在父目录非空时才 `makedirs`(`core.py:658`)。
- **状态**:已修复。回归用例:`test_core.ExportTest.test_export_file_accepts_bare_filename`。
### P1-7 没有 schema 版本校验,旧格式库连接成功后才以"导出文件失败"收场
- **位置**:原 `core.py:26`、`:65-113`(只有 `CREATE TABLE IF NOT EXISTS`)
- **根因**:库已存在时 `IF NOT EXISTS` 是空操作,旧表原样保留,代码随后按新列名读写。
- **影响**:仓库自带的 `appcation.svfs`(旧结构:`content BLOB` + `compressed BOOLEAN`,
没有 `freq_blob`/`compress_algo`)完全不可用,但打开时不报错,
只在每个文件导出时打印一行"导出文件失败",用户完全不知道是版本问题。
- **修复**:新增 `VFSFormatError` 异常(`core.py:12`)与 `_check_schema`(`core.py:60`):
连接后检查 `files` 表列集合,缺列就抛出明确指出缺哪些列、并说明"该库很可能是旧版本创建的"。
按既定选择**只检测报错、不改动用户数据**。顺带修正:校验失败时先关闭连接再抛异常
(原实现异常路径不关连接,Windows 上会把库文件锁住,连临时目录都删不掉 —— 我在测试清理阶段实际踩到过)。
- **状态**:已修复。回归用例:`test_core.SchemaCompatibilityTest`(4 个)、
`test_cli.test_rejects_old_schema_database_cleanly`。
### P1-8 所有失败路径都返回 exit code 0
- **位置**:原 `cli/packer.py:22-24`、`cli/unpacker.py:29-31`(用 `return` 而非 `sys.exit`);
`FolderUnpacker.unpack_filesystem` 在数据库检查失败时 `return 0`
- **影响**:命令行工具与 CI 完全无法感知失败——脚本里 `vfsunpack` 挂了也继续往下跑。
- **修复**:校验失败、库格式错误、非 SQLite 文件、导出路径不存在等情况统一 `sys.exit(1)` 并打印中文错误;
`unpack_filesystem` 在数据库检查失败时抛 `RuntimeError` 由 CLI 转成退出码(`cli/packer.py:26-58`、`cli/unpacker.py:33-89`)。
- **状态**:已修复。回归用例:`test_cli.CliFailureTest`(5 个)。
### P1-9 `--exclude` 实为 basename 子串匹配,README 承诺的是 glob
- **位置**:原 `folder_packer.py:21-27`
- **根因**:`if pattern in filename` —— 子串包含判断。README 示例 `--exclude "*.tmp"` 永远匹配不到任何文件。
而且只跳过目录项本身,`os.walk` 仍会走进被"排除"的目录。
- **修复**:改用 `fnmatch`,同时匹配相对路径与文件名;在 `os.walk` 中原地剪枝 `dirs[:]`,
被排除的目录整棵不再遍历(`folder_packer.py:31-42`、`:66`)。
- **状态**:已修复。回归用例:`test_cli.test_exclude_supports_glob_patterns`、
`test_exclude_prunes_directory_subtree`。
### P1-10 `FolderPacker.add_file` 用库内内容覆盖用户的源文件
- **位置**:原 `folder_packer.py:95`
- **根因**:`self.sqlite_vfs.export_file_safe(rel_path, abs_path)` —— 参数顺序是 `(vfs 路径, 本地路径)`,
这里却把"源文件的绝对路径"当成了输出目标,等于把库里的内容写回源文件。
紧跟其后的两行还打印了两个互相矛盾的"已保存到"。
- **影响**:目前 `pack_folder` 没走这条路,属于潜伏地雷,但任何库使用者调用一次就会损坏源文件。
- **修复**:删掉该调用,`FolderPacker.add_file` 只做写入库这一件事(`folder_packer.py:122`)。
- **状态**:已修复。
### P1-11 `FolderPacker.add_directory` 实参顺序颠倒
- **位置**:原 `folder_packer.py:99-101`
- **根因**:调用 `add_directory(rel_path, abs_path)`,而 core 的签名是 `(rel_path, source_folder)`
且内部执行 `os.path.join(source_folder, rel_path)` → 拼出的路径无效,`os.stat` 必然失败。
- **修复**:新增 `_source_root(abs_path, rel_path)` 反推源根目录,保证
`os.path.join(root, rel_path) == abs_path`(`folder_packer.py:12`、`:139`)。
- **状态**:已修复。
### P1-12 输出数据库会被自己打包,导致自包含
- **位置**:`folder_packer.py`(原实现完全没有这个判断)
- **根因**:`vfspack . ./out.db` 时 `os.walk` 会走到 `out.db`,而它正是当前正在写入的数据库文件。
- **影响**:读一个正在被 SQLite 写入的文件,结果不可预期(重复打包时库体积翻倍,或读到损坏页)。
- **修复**:与 `db_path` 绝对路径相同的文件直接跳过并打印提示(`folder_packer.py:61`、`:89`)。
- **状态**:已修复(这一条是在写测试用例时新发现的,不在最初列出的清单里)。
---
## 五、测试套件
位置 `tests/`,**纯标准库 `unittest`,零新增依赖**(`unittest.TestCase` 同时可被 pytest 运行)。
当前 `.venv` 里没有 pytest,dev 依赖组只有 twine,所以不想为了跑测试引入网络依赖。
```bash
# 在仓库根目录
.venv/Scripts/python.exe -m unittest discover -s tests -v
# 或(若安装了 pytest)
pytest tests/ -v
```
| 文件 | 用例数 | 覆盖内容 |
|---|---|---|
| `tests/helpers.py` | — | 路径引导、临时目录(含超长路径清理)、目录树构造与差异比对、打包/解包/CLI 封装、原始 SQL 造数据 |
| `tests/test_huffman.py` | 15 | 往返(空/单字节/单符号/双符号/全 256 字节值/不可压缩随机/中文/阈值边界)、频率表长度、魔数与截断校验 |
| `tests/test_core.py` | 52 | schema 契约、事务持久化、读写往返与压缩分支、目录列举语义、移动/删除、唯一约束、导出、旧格式库拒绝 |
| `tests/test_roundtrip.py` | 8 | 端到端打包→解包逐字节比对;含空目录、空文件、中文与空格文件名、`%`/`_` 文件名、`doc`+`docs` 兄弟目录 |
| `tests/test_cli.py` | 34 | 子进程跑四个 CLI:退出码、`--list-root`、`--path`、`--ignore-timestamps`、`--exclude`、快照选项、`vfsverify`、`vfsmigrate` |
| 小计(缺陷修复部分) | **81** | |
| `tests/test_fastapi_static.py` | 51 | 可选插件(见第九节),未安装框架时整类跳过 |
| `tests/test_contrib_optional.py` | 5 | "核心包不依赖第三方框架"的契约,有/无框架两种环境都运行 |
| `tests/test_dedup.py` | 16 | 相同内容合并、跨快照共享、GC 时机、重打不增长 |
| `tests/test_snapshots.py` | 22 | 建/切/删快照、时间旅行、派生复制、按快照导出与统计隔离 |
| `tests/test_integrity.py` | 24 | 摘要稳定性、两级 verify、篡改检测、HMAC 签名与只读验签 |
| `tests/test_migration.py` | 22 | v1/v2 迁移、备份、幂等、去重收益、失败时原库不动 |
| `tests/test_incremental.py` | 21 | 增/改/删/类型互换、不重写未变文件、rehash 抓同秒同大小修改、新快照 |
| `tests/test_publish.py` | 29 | 对象去重导出、清单确定性与校验、真实 HTTP 取回、缓存自愈、损坏检测、统计口径 |
| `tests/test_compression.py` | 12 | 算法分发层:注册表、三种算法往返、未知算法报错、取舍不被退化 |
| `tests/test_core.py` 追加 | +30 | 时间戳存储契约、read_file 边界、blob 原语、add_directory replace、clean、is_file |
| `tests/test_cli.py` 追加 | +1 | `main.py` 冒烟(防止示例脚本腐烂) |
| **合计** | **343** | |
两个设计取舍值得说明:
1. **目录语义类用例用原始 SQL 造数据**(`helpers.seed_rows`),只写新旧 schema 共有的列。
这样"`list_directory` 语义错误"不会因为"列名拼写错误"而连带失败,失败原因单一、定位快。
2. **`test_huffman.py` 的作用是排除法**:它在修复前后都是 15/15 通过,说明哈夫曼算法本身没问题,
所有故障都出在 `core.py` 的集成层。这省掉了"是不是压缩算法写错了"的排查成本。
> 说明:`docs/evidence-before.txt` 的红色证据是 77 个用例版本下的结果(sessions 中测试集还在完善)。
> 定稿为 81 个用例,其中 4 个是修复过程中为新增边界补的
> (`test_single_symbol_payload_is_not_validated`、`test_mv_dir_to_missing_parent_raises`、
> `test_mv_dir_into_itself_raises`、`test_stored_blob_of_compressible_file_is_smaller`)。
> 修复后的 81 个用例全绿。
---
## 六、P2 / P3:清单(本次未修改;P2-7 与 P3-5 因后续结论已处理,见第九、十节)
### P2(健壮性 / 兼容性,建议尽快处理)
| 编号 | 位置 | 问题 | 建议 |
|---|---|---|---|
| P2-1 | `core.py:585` | `get_stats` 硬编码 `WHERE id = 1`;若元数据表里有多行(例如未 `clean()` 就再次 `add_vfs_metadata`),返回的不是最新那行 | 改成 `ORDER BY id DESC LIMIT 1` |
| P2-2 | `core.py:639`、`:644` | `is_dir`/`is_file` 返回 `1` / `None` 而不是布尔值(`file_info and file_info[...]` 直接返回了原值) | 外层加 `bool(...)` |
| P2-3 | `core.py:235`、`:377` | 大文件整个 `f.read()` 进内存后再压缩、再交给 SQLite;解包时也是整块读出来再切片 | 大文件改为分块读写;`read_file` 的 `offset/size` 用 SQL `substr()` 下推到数据库 |
| P2-4 | `core.py:222`、`:275`、`:422` | 时间戳用 `datetime.fromtimestamp` / `datetime.now()` 存的是**本地时间**(README 却写"UTC");且向 sqlite3 传 `datetime` 对象自 Python 3.12 起已 `DeprecationWarning`(`.venv` 的 `sqlite3/dbapi2.py:60` 确认),当前解释器是 3.13。跑测试时该警告已实际出现:`core.py:334 / :495: DeprecationWarning: The default datetime adapter is deprecated as of Python 3.12` | 统一存 UTC 并显式 `isoformat()` 成字符串;或注册自定义 adapter。这是剩余 P2 里最该先修的一条——它在 3.12+ 已经可见,且属于"将来某次升级就会坏"的类型 |
| P2-5 | `core.py:70-71` | `_check_schema` 只校验列存在性,不校验列类型,也没有版本号 | 增加 `schema_version` 表或 `PRAGMA user_version`,为将来升级留出通道 |
| P2-6 | `huffman.py:176` | 单符号数据解压走快捷路径,完全不读 payload,**payload 被截断也检测不出来**;整体也没有任何校验和 | 若要防损坏,可加校验和;至少已知这是"无完整性校验"的设计 |
| P2-7 | `huffman.py:115`、`:131` | 每个文件固定带 1024 字节频率表(1.5KB 文件压完反而 120%);`struct.pack('<256I')` 对超过 4GB 的文件会因 uint32 溢出抛 `struct.error` | **已按第九节的结论处理**:默认算法换成 zlib 后,频率表只在使用 `compress_algorithm='huffman'` 时才产生;huffman 现在仅为读旧包而保留。4GB 溢出仍存在于 huffman 路径 |
| P2-8 | `folder_packer.py:56`、`:72` | `os.walk` 不跟随目录符号链接(默认行为,符合预期),但文件型软链会被 `open` 跟随读取,坏软链只在 `add_file` 内部被捕获并计入失败列表 | 明确策略并在文档里说明 |
| P2-9 | `cli/unpacker.py:22` | `--debug` 选项被解析但从未使用 | 实现它或删除 |
| P2-10 | `Scripts/clean_pycache.py` | 无确认地对 `./` 下所有 `__pycache__` 执行 `shutil.rmtree` | 加 `--dry-run` 或范围限制 |
### P3(工程配置与文档)
| 编号 | 位置 | 问题 | 建议 |
|---|---|---|---|
| P3-1 | `pyproject.toml` `[tool.uv.workspace]` | `members = ["devmo"]`,但仓库里**没有 `devmo` 目录** | **更正**:本报告早先写的"`uv sync` 会直接失败"是**错的**。实测 `uv sync --dry-run` 退出码 0、正常解析 97 个包——uv 0.11.8 容忍不存在的 workspace 成员。所以这是**死配置(有误导性),不是阻塞项**。教训记在这里:当时只验证了"目录不存在"这个前提,没验证"所以会失败"这个推论 |
| P3-2 | `dist/sqlite_vfs-0.1.0-py3-none-any.whl` | 2025-11 的旧 wheel,里面是**未集成哈夫曼**的 `core.py`,且不含 `huffman.py`。装上它会得到与源码不兼容的行为 | 删除过期的 `dist/` 产物,或在 CI 里重建 |
| P3-3 | 整个包 | `huffman.py` 当前**未被 git 跟踪**(`?? src/sqlite_vfs/huffman.py`),而 `core.py:9` 直接 `from .huffman import ...`。任何人 clone 一份都会 `ImportError`,整包不可用 | 尽快 `git add` |
| P3-4 | `README.md:69-79`、`:139` | 项目结构写成 `src/svfs/`,示例代码也写 `from svfs.core import SQLiteVFS`,实际包名是 `sqlite_vfs` | 改为 `sqlite_vfs` |
| P3-5 | `README.md`「性能说明」 | "使用 zlib 压缩,压缩级别为 6" —— 当时其实是错的(源码已换成哈夫曼),但**说明版本迭代时的真实情况是:zlib 才是更优选择**,详见第九节 | 已修正:现在默认算法确实是 zlib,该行连同阈值、"压不动自动回退"一起重写 |
| P3-6 | `README.md:131-132` | "运行测试 `pytest`" 但当时仓库里没有任何测试 | 已补上 `tests/`(本次),可把命令改为 `python -m unittest discover -s tests` |
| P3-7 | `README.md:39-66` | `--path images/` 这种带尾斜杠的写法现在可用(路径已归一化),`--list-root` 语义也已明确为"只列顶层" | 文档可补充说明 `--path` 支持文件与目录、目录按名导出到 `<导出目录>/<目录名>` |
| P3-8 | `README.md:110-123` | `files` 表字段列表原写 `compressed: 是否压缩`,与实际 schema 不符 | 本次已更新为 `freq_blob` / `compress_algo` |
| P3-9 | 原 `core.py:96` → 现 `core.py:152` | 建表 `permissions INTEGER DEFAULT 0644` 中的 `0644` 在 SQLite 里是**十进制 644**(= 0o1204),不是 0o644 | 本次已顺带改为 `420`(0o644 的十进制)并加注释。之所以顺手修是因为它就在我改的那行隔壁,且当前所有 INSERT 都显式传 `st_mode & 0o777`,所以无行为变化 |
| P3-10 | `core.py:81` | `clean()` 不清理 `sqlite_sequence`(`files` 表用了 AUTOINCREMENT,重复打包 id 会一直涨) | 需要时加 `DELETE FROM sqlite_sequence WHERE name='files'` |
| P3-11 | 包根 | 没有 `__init__.py` 导出(`__init__.py` 为空),`import sqlite_vfs; sqlite_vfs.SQLiteVFS` 会 `AttributeError`;也没有 `__main__.py`,`python -m sqlite_vfs` 不可用 | 在 `__init__.py` 里 re-export 公共 API |
| P3-12 | `core.py:872`、`:875` | `demo_usage()` 里 `SQLiteVFS(...)` 之后又调了一次 `vfs.connect()`(构造函数已经连过),会泄漏一个连接 | 删掉多余的 `connect()` |
| P3-13 | `core.py:863` | `export_filesystem` 打印"共导出 N 个文件/目录",但 N 来自 `export_directory` 的计数,只统计文件、不含目录 | 文案或计数二选一对齐 |
---
## 七、本次变更清单
| 文件 | 变更 |
|---|---|
| `src/sqlite_vfs/core.py` | 修复 P0-1~P0-4、P1-1~P1-8;新增 `VFSFormatError`、`_check_schema`、`_normalize`、`_escape_like`、`recount`、`_parent_of`、`_basename_of`、`_require_parent_dir`、`_insert_directory_row`、`_ensure_parent_dir`、`_apply_timestamps`、`_apply_permissions`、`_export_one`;`export_file`/`export_file_safe` 合并到 `_export_file`(删掉约 90 行重复代码)。后续为可选插件追加(见第九节):`readonly` / `check_same_thread` 两个构造参数、`_readonly_uri`、`@_writable` 写保护 |
| `src/sqlite_vfs/folder_packer.py` | 修复 P1-5、P1-9、P1-10、P1-11、P1-12;`--exclude` 改 glob + 目录剪枝;统计改为从库里 `recount()` |
| `src/sqlite_vfs/folder_unpacker.py` | `unpack_filesystem`/`unpack_directory` 支持并透传 `ignore_timestamps`;数据库检查失败改为抛异常(供 CLI 转退出码) |
| `src/sqlite_vfs/cli/packer.py` | 修复 P1-8;新增源目录/输出目录校验、覆盖警告、数据库异常处理 |
| `src/sqlite_vfs/cli/unpacker.py` | 修复 P0-3、P1-8;删除自引用 lambda;`--path` 不存在时明确报错 |
| `src/sqlite_vfs/contrib/`(新增,见第九节) | 可选插件子包:`__init__.py` + `fastapi_static.py` |
| `pyproject.toml` | 新增 `[project.optional-dependencies] fastapi`,把 FastAPI/Starlette 声明为可选依赖而不是固定依赖 |
| `README.md` | 更新 `files` 表字段列表(P3-8)、新增"可选集成:FastAPI 静态文件服务"章节;其余陈旧内容见第六节 |
| `tests/`(新增 12 个文件) | 270 个用例(核心回归 81 + 插件集成 51 + 可选性契约 5 + 去重/快照/完整性/迁移/增量 133,见第十一节) |
| `docs/`(新增 3 个文件) | `evidence-before.txt`、`evidence-after.txt`、`evidence-root-cause.txt` |
未改动:`huffman.py`(算法本身经 15 个用例验证无误)、`appcation.svfs`、`dist/`、`Scripts/`、`resources/`、`uv.lock`。
未执行 git commit。
---
## 八、后续建议
1. **先提交再改**:`huffman.py` 目前未被 git 跟踪,而 `core.py` 已经 import 它。
现在的仓库状态是"clone 下来直接 ImportError"。建议优先提交这两个文件。
2. **把测试纳入流程**:README 声称用 pytest 但仓库没有测试,现在有了。建议在 CI 里跑
`python -m unittest discover -s tests`,上面 12 个 P1 里有 8 个是"静默失败"类型,
只有自动化测试能拦住。
3. **批量提交与正确性的取舍**:`add_file` 现在每写一个文件提交一次。如果需要打包大目录树时的吞吐,
正确的做法是在 `pack_folder` 里显式包一个事务,并在结束时统一 commit + rollback,
而不是回到"忘了 commit、靠别人顺手提交"的状态。
4. **不要吞异常**:`except Exception: print(...)` 是这次最严重问题的放大器——
它把一个"必然失败"变成了"看起来成功"。建议至少把失败条目累计起来,在收尾时以非 0 退出码反馈
(`pack_folder` 现在会返回成功与否,CLI 也会 `sys.exit(1)`)。
5. **`permissions` 与跨平台**:Windows 上 `st_mode & 0o777` 是 Python 合成出来的值,
导出到 Linux 后权限可能与预期不符;`os.chmod` 在 Windows 也只对只读位有效。
若这个工具要跨平台搬文件,建议在文档里说明这一点。
---
## 九、后续追加(一):FastAPI 静态文件服务(可选插件)
在缺陷修复之外,追加了一个把 `.svfs` 当作静态文件目录服务出去的可选插件。
它**不改变**上面的结论,但确实动到了 `core.py`,因此在此登记。
| 文件 | 说明 |
|---|---|
| `src/sqlite_vfs/contrib/__init__.py` | 可选插件子包说明;刻意不导入任何插件模块 |
| `src/sqlite_vfs/contrib/fastapi_static.py` | `SVFSStaticFiles`:复用 `StaticFiles` 的路由决策,替换字节来源 |
| `pyproject.toml` | 新增 `[project.optional-dependencies] fastapi` |
| `src/sqlite_vfs/core.py` | 追加两个**向后兼容**的构造参数 `readonly`(默认 False)、`check_same_thread`(默认 True),新增只读 URI 构造函数与 `@_writable` 写保护装饰器。默认值不变,原有调用方行为不受影响 |
| `tests/test_fastapi_static.py` | 46 个用例:真实 ASGI 栈(FastAPI + TestClient)跑 HTTP 语义 |
| `tests/test_contrib_optional.py` | 5 个用例:断言"核心包不依赖第三方框架"这一契约,在有无 FastAPI 的环境下都会运行 |
关于"可选依赖"这一约束,用测试锁死而不是靠约定:
* `import sqlite_vfs` / `core` / `contrib` 后,`sys.modules` 里不能出现
`fastapi` / `starlette` / `anyio` / `pydantic`(子进程断言);
* `contrib` 子包不能自动导入插件模块(否则缺依赖时整包不可用);
* 缺少 FastAPI 时导入插件必须给出可照做的报错(含 `pip install "sqlite-vfs[fastapi]"`)。
验证结果:
```
项目 venv(未安装 FastAPI) : Ran 132 tests OK (skipped=47) # 插件用例整类跳过
临时 venv(已安装 FastAPI) : Ran 132 tests OK (skipped=1) # 只剩"缺少依赖提示"那一条被跳过
```
性能实测(733 KB 前端产物,Python 3.13,单线程串行)——这条比缺陷本身更影响选型,
已写进 README:
| | 数据库体积 | 打包耗时 | 服务 600KB 资源 | 顺序吞吐 |
|---|---|---|---|---|
| 不压缩 | 768 KB | 0.06 s | **7.5 ms** | 255 req/s |
| `--compress`(当时是哈夫曼) | **368 KB**(50%) | 0.80 s | **529 ms** | 30 req/s |
> 注意:下面这个"不压缩更合适"的结论是**针对哈夫曼**得出的,
> 不是针对压缩本身。第十节把默认算法换回 zlib 之后,结论完全反转。
结论是:**静态服务场景不压缩更合适**。哈夫曼解码是纯 Python 实现,大资源冷启动约慢 70 倍;
`304` 路径不受影响(不读内容也不解压,实测 3.4 ms),所以配合浏览器缓存时体验良好。
另外 `Range` 请求虽然语义正确,但也是"整份解压后再切片",成本与整份读取相同
(`read_file` 用 SQL `substr()` 下推可优化,见 P2-3)。
---
## 十、后续追加(二):把默认压缩算法换回 zlib
第九节那句"不压缩更合适"是**针对哈夫曼说的**,不是针对压缩本身。查 git 历史发现:
已提交版本用的本来就是 `zlib.compress(content, level=6)`,未提交的哈夫曼改造把它替换掉了,
README 里那句"使用 zlib 压缩,压缩级别为 6"因此变成了陈旧的错误说明(P3-5)。
用标准库 zlib 重新对拍之后,结论完全反转——**zlib 是纯赢,不是取舍**:
| 方案 | 数据库体积 | 打包耗时 | 服务 600KB 资源 | 304 | 顺序 100 次请求 |
|---|---|---|---|---|---|
| 不压缩 | 768 KB | 152 ms | 6.3 ms | 5.7 ms | 497 ms |
| **`--compress`(zlib,新默认)** | **88 KB** | 121 ms | **6.9 ms** | 5.7 ms | **431 ms** |
| `--compress --compress-algo huffman` | 368 KB | 885 ms | 587 ms | 4.3 ms | 1411 ms |
纯编解码层面对拍(同一份语料):
| 语料 | huffman | zlib level=6 |
|---|---|---|
| 真实代码文本 71.8 KB | 50.0 K(70%)· 解压 109 ms | **18.9 K(26%)· 0.6 ms** |
| 高度重复文本 640 KB | 331.1 K(52%)· 解压 772 ms | **1.9 K(0.3%)· 1.2 ms** |
| 近似构建产物 630 KB | 531.2 K(84%)· 解压 1176 ms | **309.3 K(49%)· 2.1 ms** |
| 小文件 1.5 KB | 1.8 K(120%,反而变大) | **0.05 K(3%)** |
哈夫曼只有熵编码、没有 LZ77 滑动窗口,所以吃不到"重复"这个语料里最主要的冗余;
它慢在两个数量级上,原因是纯 Python 位操作(0.5~0.8 MB/s vs zlib 的 300~600 MB/s)。
改动内容:
| 文件 | 说明 |
|---|---|
| `src/sqlite_vfs/compression.py`(新增) | 把算法选择收敛到一处:`compress_with` / `decompress_with` / `stored_size`,`core.py` 只按 `compress_algo` 分发 |
| `src/sqlite_vfs/core.py` | 新增 `compress_algorithm` 参数(默认 `'zlib'`)、`COMPRESS_MIN_SIZE`(zlib 64 字节 / huffman 1025 字节,保持旧行为),读写分支改为调用 `compression`;构造时校验算法名 |
| `src/sqlite_vfs/cli/packer.py` | 新增 `--compress-algo {zlib,huffman}`,默认 zlib |
| `README.md` | 重写「要不要用 --compress」(三方案实测表 + 结论反转)、修正 P3-5 与「注意事项 2」(时间戳其实是本地时间,不是 UTC)、新增「旧格式迁移」配方 |
| `tests/` | 新增/改写 17 个用例:zlib 与 huffman 各自往返、zlib 体积必须小于 huffman、构造期与读库时的未知算法报错、阈值行为(zlib 64 字节 / huffman 1025 字节)、CLI `--compress-algo` 端到端、huffman 资源包在插件下仍可服务(含 Range 与 304) |
**huffman 保留不删的理由是向后兼容**:任何用当前工作区版本打过的包,`compress_algo='huffman'`,
删掉解码器会让那些库再也读不出来。新包不建议再用它。
顺带把 P1-7 从"旧库只能报错"提升为"旧库可迁移":旧格式的压缩数据本来就是 zlib,
所以迁移只需补两列 + 把 `compressed` 映射成算法名,**数据一个字节都不用动**。
这条配方有回归用例 `test_legacy_pack_is_readable_after_documented_migration` 兜底,
不是只写在文档里的空话。
---
## 十一、后续追加(三):存储层改造(去重 / 快照 / 完整性 / 增量)
这次是**格式变更**,不只是修缺陷,所以单独登记。
### 做了什么
| 能力 | 实现 |
|---|---|
| 内容寻址与去重 | 内容从 `files` 搬到 `blobs(hash PRIMARY KEY)`,`hash = sha256(原始字节)`;相同内容只存一份,跨路径、跨快照共享 |
| 快照 / 时间旅行 | `files` 加 `snapshot_id`,唯一约束从 `path` 改为 `UNIQUE(snapshot_id, path)`;新增 `snapshots` 表(含每个版本的统计与摘要) |
| 防篡改 | 每文件 sha256 + 快照级摘要(对按 path 排序的全部条目做 sha256)+ `signatures` 表存 HMAC-SHA256 签名;校验可在只读连接上完成 |
| 增量打包 | `pack_folder(incremental=True)` 按 mtime+size(或 `rehash=True` 时按 sha256)比对,只写变化项,删除磁盘上已消失的路径并回收 blob |
| 迁移 | `migration.migrate()` + `vfsmigrate`:v1/v2 → v3,先备份、在临时文件上重建、原子替换;也可 `--output` 输出到新文件而不动原库 |
### 有意做出的破坏性变更
1. **格式变更(v2 → v3)无法用 `ALTER TABLE` 完成**,因为 `UNIQUE(snapshot_id, path)`
改不了约束,必须重建表。按之前"不静默改用户数据"的原则,这里给的是显式迁移工具,
而不是打开时自动升级。旧库打开会报错并直接给出迁移命令。
2. **`get_file_info()` 不再返回 `content` / `freq_blob` / `compress_algo`**,改为返回
`content_hash`,并新增 `get_blob_info()` 做内省。
这么改的收益是实打实的:盘点时发现 `exists()` / `is_dir()` / `list_directory()`
都在用 `SELECT *` 把整个 BLOB 拖进内存,拆表顺手把这个也修了。
代价是 16 处测试断言要改(已全部改完)。
### 过程中发现并修掉的问题
除了计划内的改动,实施时又踩到几个真问题:
| # | 问题 | 影响 |
|---|---|---|
| 1 | `get_stats()` 在把 total_* 从 `filesystem_metadata` 挪进 `snapshots` 后忘了透出 | 统计恒为 0——测试直接抓到 |
| 2 | 默认快照解析成"最早"而不是"最新" | 只读会话验签落在错误的快照上,验签必然失败 |
| 3 | 增量打包对已存在的路径直接 `add_file` | 撞 `UNIQUE(snapshot_id, path)`,被 `except` 吞成"未能写入",**文件更新静默丢失**;已加 `replace=True` |
| 4 | 增量不处理"文件变目录 / 目录变文件" | 类型不一致的陈旧条目会残留 |
| 5 | `SQLiteVFS(snapshot_id=999)` 不校验存在性 | `vfsunpack --snapshot 999` 静默导出 0 个文件、退出码 0(正是本报告一直在批的那类失败) |
| 6 | `verify()` 对"没有记录摘要"的快照判为通过 | 摘要被抹掉就能掩盖篡改;改为保守的判失败 |
| 7 | 只读迁移在 Windows 上 `os.replace` 失败 | 只读文件无法被替换;补了 `--output` 模式,让只读介质上的库也能迁移 |
### 验证
```
项目 venv(未安装 FastAPI): Ran 270 tests OK (skipped=52)
临时 venv(已安装 FastAPI): Ran 270 tests OK (skipped=1)
```
用例分布:核心回归 81 + 可选插件 51 + 可选性契约 5 + 去重 16 + 快照 22 + 完整性 24 +
迁移 22 + 增量 21 + CLI 扩展 28(部分重叠计数,合计 270)。
端到端冒烟(真实 CLI):
* 打包含两个内容相同的文件 → `files` 3 行、`blobs` **2 行**(相同内容合并)
* 旧格式库 → 报错并给出 `vfsmigrate` 命令,退出码 1
* `vfsmigrate` → 生成 `.bak`、摘要落库、解包退出码 0 且内容一致
* `vfsverify --check-content` → 通过;绕过 API 改一个 `permissions` → 摘要不一致、退出码 1
* `--snapshot v2` 增量 → 旧版本原样保留,新版本"新增 0 / 更新 1 / 未变 1 / 删除 1"
### 新增的命令行工具
| 命令 | 用途 |
|---|---|
| `vfspack` | 新增 `--incremental` / `--rehash` / `--snapshot NAME` |
| `vfsunpack` | 新增 `--snapshot ID` / `--list-snapshots` |
| `vfsverify` | 列快照、打印摘要、`--check-content` 权威校验、`--sign` / `--key` 签名验签、`--json` 输出 |
| `vfsmigrate` | v1/v2 → v3,支持原地(带 `.bak`)或 `--output` 输出到新文件 |
---
## 十二、后续追加(四):按 hash 发布到对象存储
起因是查"把 .svfs 放对象存储、按 HTTP Range 按页读"这条路时发现的坑:CPython 的 `sqlite3`
**没有注册自定义 VFS 的 API**,要按页远程读必须引入 APSW 之类的编译扩展;实测那条路也不便宜
(不做预读时读 1 MB 要 265 次往返、约 13 秒 @50ms RTT)。
换个思路就绕开了整个问题:库里的内容本来就按 sha256 寻址,那就把每个 blob 导出成
一个以 hash 命名的对象,再把目录结构导出成一份小清单。
| 文件 | 内容 |
|---|---|
| `src/sqlite_vfs/publish.py`(新增) | `export_objects()` / `build_manifest()` 与 `ObjectFetcher`(取回 + 逐对象校验 + 缓存自愈) |
| `src/sqlite_vfs/mime.py`(新增) | 把 Content-Type 覆盖表从插件里抽出来共用,避免发布工具误引入 fastapi 依赖 |
| `src/sqlite_vfs/core.py` | 追加内容寻址读原语 `all_entries()` / `list_blobs()` / `read_blob()`;移除未使用的 `iter_blob_contents()` |
| `src/sqlite_vfs/cli/publish.py`、`cli/fetch.py`(新增) | `vfspublish` / `vfsfetch` 两个入口 |
| `tests/test_publish.py`(新增) | 29 个用例,含真实 HTTP 服务器、损坏检测、缓存自愈、硬链接、统计口径 |
实测对比(1108 KB 包、61 文件、真实 HTTP 服务器):
| 操作 | 按 hash 取对象 | 远程 VFS 按页读 |
|---|---|---|
| 列目录 | 1 次请求 / 13 KB(gzip 0.34 KB) | 5 次请求 / 20 KB |
| 取 0.8 KB 文件 | 1 次请求 / 0.8 KB | 9 次请求 / 36 KB |
| 取 1 MB 文件 | 1 次请求 / 1 MB | 265 次请求 / 1060 KB |
过程中的三个发现:
1. **对象缓存是用户级的**(`~/.cache/svfs-objects`),这让我的 CLI 测试一开始不隔离,
出现"下载 0 个对象"的假失败——其实是跨测试的内容寻址去重生效了。测试已改为指定临时缓存。
这条本身说明了该设计在跨项目复用上的效果,但也提醒:默认用户级缓存的工具,
测试必须显式隔离。
2. **硬链接模式不能还原时间戳/权限**。硬链接与缓存对象共用 inode,`utime`/`chmod`
会连带改到缓存,破坏"缓存对象不可变"的前提;现在 `--link` 跳过这两步并写进文档。
3. **构造期校验失败会泄漏连接**(core 的真缺陷,不是测试问题)。`SQLiteVFS(db, snapshot_id=999)`
在 `__init__` 里校验快照并抛 `FileNotFoundError`,但那时 `connect()` 已经成功,
连接没人释放——Windows 上库文件从此删不掉(就是测试临时目录残留暴露出来的)。
已按 `connect()` 里同样的方式补上 `close()`,并加了回归用例。
另外把 MIME 覆盖表从 `contrib/fastapi_static.py` 抽到 `mime.py`:原来的位置会让发布工具
为了一个 Content-Type 表而依赖 fastapi(可选依赖),这属于"顺手踩到的耦合",
抽出来两边共用一份,也避免了两处漂移。
签名这条线也顺下来了:清单里带快照摘要,所以可以用 `vfsverify --sign` 签完摘要后把清单一起发布——
客户端既逐个校验对象的 sha256,也能用公开摘要核对整棵树的版本。
---
## 十三、后续追加(五):清理 P2-4、补齐直接单测、定下算法扩展点
按"会不会咬人"的顺序做收尾,这一节的四件事都是纯收益。
### 1. P2-4 清掉了:1240 条弃用警告 → 0
向 sqlite3 传 `datetime` 对象自 Python 3.12 起弃用(将来会移除),实测**一次测试跑
产生 1240 条 `DeprecationWarning`**。修法是显式转换:
```python
def _db_time(value):
"""必须显式转换:sqlite3 的默认 datetime adapter 已弃用。"""
return None if value is None else value.isoformat(sep=" ")
```
关键点是**盘上格式一个字节都没变**——当年 adapter 的实现就是 `val.isoformat(" ")`,
所以现有库、旧包、以及依赖 `modified_time` 的快照摘要全都不受影响。两条用例钉住:
一条用 `simplefilter("error", DeprecationWarning)` 把所有写入路径跑一遍(再出现就直接失败),
一条断言落库文本仍然匹配 `^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?$`。
### 2. 补直接单测:这些 API 之前只有间接覆盖
盘点发现 66 个公开 API 里,多数只通过 packer/CLI/publish 路径被间接覆盖过。
端到端测试价值更高,但**重构核心方法时没有安全网**(比如把 `read_file` 改成分块读取,
现有用例不一定抓得住细微语义变化)。补了 30 个直接用例,覆盖:
* `read_file` 的 offset/size 边界(0、越界、等于 EOF、超出剩余)+ 压缩内容下的边界;
* `list_blobs` / `read_blob` / `all_entries` / `get_blob_info` 四个内容寻址原语;
* `add_directory` 的返回值与 `replace` 语义、失败不留半条记录;
* `clean()` 真的清空全部五张表;`exists/is_dir/is_file` 三者一致(`is_file` 之前
在 src 里**没有任何调用方**,属于死 API,现在至少被钉住了)。
顺手加固了两个契约边界:
* `read_file(offset=-3)` 之前会走 Python 切片语义**从末尾取**——对调用方是陷阱,现在报错;
* `get_blob_info(目录)` 之前返回一堆 `None` 字段,现在返回 `None`。
### 3. 算法层定成明确的扩展点(配合"自定义算法 + 后续加密")
`compression.py` 的 docstring 现在写清了契约与扩展步骤(往 `ALGORITHMS` 加名字、
两个分支、`COMPRESS_MIN_SIZE`、补往返用例),并点出一个对加密有用的既成事实:
**`blobs.freq_blob` 本质是通用的"每块附加数据"槽**,加密的 nonce + 认证标签可以直接
放这里,不必改 schema。新增 `tests/test_compression.py`(12 个用例)把契约钉住,
其中一条专门断言"zlib 对重复文本的体积优势必须在一个数量级以上"——
防止默认算法被悄悄改回哈夫曼。
### 4. 加密的设计说明:两件事必须先定
写成 `docs/encryption-design.md`,因为它们在写代码前就要决定,事后发现要返工:
* **去重与随机 nonce 冲突**。`blobs.hash` 是 `sha256(明文)` 且为主键,去重全靠它。
确定性(收敛)加密能保留去重与稳定的对象名,代价是确认攻击;每块随机 nonce 更安全,
但对象名不能再是明文 hash,`vfsfetch` 的"名字即 sha256"校验和 CDN 缓存语义都要跟着改。
* **标准库没有密码学原语**。所以"尽可能高效"指向 `cryptography` 的 AES-GCM(AES-NI,
1–5 GB/s)而不是自己写——纯 Python AES 慢三个数量级,自己写密码学本身也是风险。
另外三条纪律:先压缩再加密、必须做认证、密钥不要进库。
### 扫尾
`pyproject.toml` 里 `[tool.uv.workspace] members = ["devmo"]` 是死配置(uv 容忍它,
所以不是阻塞项,已删并留注释);`requires-python` 从拍出来的 `>=3.7` 改成按实际 API
推导的 `>=3.9`(`usedforsecurity=` 与 `randbytes` 各要 3.9),并注明这是代码审查结论、
未在 3.9 上实测;`main.py` 从"引用早已不存在的路径"改成自给自足的端到端演示
(打包→校验→快照→发布→取回→逐字节比对),并加了冒烟测试——**上一版就是没人跑它才烂掉的**。
`dist/` 里 2025-11 的过期 wheel 我没动:那是本地未跟踪的产物,`uv build` 可重新生成,
删它属于你的决定。