Skip to main content

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 来改进项目。

Metadata

Release files for sqlite-vfs 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sqlite-vfs 0.2.0
File Size Uploaded
sqlite_vfs-0.2.0.tar.gz 256.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sqlite-vfs 0.2.0
File Interpreter ABI Platform
sqlite_vfs-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 325.1 kB

Release files / sqlite_vfs-0.2.0.tar.gz

Download URL sqlite_vfs-0.2.0.tar.gz
Size 256.9 kB
Tags Source
SHA-256 checksum
How to use checksums
b7eece43438067196eb06d6840e393f389360baf5d45be08b6071ef5d211eb8c
BLAKE2b-256 checksum
How to use checksums
165a02f0ed40f663aac63afb7e4a8a27ca7a802f2a16d46ba8911322bf2e7353
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.12

Release files / sqlite_vfs-0.2.0-py3-none-any.whl

Download URL sqlite_vfs-0.2.0-py3-none-any.whl
Size 68.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
df7178b897cc604333f727970435a07b93c1ba22253d4aa21e996b31c06ac468
BLAKE2b-256 checksum
How to use checksums
75bfb8a60d29332f1e594f0eab4c850c27c04a5c91eb40f619a6aa440b1a379e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.12

Release history Release notifications | RSS feed

0.4.0

2 release files

0.3.0

1 release file

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page