chakcy 1620d3eb5a docs: 补 README 能力章节、缺陷报告与两份设计说明
- README:新增「数据能力」四节(内容寻址去重 / 快照与时间旅行 / 完整性校验与签名 /
  增量打包)与「发布到对象存储」一节,附实测对比表;重写「要不要用 --compress」
  (结论从"别用"反转为"默认就该开")、「迁移旧格式」;修正项目结构与字段表
- BUG_REPORT.md:13 节,含 P0/P1 逐条分析、P2/P3 清单、五批后续追加的记录,
  以及过程中被推翻的两处结论(第九节的压缩建议、P3-1 的 uv sync 判断)
- docs/encryption-design.md:加密的设计说明——去重与随机 nonce 的冲突、
  标准库没有密码学原语、以及现有 freq_blob 可作为每块附加数据的槽位
- docs/evidence-*.txt:各阶段的测试运行证据(修复前 / 修复后 / 双环境)
2026-09-14 00:11:53 +08:00
2025-11-07 20:52:44 +08:00
2025-11-07 20:52:44 +08:00
2026-01-23 17:50:27 +08:00

SQLite Virtual File System (SVFS)

一个基于 SQLite 的虚拟文件系统实现,可以将本地文件夹打包成单个 SQLite 数据库文件,并支持从数据库中解包还原文件。

特性

  • 高效存储: 使用 SQLite 数据库存储文件和目录结构
  • 压缩支持: 可选压缩,默认 zlib(标准库 C 实现),压不动的内容自动回退为原样存储
  • 内容寻址去重: 相同内容只存一份,多个路径/多个版本共享同一份数据
  • 快照与版本历史: 一个库里存同一棵树的历史版本,可以按版本导出、按版本校验
  • 完整性校验: 每个文件带 sha256,快照级摘要 + HMAC-SHA256 签名,支持只读校验
  • 增量打包: 只写入变化的条目,未变的文件不重写、不重压、不重复存储
  • 可发布到对象存储: 导出成「按 sha256 命名的对象 + 一份小清单」,普通静态托管 / CDN 即可分发, 客户端按清单取回并逐个校验(不需要任何编译扩展)
  • 完整元数据: 保留文件权限、创建时间、修改时间等元数据
  • 目录结构保持: 完整的目录层级结构保持
  • 简单易用: 四个命令行工具(打包 / 解包 / 校验 / 迁移),零第三方依赖
  • 跨平台: 支持 Windows、Linux、macOS
  • 可选集成: FastAPI 静态文件服务作为 extras 提供,核心不依赖任何第三方框架

安装

从源码安装

# 克隆仓库
git clone <repository-url>
cd svfs

# 使用 uv 安装(推荐)
uv sync

# 或者使用 pip
pip install -e .

使用 uv

项目使用 uv 作为包管理器和构建工具,确保已安装 uv:

# 安装 uv
curl -LsSf https://astral.sh/uv/install.sh | sh

# 在项目目录中安装依赖
uv sync

使用方法

打包文件夹

# 基本用法
vfspack <源文件夹路径> <输出数据库路径>

# 启用压缩(默认 zlib,推荐)
vfspack ./my_folder ./output.db --compress

# 指定压缩算法(zlib 默认 / huffman 仅用于兼容早期版本打的包)
vfspack ./my_folder ./output.db --compress --compress-algo huffman

# 指定文件系统名称
vfspack ./my_folder ./output.db --name "MyFileSystem"

# 排除特定文件模式(glob 语义,作用于相对路径与文件名)
vfspack ./my_folder ./output.db --exclude "*.tmp" "*.log"

# 增量打包:只写变化的条目(默认按 mtime+size 判定)
vfspack ./my_folder ./output.db --compress --incremental

# 增量 + 按 sha256 逐文件比对(更可靠,代价是要把文件都读一遍)
vfspack ./my_folder ./output.db --compress --incremental --rehash

