initial: vfs scaffold (block layer + file:// + s3 backends + age PQ crypto)

Phase 0.1.0 — production-shaped scaffold for the S3-backed virtual block
filesystem. Stateful services (IAM, BD, ATS, TA, KMS, onyxd, lqd
snapshots) get unlimited disk via S3 with PQ-encrypted blocks; the
hot tier is a configurable LRU cache. Companion to hanzo/replicate
(WAL streaming) — `replicate` is file-level granularity, `vfs` is
block-level (4 KiB pages, content-addressable via blake3-256).

Included
--------
- pkg/backend: pluggable Backend interface + Open(URL) dispatch.
- pkg/backend/file: file:// backend (atomic put via temp+rename, path
  traversal blocked, recursive list with prefix filter).
- pkg/backend/s3: AWS SDK v2 S3 backend with `?endpoint=` override
  for S3-compat (R2, MinIO, Hanzo Storage). NotFound → ErrNotFound.
- pkg/vfs/block: blake3-256 hashing → BlockID, 2-hex shard prefix
  for the backend key, BlockSize=4096, zero-pad to BlockSize before
  encryption (block boundaries don't leak file lengths).
- pkg/vfs/crypto: luxfi/age v1.5.0 wrapper. Recipients (write-side)
  + Identities (read-side); hybrid X25519 + ML-KEM-768 supported via
  the same recipient list.
- pkg/vfs/cache: in-memory LRU bound by total bytes; touch-promotes
  on read; eviction on Put when over cap. NVMe write-back tier lands
  in 0.2.0.
- pkg/vfs: top-level handle. PutBlock/GetBlock with cache hit-path
  + content-hash integrity check on read. Stats() surfaces cache
  + backend metadata.
- pkg/mount: fuse_stub.go (default build) + fuse_unix.go (-tags fuse).
  Real FUSE wiring lands in 0.2.0; current stub returns a build-tag
  error pointing at `make build-fuse`.
- cmd/vfs: cobra CLI (put/get/stats/mount). Backend registered via
  blank-import; --age-recipient repeatable; --age-key reads identity
  PEM from file or VFS_AGE_KEY env.

Tests
-----
- block: hash determinism, path shape, verify match/mismatch, pad.
- cache: LRU eviction, touch-promotion, basic ops.
- file backend: put/get/delete/stat/list, path-traversal block.
- vfs roundtrip: encrypt → backend → decrypt → byte-equal plaintext,
  zero-pad correctness; multiple writes of identical plaintext both
  round-trip; stats reflect insertions.

CI / build
----------
- .github/workflows/build.yml: vet + race-tests on Go 1.24, amd64
  binary upload, GHCR image build + push on main / v* tags.
- Dockerfile: distroless static, CGO_ENABLED=0, ARG VERSION ldflag.
- Makefile: build / build-fuse / test / fmt / vet / check / image /
  install. VERSION-pinned.

Roadmap
-------
- 0.2.0: bazil.org/fuse mount, NVMe disk cache layer
- 0.3.0: gcs + azureblob backends
- 0.4.0: K8s sidecar mode + Helm chart
- 0.5.0: WASI guest mode
- 1.0.0: chunked uploads, multipart resume, GC for orphan blocks,
  metrics + SLO targets
This commit is contained in:
Hanzo Dev
2026-05-08 10:59:05 -07:00
commit 837e5bac96
16 changed files with 1098 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Build
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
permissions:
contents: read
packages: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: vet
run: go vet ./...
- name: test
run: go test -race ./...
build-amd64:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: build
run: |
VERSION=$(tr -d ' \n\r\t' < VERSION)
mkdir -p dist
CGO_ENABLED=0 go build \
-ldflags "-X main.version=${VERSION}" \
-o dist/vfs-linux-amd64 ./cmd/vfs
- uses: actions/upload-artifact@v4
with:
name: vfs-linux-amd64
path: dist/vfs-linux-amd64
image:
needs: test
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: login ghcr
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: build + push
run: |
VERSION=$(tr -d ' \n\r\t' < VERSION)
IMG=ghcr.io/hanzoai/vfs
docker build --build-arg VERSION=${VERSION} -t ${IMG}:${VERSION} .
docker tag ${IMG}:${VERSION} ${IMG}:latest
docker push ${IMG}:${VERSION}
docker push ${IMG}:latest
+11
View File
@@ -0,0 +1,11 @@
bin/
vfs
*.test
*.out
.DS_Store
.idea/
.vscode/
dist/
.cache/
CLAUDE.md
AGENTS.md
+14
View File
@@ -0,0 +1,14 @@
FROM golang:1.24-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build \
-ldflags "-X main.version=${VERSION}" \
-o /vfs ./cmd/vfs
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /vfs /usr/local/bin/vfs
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/vfs"]
+17
View File
@@ -0,0 +1,17 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2026 Hanzo AI, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+193
View File
@@ -0,0 +1,193 @@
# 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
```bash
# 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:
```yaml
spec:
containers:
- name:
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 (requires `bazil.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 |
|---|---|
| 0.1.0 (this commit) | Block layer, file:// + s3 backends, age PQ crypto, LRU cache, CLI scaffolding (put/get/stats), tests |
| 0.2.0 | FUSE mount via bazil.org/fuse (Linux/macOS) |
| 0.3.0 | gcs + azureblob backends |
| 0.4.0 | K8s sidecar mode + Helm chart |
| 0.5.0 | WASI guest mode (browser/dev embedding) |
| 1.0.0 | Production-hardened: chunked uploads, multipart resume, GC for orphan blocks, metrics + SLO targets |
## Rules
1. NEVER store plaintext blocks on the backend. Encryption happens
before write, decryption after read; the backend interface only
sees ciphertext bytes.
2. NEVER allocate disk space proactively for the backend. The whole
point is "unlimited" — let S3 deal with capacity.
3. NEVER mix block sizes within one filesystem. 4 KiB is canonical;
future formats bump the on-disk magic byte.
4. NEVER skip age recipient verification on read. A block decryption
failure is a hard error, not a silent zero.
5. CLAUDE.md / AGENTS.md are symlinks to LLM.md — never commit them.
+38
View File
@@ -0,0 +1,38 @@
SHELL := /usr/bin/env bash
VERSION := $(shell tr -d ' \n\r\t' < VERSION)
BIN := bin/vfs
.PHONY: help build test fmt vet check clean install image
.DEFAULT_GOAL := help
help: ## Show targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
build: ## Build vfs CLI
@mkdir -p bin
go build -ldflags "-X main.version=$(VERSION)" -o $(BIN) ./cmd/vfs
build-fuse: ## Build with FUSE mount support (Linux/macOS)
@mkdir -p bin
go build -tags=fuse -ldflags "-X main.version=$(VERSION)" -o $(BIN) ./cmd/vfs
test: ## Run unit tests
go test -race ./...
fmt: ## gofmt
gofmt -s -w .
vet: ## go vet
go vet ./...
check: fmt vet test ## fmt + vet + test
install: build ## Install to $GOBIN
go install -ldflags "-X main.version=$(VERSION)" ./cmd/vfs
clean: ## Clean build artifacts
rm -rf bin/ dist/
image: ## Build container image
docker build --build-arg VERSION=$(VERSION) -t ghcr.io/hanzoai/vfs:$(VERSION) .
+50
View File
@@ -0,0 +1,50 @@
# vfs
S3-backed virtual block filesystem with PQ encryption — unlimited write storage for stateful services.
[![Build](https://github.com/hanzoai/vfs/actions/workflows/build.yml/badge.svg)](https://github.com/hanzoai/vfs/actions/workflows/build.yml)
## Quick start
```bash
# Build
make build
# Generate an age key (one-time)
go run filippo.io/age/cmd/age-keygen > /tmp/vfs.key
RECIPIENT=$(grep 'public key' /tmp/vfs.key | cut -d: -f2 | tr -d ' ')
# Put + get a block (file:// backend, dev only)
echo "hello world" > /tmp/in.txt
ID=$(./bin/vfs put /tmp/in.txt --backend file:///tmp/vfs-store --age-recipient "$RECIPIENT" --age-key /tmp/vfs.key)
echo "block: $ID"
./bin/vfs get "$ID" --backend file:///tmp/vfs-store --age-key /tmp/vfs.key
# Same flow against S3
./bin/vfs put /tmp/in.txt \
--backend "s3://my-bucket/vfs-prefix?region=us-east-1" \
--age-recipient "$RECIPIENT" \
--age-key /tmp/vfs.key
```
## What it is
A 4 KiB block-level virtual filesystem that:
- Hashes every block with **blake3-256** for content addressability + integrity.
- Encrypts every block with **luxfi/age** (X25519, optionally hybrid PQ via ML-KEM-768) before it touches the backend.
- Stores blocks as `blocks/<2-hex-shard>/<full-hash>.zap.age` on a pluggable backend (`file://`, `s3://`, `gcs://` (TODO), `azureblob://` (TODO)).
- Caches recently-used blocks in an in-memory LRU (NVMe write-back cache lands in 0.2.0).
- Mounts as a FUSE filesystem (`make build-fuse`; mount layer lands in 0.2.0).
## Why
Stateful services in K8s usually pre-size a PVC and either over-provision or run out of disk. `vfs` lets a service write as if it has unlimited local NVMe — the hot tier is a configurable local cache, the cold tier is S3 (or any object store), and the PQ-encrypted blocks let the operator put cold pages in any region without leaking content.
Companion to [hanzo/replicate](https://github.com/hanzoai/replicate) — `replicate` streams SQLite WAL frames file-level; `vfs` does block-level for any file system.
See [`LLM.md`](./LLM.md) for the full architecture, K8s sidecar pattern, encryption design, and roadmap.
## License
Apache 2.0
+1
View File
@@ -0,0 +1 @@
0.1.0
+36
View File
@@ -0,0 +1,36 @@
module github.com/hanzoai/vfs
go 1.24.0
require (
github.com/aws/aws-sdk-go-v2 v1.30.0
github.com/aws/aws-sdk-go-v2/config v1.27.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.55.0
github.com/luxfi/age v1.5.0
github.com/spf13/cobra v1.8.0
github.com/zeebo/blake3 v0.2.4
)
require (
filippo.io/hpke v0.4.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.19.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.22.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.27.0 // indirect
github.com/aws/smithy-go v1.20.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.12 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
)
+66
View File
@@ -0,0 +1,66 @@
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/aws/aws-sdk-go-v2 v1.30.0 h1:6qAwtzlfcTtcL8NHtbDQAqgM5s6NDipQTkPxyH/6kAA=
github.com/aws/aws-sdk-go-v2 v1.30.0/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg=
github.com/aws/aws-sdk-go-v2/config v1.27.0 h1:J5sdGCAHuWKIXLeXiqr8II/adSvetkx0qdZwdbXXpb0=
github.com/aws/aws-sdk-go-v2/config v1.27.0/go.mod h1:cfh8v69nuSUohNFMbIISP2fhmblGmYEOKs5V53HiHnk=
github.com/aws/aws-sdk-go-v2/credentials v1.17.0 h1:lMW2x6sKBsiAJrpi1doOXqWFyEPoE886DTb1X0wb7So=
github.com/aws/aws-sdk-go-v2/credentials v1.17.0/go.mod h1:uT41FIH8cCIxOdUYIL0PYyHlL1NoneDuDSCwg5VE/5o=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0 h1:xWCwjjvVz2ojYTP4kBKUuUh9ZrXfcAXpflhOUUeXg1k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0/go.mod h1:j3fACuqXg4oMTQOR2yY7m0NmJY0yBK4L4sLsRXq1Ins=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8 h1:RnLB7p6aaFMRfyQkD6ckxR7myCC9SABIqSz4czYUUbU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8/go.mod h1:XH7dQJd+56wEbP1I4e4Duo+QhSMxNArE8VP7NuUOTeM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8 h1:jzApk2f58L9yW9q1GEab3BMMFWUkkiZhyrRUtbwUbKU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8/go.mod h1:WqO+FftfO3tGePUtQxPXM6iODVfqMwsVMgTbG/ZXIdQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.8 h1:jH33S0y5Bo5ZVML62JgZhjd/LrtU+vbR8W7XnIE3Srk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.8/go.mod h1:hD5YwHLOy6k7d6kqcn3me1bFWHOtzhaXstMd6BpdB68=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.10 h1:pkYC5zTOSPXEYJj56b2SOik9AL432i5MT1YVTQbKOK0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.10/go.mod h1:/WNsBOlKWZCG3PMh2aSp8vkyyT/clpMZqOtrnIKqGfk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.10 h1:7kZqP7akv0enu6ykJhb9OYlw16oOrSy+Epus8o/VqMY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.10/go.mod h1:gYVF3nM1ApfTRDj9pvdhootBb8WbiIejuqn4w8ruMes=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.8 h1:iQNXVs1vtaq+y9M90M4ZIVNORje0qXTscqHLqoOnFS0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.8/go.mod h1:yUQPRlWqGG0lfNsmjbRWKVwgilfBtZTOFSLEYALlAig=
github.com/aws/aws-sdk-go-v2/service/s3 v1.55.0 h1:6kq0Xql9qiwNGL/Go87ZqR4otg9jnKs71OfWCVbPxLM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.55.0/go.mod h1:oSkRFuHVWmUY4Ssk16ErGzBqvYEbvORJFzFXzWhTB2s=
github.com/aws/aws-sdk-go-v2/service/sso v1.19.0 h1:u6OkVDxtBPnxPkZ9/63ynEe+8kHbtS5IfaC4PzVxzWM=
github.com/aws/aws-sdk-go-v2/service/sso v1.19.0/go.mod h1:YqbU3RS/pkDVu+v+Nwxvn0i1WB0HkNWEePWbmODEbbs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.22.0 h1:6DL0qu5+315wbsAEEmzK+P9leRwNbkp+lGjPC+CEvb8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.22.0/go.mod h1:olUAyg+FaoFaL/zFaeQQONjOZ9HXoxgvI/c7mQTYz7M=
github.com/aws/aws-sdk-go-v2/service/sts v1.27.0 h1:cjTRjh700H36MQ8M0LnDn33W3JmwC77mdxIIyPWCdpM=
github.com/aws/aws-sdk-go-v2/service/sts v1.27.0/go.mod h1:nXfOBMWPokIbOY+Gi7a1psWMSvskUCemZzI+SMB7Akc=
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/luxfi/age v1.5.0 h1:G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=
github.com/luxfi/age v1.5.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+115
View File
@@ -0,0 +1,115 @@
// Package backend defines the object-store interface that VFS writes
// encrypted blocks against. Implementations cover S3, GCS, Azure Blob,
// and a `file://` adapter for local dev. The interface is intentionally
// minimal: VFS does its own content addressing and encryption above
// this layer; the backend is just a typed key→bytes store.
package backend
import (
"context"
"errors"
"fmt"
"io"
"net/url"
)
// ErrNotFound is returned by Get when the requested key is absent.
var ErrNotFound = errors.New("backend: key not found")
// Backend is the contract every object-store adapter implements.
//
// Keys are arbitrary `/`-delimited strings; VFS uses
// `blocks/<blake3-prefix>/<full-hash>.zap.age` for block storage.
//
// Implementations MUST be safe for concurrent use. Get/Put/Delete are
// idempotent — repeating the same operation is allowed and SHOULD NOT
// surface as an error.
type Backend interface {
// Get returns the bytes stored at key. If the key does not exist,
// the returned error wraps ErrNotFound (errors.Is(err, ErrNotFound)).
Get(ctx context.Context, key string) ([]byte, error)
// Put writes data at key. Overwriting is allowed.
Put(ctx context.Context, key string, data []byte) error
// Delete removes the key. Removing a non-existent key MUST NOT error.
Delete(ctx context.Context, key string) error
// List returns keys with the given prefix. The returned channel is
// closed when the listing completes; on error the channel is closed
// after the error is sent on errCh.
List(ctx context.Context, prefix string) (keys <-chan string, errCh <-chan error)
// Stat returns the size in bytes for key without fetching the body.
// ErrNotFound is returned if the key is absent.
Stat(ctx context.Context, key string) (size int64, err error)
// Close releases any resources (HTTP connections, file handles).
// Safe to call multiple times.
Close() error
// String returns a stable, human-readable description of the
// backend (e.g. "s3://bucket/prefix").
String() string
}
// Reader returns an io.ReadCloser when streaming is preferred over
// in-memory Get. Optional — implementations may return ErrNotImplemented.
type Reader interface {
GetReader(ctx context.Context, key string) (io.ReadCloser, error)
}
// Writer returns an io.WriteCloser for streaming uploads. Optional.
type Writer interface {
PutWriter(ctx context.Context, key string) (io.WriteCloser, error)
}
// ErrNotImplemented signals that a backend doesn't support the optional
// streaming interface for the given operation.
var ErrNotImplemented = errors.New("backend: operation not implemented")
// Open dispatches a backend URL to the right implementation. Recognised
// schemes:
//
// file://path — local filesystem (dev only)
// s3://bucket/prefix — AWS S3 / S3-compatible (R2, Hanzo Storage, MinIO)
// gcs://bucket/prefix — Google Cloud Storage (TODO)
// azureblob://... — Azure Blob (TODO)
//
// The opener function is registered by each backend package's init().
type Opener func(ctx context.Context, u *url.URL) (Backend, error)
var openers = map[string]Opener{}
// Register attaches an Opener for a URL scheme. Called from backend
// implementations' init().
func Register(scheme string, opener Opener) {
if scheme == "" || opener == nil {
panic("backend.Register: scheme and opener are required")
}
openers[scheme] = opener
}
// Open parses the backend URL and dispatches to the registered opener.
func Open(ctx context.Context, raw string) (Backend, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("backend.Open: parse %q: %w", raw, err)
}
if u.Scheme == "" {
return nil, fmt.Errorf("backend.Open: missing scheme in %q", raw)
}
opener, ok := openers[u.Scheme]
if !ok {
return nil, fmt.Errorf("backend.Open: no opener registered for %q (have: %v)", u.Scheme, registeredSchemes())
}
return opener(ctx, u)
}
func registeredSchemes() []string {
out := make([]string, 0, len(openers))
for k := range openers {
out = append(out, k)
}
return out
}
+177
View File
@@ -0,0 +1,177 @@
// Package file is a filesystem-backed Backend for local dev. NOT
// suitable for production — no fsync barrier discipline, no checksum.
package file
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"github.com/hanzoai/vfs/pkg/backend"
)
func init() {
backend.Register("file", openFile)
}
type fileBackend struct {
root string
closeOnce sync.Once
closed bool
mu sync.Mutex
}
func openFile(_ context.Context, u *url.URL) (backend.Backend, error) {
// file:///abs/path → u.Path = "/abs/path"
// file://./relpath → u.Host = ".", u.Path = "/relpath"
root := u.Path
if u.Host != "" && u.Host != "localhost" {
root = filepath.Join(u.Host, u.Path)
}
if root == "" {
return nil, fmt.Errorf("file backend: missing path in %q", u.String())
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("file backend: mkdir %q: %w", root, err)
}
return &fileBackend{root: root}, nil
}
func (f *fileBackend) keyPath(key string) string {
// Disallow "..". Backend keys are simple `/`-delimited names; never user paths.
if strings.Contains(key, "..") {
return ""
}
return filepath.Join(f.root, filepath.FromSlash(key))
}
func (f *fileBackend) Get(_ context.Context, key string) ([]byte, error) {
p := f.keyPath(key)
if p == "" {
return nil, fmt.Errorf("file backend: invalid key %q", key)
}
b, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", backend.ErrNotFound, key)
}
return nil, err
}
return b, nil
}
func (f *fileBackend) Put(_ context.Context, key string, data []byte) error {
p := f.keyPath(key)
if p == "" {
return fmt.Errorf("file backend: invalid key %q", key)
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
// Atomic write: temp file + rename.
tmp, err := os.CreateTemp(filepath.Dir(p), ".vfs-"+filepath.Base(p)+"-*")
if err != nil {
return err
}
defer func() {
_ = os.Remove(tmp.Name())
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), p)
}
func (f *fileBackend) Delete(_ context.Context, key string) error {
p := f.keyPath(key)
if p == "" {
return fmt.Errorf("file backend: invalid key %q", key)
}
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
func (f *fileBackend) List(ctx context.Context, prefix string) (<-chan string, <-chan error) {
keys := make(chan string, 64)
errCh := make(chan error, 1)
go func() {
defer close(keys)
defer close(errCh)
base := f.root
walkRoot := base
if prefix != "" {
walkRoot = filepath.Join(base, filepath.FromSlash(prefix))
}
err := filepath.WalkDir(walkRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil // empty prefix
}
return err
}
if d.IsDir() {
return nil
}
rel, rerr := filepath.Rel(base, path)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
select {
case <-ctx.Done():
return ctx.Err()
case keys <- rel:
}
return nil
})
if err != nil && !errors.Is(err, context.Canceled) {
errCh <- err
}
}()
return keys, errCh
}
func (f *fileBackend) Stat(_ context.Context, key string) (int64, error) {
p := f.keyPath(key)
if p == "" {
return 0, fmt.Errorf("file backend: invalid key %q", key)
}
st, err := os.Stat(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return 0, fmt.Errorf("%w: %s", backend.ErrNotFound, key)
}
return 0, err
}
return st.Size(), nil
}
func (f *fileBackend) Close() error {
f.closeOnce.Do(func() { f.closed = true })
return nil
}
func (f *fileBackend) String() string { return "file://" + f.root }
// Compile-time interface check.
var _ backend.Backend = (*fileBackend)(nil)
// Unused but reserved for streaming when we need it.
type _ = io.Reader
+96
View File
@@ -0,0 +1,96 @@
package file
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/hanzoai/vfs/pkg/backend"
)
func newFileBE(t *testing.T) (backend.Backend, string) {
t.Helper()
dir := t.TempDir()
be, err := backend.Open(context.Background(), "file://"+filepath.ToSlash(dir))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = be.Close() })
return be, dir
}
func TestFilePutGetDelete(t *testing.T) {
be, _ := newFileBE(t)
ctx := context.Background()
if err := be.Put(ctx, "blocks/aa/abc.zap.age", []byte("hello")); err != nil {
t.Fatalf("put: %v", err)
}
got, err := be.Get(ctx, "blocks/aa/abc.zap.age")
if err != nil {
t.Fatalf("get: %v", err)
}
if string(got) != "hello" {
t.Fatalf("got %q want hello", got)
}
sz, err := be.Stat(ctx, "blocks/aa/abc.zap.age")
if err != nil {
t.Fatalf("stat: %v", err)
}
if sz != 5 {
t.Fatalf("stat size %d want 5", sz)
}
if err := be.Delete(ctx, "blocks/aa/abc.zap.age"); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := be.Get(ctx, "blocks/aa/abc.zap.age"); !errors.Is(err, backend.ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
// Delete-of-absent must not error
if err := be.Delete(ctx, "blocks/zz/missing.zap.age"); err != nil {
t.Fatalf("idempotent delete: %v", err)
}
}
func TestFileList(t *testing.T) {
be, _ := newFileBE(t)
ctx := context.Background()
keys := []string{
"blocks/aa/1.zap.age",
"blocks/aa/2.zap.age",
"blocks/bb/3.zap.age",
}
for _, k := range keys {
if err := be.Put(ctx, k, []byte("x")); err != nil {
t.Fatalf("put %s: %v", k, err)
}
}
ch, errCh := be.List(ctx, "blocks/aa")
got := map[string]bool{}
for k := range ch {
got[k] = true
}
if err := <-errCh; err != nil {
t.Fatalf("list err: %v", err)
}
if !got["blocks/aa/1.zap.age"] || !got["blocks/aa/2.zap.age"] {
t.Fatalf("missing aa keys: %v", got)
}
if got["blocks/bb/3.zap.age"] {
t.Fatalf("bb key leaked into aa prefix list: %v", got)
}
}
func TestFileTraversalBlocked(t *testing.T) {
be, _ := newFileBE(t)
ctx := context.Background()
if err := be.Put(ctx, "../../../etc/evil", []byte("x")); err == nil {
t.Fatal("path traversal should be blocked")
}
}
+168
View File
@@ -0,0 +1,168 @@
// Package s3 is an AWS S3 / S3-compatible Backend.
//
// Endpoint override (R2, Hanzo Storage, MinIO) via AWS_ENDPOINT_URL or
// the URL's `endpoint` query parameter.
package s3
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/url"
"strings"
"sync"
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
s3v2 "github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/hanzoai/vfs/pkg/backend"
)
func init() {
backend.Register("s3", openS3)
}
type s3Backend struct {
client *s3v2.Client
bucket string
prefix string
desc string
closeOnce sync.Once
}
func openS3(ctx context.Context, u *url.URL) (backend.Backend, error) {
bucket := u.Host
if bucket == "" {
return nil, fmt.Errorf("s3 backend: missing bucket in %q", u.String())
}
prefix := strings.TrimPrefix(u.Path, "/")
loadOpts := []func(*config.LoadOptions) error{}
if region := u.Query().Get("region"); region != "" {
loadOpts = append(loadOpts, config.WithRegion(region))
}
cfg, err := config.LoadDefaultConfig(ctx, loadOpts...)
if err != nil {
return nil, fmt.Errorf("s3 backend: load config: %w", err)
}
clientOpts := []func(*s3v2.Options){}
if endpoint := u.Query().Get("endpoint"); endpoint != "" {
clientOpts = append(clientOpts, func(o *s3v2.Options) {
o.BaseEndpoint = awsv2.String(endpoint)
o.UsePathStyle = true
})
}
cli := s3v2.NewFromConfig(cfg, clientOpts...)
return &s3Backend{
client: cli,
bucket: bucket,
prefix: prefix,
desc: u.String(),
}, nil
}
func (s *s3Backend) k(key string) string {
if s.prefix == "" {
return key
}
return s.prefix + "/" + key
}
func (s *s3Backend) Get(ctx context.Context, key string) ([]byte, error) {
out, err := s.client.GetObject(ctx, &s3v2.GetObjectInput{
Bucket: awsv2.String(s.bucket),
Key: awsv2.String(s.k(key)),
})
if err != nil {
var nsk *s3types.NoSuchKey
if errors.As(err, &nsk) {
return nil, fmt.Errorf("%w: %s", backend.ErrNotFound, key)
}
return nil, err
}
defer out.Body.Close()
return io.ReadAll(out.Body)
}
func (s *s3Backend) Put(ctx context.Context, key string, data []byte) error {
_, err := s.client.PutObject(ctx, &s3v2.PutObjectInput{
Bucket: awsv2.String(s.bucket),
Key: awsv2.String(s.k(key)),
Body: bytes.NewReader(data),
})
return err
}
func (s *s3Backend) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3v2.DeleteObjectInput{
Bucket: awsv2.String(s.bucket),
Key: awsv2.String(s.k(key)),
})
// S3 DeleteObject is idempotent — absent keys are not an error.
return err
}
func (s *s3Backend) List(ctx context.Context, prefix string) (<-chan string, <-chan error) {
keys := make(chan string, 256)
errCh := make(chan error, 1)
go func() {
defer close(keys)
defer close(errCh)
fullPrefix := s.k(prefix)
paginator := s3v2.NewListObjectsV2Paginator(s.client, &s3v2.ListObjectsV2Input{
Bucket: awsv2.String(s.bucket),
Prefix: awsv2.String(fullPrefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
errCh <- err
return
}
for _, obj := range page.Contents {
k := awsv2.ToString(obj.Key)
if s.prefix != "" {
k = strings.TrimPrefix(k, s.prefix+"/")
}
select {
case <-ctx.Done():
return
case keys <- k:
}
}
}
}()
return keys, errCh
}
func (s *s3Backend) Stat(ctx context.Context, key string) (int64, error) {
out, err := s.client.HeadObject(ctx, &s3v2.HeadObjectInput{
Bucket: awsv2.String(s.bucket),
Key: awsv2.String(s.k(key)),
})
if err != nil {
var nfe *s3types.NotFound
if errors.As(err, &nfe) {
return 0, fmt.Errorf("%w: %s", backend.ErrNotFound, key)
}
return 0, err
}
return awsv2.ToInt64(out.ContentLength), nil
}
func (s *s3Backend) Close() error {
s.closeOnce.Do(func() {})
return nil
}
func (s *s3Backend) String() string { return s.desc }
var _ backend.Backend = (*s3Backend)(nil)
+21
View File
@@ -0,0 +1,21 @@
//go:build !fuse
// Package mount is the FUSE/WASI mount layer. Without the `fuse` build
// tag this is a stub — `vfs mount` returns a clear error pointing at
// `make build-fuse`.
package mount
import (
"context"
"fmt"
"github.com/hanzoai/vfs/pkg/vfs"
)
// Mount returns a build-tag error. Build with `-tags fuse` to enable.
func Mount(ctx context.Context, v *vfs.VFS, mountpoint string) error {
_ = ctx
_ = v
_ = mountpoint
return fmt.Errorf("mount: this binary was built without FUSE support — use `make build-fuse` or `go build -tags fuse`")
}
+31
View File
@@ -0,0 +1,31 @@
//go:build fuse && !windows
package mount
import (
"context"
"fmt"
"github.com/hanzoai/vfs/pkg/vfs"
)
// Mount mounts a VFS at the given mountpoint via FUSE. Requires the
// `fuse` build tag and the bazil.org/fuse runtime (Linux/macOS).
//
// TODO(0.2.0): full FUSE filesystem implementation. This stub
// compiles under the `fuse` tag but returns a not-yet-implemented
// error. Wiring requires:
// 1. bazil.org/fuse + bazil.org/fuse/fs imports (added to go.mod
// under the fuse tag — keep the default build dependency-light)
// 2. an FS root + Node implementations that map POSIX ops to
// vfs.PutBlock/GetBlock with a per-file inode + extent index
// stored in the same VFS as a special "metadata/" key prefix
// 3. proper handling of unmount on Ctrl-C + SIGTERM
// 4. tests against a tmpfs-backed file:// VFS on Linux CI
func Mount(ctx context.Context, v *vfs.VFS, mountpoint string) error {
_ = ctx
_ = v
_ = mountpoint
return fmt.Errorf("mount: FUSE implementation pending (0.2.0 milestone). " +
"PutBlock/GetBlock work today via the CLI; mount-as-filesystem ships next")
}