- README:新增「数据能力」四节(内容寻址去重 / 快照与时间旅行 / 完整性校验与签名 / 增量打包)与「发布到对象存储」一节,附实测对比表;重写「要不要用 --compress」 (结论从"别用"反转为"默认就该开")、「迁移旧格式」;修正项目结构与字段表 - BUG_REPORT.md:13 节,含 P0/P1 逐条分析、P2/P3 清单、五批后续追加的记录, 以及过程中被推翻的两处结论(第九节的压缩建议、P3-1 的 uv sync 判断) - docs/encryption-design.md:加密的设计说明——去重与随机 nonce 的冲突、 标准库没有密码学原语、以及现有 freq_blob 可作为每块附加数据的槽位 - docs/evidence-*.txt:各阶段的测试运行证据(修复前 / 修复后 / 双环境)
50 KiB
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(INSERTcompress_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 - 根因:
把实例属性覆盖成 lambda,而 lambda 体内取的还是这个实例属性,于是自己调自己。 实例属性一旦赋值还会永久污染该实例。
unpacker.sqlite_vfs.export_filesystem = ( lambda export_root: unpacker.sqlite_vfs.export_filesystem(export_root) ) - 影响:
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 - 根因与影响:
parent_path = dest || '/' || parent_path,对顶层条目(parent_path为空串)得到dest/, 多了个尾斜杠 → 之后按parent_path = 'dest'查询不到这些条目;- 两条 UPDATE 分别用
is_directory = 1和is_directory = 0过滤,逻辑不一致、易漏; - 从不创建
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 |
两个设计取舍值得说明:
- 目录语义类用例用原始 SQL 造数据(
helpers.seed_rows),只写新旧 schema 共有的列。 这样"list_directory语义错误"不会因为"列名拼写错误"而连带失败,失败原因单一、定位快。 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-1VFSFormatError、_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。
八、后续建议
- 先提交再改:
huffman.py目前未被 git 跟踪,而core.py已经 import 它。 现在的仓库状态是"clone 下来直接 ImportError"。建议优先提交这两个文件。 - 把测试纳入流程:README 声称用 pytest 但仓库没有测试,现在有了。建议在 CI 里跑
python -m unittest discover -s tests,上面 12 个 P1 里有 8 个是"静默失败"类型, 只有自动化测试能拦住。 - 批量提交与正确性的取舍:
add_file现在每写一个文件提交一次。如果需要打包大目录树时的吞吐, 正确的做法是在pack_folder里显式包一个事务,并在结束时统一 commit + rollback, 而不是回到"忘了 commit、靠别人顺手提交"的状态。 - 不要吞异常:
except Exception: print(...)是这次最严重问题的放大器—— 它把一个"必然失败"变成了"看起来成功"。建议至少把失败条目累计起来,在收尾时以非 0 退出码反馈 (pack_folder现在会返回成功与否,CLI 也会sys.exit(1))。 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 输出到新文件而不动原库 |
有意做出的破坏性变更
- 格式变更(v2 → v3)无法用
ALTER TABLE完成,因为UNIQUE(snapshot_id, path)改不了约束,必须重建表。按之前"不静默改用户数据"的原则,这里给的是显式迁移工具, 而不是打开时自动升级。旧库打开会报错并直接给出迁移命令。 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):
- 打包含两个内容相同的文件 →
files3 行、blobs2 行(相同内容合并) - 旧格式库 → 报错并给出
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 |
过程中的三个发现:
- 对象缓存是用户级的(
~/.cache/svfs-objects),这让我的 CLI 测试一开始不隔离, 出现"下载 0 个对象"的假失败——其实是跨测试的内容寻址去重生效了。测试已改为指定临时缓存。 这条本身说明了该设计在跨项目复用上的效果,但也提醒:默认用户级缓存的工具, 测试必须显式隔离。 - 硬链接模式不能还原时间戳/权限。硬链接与缓存对象共用 inode,
utime/chmod会连带改到缓存,破坏"缓存对象不可变"的前提;现在--link跳过这两步并写进文档。 - 构造期校验失败会泄漏连接(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 可重新生成,
删它属于你的决定。