bazil.org/fuse dropped macOS support (commit 65cc252+). 0.3.0
shipped Linux-only. This drop adds a parallel pkg/mount/fuse_darwin.go
using github.com/jacobsa/fuse, which still supports both macFUSE
(kext) and fuse-t (userspace, no kext, SIP-friendly).
Same Mount(*vfs.FS, mountpoint) signature; the `fuse` build tag
picks the right driver per GOOS at compile time.
What's new
----------
- pkg/mount/fuse_darwin.go (`-tags fuse && darwin`): full
fuseutil.FileSystem implementation using jacobsa/fuse. Maps every
POSIX op the kernel sends:
LookUpInode → FS.Lookup
GetInodeAttributes → FS.Lookup + inode→attrs
SetInodeAttributes(size) → File.Truncate + Sync
MkDir → FS.Mkdir
CreateFile → FS.Create + FS.Open + handle alloc
Unlink/RmDir → FS.Remove
OpenDir/ReadDir/ReleaseDirHandle → FS.ReadDir
OpenFile/ReadFile/WriteFile → FS.Open + File.{ReadAt,WriteAt}
SyncFile/FlushFile → File.Sync
ReleaseFileHandle → File.Close
StatFS → ~1 PiB pseudo-quota (backend is unlimited)
- pkg/vfs/fs.go: new FS.PathOfInode(InodeID) for the macOS driver,
which gets ops as (parent_id, name) tuples instead of bazil's
node-pointer style. O(depth) walk up the parent chain.
- pkg/mount/fuse_sqlite_test.go: build tag widened to
`fuse && (linux || darwin)`. Adds `runtime.GOOS` switch to invoke
the right unmount tool (`fusermount`/`fusermount3` on Linux,
`umount`/`diskutil unmount` on macOS).
Verified
--------
On macOS with fuse-t installed:
VFS_FUSE_E2E=1 go test -tags fuse -timeout 60s \
-run TestFUSESQLite ./pkg/mount/ -v
→ PASS: TestFUSESQLite (1.16s)
→ 100 rows survived umount+remount, integrity_check=ok
Real FUSE mount, real SQLite opening test.db directly on the
mountpoint, 100 INSERTs, unmount, remount, PRAGMA integrity_check.
Zero copy step.
8.4 KiB
hanzoai/vfs — AI Engineering Guide
What this is
S3-backed virtual block filesystem. Local-NVMe hot tier + S3 (or any object store) cold tier with PQ encryption per block. Stateful services (IAM, BD, ATS, TA, KMS, onyxd, lqd snapshots) get unlimited disk via S3 without needing K8s PVC sizing decisions.
Companion to ~/work/hanzo/replicate — replicate streams SQLite WAL
frames to S3 (file-level granularity); vfs is block-level granularity
that transparently spills cold pages.
Architecture
┌──────────────────────────────┐
│ FUSE / WASI mount point │ /data/
└──────────────┬───────────────┘
│ POSIX read/write
┌──────────────▼───────────────┐
│ Block layer (4 KiB pages) │
│ Content-addressable │
│ blake3 hash → block ID │
└──────────────┬───────────────┘
│
┌─────────┴─────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Local NVMe │ │ Object backend │
│ hot cache │ │ (s3/gcs/azure/ │
│ (configurable) │ │ file://) │
│ LRU eviction │ │ PQ-encrypted │
└──────────────────┘ └──────────────────┘
Encryption: every block is age-encrypted with hybrid X25519 + ML-KEM-768
recipients (via luxfi/age) before leaving the local cache. Backends
never see plaintext.
Content-addressable: each block stored at blocks/<blake3-prefix>/<hash>.zap.age.
Identical blocks dedupe naturally.
Layout
vfs/
├── cmd/
│ └── vfs/ # CLI entry point
│ └── main.go
├── pkg/
│ ├── vfs/
│ │ ├── vfs.go # Top-level FS interface
│ │ ├── block.go # 4 KiB block + content-addressable hashing
│ │ ├── cache.go # LRU write-back cache
│ │ └── crypto.go # luxfi/age PQ encryption per block
│ ├── backend/
│ │ ├── backend.go # Backend interface (Get/Put/Delete/List)
│ │ ├── file/file.go # file:// backend (local dev)
│ │ ├── s3/s3.go # AWS S3 / S3-compatible backend
│ │ ├── gcs/ # (TODO) Google Cloud Storage
│ │ └── azure/ # (TODO) Azure Blob
│ ├── mount/
│ │ ├── fuse_unix.go # bazil.org/fuse (build tag: fuse + !windows)
│ │ ├── fuse_stub.go # stub for builds without FUSE
│ │ └── wasi.go # (TODO) WASI guest mode
│ └── sidecar/
│ └── sidecar.go # (TODO) K8s sidecar mode
├── tests/ # E2E tests
├── docs/ # Architecture docs
├── go.mod
├── Makefile
├── Dockerfile
└── VERSION
CLI
# Mount an S3-backed VFS (FUSE)
vfs mount /tmp/v \
--backend s3://bucket/prefix \
--age-recipient age1xyz... \
--age-key /etc/vfs/age.key \
--cache-dir /var/cache/vfs \
--cache-size 10Gi
# One-shot put/get (no mount; useful for testing)
vfs put /path/to/file --backend file:///tmp/store
vfs get hash --backend file:///tmp/store > /tmp/out
# Stats (cache hit ratio, backend bytes, block count)
vfs stats --cache-dir /var/cache/vfs
K8s sidecar pattern
Same shape as hanzo/replicate — runs alongside Base/SQLite services:
spec:
containers:
- name: liquid-bd
volumeMounts:
- name: data
mountPath: /data # FUSE-mounted VFS
- name: vfs-sidecar
image: ghcr.io/hanzoai/vfs:0.1.0
args:
- mount
- /data
- --backend=s3://liquidity-vfs/{env}/bd
- --age-key=/etc/vfs/age.key
securityContext:
privileged: true # FUSE needs CAP_SYS_ADMIN
volumeMounts:
- name: data
mountPath: /data
mountPropagation: Bidirectional
- name: vfs-cache
mountPath: /var/cache/vfs
- name: vfs-keys
mountPath: /etc/vfs
readOnly: true
volumes:
- name: data
emptyDir: {} # the VFS overlays this
- name: vfs-cache
ephemeral:
volumeClaimTemplate:
spec:
accessModes: [ReadWriteOnce]
storageClassName: premium-rwo
resources:
requests:
storage: 10Gi # NVMe hot tier
- name: vfs-keys
secret:
secretName: vfs-age-keys
Backend URL grammar
| Scheme | Form | Notes |
|---|---|---|
file:// |
file:///tmp/store |
Local dev; no network. |
s3:// |
s3://bucket/prefix |
Region from AWS_REGION env or IRSA. Endpoint override via AWS_ENDPOINT_URL for S3-compat (Hanzo Storage, MinIO, R2). |
gcs:// |
gcs://bucket/prefix |
(TODO) |
azureblob:// |
azureblob://account/container/prefix |
(TODO) |
Encryption
Each 4 KiB block is age-encrypted with one or more recipients (X25519 +
optional ML-KEM-768 for hybrid PQ). Recipients are configured at mount
time via --age-recipient (repeatable). The decryption key is loaded
from --age-key (file path) or VFS_AGE_KEY env var.
Hybrid PQ recipients use luxfi/age extensions (see
~/work/lux/age/internal/x25519 and ~/work/lux/age/pq.go). Plain
classical X25519 also works; mix-and-match is supported.
Build tags
fuse— compile FUSE mount support (requiresbazil.org/fuse, Linux/macOS; not Windows)wasi— compile WASI guest mode (TODO)
Default go build produces a CLI that supports put/get/stats but
not mount. Use make build-fuse for the full mount-capable binary.
Roadmap
| Phase | Scope | Status |
|---|---|---|
| 0.1.0 | Block layer, file:// + s3 backends, age PQ crypto, LRU cache, CLI (put/get/stats), tests | ✅ shipped |
| 0.2.0 | Multi-block File API (ReadAt/WriteAt/Truncate/Sync), FS with inode tree + dirs, persisted metadata blob, SQLite roundtrip proven (20 KiB DB → encrypted blocks → restore → PRAGMA integrity_check ok) |
✅ shipped |
| 0.3.0 | bazil.org/fuse mount on Linux: kernel POSIX read/write/fsync/setattr land at File.{ReadAt,WriteAt,Sync,Truncate}; SQLite opens DB directly on the mountpoint with no copy step. End-to-end FUSE+SQLite test verifies create→100 INSERTs→close→umount→remount→PRAGMA integrity_check ok, 100 rows survived. macOS via jacobsa/fuse lands in 0.3.1. |
✅ shipped |
| 0.3.1 | macOS FUSE via github.com/jacobsa/fuse — verified live on macOS with fuse-t: TestFUSESQLite … 100 rows survived umount+remount, integrity_check=ok. Linux uses bazil/fuse, macOS uses jacobsa/fuse, same Mount(*vfs.FS, mountpoint) signature. The fuse build tag picks the right driver per GOOS. |
✅ shipped |
| 0.4.0 | NVMe disk write-back cache: spill LRU evictions to a local fs cache (configurable via --cache-dir /var/cache/vfs --cache-size 10Gi). Survives process restarts. |
|
| 0.5.0 | gcs + azureblob backends | |
| 0.6.0 | K8s sidecar mode + Helm chart | |
| 0.7.0 | WASI guest mode (browser/dev embedding) | |
| 1.0.0 | Production-hardened: chunked uploads, multipart resume, GC for orphan blocks (block reference-count map at metadata/refs.zap.age), metrics (Prometheus / OTel) + SLO targets |
Rules
- NEVER store plaintext blocks on the backend. Encryption happens before write, decryption after read; the backend interface only sees ciphertext bytes.
- NEVER allocate disk space proactively for the backend. The whole point is "unlimited" — let S3 deal with capacity.
- NEVER mix block sizes within one filesystem. 4 KiB is canonical; future formats bump the on-disk magic byte.
- NEVER skip age recipient verification on read. A block decryption failure is a hard error, not a silent zero.
- CLAUDE.md / AGENTS.md are symlinks to LLM.md — never commit them.