# 打成新快照:保留历史版本,只写入相对上一版的变化
vfspack ./my_folder ./output.db --snapshot v2

解包文件系统

# 解包整个文件系统
vfsunpack <数据库路径> <目标文件夹路径>

# 仅列出根目录内容
vfsunpack ./output.db ./output --list-root

# 解包特定文件或目录
vfsunpack ./output.db ./output --path "docs/readme.txt"
vfsunpack ./output.db ./output --path "images/"

# 忽略时间戳错误(仅导出文件内容)
vfsunpack ./output.db ./output --ignore-timestamps

# 列出所有快照
vfsunpack ./output.db ./output --list-snapshots

# 导出指定快照(默认取最新快照)
vfsunpack ./output.db ./output --snapshot 1

校验完整性

# 元数据级校验:摘要是否被改动、blob 是否缺失、大小是否一致
vfsverify ./output.db

# 权威校验:逐文件重算 sha256(代价是读一遍全部内容)
vfsverify ./output.db --check-content

# 列出快照与各自摘要
vfsverify ./output.db --list-snapshots

# 签名与验签(HMAC-SHA256,对称密钥)
vfsverify ./output.db --sign --key my-secret --key-id release-1
vfsverify ./output.db --key my-secret --key-id release-1

# 输出 JSON,便于接到 CI 或外部签名工具
vfsverify ./output.db --check-content --json

可选集成:FastAPI 静态文件服务

把 .svfs 直接当作 FastAPI / Starlette 的静态文件目录来用, 适合"把前端构建产物打包成一个文件分发"的场景(单文件部署、资源包随镜像走)。

FastAPI 属于可选依赖:核心功能只用标准库,import sqlite_vfs 不会导入任何第三方框架。

pip install "sqlite-vfs[fastapi]"

# 打包前端构建产物(不需要 --compress 时见下方性能说明)
vfspack ./dist static.svfs
from fastapi import FastAPI
from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles

app = FastAPI()
statics = SVFSStaticFiles("static.svfs")   # 默认 html=True, spa=True, readonly=True
app.mount("/", statics, name="static")

@app.on_event("shutdown")
def _close():
    statics.close()

行为说明:

  • SPA 回退:找不到且不像静态资源(末段路径无扩展名、且客户端接受 HTML)时返回 index.html, 前端路由(/settings、/user/42)可直接刷新。需要严格 404 就传 spa=False。
  • 目录索引:/sub 会 307 重定向到 /sub/,然后返回 sub/index.html; 与 StaticFiles 一致,索引文件名固定为 index.html。传 html=False 关闭。
  • 缓存:每个响应带 ETag 与 Last-Modified,命中 If-None-Match / If-Modified-Since 时返回 304(此时不会读取文件内容)。需要强缓存可传 max_age=3600。
  • Range:支持单段 Range(视频/断点续传),返回 206;多段请求按 RFC 允许的方式退回整份内容。 越界返回 416,语法错误则忽略该头。
  • 只读打开:默认以 mode=ro 打开数据库,因此资源包放在只读挂载/容器镜像层里也能用, 且服务过程永远不会改动资源包(有测试用 md5 校验这一点)。
  • 安全:路径只在数据库内做精确匹配,不做磁盘路径拼接,.. 之类无法越出资源包。

要不要用 --compress?

要,默认就该开。 用默认的 zlib 算法时,压缩几乎不花钱(实测 733 KB 前端产物, Python 3.13,TestClient 单线程串行):

方案 数据库体积 打包耗时 服务 600KB 资源 304 条件请求 顺序 100 次请求
不压缩 768 KB 152 ms 6.3 ms 5.7 ms 497 ms
--compress(zlib,默认) 88 KB 121 ms 6.9 ms 5.7 ms 431 ms
--compress --compress-algo huffman 368 KB 885 ms 587 ms 4.3 ms 1411 ms

zlib 把体积压到 1/8.7,而读写延迟基本不变——解压是 C 实现的 300~600 MB/s, 630 KB 只要 2 ms,比省下来的 I/O 还划算;打包反而更快,因为要写的字节更少。

