Files
svfs/tests/test_snapshots.py
T
chakcy ecdfacc620 fix+feat: 修复打包/解包主链路,并把存储层重建为内容寻址 + 快照
起始状态:主链路 100% 不可用,而且失败被静默吞掉——打包后库里 0 个文件、
界面仍打印"文件数量: N";解包导出 0 个文件并在磁盘上生成几百层 sub/sub/sub 垃圾目录。

缺陷修复(4 个 P0 + 12 个 P1,逐条见 BUG_REPORT.md):
- 建表列名 compresse_algo 与 INSERT/read_file 用的 compress_algo 不一致
- list_directory 按 path 而非 parent_path 过滤,导致导出无限自递归
- CLI --ignore-timestamps 把 export_filesystem 赋成引用自身的 lambda
- add_file 缺少 commit(直接调用 API 时静默丢数据)
- LIKE 前缀误伤兄弟目录(删 doc 连带删 docs);mv_dir 二次改名
- 失败路径一律返回 exit code 0;--exclude 是子串匹配而非 glob
- FolderPacker.add_file 反向调用 export_file_safe,会用库内内容覆盖源文件

存储层(schema v3,格式变更,提供 vfsmigrate 迁移):
- 内容移入 blobs(hash PRIMARY KEY):相同内容只存一份,跨路径/跨快照共享
- files 的唯一约束改为 UNIQUE(snapshot_id, path):一个库存多个版本
- 完整性:每文件 sha256 + 快照级摘要 + HMAC-SHA256 签名,只读连接即可验证
- 增量打包:按 mtime+size 或 sha256 只写变化项,未变文件不重写不重压
- get_file_info 不再返回 content(元数据查询不该搬 BLOB)

新增能力:
- 可选 FastAPI 静态文件插件(extras,核心仍零第三方依赖)
- 内容寻址发布到对象存储 + 逐对象校验的取回客户端
- 压缩默认改回 zlib:实测比自研哈夫曼体积小 1/3~1/170、快数百倍

其它:
- P2-4:时间戳显式转字符串,清掉 1240 条 DeprecationWarning(盘上格式不变)
- read_file 负数 offset/size 改为报错(原先会走切片语义从末尾取)
- requires-python 从 >=3.7 改为按实际 API 推导的 >=3.9

测试:0 → 343 个用例,纯标准库 unittest,有/无 FastAPI 两种环境都跑通。
证据见 docs/evidence-*.txt。
2026-09-14 00:11:42 +08:00

