Files
svfs/tests/test_publish.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

562 lines
22 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.
"""按内容寻址发布:导出对象 + 清单,客户端按清单取回并校验。
这里刻意用**真实的 HTTP 服务器**跑一遍(而不是只读本地文件),
因为这条路线的卖点正是"普通的静态托管 / CDN 就能用",
顺带也能量出"取一个文件到底要几次请求、多少字节"。
"""
import functools
import hashlib
import http.server
import json
import os
import shutil
import threading
import time
import unittest
import helpers
from sqlite_vfs import publish
from sqlite_vfs.core import SQLiteVFS
import zlib
class CountingHandler(http.server.SimpleHTTPRequestHandler):
"""记录请求次数与字节数的静态文件服务。"""
requests = None # 由测试注入的列表
def log_message(self, *args): # 安静点
pass
def do_GET(self):
CountingHandler.requests.append(self.path)
super().do_GET()
def send_head(self):
path = self.translate_path(self.path)
if os.path.isdir(path):
# 不列目录,避免噪音
self.send_error(404, "no listing")
return None
return super().send_head()
class HttpServerFixture(helpers.VFSTestCase):
def serve(self, directory):
CountingHandler.requests = []
handler = functools.partial(CountingHandler, directory=directory)
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
def serve():
try:
httpd.serve_forever()
except OSError:
# 关停时 select 作用在已关闭的 socket 上(Windows 上的收尾竞态),
# 与被测代码无关,静音以免掩盖真实错误
pass
thread = threading.Thread(target=serve, daemon=True)
thread.start()
self.addCleanup(httpd.shutdown)
self.addCleanup(httpd.server_close)
host, port = httpd.server_address[:2]
return f"http://{host}:{port}"
class ExportTest(helpers.VFSTestCase):
def _pack(self, tree=None, compress=True, name="p.svfs"):
src = helpers.make_tree(
self.path("src"),
tree
or {
"index.html": b"<div id=app></div>",
"a.txt": b"same-content",
"dup.txt": b"same-content",
"sub/b.js": b"console.log('hello');\n" * 40,
"empty_dir": None,
},
)
db = self.path(name)
helpers.pack(src, db, compress=compress, name="demo-pack")
return src, db
def test_objects_are_deduplicated(self):
_src, db = self._pack()
out = self.path("objects")
result = publish.export_objects(db, out)
self.assertEqual(4, result["files"], "4 个文件")
self.assertEqual(
3, result["objects"], "a.txt 与 dup.txt 内容相同 → 只导出 3 个对象"
)
self.assertGreater(result["dedup_saved"], 0)
def test_object_content_is_the_original_bytes(self):
"""库里内容是压缩的,导出必须是原始字节——消费方不该需要我们的解压代码。"""
_src, db = self._pack(compress=True)
out = self.path("objects")
publish.export_objects(db, out)
vfs = SQLiteVFS(db, readonly=True)
try:
self.assertEqual("zlib", vfs.get_blob_info("sub/b.js")["compress_algo"])
digest = vfs.get_blob_info("sub/b.js")["hash"]
finally:
vfs.close()
expected = b"console.log('hello');\n" * 40
blob = os.path.join(out, "blobs", digest[:2], digest)
with open(blob, "rb") as f:
self.assertEqual(expected, f.read())
self.assertEqual(digest, hashlib.sha256(expected).hexdigest())
def test_object_name_is_its_hash(self):
_src, db = self._pack()
out = self.path("objects")
publish.export_objects(db, out)
blob_root = os.path.join(out, "blobs")
found = 0
for root, _dirs, files in os.walk(blob_root):
for name in files:
with open(os.path.join(root, name), "rb") as f:
self.assertEqual(
name, hashlib.sha256(f.read()).hexdigest(), f"{name} 名字与内容不符"
)
found += 1
self.assertEqual(3, found)
def test_savings_are_reported_in_separate_units(self):
"""去重与压缩必须分开报:把"压缩省下的"算进"去重省下的"会误导选型。"""
_src, db = self._pack()
out = self.path("objects")
result = publish.export_objects(db, out)
manifest = publish.load_manifest(os.path.join(out, publish.MANIFEST_NAME))
vfs = SQLiteVFS(db, readonly=True)
try:
blobs = vfs.list_blobs()
finally:
vfs.close()
unique = sum(b["size"] for b in blobs)
stored = sum(b["stored_size"] for b in blobs)
self.assertEqual(unique, result["unique_bytes"])
self.assertEqual(stored, result["stored_bytes"])
self.assertEqual(
unique, result["objects_bytes"], "导出的对象正好是去重后的原始字节"
)
self.assertEqual(
max(0, manifest["stats"]["total_size"] - unique), result["dedup_saved"]
)
self.assertEqual(max(0, unique - stored), result["compression_saved"])
# 两个口径加起来才是"相对原始文件总量省下的全部"
self.assertLessEqual(
result["dedup_saved"] + result["compression_saved"],
manifest["stats"]["total_size"],
)
def test_manifest_contents(self):
_src, db = self._pack()
out = self.path("objects")
publish.export_objects(db, out)
manifest = publish.load_manifest(os.path.join(out, publish.MANIFEST_NAME))
self.assertEqual(publish.MANIFEST_FORMAT, manifest["format"])
self.assertEqual(publish.MANIFEST_VERSION, manifest["version"])
self.assertEqual("demo-pack", manifest["name"])
self.assertEqual(1, manifest["snapshot"]["id"])
self.assertEqual(64, len(manifest["snapshot"]["digest"]))
entries = manifest["entries"]
self.assertTrue(entries["empty_dir"]["dir"], "空目录也要出现在清单里")
self.assertEqual("text/javascript", entries["sub/b.js"]["mime"])
self.assertEqual(len(b"console.log('hello');\n" * 40), entries["sub/b.js"]["size"])
self.assertEqual(entries["a.txt"]["hash"], entries["dup.txt"]["hash"])
self.assertEqual(
{"files": 4, "directories": 2},
{k: manifest["stats"][k] for k in ("files", "directories")},
)
def test_manifest_is_deterministic_for_the_same_snapshot(self):
"""清单里不放"生成时间",所以同一快照导出两次得到完全一样的清单(可 diff、可签名)。"""
_src, db = self._pack()
first = self.path("out1")
second = self.path("out2")
publish.export_objects(db, first)
publish.export_objects(db, second)
self.assertEqual(
helpers.read_bytes(os.path.join(first, publish.MANIFEST_NAME)),
helpers.read_bytes(os.path.join(second, publish.MANIFEST_NAME)),
)
def test_publish_is_read_only_on_the_pack(self):
_src, db = self._pack()
before = helpers.md5_of(db)
publish.export_objects(db, self.path("objects"))
self.assertEqual(before, helpers.md5_of(db))
def test_refuses_non_empty_output_without_overwrite(self):
_src, db = self._pack()
out = self.path("objects")
publish.export_objects(db, out)
with self.assertRaises(FileExistsError):
publish.export_objects(db, out)
publish.export_objects(db, out, overwrite=True)
def test_shard_zero_puts_objects_flat(self):
_src, db = self._pack()
out = self.path("objects")
publish.export_objects(db, out, shard=0)
names = os.listdir(os.path.join(out, "blobs"))
self.assertEqual(3, len(names))
self.assertTrue(all(len(n) == 64 for n in names))
def test_publish_a_specific_snapshot(self):
src, db = self._pack(tree={"a.txt": b"v1"})
vfs = SQLiteVFS(db, compress=True)
try:
vfs.create_snapshot("v2", copy_entries=True)
vfs.remove("a.txt")
with helpers.quiet():
vfs.add_file(helpers.write_file(self.path("a2.txt"), b"v2"), "a.txt")
vfs.recount()
finally:
vfs.close()
out = self.path("objects")
publish.export_objects(db, out, snapshot_id=1)
manifest = publish.load_manifest(os.path.join(out, publish.MANIFEST_NAME))
self.assertEqual(1, manifest["snapshot"]["id"])
digest = manifest["entries"]["a.txt"]["hash"]
with open(os.path.join(out, "blobs", digest[:2], digest), "rb") as f:
self.assertEqual(b"v1", f.read())
def test_manifest_of_a_nonexistent_snapshot_is_rejected(self):
_src, db = self._pack()
with self.assertRaises(FileNotFoundError):
publish.export_objects(db, self.path("objects"), snapshot_id=99)
class FetchLocalTest(helpers.VFSTestCase):
def _export(self, tree=None):
src = helpers.make_tree(
self.path("src"),
tree
or {
"index.html": b"<div id=app></div>",
"a.txt": b"same-content",
"dup.txt": b"same-content",
"sub/b.js": b"console.log(1)",
"empty_dir": None,
},
)
db = self.path("p.svfs")
helpers.pack(src, db, compress=True)
out = self.path("objects")
publish.export_objects(db, out)
return src, out
def _fetch(self, objects, target, cache=None, **kwargs):
manifest = publish.load_manifest(os.path.join(objects, publish.MANIFEST_NAME))
fetcher = publish.ObjectFetcher(
manifest, local_root=objects, cache_dir=cache, **kwargs
)
return fetcher, fetcher.fetch(target, paths=kwargs.pop("paths", None))
def test_roundtrip_restores_the_tree(self):
src, objects = self._export()
target = self.path("restored")
_fetcher, stats = self._fetch(objects, target)
helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(target))
self.assertEqual(4, stats.files)
self.assertEqual(2, stats.directories, "empty_dir 与 sub 都要建出来")
self.assertEqual(3, stats.objects_downloaded, "一个对象只取一次(两个路径共用)")
def test_mtime_and_mode_are_preserved(self):
src, objects = self._export()
target = self.path("restored")
self._fetch(objects, target)
for name in ("a.txt", "sub/b.js"):
with self.subTest(name=name):
source = os.path.join(src, *name.split("/"))
restored = os.path.join(target, *name.split("/"))
self.assertAlmostEqual(
os.path.getmtime(source), os.path.getmtime(restored), delta=2
)
def test_partial_fetch_by_file_and_by_prefix(self):
_src, objects = self._export()
manifest = publish.load_manifest(os.path.join(objects, publish.MANIFEST_NAME))
fetcher = publish.ObjectFetcher(manifest, local_root=objects)
self.assertEqual({"sub/b.js"}, fetcher.resolve(["sub/b.js"]))
self.assertEqual({"sub/b.js"}, fetcher.resolve(["sub"]))
self.assertEqual({"sub/b.js"}, fetcher.resolve(["sub/"]))
self.assertEqual(
{"a.txt", "sub/b.js"},
fetcher.resolve(["a.txt", "sub"]),
"没被选中的同级文件不该被带进来",
)
self.assertEqual(
{"index.html", "a.txt", "dup.txt", "sub/b.js"},
fetcher.resolve(None),
"不指定 --path 就是全部文件(不含目录)",
)
with self.assertRaises(FileNotFoundError):
fetcher.resolve(["nope.txt"])
def test_partial_fetch_writes_only_selected(self):
_src, objects = self._export()
target = self.path("partial")
manifest = publish.load_manifest(os.path.join(objects, publish.MANIFEST_NAME))
stats = publish.ObjectFetcher(manifest, local_root=objects).fetch(
target, paths=["sub"]
)
self.assertEqual(1, stats.files)
self.assertEqual(
{"sub/b.js"}, {k for k, v in helpers.read_tree(target).items() if v is not None}
)
def test_cache_is_reused_and_verified(self):
_src, objects = self._export()
cache = self.path("cache")
_fetcher, first = self._fetch(objects, self.path("r1"), cache=cache)
self.assertEqual(3, first.objects_downloaded)
self.assertEqual(0, first.objects_from_cache)
_fetcher, second = self._fetch(objects, self.path("r2"), cache=cache)
self.assertEqual(0, second.objects_downloaded, "第二次应当全部命中缓存")
self.assertEqual(3, second.objects_from_cache)
self.assertEqual(3, second.verified)
def test_corrupted_cache_entry_is_healed(self):
_src, objects = self._export()
cache = self.path("cache")
self._fetch(objects, self.path("r1"), cache=cache)
# 把缓存里的某个对象改坏
blob_root = os.path.join(cache, "blobs")
victim = None
for root, _dirs, files in os.walk(blob_root):
for name in files:
victim = os.path.join(root, name)
with open(victim, "wb") as f:
f.write(b"corrupted!!")
_fetcher, stats = self._fetch(objects, self.path("r2"), cache=cache)
self.assertEqual(1, stats.objects_downloaded, "坏掉的缓存应当被重新下载")
self.assertEqual(2, stats.objects_from_cache)
def test_link_mode_hardlinks_into_the_cache(self):
if os.name == "nt" and not hasattr(os, "link"):
self.skipTest("平台不支持硬链接")
_src, objects = self._export()
cache = self.path("cache")
target = self.path("linked")
manifest = publish.load_manifest(os.path.join(objects, publish.MANIFEST_NAME))
publish.ObjectFetcher(
manifest, local_root=objects, cache_dir=cache, link=True
).fetch(target)
linked = os.path.join(target, "a.txt")
digest = hashlib.sha256(b"same-content").hexdigest()
cached = os.path.join(cache, "blobs", digest[:2], digest)
self.assertEqual(
os.stat(cached).st_ino, os.stat(linked).st_ino, "应当共用同一个 inode"
)
class FetchOverHttpTest(HttpServerFixture):
def _export_and_serve(self):
src = helpers.make_tree(
self.path("src"),
{
"index.html": b"<div id=app></div>",
"big.bin": b"x" * (256 * 1024),
"sub/b.js": b"console.log(1)",
},
)
db = self.path("p.svfs")
helpers.pack(src, db, compress=True)
objects = self.path("objects")
publish.export_objects(db, objects)
return src, objects, self.serve(objects), db
def test_fetch_from_a_real_http_server(self):
src, objects, base, db = self._export_and_serve()
manifest_url = f"{base}/manifest.json"
target = self.path("restored")
manifest = publish.load_manifest(manifest_url)
fetcher = publish.ObjectFetcher(
manifest, base_url=base, cache_dir=self.path("cache")
)
started = time.time()
stats = fetcher.fetch(target)
elapsed = time.time() - started
helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(target))
self.assertEqual(3, stats.files)
self.assertEqual(3, stats.objects_downloaded)
requests = CountingHandler.requests
manifest_bytes = os.path.getsize(os.path.join(objects, publish.MANIFEST_NAME))
total_bytes = os.path.getsize(db)
print(
f"\n [HTTP 实测] 请求 {len(requests)} 次"
f"(清单 1 + 对象 {stats.objects_downloaded})"
f",下载 {stats.bytes_downloaded / 1024:.1f} KB"
f"(清单 {manifest_bytes / 1024:.1f} KB)"
f",耗时 {elapsed * 1000:.0f}ms"
)
self.assertEqual(
1 + stats.objects_downloaded,
len(requests),
"总请求数应当是 1 次清单 + 每个对象一次",
)
self.assertLess(manifest_bytes, total_bytes, "清单必须远小于整包")
def test_missing_object_is_reported_clearly(self):
_src, objects, base, _db = self._export_and_serve()
manifest = publish.load_manifest(f"{base}/manifest.json")
# 删掉一个对象,模拟"CDN 上少传了一个文件"
digest = manifest["entries"]["sub/b.js"]["hash"]
os.remove(os.path.join(objects, "blobs", digest[:2], digest))
fetcher = publish.ObjectFetcher(manifest, base_url=base)
with self.assertRaises(Exception):
fetcher.fetch(self.path("restored"))
def test_tampered_object_over_http_is_rejected(self):
_src, objects, base, _db = self._export_and_serve()
manifest = publish.load_manifest(f"{base}/manifest.json")
digest = manifest["entries"]["sub/b.js"]["hash"]
victim = os.path.join(objects, "blobs", digest[:2], digest)
with open(victim, "wb") as f:
f.write(b"console.log(2)") # 同样长度,但内容不同
fetcher = publish.ObjectFetcher(manifest, base_url=base, verify=True)
with self.assertRaises(ValueError) as ctx:
fetcher.fetch(self.path("restored"))
self.assertIn("校验失败", str(ctx.exception))
# 关掉校验就会放过(默认必须是开的)
manifest2 = publish.load_manifest(f"{base}/manifest.json")
fetcher2 = publish.ObjectFetcher(manifest2, base_url=base, verify=False)
fetcher2.fetch(self.path("unverified"))
with open(os.path.join(self.path("unverified"), "sub", "b.js"), "rb") as f:
self.assertEqual(b"console.log(2)", f.read())
def test_manifest_from_url_mismatch_is_reported(self):
_src, objects, base, _db = self._export_and_serve()
with self.assertRaises(Exception):
publish.load_manifest(f"{base}/nope.json")
def test_bad_manifest_format_is_rejected(self):
bad = helpers.write_file(
self.path("bad.json"), json.dumps({"format": "something-else"}).encode()
)
with self.assertRaises(ValueError) as ctx:
publish.load_manifest(bad)
self.assertIn("svfs-objects", str(ctx.exception))
old = helpers.write_file(
self.path("old.json"),
json.dumps(
{"format": publish.MANIFEST_FORMAT, "version": 999}
).encode(),
)
with self.assertRaises(ValueError):
publish.load_manifest(old)
class PublishCliTest(helpers.VFSTestCase):
PUBLISH = "sqlite_vfs.cli.publish"
FETCH = "sqlite_vfs.cli.fetch"
def _pack(self):
src = helpers.make_tree(
self.path("src"), {"a.txt": b"aaa", "sub/b.txt": b"bbb"}
)
db = self.path("p.svfs")
helpers.run_cli("sqlite_vfs.cli.packer", src, db, "--compress")
return src, db
def _fetch(self, manifest, target, *extra):
"""CLI 默认用的是用户级缓存目录,测试必须换成临时目录才可复现。"""
return helpers.run_cli(
self.FETCH,
manifest,
target,
"--cache",
self.path("cache"),
*extra,
)
def test_publish_and_fetch_via_cli(self):
src, db = self._pack()
objects = self.path("objects")
published = helpers.run_cli(self.PUBLISH, db, objects)
self.assertEqual(0, published.returncode, helpers.cli_output(published))
self.assertIn("发布完成", published.stdout)
self.assertTrue(os.path.exists(os.path.join(objects, "manifest.json")))
target = self.path("restored")
fetched = self._fetch(os.path.join(objects, "manifest.json"), target)
self.assertEqual(0, fetched.returncode, helpers.cli_output(fetched))
helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(target))
def test_publish_missing_db_exits_nonzero(self):
result = helpers.run_cli(self.PUBLISH, self.path("nope.svfs"), self.path("o"))
self.assertNotEqual(0, result.returncode)
self.assertNotIn("Traceback", helpers.cli_output(result))
def test_publish_to_nonempty_dir_requires_overwrite(self):
_src, db = self._pack()
objects = self.path("objects")
helpers.run_cli(self.PUBLISH, db, objects)
again = helpers.run_cli(self.PUBLISH, db, objects)
self.assertNotEqual(0, again.returncode)
self.assertIn("错误", again.stdout)
forced = helpers.run_cli(self.PUBLISH, db, objects, "--overwrite")
self.assertEqual(0, forced.returncode, helpers.cli_output(forced))
def test_manifest_only_mode(self):
_src, db = self._pack()
objects = self.path("objects")
result = helpers.run_cli(self.PUBLISH, db, objects, "--no-objects")
self.assertEqual(0, result.returncode, helpers.cli_output(result))
self.assertTrue(os.path.exists(os.path.join(objects, "manifest.json")))
self.assertFalse(os.path.exists(os.path.join(objects, "blobs")))
def test_fetch_json_output(self):
_src, db = self._pack()
objects = self.path("objects")
helpers.run_cli(self.PUBLISH, db, objects)
result = self._fetch(
os.path.join(objects, "manifest.json"), self.path("restored"), "--json"
)
self.assertEqual(0, result.returncode, helpers.cli_output(result))
stats = json.loads(result.stdout)
self.assertEqual(2, stats["files"])
self.assertEqual(2, stats["objects_downloaded"])
def test_fetch_missing_path_exits_nonzero(self):
_src, db = self._pack()
objects = self.path("objects")
helpers.run_cli(self.PUBLISH, db, objects)
result = self._fetch(
os.path.join(objects, "manifest.json"),
self.path("restored"),
"--path",
"nope.txt",
)
self.assertNotEqual(0, result.returncode)
self.assertIn("清单里没有", helpers.cli_output(result))
if __name__ == "__main__":
unittest.main()