huffman 是纯 Python 实现的历史算法(没有 LZ77,压不动"重复"),现在只为读得懂 早期版本打的包而保留:同一份数据它体积是 zlib 的 4 倍,解压慢 85 倍。新包不要用它。

  • 304 路径不需要读内容也不需要解压,配合浏览器缓存时几乎零成本;
  • 小于 64 字节的文件不压缩(压了反而更大),库内会记成 none。

迁移旧格式

格式演进:

schema 内容存放 说明
v1 files.content + files.compressed(布尔) 最早的格式,压缩用 zlib
v2 files.content + freq_blob + compress_algo 引入可选压缩算法
v3 blobs(内容寻址)+ files.content_hash 当前格式:去重、快照、完整性校验

当前版本打开 v1 / v2 的库会直接报错并给出迁移命令,不会静默失败:

错误: old.db 是旧格式的虚拟文件系统,当前版本(schema 3)需要 blobs + snapshots 结构。
请先迁移并保留备份:vfsmigrate "old.db"
vfsmigrate ./old.db              # 原地迁移,先复制一份 ./old.db.bak
vfsmigrate ./old.db --output ./new.db   # 输出到新文件,原库一个字节都不动

迁移会读旧库、在旁边的临时文件上重建、校验通过后原子替换,所以中途失败不会留下半成品。 迁移过程顺带按 sha256 去重:历史库里同一份内容存了多次会被合并(vfsmigrate 会报告省下多少字节)。 迁移完可以用 vfsverify --check-content 复核每一个文件的内容。

数据能力

内容寻址与去重

文件内容不放在 files 表里,而是存进 blobs 表,主键是 sha256(原始字节), 所以相同内容天然只存一份:

vfs.add_file("copy1/logo.png", "copy1/logo.png")
vfs.add_file("copy2/logo.png", "copy2/logo.png")
# blobs 表里只有一行,两个路径的 content_hash 相同

去重是按内容算的,与路径无关,所以这些场景都省空间:同一个文件出现在多个目录、 多个快照共享没变过的文件、重新打包一棵没变的树(内容命中的 blob 连压缩都跳过)。

代价是引用计数语义:删掉一个路径不会立刻删掉内容,只有没有任何快照再引用它时 才会被回收(gc_blobs(),在 remove() / clear_snapshot() / delete_snapshot() 里自动执行)。

快照与版本历史

一个库里可以装同一棵树的历史版本。files 表的唯一约束是 UNIQUE(snapshot_id, path),所以同一路径在不同快照里各有一行:

vfs = SQLiteVFS("history.svfs")
vfs.add_vfs_metadata("my-app")

# 第一版
vfs.create_snapshot("v1")          # 或直接写入,会自动建默认快照
vfs.add_file("dist/app.js", "app.js")
vfs.recount()

# 第二版:从 v1 派生(只复制元数据行,内容 blob 共享,所以几乎不占空间)
vfs.create_snapshot("v2", parent_id=1, copy_entries=True)
vfs.remove("app.js")
vfs.add_file("dist/app-v2.js", "app.js")
vfs.recount()

vfs.use_snapshot(1)                # 切回第一版
vfs.read_file("app.js")            # 拿到的是第一版的内容

for snapshot in vfs.list_snapshots():
    print(snapshot["id"], snapshot["name"], snapshot["digest"])

配合增量打包就是一个完整的版本流程:

vfspack ./dist app.svfs --compress                 # 第一版
vfspack ./dist app.svfs --compress --snapshot v2   # 新版本,只写入变化的部分
vfsunpack app.svfs ./restore --snapshot 1          # 按版本导出

完整性校验与签名

每个文件都带 sha256,每个快照都有一份摘要(对"按 path 排序后的全部条目"做 sha256, 字段包括路径、类型、大小、时间戳、权限、内容哈希):