309 lines
11 KiB
Python
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.
"""快照与时间旅行。
一个库里可以存同一棵树的历史版本:``files`` 表的唯一约束从 ``path``
变成 ``UNIQUE(snapshot_id, path)``,所以同一路径在不同快照里各有一行。
"""
import os
import unittest
import helpers
from sqlite_vfs.core import SQLiteVFS
class SnapshotLifecycleTest(helpers.VFSTestCase):
def _vfs(self, **kwargs):
vfs = SQLiteVFS(self.path("s.svfs"), **kwargs)
self.addCleanup(vfs.close)
return vfs
def test_first_write_creates_a_default_snapshot(self):
vfs = self._vfs()
self.assertIsNone(vfs.snapshot_id)
src = helpers.write_file(self.path("a.txt"), b"a")
with helpers.quiet():
vfs.add_file(src, "a.txt")
self.assertIsNotNone(vfs.snapshot_id)
snapshots = vfs.list_snapshots()
self.assertEqual(1, len(snapshots))
self.assertEqual(vfs.snapshot_id, snapshots[0]["id"])
def test_add_vfs_metadata_names_the_default_snapshot(self):
vfs = self._vfs()
vfs.add_vfs_metadata("my-pack")
self.assertEqual("my-pack", vfs.list_snapshots()[0]["name"])
def test_readonly_never_creates_a_snapshot(self):
"""只读打开不能因为"读一下"就写库——否则只读挂载会失败。"""
db = self.path("ro.svfs")
vfs = SQLiteVFS(db)
vfs.close()
before = helpers.md5_of(db)
readonly = SQLiteVFS(db, readonly=True)
self.addCleanup(readonly.close)
self.assertIsNone(readonly._active_snapshot())
self.assertEqual([], readonly.list_directory("/"))
self.assertIsNone(readonly.get_file_info("anything"))
self.assertEqual(0, readonly.get_stats()["total_files"])
self.assertEqual(before, helpers.md5_of(db), "只读会话改动了库文件")
def test_create_snapshot_becomes_active_and_is_listed(self):
vfs = self._vfs()
src = helpers.write_file(self.path("a.txt"), b"a")
with helpers.quiet():
vfs.add_file(src, "a.txt")
first = vfs.snapshot_id
second = vfs.create_snapshot("v2")
self.assertNotEqual(first, second)
self.assertEqual(second, vfs.snapshot_id, "新建的快照应当成为当前快照")
self.assertEqual(["v2"], [s["name"] for s in vfs.list_snapshots() if s["id"] == second])
self.assertEqual(0, helpers.count_files(vfs), "新快照本身是空的")
def test_use_snapshot_switches_the_view(self):
vfs = self._vfs()
src = helpers.write_file(self.path("a.txt"), b"a")
with helpers.quiet():
vfs.add_file(src, "a.txt")
first = vfs.snapshot_id
vfs.create_snapshot("v2")
vfs.use_snapshot(first)
self.assertEqual(first, vfs.snapshot_id)
self.assertTrue(vfs.exists("a.txt"))
def test_use_missing_snapshot_raises(self):
vfs = self._vfs()
with self.assertRaises(FileNotFoundError):
vfs.use_snapshot(999)
def test_constructor_releases_the_connection_when_snapshot_is_invalid(self):
"""构造期抛异常也要释放连接,否则 Windows 上库文件被锁住删不掉。
注意:POSIX 上删除已打开的文件总是成功,所以这条断言主要是在 Windows 上生效
(那正是它被发现的地方)。
"""
db = self.path("locked.svfs")
SQLiteVFS(db).close() # 先建出一个合法的库
with self.assertRaises(FileNotFoundError):
SQLiteVFS(db, snapshot_id=999)
os.remove(db) # 句柄没释放的话这里会 PermissionError
self.assertFalse(os.path.exists(db))
def test_reopen_defaults_to_the_latest_snapshot(self):
db = self.path("multi.svfs")
vfs = SQLiteVFS(db)
src = helpers.write_file(self.path("a.txt"), b"a")
with helpers.quiet():
vfs.add_file(src, "a.txt")
vfs.create_snapshot("v2")
vfs.close()
reopened = SQLiteVFS(db)
self.addCleanup(reopened.close)
self.assertEqual(2, reopened.get_stats()["snapshot_id"])
self.assertEqual("v2", reopened.get_stats()["snapshot_name"])
def test_delete_snapshot_removes_entries_and_signature_rows(self):
vfs = self._vfs()
src = helpers.write_file(self.path("a.txt"), b"a")
with helpers.quiet():
vfs.add_file(src, "a.txt")
vfs.create_snapshot("v2")
vfs.sign("k")
self.assertEqual(1, len(vfs.list_signatures(2)))
deleted = vfs.delete_snapshot(2)
self.assertEqual(0, deleted, "v2 是空快照,没有条目被删")
self.assertEqual([1], [s["id"] for s in vfs.list_snapshots()])
self.assertIsNone(vfs.snapshot_id, "当前快照被删后应当需要重新解析")
class TimeTravelTest(helpers.VFSTestCase):
def _pack_v1_then_v2(self):
"""构造两个版本的库:v1 有 a.txt=first,v2 里 a.txt=second 且多了 b.txt。"""
db = self.path("hist.svfs")
vfs = SQLiteVFS(db)
self.addCleanup(vfs.close)
vfs.add_vfs_metadata("history")
first = helpers.write_file(self.path("a1.txt"), b"first")
with helpers.quiet():
vfs.add_file(first, "a.txt")
vfs.recount()
vfs.create_snapshot("v2", copy_entries=True)
vfs.remove("a.txt")
second = helpers.write_file(self.path("a2.txt"), b"second")
extra = helpers.write_file(self.path("b.txt"), b"b")
with helpers.quiet():
vfs.add_file(second, "a.txt")
vfs.add_file(extra, "b.txt")
vfs.recount()
return vfs
def test_same_path_has_different_content_per_snapshot(self):
vfs = self._pack_v1_then_v2()
self.assertEqual(b"second", vfs.read_file("a.txt"))
vfs.use_snapshot(1)
self.assertEqual(b"first", vfs.read_file("a.txt"))
def test_new_files_are_invisible_in_the_older_snapshot(self):
vfs = self._pack_v1_then_v2()
self.assertTrue(vfs.exists("b.txt"))
vfs.use_snapshot(1)
self.assertFalse(vfs.exists("b.txt"))
def test_copy_entries_forks_from_the_parent(self):
vfs = self._pack_v1_then_v2()
vfs.use_snapshot(1)
self.assertEqual({b"first"}, {vfs.read_file("a.txt")})
self.assertEqual({"a.txt"}, helpers.all_paths(vfs))
def test_blobs_are_shared_between_snapshots(self):
vfs = self._pack_v1_then_v2()
# first / second / b 三份内容
self.assertEqual(3, helpers.blob_count(vfs))
def test_each_snapshot_has_its_own_digest(self):
vfs = self._pack_v1_then_v2()
digests = {s["id"]: s["digest"] for s in vfs.list_snapshots()}
self.assertEqual(2, len(digests))
self.assertTrue(all(digests.values()), "每个快照都应当有摘要")
self.assertNotEqual(digests[1], digests[2], "内容不同,摘要必须不同")
def test_counts_are_per_snapshot(self):
vfs = self._pack_v1_then_v2()
self.assertEqual(2, vfs.get_stats()["total_files"])
vfs.use_snapshot(1)
self.assertEqual(1, vfs.get_stats()["total_files"])
def test_export_respects_the_active_snapshot(self):
vfs = self._pack_v1_then_v2()
out_v2 = self.path("out_v2")
with helpers.quiet():
vfs.export_filesystem(out_v2)
vfs.use_snapshot(1)
out_v1 = self.path("out_v1")
with helpers.quiet():
vfs.export_filesystem(out_v1)
self.assertEqual(
{"a.txt": b"first"}, {k: v for k, v in helpers.read_tree(out_v1).items()}
)
self.assertEqual(
{"a.txt": b"second", "b.txt": b"b"},
{k: v for k, v in helpers.read_tree(out_v2).items()},
)
def test_list_directory_is_snapshot_scoped(self):
vfs = self._pack_v1_then_v2()
self.assertEqual(
["a.txt", "b.txt"], sorted(r["path"] for r in vfs.list_directory("/"))
)
vfs.use_snapshot(1)
self.assertEqual(
["a.txt"], sorted(r["path"] for r in vfs.list_directory("/"))
)
def test_remove_in_one_snapshot_does_not_affect_the_other(self):
vfs = self._pack_v1_then_v2()
vfs.remove("a.txt")
self.assertFalse(vfs.exists("a.txt"))
vfs.use_snapshot(1)
self.assertEqual(b"first", vfs.read_file("a.txt"))
def test_mkdir_in_one_snapshot_does_not_leak(self):
vfs = self._pack_v1_then_v2()
vfs.mk_dir("only-here")
self.assertTrue(vfs.is_dir("only-here"))
vfs.use_snapshot(1)
self.assertFalse(vfs.exists("only-here"))
def test_snapshot_id_can_be_chosen_at_construction(self):
vfs = self._pack_v1_then_v2()
db = vfs.db_path
first = SQLiteVFS(db, snapshot_id=1)
self.addCleanup(first.close)
self.assertEqual(b"first", first.read_file("a.txt"))
self.assertEqual(1, first.get_stats()["snapshot_id"])
class UnpackerSnapshotTest(helpers.VFSTestCase):
def _two_versions(self, db_name="u.svfs"):
"""v1 有 one.txt,v2(派生自 v1)删掉 one.txt 并加上 two.txt。"""
db = self.path(db_name)
vfs = SQLiteVFS(db)
vfs.add_vfs_metadata("u")
one = helpers.write_file(self.path("one.txt"), b"one")
with helpers.quiet():
vfs.add_file(one, "one.txt")
vfs.recount()
vfs.create_snapshot("v2", copy_entries=True)
vfs.remove("one.txt")
two = helpers.write_file(self.path("two.txt"), b"two")
with helpers.quiet():
vfs.add_file(two, "two.txt")
vfs.recount()
return vfs
def test_unpacker_list_root_is_snapshot_scoped(self):
from sqlite_vfs.folder_unpacker import FolderUnpacker
vfs = self._two_versions()
self.addCleanup(vfs.close)
self.assertEqual(
["two.txt"], [r["path"] for r in FolderUnpacker(vfs).list_root()]
)
vfs.use_snapshot(1)
self.assertEqual(
["one.txt"], [r["path"] for r in FolderUnpacker(vfs).list_root()]
)
def test_unpacker_export_follows_the_snapshot(self):
from sqlite_vfs.folder_unpacker import FolderUnpacker
vfs = self._two_versions("u2.svfs")
self.addCleanup(vfs.close)
out = self.path("out")
with helpers.quiet():
FolderUnpacker(vfs).unpack_filesystem(out)
self.assertEqual({"two.txt": b"two"}, helpers.read_tree(out))
vfs.use_snapshot(1)
out1 = self.path("out1")
with helpers.quiet():
FolderUnpacker(vfs).unpack_filesystem(out1)
self.assertEqual({"one.txt": b"one"}, helpers.read_tree(out1))
def test_unpacker_check_database_counts_only_the_active_snapshot(self):
from sqlite_vfs.folder_unpacker import FolderUnpacker
vfs = self._two_versions("u3.svfs")
self.addCleanup(vfs.close)
# v2 只有一个条目(two.txt),v1 也只有一个(one.txt);
# 两个快照加起来是 2 条,所以"报 1"就证明统计是分快照的
ok, message = FolderUnpacker(vfs).check_database()
self.assertTrue(ok, message)
self.assertIn("快照 2", message)
self.assertIn("1 个文件/目录", message)
vfs.use_snapshot(1)
ok, message = FolderUnpacker(vfs).check_database()
self.assertTrue(ok, message)
self.assertIn("快照 1", message)
self.assertIn("1 个文件/目录", message)
if __name__ == "__main__":
unittest.main()