Skip to main content

SQLite Virtual File System (SVFS)

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

特性

  • 高效存储: 使用 SQLite 数据库存储文件和目录结构
  • 压缩支持: 可选压缩,默认 zlib(标准库 C 实现),压不动的内容自动回退为原样存储
  • 内容寻址去重: 相同内容只存一份,多个路径/多个版本共享同一份数据
  • 快照与版本历史: 一个库里存同一棵树的历史版本,可以按版本导出、按版本校验
  • 完整性校验: 每个文件带 sha256,快照级摘要 + HMAC-SHA256 签名,支持只读校验
  • 增量打包: 只写入变化的条目,未变的文件不重写、不重压、不重复存储
  • 可发布到对象存储: 导出成「按 sha256 命名的对象 + 一份小清单」,普通静态托管 / CDN 即可分发, 客户端按清单取回并逐个校验(不需要任何编译扩展)
  • 插件化客户端: svfs-client(PySide6 + WebView)把 .svfs 当插件分发——完整前端直接 从包内加载进 WebView,多插件互相隔离,升级即新快照、可随时回滚
  • 完整元数据: 保留文件权限、创建时间、修改时间等元数据
  • 目录结构保持: 完整的目录层级结构保持
  • 简单易用: 四个命令行工具(打包 / 解包 / 校验 / 迁移),零第三方依赖
  • 跨平台: 支持 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

使用方法

命令行入口:svfs <子命令>(推荐)

所有功能收拢在一个入口下,git 风格的子命令;退出码统一为 0 成功 / 1 失败 / 2 用法错误:

svfs pack     <源文件夹> <输出.svfs>      # 打包(--compress 压缩;--incremental 增量;
                                          #  --snapshot NAME 打成新快照)
svfs unpack   <包> <目标目录>             # 解包(--snapshot ID 选版本;--path 只导出一个路径)
svfs info     <包>                        # 概要:统计、去重/压缩收益、快照列表(--json)
svfs verify   <包>                        # 校验(--check-content 逐文件重算哈希;--key 验签)
svfs sign     <包> --key <密钥>           # 签名(--key-id 起标识;--snapshot ID 选版本)
svfs migrate  <旧包>                      # v1/v2 → v3(默认先留 .bak 备份)
svfs publish  <包> <输出目录>             # 发布成对象 + manifest.json
svfs fetch    <manifest> <目标目录>       # 按清单取回(对象按 sha256 校验)

svfs --help        # 总览
svfs pack --help   # 每个子命令的完整参数

典型的发布/取回链路:

svfs pack ./dist app.svfs --compress
svfs sign app.svfs --key "$SVFS_KEY" --key-id release-1
svfs verify app.svfs --key "$SVFS_KEY" --check-content
svfs publish app.svfs public/
aws s3 sync public/ s3://你的桶/ --cache-control "public, max-age=31536000, immutable"
svfs fetch https://cdn.example.com/app/manifest.json ./restored

兼容性说明:早期的六个独立命令 vfspack / vfsunpack / vfsverify / vfsmigrate / vfspublish / vfsfetch 全部保留为别名——它们与 svfs 的同名子命令跑的是 同一段实现,输出与退出码一致,已有脚本不必迁移。区别只有一点: vfsverify --sign 的功能在 svfs 里拆成了独立的 svfs sign 子命令。

打包文件夹

# 基本用法
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"
svfs migrate ./old.db              # 原地迁移,先复制一份 ./old.db.bak
svfs migrate ./old.db --output ./new.db   # 输出到新文件,原库一个字节都不动

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

桌面工具:SVFS Studio

svfs-studio 是配套的可视化工具(PySide6,暗色界面),把一个 .svfs 摊开来看: 文件树与内容预览、版本快照与两版差异、内容去重与压缩的实际收益、完整性校验与签名, 以及它作为一个 SQLite 数据库的内部结构(文件头、页、各表占用)和一个只读 SQL 控制台。

它做的一切都调用本库的公开 API(FolderPacker、migration.migrate、publish.export_objects…), 没有第二份实现,所以界面上得到的结果与 CLI 逐字节一致。

# 安装(GUI 依赖在可选 extra 里,核心库与 CLI 依然零第三方依赖)
uv sync --extra gui
# 或:pip install "sqlite-vfs[gui]"

# 启动
uv run svfs-studio
# 或:python -m svfs_studio

工具里能做的不只是"看":打包(真实逐文件进度,因为 pack_folder 现在支持进度回调)、 包内编辑、解包、迁移旧格式、回收未引用内容、发布成对象存储布局、从清单拉取、 与本地目录逐文件对比——都跑在后台线程上,界面不卡。

