Files
svfs/README.md
T
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

509 lines
22 KiB
Markdown
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.
# SQLite Virtual File System (SVFS)
一个基于 SQLite 的虚拟文件系统实现,可以将本地文件夹打包成单个 SQLite 数据库文件,并支持从数据库中解包还原文件。
## 特性
- 高效存储: 使用 SQLite 数据库存储文件和目录结构
- 压缩支持: 可选压缩,默认 zlib(标准库 C 实现),压不动的内容自动回退为原样存储
- 内容寻址去重: 相同内容只存一份,多个路径/多个版本共享同一份数据
- 快照与版本历史: 一个库里存同一棵树的历史版本,可以按版本导出、按版本校验
- 完整性校验: 每个文件带 sha256,快照级摘要 + HMAC-SHA256 签名,支持只读校验
- 增量打包: 只写入变化的条目,未变的文件不重写、不重压、不重复存储
- 可发布到对象存储: 导出成「按 sha256 命名的对象 + 一份小清单」,普通静态托管 / CDN 即可分发,
客户端按清单取回并逐个校验(不需要任何编译扩展)
- 完整元数据: 保留文件权限、创建时间、修改时间等元数据
- 目录结构保持: 完整的目录层级结构保持
- 简单易用: 四个命令行工具(打包 / 解包 / 校验 / 迁移),零第三方依赖
- 跨平台: 支持 Windows、Linux、macOS
- 可选集成: FastAPI 静态文件服务作为 extras 提供,核心不依赖任何第三方框架
## 安装
### 从源码安装
```bash
# 克隆仓库
git clone <repository-url>
cd svfs
# 使用 uv 安装(推荐)
uv sync
# 或者使用 pip
pip install -e .
```
### 使用 uv
项目使用 uv 作为包管理器和构建工具,确保已安装 uv:
```bash
# 安装 uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# 在项目目录中安装依赖
uv sync
```
## 使用方法
### 打包文件夹
```bash
# 基本用法
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
```
### 解包文件系统
```bash
# 解包整个文件系统
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
```
### 校验完整性
```bash
# 元数据级校验:摘要是否被改动、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` 不会导入任何第三方框架。
```bash
pip install "sqlite-vfs[fastapi]"
# 打包前端构建产物(不需要 --compress 时见下方性能说明)
vfspack ./dist static.svfs
```
```python
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"
```
```bash
vfsmigrate ./old.db # 原地迁移,先复制一份 ./old.db.bak
vfsmigrate ./old.db --output ./new.db # 输出到新文件,原库一个字节都不动
```
迁移会读旧库、在旁边的临时文件上重建、校验通过后原子替换,所以中途失败不会留下半成品。
迁移过程顺带按 sha256 去重:历史库里同一份内容存了多次会被合并(`vfsmigrate` 会报告省下多少字节)。
迁移完可以用 `vfsverify --check-content` 复核每一个文件的内容。
## 数据能力
### 内容寻址与去重
文件内容不放在 `files` 表里,而是存进 `blobs` 表,主键是 `sha256(原始字节)`,
所以**相同内容天然只存一份**:
```python
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)`,所以同一路径在不同快照里各有一行:
```python
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"])
```
配合增量打包就是一个完整的版本流程:
```bash
vfspack ./dist app.svfs --compress # 第一版
vfspack ./dist app.svfs --compress --snapshot v2 # 新版本,只写入变化的部分
vfsunpack app.svfs ./restore --snapshot 1 # 按版本导出
```
### 完整性校验与签名
每个文件都带 `sha256`,每个快照都有一份摘要(对"按 path 排序后的全部条目"做 sha256,
字段包括路径、类型、大小、时间戳、权限、内容哈希):
```python
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 的改动"。要当防篡改凭证用,请在打包完成后立刻签名或把摘要记到库外。
### 增量打包
```bash
vfspack ./dist app.svfs --compress --incremental
```
只写入变化的条目,磁盘上已消失的路径会被删除,并汇报四个计数:
```
增量结果: 新增 1,更新 1,未变 1,删除 1
```
变更判定默认用 `mtime + size`:不读文件内容,所以很快,代价是**理论上会漏掉
"同一秒内、同样大小"的修改**(这类场景请用 `--rehash`,它按 sha256 逐个文件比对)。
未变的文件不会被重写、重压或重新存储;内容相同的新路径会直接复用已有 blob。
### 发布到对象存储(按 hash 取对象)
`.svfs` 是一个文件,但**不必**把它当"远程文件系统"用。更简单也更快的一条路:
既然库里的内容本来就按 sha256 寻址,就把每个 blob 直接导出成一个以 hash 命名的对象,
再把目录结构导出成一份小清单。
```bash
# 导出成「对象 + 清单」
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
```
产出结构:
```text
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` 会连带改到缓存,破坏"缓存对象不可变"的前提。
## 项目结构
```text
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 读进内存。
## 开发
### 设置开发环境
```bash
# 安装开发依赖
uv sync --dev
# 运行测试
pytest
# 构建包
uv build
```
### 代码示例
```python
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 来改进项目。