起始状态:主链路 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。
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""快照与时间旅行。
|
||
|
||
一个库里可以存同一棵树的历史版本:``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()
|