Metadata-Version: 2.5
Name: sqlite-vfs
Version: 0.2.0
Summary: sqlite virtual file system
License-File: LICENSE
Requires-Python: >=3.9
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
Requires-Dist: starlette>=0.27; extra == 'fastapi'
Description-Content-Type: text/markdown

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

