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。
This commit is contained in:
2026-09-14 00:11:42 +08:00
parent 161ac9129f
commit ecdfacc620
33 changed files with 10063 additions and 632 deletions
+163 -5
View File
@@ -1,8 +1,166 @@
#!/usr/bin/env python3
"""端到端冒烟示例:把"打包 → 校验 → 快照 → 发布 → 取回"跑一遍。
直接运行即可(全部在临时目录里操作,不碰仓库里的任何文件):
python main.py
原来的版本拿仓库自带的 appcation.svfs 做演示,但那个库是**旧格式**(schema 1),
当前版本会直接拒绝并提示用 `vfsmigrate` 迁移——所以示例改成自给自足的一棵树,
走到哪儿都能跑通。
要了解完整能力看 README.md;要了解踩过的坑与取舍看 BUG_REPORT.md。
"""
import contextlib
import io
import os
import shutil
import sys
import tempfile
from sqlite_vfs import publish
from sqlite_vfs.core import SQLiteVFS
from sqlite_vfs.folder_packer import FolderPacker
vfs = SQLiteVFS("appcation.svfs")
print(vfs.get_file_info(".gitignore"))
for item in vfs.list_directory("/"):
print(f"{'DIR' if item['is_directory'] else 'FILE'}: {item['path']}")
vfs.close()
def step(text):
print(f"\n=== {text} ===")
@contextlib.contextmanager
def quiet():
"""屏蔽打包过程的逐文件输出,让演示保持可读。"""
with contextlib.redirect_stdout(io.StringIO()):
yield
def write_file(root, rel_path, data):
target = os.path.join(root, *rel_path.split("/"))
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "wb") as f:
f.write(data)
return target
def read_tree(root):
out = {}
for base, _dirs, files in os.walk(root):
for name in files:
path = os.path.join(base, name)
with open(path, "rb") as f:
out[os.path.relpath(path, root).replace(os.sep, "/")] = f.read()
return out
def main():
work = tempfile.mkdtemp(prefix="svfs_demo_")
try:
# ---- 1. 造一棵源目录:含两份内容完全相同的文件,用来展示去重 ----
src = os.path.join(work, "dist")
write_file(src, "index.html", b"<!doctype html><title>demo</title>" * 8)
write_file(src, "app.js", b"console.log('hello');\n" * 40)
write_file(src, "assets/logo.svg", b"<svg/>" * 50)
write_file(src, "assets/vendor.js", b"export const V = 1;\n" * 60)
write_file(src, "admin/vendor.js", b"export const V = 1;\n" * 60)
os.makedirs(os.path.join(src, "empty_dir"), exist_ok=True)
# ---- 2. 打包(zlib 压缩 + 内容寻址去重)----
step("打包:整个文件夹 → 单个 .svfs")
db = os.path.join(work, "app.svfs")
vfs = SQLiteVFS(db, compress=True)
with quiet():
FolderPacker(vfs, fs_name="demo").pack_folder(src)
vfs.close()
vfs = SQLiteVFS(db, readonly=True)
stats = vfs.get_stats()
blobs = vfs.list_blobs()
unique_bytes = sum(b["size"] for b in blobs)
stored_bytes = sum(b["stored_size"] for b in blobs)
print(f" 包大小: {os.path.getsize(db) / 1024:.1f} KB")
print(f" 文件数: {stats['total_files']} 原始总大小: {stats['total_size']} 字节")
print(
f" 去重后唯一内容: {len(blobs)} 份 / {unique_bytes} 字节"
f"(两份 vendor.js 共用一份,省 {stats['total_size'] - unique_bytes} 字节)"
)
print(f" 压缩后库内实际占用: {stored_bytes} 字节")
vfs.close()
# ---- 3. 完整性校验 + 签名(只读即可验证)----
step("完整性校验与签名")
vfs = SQLiteVFS(db)
report = vfs.verify(check_content=True)
print(
f" 校验通过: {report['ok']}"
f"(逐文件重算 sha256:{report['content_checked']} 个)"
)
print(f" 快照摘要: {report['actual_digest'][:32]}…")
vfs.sign("demo-key", key_id="demo")
vfs.close()
readonly = SQLiteVFS(db, readonly=True)
print(f" 只读连接上验签: {readonly.verify_signature('demo-key')['ok']}")
readonly.close()
# ---- 4. 快照:同一路径存两个版本 ----
step("版本历史(快照)")
vfs = SQLiteVFS(db, compress=True)
parent = vfs._active_snapshot()
vfs.create_snapshot("v2", parent_id=parent, copy_entries=True)
vfs.remove("app.js")
new_app = write_file(work, "app-v2.js", b"console.log('v2');\n" * 40)
with quiet():
vfs.add_file(new_app, "app.js")
vfs.recount()
print(f" v2 里的 app.js: {vfs.read_file('app.js')[:24]!r}")
vfs.use_snapshot(parent)
print(f" v1 里的 app.js: {vfs.read_file('app.js')[:24]!r}")
for snapshot in vfs.list_snapshots():
print(
f" 快照 {snapshot['id']} {snapshot['name']}: "
f"{snapshot['total_files']} 个文件,摘要 {snapshot['digest'][:12]}…"
)
vfs.close()
# ---- 5. 发布成"按 hash 取对象 + 清单" ----
step("发布到对象存储(对象名 = 内容 sha256)")
objects = os.path.join(work, "public")
# 注意默认发布**最新**快照(此时是 v2,app.js 已被替换)。这里显式指定 v1,
# 因为第 7 步要拿它和源目录比对;要发布 v2 就去掉 snapshot_id。
result = publish.export_objects(db, objects, snapshot_id=parent)
print(f" 发布快照: {result['snapshot_id']}(默认会发布最新的那个)")
print(f" 对象: {result['objects']} 个,共 {result['objects_bytes']} 字节")
print(
f" 清单: {os.path.basename(result['manifest'])}"
f"({result['manifest_bytes']} 字节,含全部路径、大小与 sha256)"
)
# ---- 6. 从"对象 + 清单"取回,并逐个校验 sha256 ----
step("取回并逐个校验 sha256")
manifest = publish.load_manifest(result["manifest"])
restored = os.path.join(work, "restored")
fetch_stats = publish.ObjectFetcher(
manifest, local_root=objects, cache_dir=os.path.join(work, "cache")
).fetch(restored)
print(
f" 取回 {fetch_stats.files} 个文件,"
f"校验 {fetch_stats.verified} 个对象"
)
# ---- 7. 逐字节比对 ----
step("与源目录逐字节比对")
if read_tree(src) == read_tree(restored):
print(" 一致 ✓")
else:
print(" 不一致 ✗")
return 1
print(f"\n全部演示完成(文件都在临时目录里,退出时删除)")
return 0
finally:
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())