0.3.0: bazil.org/fuse mount + SQLite-on-FUSE e2e test (linux)

The mount layer is no longer a stub. With `-tags fuse` on Linux,
SQLite (and any POSIX consumer) opens files directly on the
mountpoint — no intermediate copy. Default builds stay
dependency-light: bazil.org/fuse + bazil.org/fuse/fs are gated
behind the build tag.

What's new
----------
- pkg/mount/fuse_unix.go (`-tags fuse && linux`): full FS/Node/Handle
  bridges. Maps every POSIX op the kernel sends:
    Read       → File.ReadAt
    Write      → File.WriteAt
    Fsync      → File.Sync         (durable through age + backend)
    Flush      → File.Sync         (close-time flush)
    Release    → File.Close
    Setattr.Size → File.Truncate
    Lookup     → FS.Lookup
    ReadDirAll → FS.ReadDir
    Create     → FS.Create + Open
    Mkdir      → FS.Mkdir
    Remove     → FS.Remove
    Open(O_TRUNC) → File.Truncate(0)
- pkg/mount/fuse_stub.go: same Mount/MountWithVFSAdapter signatures
  for the default no-fuse build; returns a build-tag error.
- pkg/mount/fuse_sqlite_test.go: gated by VFS_FUSE_E2E=1 +
  `-tags fuse`. Mounts in tempdir, creates `test.db`, 100 INSERTs,
  unmounts, remounts, runs PRAGMA integrity_check. Verifies the DB
  survives the full encrypt → backend → decrypt round trip with
  zero copy.
- cmd/vfs: mount subcommand wires through MountWithVFSAdapter.

CI
--
.github/workflows/build.yml runs three test passes on Ubuntu:
  1. default `go test -race ./...`
  2. `go vet -tags fuse ./...`
  3. apt-get fuse + `VFS_FUSE_E2E=1 go test -race -tags fuse
     -run TestFUSESQLite ./pkg/mount/ -v`

Caveats
-------
- macOS: bazil.org/fuse dropped macOS support upstream (commit
  65cc252). 0.3.1 will add a parallel mount_darwin.go using
  github.com/jacobsa/fuse so devs on macOS get the same e2e flow.
  Until then, macOS devs use the in-process API + the SQLite
  byte-roundtrip test in pkg/vfs/sqlite_test.go (already shipped).
- Local NVMe disk cache (write-back tier) lands in 0.4.0; today the
  cache is in-memory LRU.