report = vfs.verify()                      # 元数据级:便宜
report["ok"], report["errors"]

report = vfs.verify(check_content=True)    # 权威级:逐文件重算 sha256
report["content_checked"]

vfs.sign("my-secret", key_id="release-1")  # HMAC-SHA256
vfs.verify_signature("my-secret")["ok"]

两个层级必须分清:

  • verify() 只看元数据。同大小的内容被替换时它发现不了(因为 file_size 没变)。
  • verify(check_content=True) 会重算每个文件的 sha256,能发现任何内容改动,代价是读一遍全部数据。

vfsverify 的退出码可以直接用于 CI:校验失败或验签失败都返回非 0。

签名用标准库的 HMAC-SHA256,是对称的——验证方必须持有同一个密钥。 需要非对称信任链(谁都能验、只有持有私钥的人能签)时,用 --json 把摘要交给 GPG / cosign 之类的外部工具签名。

另外要注意摘要的语义:走 API 的写入会在 recount() 时刷新摘要,所以摘要防的是 "绕过 API 的改动"。要当防篡改凭证用,请在打包完成后立刻签名或把摘要记到库外。

增量打包

vfspack ./dist app.svfs --compress --incremental

只写入变化的条目,磁盘上已消失的路径会被删除,并汇报四个计数:

增量结果: 新增 1,更新 1,未变 1,删除 1

变更判定默认用 mtime + size:不读文件内容,所以很快,代价是理论上会漏掉 "同一秒内、同样大小"的修改(这类场景请用 --rehash,它按 sha256 逐个文件比对)。

未变的文件不会被重写、重压或重新存储;内容相同的新路径会直接复用已有 blob。

发布到对象存储(按 hash 取对象)

.svfs 是一个文件,但不必把它当"远程文件系统"用。更简单也更快的一条路: 既然库里的内容本来就按 sha256 寻址,就把每个 blob 直接导出成一个以 hash 命名的对象, 再把目录结构导出成一份小清单。

# 导出成「对象 + 清单」
vfspublish ./app.svfs ./public

# 同步到对象存储 / 静态托管(任选其一)
aws s3 sync ./public s3://my-bucket/app --cache-control "public, max-age=31536000, immutable"
rclone copy ./public remote:bucket/app

# 客户端取回(对象默认从清单同级目录取,也可以指向 CDN)
vfsfetch https://cdn.example.com/app/manifest.json ./dist
vfsfetch https://cdn.example.com/app/manifest.json ./dist --path sub/ --workers 16

产出结构:

public/
├── blobs/cd/cd8f...e21            # 对象名 = 内容的 sha256,内容是原始(解压后)字节
├── blobs/7a/7a41...09c
└── manifest.json                  # 路径 → {hash, size, mime, mtime, mode},含空目录

这条路带来的性质:

  • 可端到端校验:对象名就是内容的 sha256,客户端逐个核对,CDN 或中间人替换不掉内容 (把某个对象改成同样长度的别的内容,取回时会被拒绝——有测试覆盖)。
  • 缓存可以永久:内容不可变,对象 URL 天然适合 max-age=31536000, immutable。
  • 去重跨路径、跨版本生效:相同内容只有一个对象,多个版本共享没变过的文件。
  • 不需要编译扩展:普通静态托管 / CDN 就能用。
  • 清单可复现:里面不放"生成时间"(用快照的创建时间),所以同一快照导出两次得到完全一样的清单, 可以 diff、可以签名。

实测对比(1108 KB 的包、61 个文件、真实 HTTP 服务器):

操作 按 hash 取对象 远程 VFS 按页读(APSW)
列目录 1 次请求 / 13 KB(清单,gzip 后 0.34 KB) 5 次请求 / 20 KB
取一个 0.8 KB 文件 1 次请求 / 0.8 KB(精确等于文件大小) 9 次请求 / 36 KB
取一个 1 MB 文件 1 次请求 / 1 MB 265 次请求 / 1060 KB(不做预读,约 13 秒 @50ms RTT)