包内编辑(「文件」页的编辑那一排,或右键菜单):

  • 添加文件 / 添加文件夹:加进当前快照;文件夹整棵进去。同名路径会先问一句, 选"替换"就覆盖(内容仍按 sha256 去重,重复字节不会多占空间);
  • 新建目录:在选中的目录下建(没选中就是根目录);
  • 重命名:改名或移动文件/目录(目录连同子项一起改);
  • 删除:删掉文件或整个子树。库里是内容寻址 + 引用计数,所以删路径不等于删内容—— 只有一条引用都不剩时,那份内容才会被回收(「存储」页能看到可回收多少);
  • 编辑内容:选中文本文件后点「编辑内容」(或直接改完按 Ctrl+S),就地改完写回包内。 按 UTF-8/GBK 嗅探编码并以原编码保存;上限 1 MB——编辑器装不下的文件不给编辑, 因为"只看到一半就保存"等于截断。二进制文件与超限文件请导出改好后用「添加文件」替换。

每次编辑结束都会调一次 recount():刷新快照的统计与摘要,保证改完立刻能校验通过。 代价是之前做的签名会失效(签名只覆盖摘要),界面上会明确提示这件事—— 这是"摘要跟着内容走"与"签名绑定摘要"这套设计的必然结果,不是 bug。

几个值得先试的地方:

  • 没有现成的包?点「打个示例包看看」,会在临时目录里造一棵含两份相同内容的小目录树并打包;
  • 仓库自带的 appcation.svfs 是 schema v1,打开它会看到「旧格式」横幅和「去迁移」按钮, 正好演示从旧格式升到 v3 的完整流程;
  • 「存储」页把去重与压缩分开算:logical_size → unique_bytes → stored_bytes 三步各省了多少一目了然;
  • 「内部」页可以直接 SELECT——包就是个普通 SQLite 库,这点骗不了人。

想要一份界面截图用于文档或评审(不需要显示器):

python -m svfs_studio --shots ./shots ./某个包.svfs   # 每个标签页各存一张 PNG

界面测试(offscreen 渲染,含像素级暗色检查、预览内容与库字节比对、中断语义):

python -m unittest discover -s tests -p "test_studio_ui.py" -v

插件化客户端:svfs-client

svfs-client 是基于同一套 SVFS 格式的插件化桌面客户端(PySide6 + QWebEngineView): 插件以 .svfs 单文件分发,客户端安装插件、把插件的 Web 界面直接从包内加载进 WebView——不落盘、不起端口、不需要解包。