This commit is contained in:
Hanzo Dev
2026-05-08 11:46:16 -07:00
parent 2380df1216
commit 6334500191
10 changed files with 529 additions and 27 deletions
+9 -1
View File
@@ -22,8 +22,16 @@ jobs:
cache: true
- name: vet
run: go vet ./...
- name: test
- name: test (default)
run: go test -race ./...
- name: vet (fuse)
run: go vet -tags fuse ./...
- name: install fuse
run: sudo apt-get update && sudo apt-get install -y fuse
- name: build (fuse)
run: go build -tags fuse ./...
- name: test (fuse + sqlite e2e)
run: VFS_FUSE_E2E=1 go test -race -tags fuse -run TestFUSESQLite ./pkg/mount/ -v
build-amd64:
needs: test
+2 -1
View File
@@ -174,7 +174,8 @@ not `mount`. Use `make build-fuse` for the full mount-capable binary.
|---|---|---|
| 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: kernel POSIX VFS calls land at File.{ReadAt,WriteAt,Sync,Truncate}; SQLite opens DB directly on the mountpoint with no copy step. Linux + macOS. | next |
| 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 (bazil.org/fuse dropped macOS support). Same Mount(*vfs.FS, mountpoint) signature, build-tag `fuse_darwin`. | next |
| 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 | |
+5
View File
@@ -36,3 +36,8 @@ clean: ## Clean build artifacts
image: ## Build container image
docker build --build-arg VERSION=$(VERSION) -t ghcr.io/hanzoai/vfs:$(VERSION) .
test-fuse: ## Run FUSE end-to-end test (Linux only, requires kernel FUSE)
GOOS=linux go vet -tags fuse ./...
@echo "Cross-compile check passed. Run actual e2e on Linux:"
@echo " VFS_FUSE_E2E=1 go test -race -tags fuse -run TestFUSESQLite ./pkg/mount/ -v"
+1 -1
View File
@@ -1 +1 @@
0.2.0
0.3.0
+1 -1
View File
@@ -148,7 +148,7 @@ func main() {
return err
}
defer v.Close()
return mount.Mount(ctx, v, args[0])
return mount.MountWithVFSAdapter(ctx, v, args[0])
},
}
+1
View File
@@ -3,6 +3,7 @@ module github.com/hanzoai/vfs
go 1.25.0
require (
bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5
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
+4
View File
@@ -1,3 +1,5 @@
bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 h1:A0NsYy4lDBZAC6QiYeJ4N+XuHIKBpyhAVRMHRQZKTeQ=
bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5/go.mod h1:gG3RZAMXCa/OTes6rr9EwusmR1OH1tDDy+cg9c5YliY=
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=
@@ -66,6 +68,8 @@ 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/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ=
github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM=
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=
+169
View File
@@ -0,0 +1,169 @@
//go:build fuse && linux
// End-to-end FUSE + SQLite test. Mounts a *vfs.FS at a tempdir,
// opens SQLite directly on the mountpoint (no copy step), runs CREATE
// + INSERT + SELECT + integrity_check, unmounts, remounts, verifies
// the database survived the round trip.
//
// Run with:
// GOOS=linux go test -tags fuse -run TestFUSESQLite ./pkg/mount/
//
// Requires Linux kernel ≥ 3.10 with FUSE support (default on every
// modern distro). The test allocates a tempdir mountpoint and
// auto-unmounts on teardown.
package mount_test
import (
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"bazil.org/fuse"
"github.com/luxfi/age"
_ "modernc.org/sqlite"
"github.com/hanzoai/vfs/pkg/backend"
_ "github.com/hanzoai/vfs/pkg/backend/file"
"github.com/hanzoai/vfs/pkg/mount"
"github.com/hanzoai/vfs/pkg/vfs"
)
func TestFUSESQLite(t *testing.T) {
if os.Getenv("VFS_FUSE_E2E") == "" {
t.Skip("set VFS_FUSE_E2E=1 to run FUSE e2e tests (requires Linux kernel FUSE + privileges)")
}
tmp := t.TempDir()
storeDir := filepath.Join(tmp, "store")
mountpoint := filepath.Join(tmp, "mnt")
if err := os.MkdirAll(mountpoint, 0o755); err != nil {
t.Fatalf("mkdir mountpoint: %v", err)
}
defer func() {
// Best-effort unmount on test exit; fuse.Unmount may already
// have run via Mount's ctx-cancel goroutine.
_ = fuse.Unmount(mountpoint)
_ = exec.Command("fusermount", "-u", mountpoint).Run()
}()
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("age: %v", err)
}
mountFS := func() (*vfs.FS, context.CancelFunc, *sync.WaitGroup) {
t.Helper()
be, err := backend.Open(context.Background(), "file://"+storeDir)
if err != nil {
t.Fatalf("backend.Open: %v", err)
}
c, err := vfs.NewCrypto([]age.Recipient{id.Recipient()}, []age.Identity{id})
if err != nil {
t.Fatalf("NewCrypto: %v", err)
}
v, err := vfs.New(vfs.Config{Backend: be, Crypto: c})
if err != nil {
t.Fatalf("vfs.New: %v", err)
}
fs, err := vfs.NewFS(context.Background(), v)
if err != nil {
t.Fatalf("NewFS: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if err := mount.Mount(ctx, fs, mountpoint); err != nil &&
!strings.Contains(err.Error(), "transport endpoint is not connected") {
t.Logf("Mount returned: %v", err)
}
}()
// Wait for mount to be live.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(mountpoint); err == nil {
if entries, _ := os.ReadDir(mountpoint); entries != nil {
break
}
}
time.Sleep(50 * time.Millisecond)
}
return fs, cancel, &wg
}
dbPath := filepath.Join(mountpoint, "test.db")
// === First mount: create + populate the DB ===
{
_, cancel, wg := mountFS()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
cancel()
wg.Wait()
t.Fatalf("sql.Open via FUSE: %v", err)
}
if _, err := db.Exec(`CREATE TABLE coins (sym TEXT PRIMARY KEY, name TEXT, decimals INT)`); err != nil {
t.Fatalf("CREATE: %v", err)
}
stmt, err := db.Prepare(`INSERT INTO coins VALUES (?, ?, ?)`)
if err != nil {
t.Fatalf("Prepare: %v", err)
}
for i := 0; i < 100; i++ {
if _, err := stmt.Exec(fmt.Sprintf("_%d", i), "Liquidity", 18); err != nil {
t.Fatalf("INSERT %d: %v", i, err)
}
}
_ = stmt.Close()
var got int
if err := db.QueryRow(`SELECT count(*) FROM coins`).Scan(&got); err != nil {
t.Fatalf("SELECT: %v", err)
}
if got != 100 {
t.Fatalf("count=%d want 100", got)
}
if err := db.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
cancel()
wg.Wait()
}
// === Second mount: re-open the DB, verify integrity ===
{
_, cancel, wg := mountFS()
defer func() { cancel(); wg.Wait() }()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("sql.Open after remount: %v", err)
}
defer db.Close()
var ok string
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&ok); err != nil {
t.Fatalf("integrity_check: %v", err)
}
if !strings.EqualFold(ok, "ok") {
t.Fatalf("integrity_check = %q want ok", ok)
}
var count int
if err := db.QueryRow(`SELECT count(*) FROM coins`).Scan(&count); err != nil {
t.Fatalf("SELECT count after remount: %v", err)
}
if count != 100 {
t.Fatalf("after remount count=%d want 100", count)
}
t.Logf("FUSE+SQLite e2e: %d rows survived umount+remount, integrity_check=%s", count, ok)
}
}
+6 -4
View File
@@ -13,9 +13,11 @@ import (
)
// 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
func Mount(_ context.Context, _ *vfs.FS, _ string) error {
return fmt.Errorf("mount: this binary was built without FUSE support — use `make build-fuse` or `go build -tags fuse`")
}
// MountWithVFSAdapter is the same stub for the CLI helper.
func MountWithVFSAdapter(_ context.Context, _ *vfs.VFS, _ string) error {
return Mount(nil, nil, "")
}
+331 -19
View File
@@ -1,31 +1,343 @@
//go:build fuse && !windows
//go:build fuse && linux
// NOTE: bazil.org/fuse dropped macOS support (commit 65cc252+). On
// macOS use the default no-fuse build for in-process testing, or
// switch to github.com/jacobsa/fuse if you need a real FUSE mount
// (a 0.3.1 follow-up adds a parallel mount_darwin.go using jacobsa).
// Package mount wires the in-process *vfs.FS and *vfs.File to the
// kernel via bazil.org/fuse so any POSIX consumer (SQLite, Postgres,
// Redis, ripgrep, anything) can read/write the encrypted block VFS as
// if it were a normal mounted filesystem.
//
// Build-tag gated: `go build -tags fuse ./...`. Default builds stay
// dependency-light (no FUSE link).
package mount
import (
"context"
"errors"
"fmt"
"io"
"os"
"syscall"
"time"
"bazil.org/fuse"
bzfs "bazil.org/fuse/fs"
"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).
// Mount mounts the given *vfs.FS at mountpoint and serves until the
// context is cancelled or the kernel unmounts the filesystem.
//
// 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")
// The caller is responsible for unmount-on-shutdown — typically:
//
// go func() { <-ctx.Done(); _ = fuse.Unmount(mountpoint) }()
//
// On clean exit Mount returns nil; on kernel-side error it returns
// the underlying fuse error.
func Mount(ctx context.Context, fs *vfs.FS, mountpoint string) error {
if fs == nil {
return errors.New("mount: vfs.FS required")
}
if mountpoint == "" {
return errors.New("mount: mountpoint required")
}
conn, err := fuse.Mount(mountpoint,
fuse.FSName("vfs"),
fuse.Subtype("hanzovfs"),
fuse.AllowNonEmptyMount(),
)
if err != nil {
return fmt.Errorf("fuse.Mount %q: %w", mountpoint, err)
}
defer conn.Close()
// Ensure unmount on context cancel.
go func() {
<-ctx.Done()
_ = fuse.Unmount(mountpoint)
}()
srv := bzfs.New(conn, nil)
if err := srv.Serve(&fsRoot{fs: fs, ctx: ctx}); err != nil {
return fmt.Errorf("fuse.Serve: %w", err)
}
return nil
}
// MountWithVFSAdapter is the alias used by the CLI when it constructs
// a *vfs.VFS first and only wraps it as a *vfs.FS at mount time.
//
// It exists so the public Mount(*vfs.FS, mp) signature stays canonical
// while the cmd path can pass the cheaper *vfs.VFS form.
func MountWithVFSAdapter(ctx context.Context, v *vfs.VFS, mountpoint string) error {
fs, err := vfs.NewFS(ctx, v)
if err != nil {
return fmt.Errorf("mount: NewFS: %w", err)
}
return Mount(ctx, fs, mountpoint)
}
// ============================================================================
// fs.FS — root + node lookup
// ============================================================================
type fsRoot struct {
fs *vfs.FS
ctx context.Context
}
var _ bzfs.FS = (*fsRoot)(nil)
func (r *fsRoot) Root() (bzfs.Node, error) {
in, err := r.fs.Lookup("/")
if err != nil {
return nil, err
}
return &node{root: r, inode: in, path: "/"}, nil
}
// ============================================================================
// fs.Node — dir + file
// ============================================================================
type node struct {
root *fsRoot
inode *vfs.Inode
path string
}
var (
_ bzfs.Node = (*node)(nil)
_ bzfs.NodeStringLookuper = (*node)(nil)
_ bzfs.HandleReadDirAller = (*node)(nil)
_ bzfs.NodeMkdirer = (*node)(nil)
_ bzfs.NodeCreater = (*node)(nil)
_ bzfs.NodeRemover = (*node)(nil)
_ bzfs.NodeSetattrer = (*node)(nil)
_ bzfs.NodeFsyncer = (*node)(nil)
)
func (n *node) Attr(_ context.Context, a *fuse.Attr) error {
// Re-fetch in case another writer mutated the inode.
fresh, err := n.root.fs.Lookup(n.path)
if err != nil {
return err
}
n.inode = fresh
a.Inode = uint64(fresh.ID)
a.Mode = os.FileMode(fresh.Mode)
a.Size = fresh.Size
a.Blocks = (fresh.Size + 511) / 512
a.Mtime = fresh.Mtime
a.Atime = fresh.Atime
a.Ctime = fresh.Ctime
a.Nlink = 1
a.Uid = uint32(os.Getuid())
a.Gid = uint32(os.Getgid())
a.BlockSize = vfs.BlockSize
return nil
}
func (n *node) Lookup(_ context.Context, name string) (bzfs.Node, error) {
child := joinPath(n.path, name)
in, err := n.root.fs.Lookup(child)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, syscall.ENOENT
}
return nil, err
}
return &node{root: n.root, inode: in, path: child}, nil
}
func (n *node) ReadDirAll(_ context.Context) ([]fuse.Dirent, error) {
if !n.inode.IsDir() {
return nil, syscall.ENOTDIR
}
children, err := n.root.fs.ReadDir(n.path)
if err != nil {
return nil, err
}
out := make([]fuse.Dirent, 0, len(children))
for _, c := range children {
typ := fuse.DT_File
if c.IsDir() {
typ = fuse.DT_Dir
}
out = append(out, fuse.Dirent{
Inode: uint64(c.ID),
Name: c.Name,
Type: typ,
})
}
return out, nil
}
func (n *node) Mkdir(_ context.Context, req *fuse.MkdirRequest) (bzfs.Node, error) {
child := joinPath(n.path, req.Name)
in, err := n.root.fs.Mkdir(child, uint32(req.Mode.Perm()))
if err != nil {
return nil, err
}
return &node{root: n.root, inode: in, path: child}, nil
}
func (n *node) Create(ctx context.Context, req *fuse.CreateRequest, resp *fuse.CreateResponse) (bzfs.Node, bzfs.Handle, error) {
child := joinPath(n.path, req.Name)
in, err := n.root.fs.Create(child, uint32(req.Mode.Perm()))
if err != nil {
return nil, nil, err
}
cn := &node{root: n.root, inode: in, path: child}
f, err := n.root.fs.Open(ctx, child)
if err != nil {
return nil, nil, err
}
resp.Attr.Inode = uint64(in.ID)
resp.Attr.Mode = os.FileMode(in.Mode)
resp.Attr.Size = in.Size
resp.Attr.Mtime = in.Mtime
resp.Attr.Atime = in.Atime
resp.Attr.Ctime = in.Ctime
resp.Attr.Nlink = 1
resp.Attr.Uid = uint32(os.Getuid())
resp.Attr.Gid = uint32(os.Getgid())
resp.Attr.BlockSize = vfs.BlockSize
return cn, &handle{node: cn, file: f}, nil
}
func (n *node) Remove(_ context.Context, req *fuse.RemoveRequest) error {
child := joinPath(n.path, req.Name)
if err := n.root.fs.Remove(child); err != nil {
return err
}
return nil
}
func (n *node) Setattr(_ context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
if req.Valid.Size() {
f, err := n.root.fs.Open(n.root.ctx, n.path)
if err != nil {
return err
}
defer f.Close()
if err := f.Truncate(req.Size); err != nil {
return err
}
if err := f.Sync(); err != nil {
return err
}
}
// Refresh attr for response.
fresh, err := n.root.fs.Lookup(n.path)
if err != nil {
return err
}
n.inode = fresh
resp.Attr.Inode = uint64(fresh.ID)
resp.Attr.Mode = os.FileMode(fresh.Mode)
resp.Attr.Size = fresh.Size
resp.Attr.Mtime = fresh.Mtime
resp.Attr.Atime = fresh.Atime
resp.Attr.Ctime = fresh.Ctime
resp.Attr.Nlink = 1
resp.Attr.Uid = uint32(os.Getuid())
resp.Attr.Gid = uint32(os.Getgid())
resp.Attr.BlockSize = vfs.BlockSize
return nil
}
func (n *node) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (bzfs.Handle, error) {
if n.inode.IsDir() {
return n, nil // fs.HandleReadDirAller
}
// O_TRUNC: caller wants size==0
if req.Flags&fuse.OpenFlags(os.O_TRUNC) != 0 {
f, err := n.root.fs.Open(ctx, n.path)
if err != nil {
return nil, err
}
if err := f.Truncate(0); err != nil {
_ = f.Close()
return nil, err
}
if err := f.Sync(); err != nil {
_ = f.Close()
return nil, err
}
return &handle{node: n, file: f}, nil
}
f, err := n.root.fs.Open(ctx, n.path)
if err != nil {
return nil, err
}
return &handle{node: n, file: f}, nil
}
func (n *node) Fsync(_ context.Context, _ *fuse.FsyncRequest) error {
// Directory fsync: persist FS metadata.
return n.root.fs.Sync(n.root.ctx)
}
// ============================================================================
// fs.Handle — open file
// ============================================================================
type handle struct {
node *node
file *vfs.File
}
var (
_ bzfs.Handle = (*handle)(nil)
_ bzfs.HandleReader = (*handle)(nil)
_ bzfs.HandleWriter = (*handle)(nil)
_ bzfs.HandleReleaser = (*handle)(nil)
_ bzfs.HandleFlusher = (*handle)(nil)
)
func (h *handle) Read(_ context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
buf := make([]byte, req.Size)
n, err := h.file.ReadAt(buf, req.Offset)
if err != nil && !errors.Is(err, io.EOF) {
return err
}
resp.Data = buf[:n]
return nil
}
func (h *handle) Write(_ context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error {
n, err := h.file.WriteAt(req.Data, req.Offset)
if err != nil {
return err
}
resp.Size = n
return nil
}
func (h *handle) Flush(_ context.Context, _ *fuse.FlushRequest) error {
return h.file.Sync()
}
func (h *handle) Release(_ context.Context, _ *fuse.ReleaseRequest) error {
return h.file.Close()
}
// ============================================================================
// helpers
// ============================================================================
func joinPath(parent, name string) string {
if parent == "/" {
return "/" + name
}
return parent + "/" + name
}
// keep imports referenced if a build mode strips one
var _ = time.Now