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