核心模型:

  • 一个插件 = 一个完整的 .svfs 包:完整前端(HTML/JS/CSS/图标)+ 根目录一份 plugin.json 清单。插件自带全部资源,不依赖宿主或其它插件的任何文件;
  • 多插件并存、互相隔离:每个插件一个包文件、一个独立 origin (svfs://<插件id>/——浏览器按 origin 隔离 localStorage / IndexedDB / cookie)、 一份独立的键值存储。协议层拒绝跨插件请求,导航守卫禁止跳到别的插件或把插件页 变成远程内容容器;
  • 快照即版本:一个插件一个 .svfs,每次升级往里打一个新快照——没变的文件共享 blob(升级通常只增加变化部分的字节),旧版本全部保留,回滚 = 切换当前快照, 包文件一个字节都不用动;
  • 完整性与签名贯穿始终:安装时跑 verify()(可选逐文件重算 sha256), 从远程安装时每个对象按 sha256 校验。

安装与体验

uv sync --extra gui          # WebEngine 需要完整版 PySide6(PySide6-Essentials 不够)
uv run svfs-client           # 或 python -m svfs_client
# 想指定数据目录:uv run svfs-client --data ./my-client-data

启动后先经过一段启动动画和登录界面(强制登录,详见下文「登录与认证」; 搭配本仓库的示例服务端时,默认账号是 admin / admin123)。侧栏列出已安装的 插件;插件管理(安装/升级/回滚/卸载)在「设置 → 插件管理」里。

plugin.json 规范

包根(当前快照根)必须有一份 plugin.json:

{
  "id": "com.example.clock",
  "name": "时钟",
  "version": "1.2.0",
  "api_version": 3,
  "entry": "index.html",
  "icon": "icon.png",
  "permissions": ["storage", "notify", "title", "net"],
  "description": "一个时钟插件"
}
  • id:反域名形式(小写字母/数字/连字符),全局唯一,同时是 svfs:// 的 host;
  • api_version:插件要求的最低宿主桥版本——宿主接受 api_version ≤ 自身 PLUGIN_API_VERSION 的插件(桥能力只增不破,升级客户端不会废掉旧插件; 要求更高版本的插件会得到「请升级客户端」提示)。取值参考:v2 = net 与 host.info 的 baseUrl;v3 = host.info 的 authUser、宿主给同源 net 请求自动 附加登录 token;v4 = dialog 权限与 ui.confirm/ui.message 原生对话框。 没用新能力就写 2(甚至 1)即可,兼容面更大;
  • entry:SPA 入口。路径 404 且末段没有扩展名时回退到它(每个插件都是完整前端);
  • permissions:声明这个插件要用哪些桥能力(storage / notify / title / net / dialog),安装时向用户展示。

服务器地址(base_url)

「插件管理 → 服务器」里配置一次服务端地址(http(s)://),它同时承担两件事:

  1. 插件下载:发布端把每个插件的 publish 产物同步到服务器的 <base_url>/<插件id>/ 子目录下;客户端安装时只填插件 id(或相对路径), 自动解析成 <base_url>/<id>/manifest.json。填完整 URL 也可以。
  2. 接口服务:声明了 net 权限的插件经桥访问这个服务器的 API—— 宿主代为发 HTTP 请求,不经过浏览器的 CORS,服务端不需要任何跨域配置。

配置存在 <data>/settings.json,改动即时生效(已打开的插件页面下次调用就能拿到)。

登录与认证(强制登录)

客户端启动时先播放一段启动动画(呼吸 logo + 状态文字,点击可跳过),随后进入 登录界面——必须登录成功才能使用客户端。登录窗用同一套暗色主题,无头一次 填写过服务器地址的话会自动预填,下次勾着「记住登录」登录后,启动动画期间就会 静默校验 token(GET /api/me),有效则直接进主窗口,过期/网络不通则回到登录窗 并提示原因。

  • 登录请求:POST <base_url>/api/login(JSON {"username","password"})→ {"ok":true,"token","username"}。服务端会话在内存里,重启服务端即全部失效;
  • 凭据持久化:勾选「记住登录」才把 token 写进 settings.json;不勾选则 token 只在本次运行内有效(服务器地址与用户名仍会记住);
  • 插件无感知:宿主桥给发往服务器(与 base_url 同源) 的 net 请求自动附加 Authorization: Bearer <token>,不会发给第三方地址,也不覆盖插件自带的 Authorization 头;
  • host.info 新增 authUser 字段(当前登录用户名,未登录为空串);
  • 「插件管理 → 服务器」卡片显示登录状态,可「重新登录…」或「退出登录」。

界面:侧栏 / 设置 / 用户中心

  • 启动后直接显示插件内容(可配置):优先用设置里配置的启动插件, 未配置则按默认规则(上次打开的 > 按 id 排序第一个启用的插件),都没有 才停在插件管理页;
  • 侧栏顶部「«」把侧栏收成窄条(只留展开箭头、头像、设置三个入口), 再点「»」展开;选择会记住,下次启动保持;
  • 侧栏底部「设置」:插件管理入口(安装本地 .svfs、从服务器安装、 升级/回滚/卸载)、启动时打开的插件、修改服务器地址(与服务器卡片 同步生效)、数据目录(插件路径,写指针文件,重启生效)、对象缓存 路径(留空 = 系统默认,安装时即时生效)与缓存清理;
  • 侧栏底部的圆形头像(登录用户首字母)点击进「用户中心」:当前登录 状态、服务器地址、本次登录时间,以及退出登录 / 重新登录。
  • 插件页面底色固定为主题底色;WebEngine 在启动阶段预热(Profile 提前 创建 + 隐藏预热视图 + 共享 OpenGL 上下文)——首次打开插件不再闪白屏 或整窗闪烁。

插件前端拿到的桥:window.svfs

宿主自动注入(插件作者不需要引用 qwebchannel.js),能力按 permissions 把关, 没声明的能力调用直接失败:

<script>
const info = await window.svfs.host.info();
//   → {pluginId, appName, appVersion, apiVersion, platform, baseUrl, authUser}

await window.svfs.storage.set('visits', 42);   // 需要 "storage" 权限
const visits = await window.svfs.storage.get('visits');   // 42(按插件隔离)
await window.svfs.storage.remove('visits');
const keys  = await window.svfs.storage.keys();

// 需要 "net" 权限:宿主代发 HTTP 请求(绕开 CORS,响应上限 20 MB)
const res = await window.svfs.net.request({ method: 'GET', path: '/api/ping' });
//   相对 path 拼在 base_url 后;也可以 { url: 'https://api.example.com/…' }
//   → { ok, status, headers, bodyText, bodyBase64 },4xx/5xx 也是正常返回
await window.svfs.net.request({ method: 'POST', path: '/api/save',
                                headers: {'Content-Type': 'application/json'},
                                body: JSON.stringify({a: 1}) });

await window.svfs.ui.notify('打包完成');        // 需要 "notify" 权限
await window.svfs.ui.setTitle('时钟 · 12:30');  // 需要 "title" 权限

// 需要 "dialog" 权限(v4+):宿主**原生对话框**,Promise 返回用户是否点了"确定"
const ok = await window.svfs.ui.confirm({ title: '删除', text: '确定删除这条记录?' });
await window.svfs.ui.message({ kind: 'warn', title: '警告', text: '余额不足' });
</script>

host.info 不需要权限——它只包含插件自己的元数据、宿主版本号与配置的服务器 地址,不含用户数据。

发布 → 安装的完整链路

发布端用现成的 svfs publish,客户端用 manifest 安装(对象逐个 sha256 校验):

# 插件开发机:打包 + 发布到静态托管
svfs pack ./my-plugin dist.svfs --compress
svfs publish dist.svfs ./public
aws s3 sync ./public s3://你的桶/plugin/com.example.clock/ \
    --cache-control "public, max-age=31536000, immutable"

# 客户端「从服务器安装…」里填插件 id 即可:
#   com.example.clock
# (publish 产物已按约定同步到 <base_url>/com.example.clock/ 下;
#   同一个插件发新版本后,客户端再装一次就完成增量升级)

服务端如果还提供接口服务,加一个普通 HTTP API 即可——插件的 net 请求就是 普通的服务端调用,无需为 svfs:// origin 做任何跨域配置。

插件开发辅助:桥调试台与 Vue 模板

  • 桥调试台:客户端「设置 → 桥调试台 → 安装调试插件」一键装一个调试 插件(com.svfs.demo),把 window.svfs 的每类能力做成可点击面板 (host.info / storage 键值 / net 自定义请求 / notify / title / 原生对话框), 开发自己的插件时用来对照验证真实桥行为;
  • Vue 开发模板:examples/vue-plugin-template/ —— Vue 3 + Vite 起步 模板,含 src/svfs.js(Promise 封装)、src/mock-svfs.js(浏览器 dev 模式下的宿主模拟器,npm run dev 不进客户端也能迭代 UI)与每类能力 一个的面板组件;npm run build 后 svfs pack ./dist plugin.svfs 即可 安装(详见模板内 README)。

服务端最小示例(FastAPI)

examples/plugin_server.py 是一个单文件服务端,同时覆盖插件下载与接口服务: POST /plugins/<id> 收 .svfs 包并就地 publish(服务端用同一套清单规则校验), 静态吐出各插件的 manifest.json + blobs/ 供客户端下载,GET /api/ping 等 API 供插件经 net 调用;可选 SVFS_UPLOAD_TOKEN 环境变量保护上传。

/api/* 需要登录(GET /api/ping、POST /api/echo 校验 Bearer token), 登录体系三个接口:

接口 说明
POST /api/login {"username","password"} → {"ok",token,"username","login_time"},密码错 401
GET /api/me 带 Bearer token → {"ok","username","login_time"},用于启动时静默校验
POST /api/logout 作废当前 token

账号来源:默认内置演示账号 admin / admin123;设置环境变量 SVFS_USERS="user:pass,user2:pass2" 即换成自己的账号表。插件下载与上传保持 开放/沿用原有令牌机制,不受登录影响。

uv sync --extra fastapi                # fastapi/starlette + uvicorn + python-multipart
uv run python examples/plugin_server.py    # → http://127.0.0.1:8000
                                        # 启动时会打印登录账号提示

# 客户端「服务器」卡片填 http://127.0.0.1:8000,登录窗输 admin / admin123;
# 「从服务器安装」填插件 id(或 curl 上传一个包试试,上传不需要登录 token):
curl -F "file=@dist.svfs" http://127.0.0.1:8000/plugins/com.example.my-plugin

# 直接调 /api/* 的外部脚本要先拿 token:
token=$(curl -s -X POST http://127.0.0.1:8000/api/login \
        -H "Content-Type: application/json" \
        -d '{"username":"admin","password":"admin123"}' | jq -r .token)
curl -H "Authorization: Bearer $token" http://127.0.0.1:8000/api/ping

它的行为有测试背书(tests/test_plugin_server.py,含"上传 → 服务端 publish → 真实 HTTP → 客户端 install_from_url 装回"的完整闭环)。

数据目录

<data>/                        Windows: %LOCALAPPDATA%/svfs-client;其它: ~/.svfs-client
├── registry.json              已安装插件注册表(enabled、当前快照、来源…)
├── settings.json              客户端设置(服务器地址 base_url、登录凭据)
├── plugins/<id>/plugin.svfs   每个插件一个包文件(快照即版本)
├── storage/<id>.json          桥 storage 的键值存储(按插件隔离)
└── web/                       WebView 的持久化目录(localStorage 等)

实现分层(与 svfs-studio 同一套约定)

manifest / paths / registry / installer / sample / auth 是纯标准库 (不 import Qt,可在无 PySide6 的环境单独测试);scheme / bridge / webview / worker / login(启动动画与登录窗)/ shell / app 属于 Qt 层。 界面复用 svfs-studio 的主题与基础部件,视觉与 Studio 一致。

测试(逻辑层纯标准库;界面层 offscreen,含一次真实的 WebEngine 冒烟——加载 示例插件并验证桥注入与 storage 往返,环境起不来 WebEngine 时自动跳过):

python -m unittest discover -s tests -p "test_client_logic.py" -v
python -m unittest discover -s tests -p "test_client_auth.py" -v        # 登录认证(纯层)
python -m unittest discover -s tests -p "test_client_login_ui.py" -v   # 登录窗/启动动画/桥注入
python -m unittest discover -s tests -p "test_client_ui.py" -v

数据能力

内容寻址与去重

文件内容不放在 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(按清单取回并校验)
├── src/svfs_studio/           # 桌面可视化工具(可选 extra: gui)
│   ├── format.py              # [无 Qt] 大小/时间/权限/文本嗅探/hexdump 的显示格式化
│   ├── analysis.py            # [无 Qt] 快照差异、去重与占用汇总、SQLite 内部结构解析
│   ├── worker.py              # 所有库调用都跑在一个专用线程里;进度回调、中断、日志
│   ├── theme.py               # 暗色调色板 + QSS
│   ├── sample.py              # 示例包(与 README 里的演示同一棵树)
│   ├── views/                 # 七个标签页:概览/文件/快照/存储/完整性/内部/工具箱
│   └── widgets/               # 外壳(主窗口)、卡片、自绘图表
├── src/svfs_client/           # 插件化桌面客户端(可选 extra: gui,需要完整版 PySide6)
│   ├── manifest.py            # [无 Qt] plugin.json 清单的读取与校验
│   ├── paths.py               # [无 Qt] 数据目录布局(注册表 / 设置 / 包 / 键值存储 / WebView)
│   ├── settings.py            # [无 Qt] 客户端设置(base_url 的读写、规范化与地址解析)
│   ├── registry.py            # [无 Qt] 已安装插件注册表(JSON 原子写)
│   ├── installer.py           # [无 Qt] 安装 / 升级 / 回滚 / 卸载(复用核心库 API)
│   ├── sample.py              # [无 Qt] 示例插件生成器(完整前端 + 桥能力演示)
│   ├── scheme.py              # svfs:// 协议:从包内直接服务文件,host=插件id 实现隔离
│   ├── bridge.py              # 受限 QWebChannel 桥(storage / net / notify / title,按权限把关)
│   ├── webview.py             # PluginView + 导航守卫 + window.svfs 注入
│   ├── worker.py              # 后台线程服务(request-id 分发、进度、独占 op)
│   └── shell.py               # 主窗口:侧栏 + 插件管理页 + 每插件一个常驻 WebView
├── 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

# 运行测试(测试是纯标准库 unittest,没有引入 pytest)
uv run python -m unittest discover -s tests

# 只跑界面测试(offscreen,不需要显示器;未装含 GUI 的 extra 时会整体跳过)
uv sync --extra gui
uv run python -m unittest discover -s tests -p "test_studio_ui.py" -v

# 构建包
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.3.0

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

Built distribution (wheel)

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

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

Download URL sqlite_vfs-0.3.0-py3-none-any.whl
Size 83.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
21db55f73867fee539aa8ae9e58e4dd06173d7b856d5d4a29e86d9d60364cffb
BLAKE2b-256 checksum
How to use checksums
132829f6e4ec636c8ea5a91912079044c415cf59828d8185006ed1ca0104b9ba
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

This release

0.3.0 This release

1 release file

0.2.0

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