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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user