对"发布一个资源包"这个场景,对象方案在请求数和字节数上都好一个数量级,而且不引入编译依赖。

远程 VFS(按页读 SQLite)真正的用武之地是另一种需求:一个很大、结构复杂的数据库, 客户端只随机查其中很少一部分。那种情况下"按页取"才有意义,代价是你得自己实现预读与缓存 (实测不做预读时读 1 MB 要 265 次往返;预读 256 KB 后降到 5 次,字节数几乎不变)。 另外 CPython 的 sqlite3 没有注册自定义 VFS 的 API,走这条路必须引入 APSW 之类的编译扩展。

已知边界:

  • 清单大小与文件数成正比(每文件约 150~200 字节;gzip 后约 5 字节/文件, 任何 CDN 都会自动对 JSON 压缩)。10 万文件的清单 gzip 后约 0.5 MB——一次性成本,但值得知道。
  • vfsfetch 取的是整个文件。如果你要"取一个 2 GB 文件的中间一段", 那属于远程 VFS 的领域,不是这条路的。
  • 清单是公开信息(路径、大小、时间戳)。要发布敏感内容,请把对象目录放在需要鉴权的位置。
  • --link 用硬链接落盘省空间,但不还原时间戳与权限:硬链接与缓存对象共用 inode, utime/chmod 会连带改到缓存,破坏"缓存对象不可变"的前提。

项目结构

sqlite_vfs/
├── pyproject.toml             # 项目配置、可选依赖(extras)与命令入口
├── src/sqlite_vfs/
│   ├── __init__.py
│   ├── core.py                # 核心 SQLiteVFS 类(存储层 / 快照 / 完整性)
│   ├── compression.py         # 压缩算法分发(zlib / huffman / none)
│   ├── huffman.py             # 哈夫曼实现(仅为读早期版本打的包而保留)
│   ├── migration.py           # v1 / v2 → v3 格式迁移
│   ├── mime.py                # Content-Type 覆盖表(插件与发布工具共用)
│   ├── publish.py             # 按 hash 发布对象 + 清单,以及取回客户端
│   ├── folder_packer.py       # 打包逻辑(全量 / 增量 / 新快照)
│   ├── folder_unpacker.py     # 解包逻辑(支持指定快照)
│   ├── contrib/
│   │   ├── __init__.py
│   │   └── fastapi_static.py  # 可选:FastAPI 静态文件服务插件
│   └── cli/
│       ├── packer.py          # vfspack
│       ├── unpacker.py        # vfsunpack
│       ├── verify.py          # vfsverify(校验 / 签名 / 列快照)
│       ├── migrate.py         # vfsmigrate
│       ├── publish.py         # vfspublish(导出对象 + 清单)
│       └── fetch.py           # vfsfetch(按清单取回并校验)
├── tests/                     # 单元与集成测试(纯标准库 unittest)
└── README.md

核心组件

SQLiteVFS 类

主要功能类,提供以下功能:

  • 创建和管理 SQLite 虚拟文件系统数据库
  • 添加/删除/移动文件和目录,读写文件内容
  • 内容寻址存储与去重(get_blob_info 可查看某个文件对应哪份 blob)
  • 快照管理:create_snapshot / use_snapshot / list_snapshots / delete_snapshot
  • 完整性:compute_digest / verify / sign / verify_signature
  • 导出文件或整个目录树到本地文件系统
  • 压缩和解压缩文件内容
  • 可以只读打开(readonly=True),适合把资源包放在只读介质上服务

FolderPacker 类

文件夹打包工具:

  • 遍历本地文件夹结构
  • 将文件和目录信息存储到 SQLite 数据库
  • 支持文件排除模式(glob 语义,按目录剪枝)
  • 支持全量与增量打包,以及"打成新快照"的版本流程

FolderUnpacker 类

