diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 81deed1..7bfc64b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/LLM.md b/LLM.md index 9a0fc78..4d84fbd 100644 --- a/LLM.md +++ b/LLM.md @@ -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 | | diff --git a/Makefile b/Makefile index 7af87f8..2dc162a 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/cmd/vfs/main.go b/cmd/vfs/main.go index b2c9684..71f3b21 100644 --- a/cmd/vfs/main.go +++ b/cmd/vfs/main.go @@ -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]) }, } diff --git a/go.mod b/go.mod index 17ff89b..a1d57f3 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 10054a6..ae6d67e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/mount/fuse_sqlite_test.go b/pkg/mount/fuse_sqlite_test.go new file mode 100644 index 0000000..f412ea7 --- /dev/null +++ b/pkg/mount/fuse_sqlite_test.go @@ -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) + } +} diff --git a/pkg/mount/fuse_stub.go b/pkg/mount/fuse_stub.go index 0536736..ddce305 100644 --- a/pkg/mount/fuse_stub.go +++ b/pkg/mount/fuse_stub.go @@ -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, "") +} diff --git a/pkg/mount/fuse_unix.go b/pkg/mount/fuse_unix.go index b9582e1..cd2d2c4 100644 --- a/pkg/mount/fuse_unix.go +++ b/pkg/mount/fuse_unix.go @@ -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