Files
svfs/BUG_REPORT.md
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

50 KiB
Raw Permalink Blame History

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)

# 修复前的建表语句(注意 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
  • 根因:
    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,所以不想为了跑测试引入网络依赖。

# 在仓库根目录
.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-1P0-4、P1-1P1-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。修法是显式转换:

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 可重新生成, 删它属于你的决定。