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

167 lines
6.3 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.
#!/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
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())