文件夹解包工具:

  • 从 SQLite 数据库恢复文件系统(可指定快照)
  • 验证数据库完整性
  • 支持部分导出和完整导出

数据库结构

filesystem_metadata 表

整包信息:名称、创建时间、格式版本(schema_version)。

snapshots 表

每个版本的记录:名称、创建时间、父快照(parent_id)、文件数、总大小、摘要(digest)。

blobs 表

内容寻址存储,相同内容只存一份:

  • hash: sha256(原始字节) 的十六进制,主键
  • size: 原始(未压缩)字节数
  • stored_size: 实际占用字节数(含 huffman 频率表)
  • content: 压缩后的数据(未压缩时就是原始字节)
  • freq_blob: 哈夫曼频率表(1024 字节 = 256 个 uint32 小端),仅 huffman 算法有值
  • compress_algo: 压缩算法,'none' / 'zlib'(默认)/ 'huffman'

signatures 表

快照摘要的签名记录:snapshot_id、algorithm(hmac-sha256)、key_id、signature、signed_time。

files 表

存储所有目录项(含目录本身),唯一约束是 (snapshot_id, path):

  • snapshot_id: 属于哪个快照
  • path / name / parent_path: 路径与层级
  • is_directory: 是否为目录
  • file_size: 原始文件大小
  • created_time / modified_time: 时间戳
  • permissions: 文件权限
  • content_hash: 指向 blobs.hash;目录为 NULL

文件内容不在这个表里——这样的拆分让"同一份内容被多个路径/多个快照引用"成为可能, 也让 get_file_info / exists / list_directory 这些元数据查询不再把 BLOB 读进内存。

开发

设置开发环境

# 安装开发依赖
uv sync --dev

# 运行测试
pytest

# 构建包
uv build

代码示例

from sqlite_vfs.core import SQLiteVFS

# 创建文件系统(open 时已自动连接,不必再调 connect())
vfs = SQLiteVFS("my_files.db", compress=True)

# 获取文件系统统计信息
stats = vfs.get_stats()
print(f"文件系统: {stats['name']}")
print(f"文件数量: {stats['total_files']}")

# 列出目录内容
for item in vfs.list_directory("/"):
    print(f"{'DIR' if item['is_directory'] else 'FILE'}: {item['name']}")

vfs.close()

性能说明

  • 压缩默认使用 zlib(标准库,C 实现),压缩级别为 6(平衡压缩比和速度)
  • 大于等于 64 字节的文件才尝试压缩;压不动的(随机数据)自动回退为原样存储
  • 每个响应命中 ETag / Last-Modified 条件请求时返回 304,不需要读内容也不需要解压
  • 文件索引优化,支持快速路径查找
  • 各算法的实测对比见上文「要不要用 --compress?」
  • verify() 是元数据级、很便宜;verify(check_content=True) 要读一遍全部内容

读取大文件时,read_file 会整块读入内存再切片,因此超大媒体文件(几百 MB 级以上) 不适合放进资源包;Range 请求虽然语义正确,但当前实现是"整份解压后再切片", 成本与整份读取相同。

注意事项

  1. 文件路径统一使用正斜杠(/)存储,与操作系统无关
  2. 时间戳按本机本地时间存储(datetime.fromtimestamp),不是 UTC;跨时区搬运后时间含义会变
  3. 文件权限使用 Unix 权限位表示
  4. 数据库文件可以在不同平台间迁移(当前格式为 schema 3,旧库用 vfsmigrate 升级)
  5. 大文件处理建议启用压缩以减少数据库大小
  6. 内容是按 sha256 去重共享的:删掉一个路径后,只有再无任何快照引用时内容才会被回收
  7. 快照摘要会被走 API 的写入刷新,所以它防的是"绕过 API 的改动";当防篡改凭证用请配合签名

许可证

MIT License

贡献

欢迎提交 Issue 和 Pull Request 来改进项目。

S
Description
基于 sqlite 的虚拟文件系统
Readme MIT
1.3 MiB
Languages
Python 100%