@cryptotaxi247 / kubo / commits / a5179f0dd

fix(fuse): switch to hanwen/go-fuse (#11272)

* test(fuse): consolidate FUSE tests into test/cli/fuse Move FUSE integration tests from sharness shell scripts (t0030, t0031, t0032) and test/cli/fuse_test.go into a dedicated test/cli/fuse/ Go sub-package, ensuring all FUSE test cases run in CI. - git mv test/cli/fuse_test.go to test/cli/fuse/ (package fuse) - convert all sharness FUSE tests to Go subtests under TestFUSE: mount failure, IPNS symlink, IPNS NS map resolution, MFS file/dir creation, xattr (Linux), files write, add --to-files, file removal, nested dirs, publish-while-mounted block, sharded directory reads - add xattr helpers with build tags (linux/other) using unix.Getxattr - split make test_fuse into test_fuse_unit (./fuse/...) and test_fuse_cli (./test/cli/fuse/...) sub-targets - set TEST_FUSE=0 in test_cli so FUSE tests skip in cli-tests CI job - increase fuse-tests CI timeout from 5m to 10m for CLI tests - delete sharness t0030, t0031, t0032 (were always skipped in CI) * docs: document FUSE test split between unit and e2e Add cross-reference comments between the unit tests in fuse/readonly/, fuse/ipns/, fuse/mfs/ and the end-to-end CLI tests in test/cli/fuse/. Also fix AGENTS.md to use a temp dir for fusermount symlink instead of sudo. * ci: prevent stale FUSE mounts from failing fuse-tests On shared self-hosted runners, leftover mount points from previous runs can exhaust the kernel FUSE mount limit. - add job-level concurrency group so only one fuse-tests runs at a time - lazy-unmount stale /tmp/fusetest* mounts before running tests * ci: only symlink fusermount3 when fusermount is missing * fix(fuse): remove goroutine leak in IPNS Flush handler The Flush handler wrapped fi.fi.Flush() in a goroutine so it could return early when the FUSE context was canceled. But the goroutine kept running in the background, and when Release arrived it called Close on the same file descriptor concurrently. The two paths both entered DagModifier.Sync, racing on its internal write buffer and causing a nil pointer panic. The fix is to call Flush directly without a goroutine. The MFS flush cannot be safely canceled mid-operation anyway, so the goroutine only added the illusion of cancellation while leaking work and masking the real error. Also bumps boxo to pick up the matching defense-in-depth fix that serializes FileDescriptor.Flush and Close with a mutex. * fix(fuse): add mutex to IPNS file handle operations bazil/fuse dispatches each FUSE request in its own goroutine. The IPNS File handle had no synchronization, so concurrent Read/Write/Flush/Release calls could overlap on the underlying DagModifier which is not safe for concurrent use. Add sync.Mutex to File, matching the pattern already used by the MFS FileHandler. * refactor(fuse): remove dead File.Forget method bazil/fuse only dispatches Forget to nodes via the NodeForgetter interface. File is a handle, not a node, so this method was never called. The /mfs mount has no equivalent. * fix(fuse): flush IPNS directory after Remove and Rename The /mfs mount flushes the directory after Unlink and Rename so changes propagate to the MFS root immediately. The /ipns mount did not, leaving mutations pending until an unrelated flush. Also add an empty-directory check before removing directories, matching the /mfs mount's safety check. * fix(fuse): inherit CID builder and flush on IPNS Create New files created via the /ipns FUSE mount now inherit the CID builder from their parent directory, preventing CIDv0 nodes from appearing inside a CIDv1 tree. The directory is also flushed after AddChild so the new entry propagates to the MFS root immediately, matching the /mfs mount. * test(fuse): add IPNS Remove and non-empty rmdir tests Cover the file removal path and the empty-directory safety check added in the previous commit. TestRemoveFile verifies a created file can be removed and is gone afterwards. TestRemoveNonEmptyDirectory verifies that rmdir on a directory with children fails, and succeeds once the children are removed first. * feat(fuse): read UnixFS mode/mtime, add StoreMtime/StoreMode config All three FUSE mounts now read mode and mtime from UnixFS metadata when present, falling back to POSIX defaults when absent. Most IPFS data does not include this optional metadata. Writing mode and mtime is opt-in via two new config flags: - Mounts.StoreMtime: persist mtime on file create and open-for-write - Mounts.StoreMode: persist mode on chmod Other changes in this commit: - align default file/dir modes across /ipns and /mfs to 0644/0755 - share mode constants via fuse/mount/mode.go - convert Mounts.FuseAllowOther from bool to Flag for consistency - add Setattr to /ipns FileNode and /mfs File for chmod and touch - move dead File.Setattr from IPNS handle to FileNode (node) - bump boxo for Directory.Mode() and Directory.ModTime() getters * feat(fuse): add ipfs.cid xattr to all mounts All three FUSE mounts now expose the node's CID via the ipfs.cid extended attribute on both files and directories. The /mfs mount also accepts the old ipfs_cid name for backward compatibility. The /ipfs mount previously had a stub that returned nil for all xattrs; it now returns the correct CID. The xattr name follows the convention used by CephFS (ceph.*), Btrfs (btrfs.*), and GlusterFS (glusterfs.*). * feat(fuse): switch from bazil.org/fuse to hanwen/go-fuse v2 Replace the unmaintained bazil.org/fuse (last commit 2020) with hanwen/go-fuse v2.9.0, fixing two architectural issues that could not be solved with the old library. ftruncate now works: hanwen/go-fuse passes the open file handle to NodeSetattrer, so Setattr can truncate through the existing write descriptor instead of trying to open a second one (which deadlocks on MFS's single-writer lock). fsync now works: FileFsyncer runs on the handle directly, flushing the write buffer through the open descriptor. Previously a no-op because bazil dispatched Fsync to the inode only. mount package: - NewMount takes (InodeEmbedder, mountpoint, *fs.Options) instead of (fs.FS, mountpoint, allowOther) - mount/unmount collapses to a single fs.Mount call - fusermount3 tried before fusermount in ForceUnmount all three mounts: - structs embed fs.Inode (hanwen's InodeEmbedder pattern) - Remove split into Unlink + Rmdir (separate FUSE interfaces) - ReadDirAll replaced with Readdir returning DirStream - fillAttr helper shared between Getattr and Lookup responses - kernel cache invalidation via NotifyContent after Flush - 1s entry/attr timeout for writable mounts (matches go-fuse default, gocryptfs, rclone) - O_APPEND tracked on file handle, writes seek to end - build tags standardized to (linux || darwin || freebsd) && !nofuse tests: - replaced bazil fstestutil.MountedT with shared fusetest.TestMount - fixed TestConcurrentRW: channel drain mismatch and missing sync between write Close and read start - added TestFsync, TestFtruncate, TestReadlink, TestSeekRead, TestLargeFile, TestRmdir, TestCrossDirRename, TestUnknownXattr - added StoreMtime disabled/enabled subtests * fix(fuse): close fd on error in Open to prevent leak MFS enforces a single-writer lock, so a leaked write descriptor blocks all subsequent opens of that file until GC. * fix(fuse): detect external unmount via server.Wait Without this, IsActive stays true after `fusermount -u` and Unmount returns nil instead of ErrNotMounted. * fix(fuse): return actual error from Unlink/Rmdir, not ENOENT After confirming the child exists, an Unlink failure could be an IO error. Returning ENOENT would hide the real cause. * fix(fuse): reuse DagReader per open, pass ctx to all reads Readonly Open now returns a file handle holding a DagReader instead of recreating one per Read call. Sequential reads no longer re-traverse the DAG from the root on each kernel request. All three mounts now use CtxReadFull with the kernel's per-request context so killing a process mid-read cancels in-flight block fetches instead of letting them complete uselessly. * chore(fuse): cleanup dead code, add var comments - remove dead `_ = mntDir` in TestXattrCID - comment why immutableAttrCacheTime and mutableCacheTime are var - add TODO for using IPNS record TTL as cache timeout * chore(fuse): replace OSXFUSE 2.x check with macFUSE detection The old check tried to verify OSXFUSE >= 2.7.2 to avoid a kernel panic from 2015. It used sysctl, tried to `go install` a third-party tool at runtime, and referenced paths that no longer exist. Replace with a simple check for the macFUSE mount helper, matching the same paths go-fuse looks for. If neither macFUSE nor OSXFUSE is found, point the user to the install page. Also standardize build tags to (linux || darwin || freebsd) && !nofuse and use strings.ReplaceAll. * fix(fuse): include mountpoint path in mount errors go-fuse's fusermount errors don't include the path, so tools that check error messages for the mountpoint name couldn't tell which mount failed. * chore(ci): remove bazil fusermount workaround go-fuse finds fusermount3 natively, no symlink needed. The stale mount cleanup was for bazil's fstestutil which we no longer use. * docs: update v0.41 changelog for FUSE rewrite * chore(deps): bump boxo for full FileDescriptor serialization boxo@64be0815 extends the mutex from Flush/Close to all FileDescriptor operations (Read, Write, Seek, Truncate, Size), preventing data races on the underlying DagModifier. * chore(deps): bump boxo to merged ipfs/boxo#1133 Picks up full FileDescriptor serialization: the mutex now covers all operations (Read, Write, Seek, Truncate, Size), not just Flush and Close. * feat(fuse): CAP_ATOMIC_O_TRUNC, new integration tests Advertise CAP_ATOMIC_O_TRUNC so the kernel sends O_TRUNC inside Open instead of doing a separate SETATTR(size=0) first. Without this, the kernel's SETATTR needs to open a write descriptor inside Setattr, which deadlocks on MFS's single-writer lock. Move kernel cache invalidation from Flush to Release because mfsFD.Close (in Release) is where the final DAG node is committed. Upgrade go-fuse to latest for ExtraCapabilities support. New tests for both MFS and IPNS: - TestOpenTrunc, TestSeekAndWrite, TestOverwriteExisting - TestTempFileRename, TestVimSavePattern, TestRsyncPattern (skipped pending rename-over-existing and cache fixes) * fix(fuse): rename-over-existing, bump boxo for flushUp race fix IPNS Rename now unlinks the target before AddChild, matching MFS. Without this, renaming onto an existing name returned "directory already has entry". Bump boxo to pick up the flushUp unlinked-entry fix (ipfs/boxo@8ae46d5): when a file descriptor outlives its directory entry (FUSE RELEASE racing with RENAME), flushUp no longer re-adds the stale name. Unskip TestTempFileRename and TestRsyncPattern on both mounts. * fix(fuse): unskip VimSavePattern, bump boxo for setNodeData fix boxo@552d8e7 fixes File.setNodeData dropping content links when updating metadata (mode, mtime). chmod or touch after write no longer makes the file appear empty. Unskip TestVimSavePattern on both mounts. Remove debug logging and temporary test functions added during investigation. * fix(fuse): build tags for cross-compilation go-fuse does not compile on windows/openbsd/netbsd/plan9. Move WritableMountCapabilities (which imports go-fuse) from mode.go (no build tag) to caps.go (platform-gated). Align build tags on fusetest and core/commands/mount stubs so unsupported platforms don't pull in go-fuse transitively. * fix(test): use fusermount3 in CLI FUSE tests The doUnmount helper hardcoded fusermount, but systems with only fuse3 installed have fusermount3. Try fusermount3 first, matching what go-fuse and our ForceUnmount already do. * feat(fuse): symlink support on writable mounts Add NodeSymlinker to MFS and IPNS directories. Symlinks are stored as UnixFS TSymlink nodes in the DAG, the same format used by `ipfs add` for directories containing symlinks. The readonly /ipfs mount already rendered existing symlinks; now /mfs and /ipns can create them too. The target string is cached at Lookup time to avoid re-parsing the DAG node on every Readlink call. Symlink permissions are always 0777 per POSIX convention (access control uses the target's mode). * fix(fuse): checked type assertion in MFS Rename The direct type assertion on newParent could panic if the kernel passed a non-directory inode. Use a checked assertion with EINVAL fallback, matching the type-switch pattern in the IPNS mount. * fix(test): add missing continue in stress test Missing continue after error sends let execution fall through to nil type assertions (read.(files.File)) that would panic on error. Also cancel the context before continuing to avoid leaking it. * fix(fuse): return error from Readdir when DAG.Get fails Abort the directory listing instead of silently omitting the unretrievable entry. Callers get EIO, which is more honest than a partial listing that hides missing blocks. * docs: remove duplicate fsync bullet in changelog * ci: clean up stale FUSE mounts in fuse-tests job On shared self-hosted runners, leftover mounts from crashed runs can exhaust the kernel mount_max limit. Lazy-unmount kubo-test and harness temp mounts before and after tests. * chore(deps): bump boxo to merged ipfs/boxo#1134 Picks up flushUp unlinked-entry guard and setNodeData content link preservation. * docs: add build tag comments, normalize tag style Add a one-line comment above every //go:build directive explaining why the constraint exists. Normalize tag style: positive platform constraints first, then feature flags/negations. Simplify redundant expressions. * fix(fuse): add Setattr to directories for chmod and mtime Tools like tar and rsync call utimensat on directories after extraction. Without Setattr on Dir, this returned ENOTSUP. Add Setattr to Dir (MFS) and Directory (IPNS) that handles mode and mtime the same way as the file-level Setattr. When StoreMtime or StoreMode is disabled the call succeeds silently, matching the file-level behavior. * docs: clarify directory support and spec link for StoreMtime/StoreMode - mention that touch and chmod work on both files and directories - note tar and rsync as practical use cases - link to UnixFS spec for optional metadata storage * fix(fuse): use proper mode conversion, document 9-bit limit Use files.UnixPermsToModePerms and files.ModePermsToUnixPerms for converting between FUSE kernel mode (unix 12-bit layout) and Go's os.FileMode (different bit positions for setuid/setgid/sticky). The UnixFS spec supports all 12 permission bits, but boxo's MFS layer (File.Mode, Directory.Mode) exposes only the lower 9. FUSE mounts are always nosuid so the upper 3 bits would have no effect. Add TestSetuidBitsStripped to both mounts confirming the behavior. * feat(fuse): symlink Setattr with mtime persistence Wire the backing mfs.File into the FUSE Symlink struct so Setattr can call SetModTime when StoreMtime is enabled. boxo's File methods (SetModTime, ModTime) already work on TSymlink nodes since they operate on the FSNode protobuf without checking the type. Without Setattr, rsync -a fails with "failed to set times" on symlinks. Every major FUSE filesystem (gocryptfs, rclone, sshfs, s3fs) implements Setattr on symlinks for this reason. Mode is always 0777 per POSIX convention, so chmod requests are silently accepted but not stored. * fix(fuse): return EIO instead of panicking on unknown node type Replace panic with log.Errorf + syscall.EIO in IPNS Directory.Lookup for unexpected MFS node types. Also remove duplicate comment block on File.Flush. * docs: update FUSE docs for go-fuse migration - fuse.md: replace stale OSXFUSE section with macFUSE, remove obsolete go-fuse-version tool, fix broken FreeBSD sudo echo, update xattr example to ipfs.cid with CIDv1, add mode/mtime section, add unixfs-v1-2025 tip, add debug logging section, add TOC, link to hanwen/go-fuse - changelog: refine bullet wording, link to fuse.md - config.md: fix double space, update fuse.md link text - experimental-features.md: fix double space, soften wording - README.md: add FUSE to features list and docs table * refactor(fuse): extract shared writable types and test suite Extract duplicated code from fuse/mfs and fuse/ipns into a shared fuse/writable package, and consolidate duplicated tests into a reusable suite in fuse/fusetest. - fuse/writable: Dir, FileInode, FileHandle, Symlink types with all FUSE interface methods, shared by both mounts - fuse/fusetest: RunWritableSuite with helpers, exercised by both mfs and ipns via mount-specific factories - fix cache invalidation race: NotifyContent in Flush (synchronous) in addition to Release (async), so stat after close sees new size - drop deprecated ipfs_cid xattr, log error guiding users to ipfs.cid - mfs_unix.go: 632 -> 19 lines (thin wrapper over writable.Dir) - ipns_unix.go: 795 -> 170 lines (Root + key resolution only) - mfs_test.go: 1183 -> 95 lines (factory + persistence test) - ipns_test.go: 1309 -> 162 lines (factory + IPNS-specific tests) - tests that were only in one mount now run on both * feat(fuse): add macOS-specific mount options Set volname, noapplexattr, and noappledouble on macOS via PlatformMountOpts, applied in NewMount so all three mounts benefit automatically. - volname: shows mount name in Finder instead of "macfuse Volume 0" - noapplexattr: suppresses Finder's com.apple.* xattr probes - noappledouble: prevents ._ resource fork sidecar files * fix(fuse): detect symlinks in readdir, fix stale refs Readdir on writable mounts now checks the underlying DAG node type for TFile entries, reporting S_IFLNK for symlinks instead of regular file. This makes ls -l and find -type l work correctly. - writable: Readdir checks SymlinkTarget for TFile entries - writablesuite: add SymlinkReaddir regression test - readonly: add TestReaddirSymlink regression test - test/cli/fuse: fix stale bazil.org/fuse reference in doc comment * fix(fuse): normalize deprecated ipfs_cid xattr to ipfs.cid Getxattr for the old "ipfs_cid" name now returns the CID instead of ENOATTR, keeping existing tooling working during the deprecation period. A log error is emitted on each access to nudge migration. * fix(fuse): serialize concurrent reads on readonly file handles The go-fuse server dispatches each FUSE request in its own goroutine. On files larger than 128 KB the kernel issues concurrent readahead Read requests on the same file handle, racing on the shared DagReader's Seek+CtxReadFull sequence and corrupting its internal state. Add sync.Mutex to roFileHandle (matching the existing pattern in writable.FileHandle) and lock in Read and Release. - fuse/readonly/readonly_unix.go: add mu sync.Mutex to roFileHandle - fuse/readonly/ipfs_test.go: add TestConcurrentLargeFileRead - fuse/fusetest/writablesuite.go: add LargeFileConcurrentRead to shared writable suite (exercised by both /mfs and /ipns tests) * fix(fuse): bypass MFS locking for read-only opens MFS uses an RWMutex (desclock) that holds RLock for the lifetime of a read descriptor and requires exclusive Lock for writes. Tools like rsync --inplace open the same file for reading and writing from separate processes, deadlocking on this mutex. For O_RDONLY opens, create a DagReader directly from the current DAG node instead of going through MFS. The reader gets a point-in-time snapshot and never touches desclock, so writers proceed independently. - fuse/writable/writable.go: add roFileHandle with DagReader for read-only opens, add DAG field to Config - fuse/mfs/mfs_unix.go: pass ipfs.DAG to writable Config - fuse/ipns/ipns_unix.go: pass ipfs.Dag() to writable Config - fuse/fusetest/writablesuite.go: add ConcurrentReadWrite test exercising simultaneous read and write on the same file * fix(fuse): support truncate(path, size) without open fd Open a temporary write descriptor in Setattr when the kernel sends a size change without a file handle (the truncate(2) syscall, as opposed to ftruncate(fd) which passes the handle). Previously this returned ENOTSUP. - fuse/writable: open, truncate, flush, close in Setattr else branch - fuse/fusetest: add TruncatePath to the shared writable suite - test/cli/fuse: add end-to-end truncation test covering ftruncate(fd), syscall.Truncate(path), and open(O_TRUNC) through a real daemon * ci(fuse): get stack traces on test hangs The fuse-tests job was being silently cancelled by GitHub at 10min because Go's per-test timeout (5m) was the same order as the job timeout, and GOTRACEBACK=single hid the hung goroutines anyway. - shrink TEST_FUSE_TIMEOUT to 4m so Go's panic fires first - shrink job timeout-minutes to 6 (normal run is ~3min) - set GOTRACEBACK=all so the panic dumps every goroutine, not just the timer * fix(fuse): fill attrs in FileInode.Setattr response Without this, the kernel could cache zero attrs after a chmod, touch, or ftruncate until AttrTimeout (1s) expired. Dir.Setattr and Symlink.Setattr already fill out.Attr; FileInode.Setattr now matches. * docs(config): clarify Mounts.IPNS writability scope Only directories backed by keys the node holds are writable. All other names resolve via IPNS to read-only symlinks into the /ipfs mount. * fuse: review cleanup for go-fuse migration Final pass on #11272 addressing review feedback. - writable: panic in NewDir if Config.DAG is nil. Both call sites already supply it, but a nil value silently fell back to the MFS path in FileInode.Open, re-introducing the rsync --inplace deadlock the read-only fast path was added to fix. - writable: document Dir.Rename non-atomicity. Source unlink happens before destination add, so any failure between the two loses the source. An atomic fix requires changes in boxo/mfs. - writable: add unit test locking in that Symlink.Setattr accepts a mode-only request without erroring and does not store the requested mode (POSIX symlinks have no meaningful permission bits). - docs/config: correct StoreMode default modes; the previous text listed 0666 for files, which the code never uses. * docs(config): list StoreMtime and StoreMode in Mounts TOC * fix(fuse): fill EntryOut attrs in Dir.Create and Dir.Mkdir Without this, fstat on the file handle returned by Create reports mode 0 and size 0 for up to AttrTimeout (1s), because the kernel caches the empty attrs from the Create response. Path-based stat goes through Lookup which already fills attrs, so the bug only shows up via fstat. Mirrors the same fix already applied to FileInode.Setattr. Dir.Mkdir gets the same fillAttr treatment for consistency, plus a TODO noting that boxo's mfs.Directory.Mkdir accepts no mode arg so the caller's mode is dropped on creation. Adds CreateAttrsImmediate and MkdirAttrsImmediate to the shared writable suite to guard both paths against future regressions. * fix(fuse): map context cancellation to EINTR in read paths When a userspace process is killed mid-read (Ctrl-C, SIGKILL on a stuck cat) the kernel sends FUSE_INTERRUPT and go-fuse cancels the per-request context. fs.ToErrno does not recognise context.Canceled and falls through to "function not implemented", which the kernel cannot act on. Map context.Canceled and DeadlineExceeded to EINTR so the syscall is correctly aborted. - mount/errno.go: new ReadErrno helper used by all context-aware read paths in both readonly and writable mounts - readonly: applied to Node.Open, Node.Readdir, roFileHandle.Read - writable: applied to FileInode.Open, FileHandle.Read, roFileHandle.Read - readonly/ipfs_test.go: TestReadCancellationUnblocks guards the contract via a blocking DagReader fake; without ReadErrno the test reports "function not implemented" instead of EINTR * test(fuse): add OExcl, DirRename, SparseWrite, FsyncCrossHandle Coverage gaps in the shared writable suite: - OExcl: lock files and atomic-create patterns rely on the second open with O_CREATE|O_EXCL failing with EEXIST - DirRename: previously only file rename and cross-dir file rename were tested; this exercises Rename on a directory inode - SparseWrite: WriteAt past the end of an empty file must report the correct size and return zeros for the gap - FsyncCrossHandle: a reader on a fresh fd must see data flushed by fsync on the writer fd, not just after close * test(fuse): cover external unmount on /ipns and /mfs Previously TestExternalUnmount only exercised /ipfs, leaving the goroutine that watches fuse.Server.Wait() untested for the other two mounts. Refactor into a table-driven test that runs the same fusermount/umount-then-IsActive flow against all three mounts. Switch to coremock.NewMockNode so the node is online: doMount only attaches the /ipns mount when node.IsOnline is true, and the table needs all three populated. * fix(commands): align 'ipfs mount' output columns MountCmd's LongDescription has "MFS mounted at:" with two spaces so the column lines up with the 4-char "IPFS" and "IPNS" rows above, but the runtime encoder and the daemon's startup print used a single space and produced misaligned output. Bring both runtime sites in line with the help text, and update the two existing test fixtures (test/cli/fuse and the sharness test-lib helper that t0040-add-and-cat.sh still uses) to expect the aligned form. * fix(fuse): invalidate kernel cache on Fsync FileHandle.Fsync only flushed the MFS file descriptor and left the kernel's cached attrs and content for the inode untouched. A fresh reader on the same path then saw the size cached from the original Create response (zero), reading zero bytes regardless of how much the writer had synced. Mirror the cache invalidation already done in Flush via inode.NotifyContent(0, 0) so a writer that fsyncs while another process opens the file (vim then a follow-up cat, IDE then a language server) sees consistent state. Sharpen the FsyncCrossHandle assertion to report the size delta on failure; the bug surfaced as got=0/want=500 only after switching from bytes.Equal to require.Equal. * chore(gitignore): ignore test_fuse_unit and test_fuse_cli json output The new test_fuse_unit and test_fuse_cli make targets emit test/fuse/fuse-unit-tests.json and test/fuse/fuse-cli-tests.json respectively, the same gotestsum --jsonfile pattern that test_unit and test_cli already use. Add them to the same .gitignore section so a local test run does not leave the working tree dirty. * test(fuse): end-to-end coverage with real POSIX tools Adds TestFUSERealWorld in test/cli/fuse/realworld_test.go: a single shared-daemon test with 18 subtests that exercise the writable /mfs mount through the actual binaries users invoke (sh, cat, seq, wc, ls, stat, cp, mv, rm, ln, readlink, find, dd, sha256sum, tar, rsync, vim). Each subtest verifies the result both via the FUSE filesystem and via 'ipfs files read|stat|ls' so both views agree. Synthetic payloads default to 1 MiB + 1 byte so multi-chunk read/write paths are exercised, not just single-chunk fast paths. External tools are required, not optional: a missing binary fails the test loudly so a CI image change cannot silently turn the suite green. The whole-suite TEST_FUSE gate is the only place a developer is allowed to skip. runCmd forces LC_ALL=C so locale-sensitive tool output (date formats in 'ls -l', decimal separators in 'wc', localized error messages, find/ls collation) is deterministic regardless of the runner's locale settings. One shared daemon across all 18 subtests keeps total runtime under two seconds; isolation comes from per-subtest subdirectories under the mount.

Marcin Rataj committed Apr 10, 2026 at 01:21 UTC a5179f0dd37d62e3b17fe4e15004041eef85e1f8
75 files changed +4393 -2952
.github/workflows/gotest.yml
+29 -5
@@ -150,12 +150,21 @@ jobs:
150 if: failure() || success()
151
152 # FUSE filesystem tests (require /dev/fuse and fusermount)
153 + # Runs both FUSE unit tests (./fuse/...) and CLI integration tests (./test/cli/fuse/...)
154 fuse-tests:
155 if: github.repository == 'ipfs/kubo' || github.event_name == 'workflow_dispatch'
156 runs-on: ${{ fromJSON(github.repository == 'ipfs/kubo' && '["self-hosted", "linux", "x64", "2xlarge"]' || '"ubuntu-latest"') }}
156 - timeout-minutes: 5
157 + concurrency:
158 + group: fuse-tests-${{ github.repository }}
159 + cancel-in-progress: false
160 + # A normal run takes ~3min. 6min gives roughly 2x and lets Go's 4min
161 + # test timeout fire first (printing a stack trace) on a hang, instead
162 + # of GitHub silently cancelling the job.
163 + timeout-minutes: 6
164 env:
158 - GOTRACEBACK: single
165 + # Dump all goroutines on a test panic, not just the panicking one,
166 + # so we can see which test is actually hung.
167 + GOTRACEBACK: all
168 TEST_FUSE: 1
169 defaults:
170 run:
@@ -169,14 +178,29 @@ jobs:
178 go-version-file: 'go.mod'
179 - name: Install FUSE
180 run: |
172 - if ! command -v fusermount &>/dev/null; then
181 + if ! command -v fusermount3 &>/dev/null && ! command -v fusermount &>/dev/null; then
182 sudo apt-get update
183 sudo apt-get install -y fuse3
175 - # bazil.org/fuse looks for "fusermount", fuse3 only ships "fusermount3"
176 - sudo ln -sf /usr/bin/fusermount3 /usr/local/bin/fusermount
184 fi
185 + - name: Clean up stale FUSE mounts
186 + run: |
187 + # On shared self-hosted runners, leftover mounts from previous
188 + # runs can exhaust the kernel FUSE mount limit (mount_max).
189 + # Unit tests mount with FsName "kubo-test"; CLI tests mount
190 + # under the harness temp dir (ipfs/ipns/mfs subdirectories).
191 + awk '$1 == "kubo-test" || $2 ~ /\/tmp\/.*\/(ipfs|ipns|mfs)$/ { print $2 }' /proc/mounts 2>/dev/null \
192 + | while read -r mp; do
193 + fusermount3 -uz "$mp" 2>/dev/null || fusermount -uz "$mp" 2>/dev/null || true
194 + done
195 - name: Run FUSE tests
196 run: make test_fuse
197 + - name: Clean up FUSE mounts
198 + if: always()
199 + run: |
200 + awk '$1 == "kubo-test" || $2 ~ /\/tmp\/.*\/(ipfs|ipns|mfs)$/ { print $2 }' /proc/mounts 2>/dev/null \
201 + | while read -r mp; do
202 + fusermount3 -uz "$mp" 2>/dev/null || fusermount -uz "$mp" 2>/dev/null || true
203 + done
204
205 # Example tests (kubo-as-a-library)
206 example-tests:
.gitignore
+3 -1
@@ -28,10 +28,12 @@ go-ipfs-source.tar.gz
28 docs/examples/go-ipfs-as-a-library/example-folder/Qm*
29 /test/sharness/t0054-dag-car-import-export-data/*.car
30
31 -# test artifacts from make test_unit / test_cli
31 +# test artifacts from make test_unit / test_cli / test_fuse
32 /test/unit/gotest.json
33 /test/unit/gotest.junit.xml
34 /test/cli/cli-tests.json
35 +/test/fuse/fuse-unit-tests.json
36 +/test/fuse/fuse-cli-tests.json
37
38 # ignore build output from snapcraft
39 /ipfs_*.snap
AGENTS.md
+2 -2
@@ -124,10 +124,10 @@ If you see "version (N) is lower than repos (M)", the `ipfs` binary in `PATH` is
124
125 ### Running FUSE Tests
126
127 -FUSE tests require `/dev/fuse` and `fusermount` in `PATH`. On systems with only fuse3, create a symlink:
127 +FUSE tests require `/dev/fuse` and `fusermount` in `PATH`. On systems with only fuse3, create a symlink in a temp directory (never use `sudo` to install system-wide):
128
129 ```bash
130 -ln -s /usr/bin/fusermount3 /tmp/fusermount && PATH="/tmp:$PATH" make test_fuse
130 +FUSE_BIN="$(mktemp -d)" && ln -s /usr/bin/fusermount3 "$FUSE_BIN/fusermount" && PATH="$FUSE_BIN:$PATH" make test_fuse
131 ```
132
133 Set `TEST_FUSE=1` to make mount failures fatal (CI does this). Without it, tests auto-detect and skip when FUSE is unavailable.
README.md
+2
@@ -34,6 +34,7 @@ Kubo was the first [IPFS](https://docs.ipfs.tech/concepts/what-is-ipfs/) impleme
34 - [HTTP Gateway](https://specs.ipfs.tech/http-gateways/) for trusted and [trustless](https://docs.ipfs.tech/reference/http/gateway/#trustless-verifiable-retrieval) content retrieval
35 - [HTTP RPC API](https://docs.ipfs.tech/reference/kubo/rpc/) to control the daemon
36 - [HTTP Routing V1](https://specs.ipfs.tech/routing/http-routing-v1/) client and server for [delegated routing](./docs/delegated-routing.md)
37 +- [FUSE mounts](./docs/fuse.md) for mounting `/ipfs`, `/ipns`, and `/mfs` as local filesystems (experimental)
38 - [Content blocking](./docs/content-blocking.md) for public node operators
39
40 **Other IPFS implementations:** [Helia](https://github.com/ipfs/helia) (JavaScript), [more...](https://docs.ipfs.tech/concepts/ipfs-implementations/)
@@ -178,6 +179,7 @@ Kubo is available in community-maintained packages across many operating systems
179 | [HTTP RPC clients](docs/http-rpc-clients.md) | Client libraries for Go, JS |
180 | [Delegated routing](docs/delegated-routing.md) | Multi-router and HTTP routing |
181 | [Metrics & monitoring](docs/metrics.md) | Prometheus metrics |
182 +| [FUSE mounts](docs/fuse.md) | Mount `/ipfs`, `/ipns`, `/mfs` as local filesystems |
183 | [Content blocking](docs/content-blocking.md) | Denylist for public nodes |
184 | [Customizing](docs/customizing.md) | Unsure if use Plugins, Boxo, or fork? |
185 | [Debug guide](docs/debug-guide.md) | CPU profiles, memory analysis, tracing |
cmd/ipfs/kubo/daemon.go
+3 -1
@@ -1239,9 +1239,11 @@ func mountFuse(req *cmds.Request, cctx *oldcmds.Context) error {
1239 if err != nil {
1240 return err
1241 }
1242 + // Extra space after "MFS" so "mounted at:" lines up with IPFS and
1243 + // IPNS in the column above. Matches MountCmd's output formatter.
1244 fmt.Printf("IPFS mounted at: %s\n", fsdir)
1245 fmt.Printf("IPNS mounted at: %s\n", nsdir)
1244 - fmt.Printf("MFS mounted at: %s\n", mfsdir)
1246 + fmt.Printf("MFS mounted at: %s\n", mfsdir)
1247 return nil
1248 }
1249
cmd/ipfs/kubo/daemon_linux.go
+1
@@ -1,3 +1,4 @@
1 +// Systemd readiness notification (sd_notify). Linux only.
2 //go:build linux
3
4 package kubo
cmd/ipfs/kubo/daemon_other.go
+1
@@ -1,3 +1,4 @@
1 +// No-op readiness notification on non-Linux platforms.
2 //go:build !linux
3
4 package kubo
cmd/ipfs/runmain_test.go
+1
@@ -1,3 +1,4 @@
1 +// Only built when collecting coverage via "go test -tags testrunmain".
2 //go:build testrunmain
3
4 package main_test
cmd/ipfs/util/signal.go
+1
@@ -1,3 +1,4 @@
1 +// Signal handling. Excluded from wasm where os.Signal is unavailable.
2 //go:build !wasm
3
4 package util
cmd/ipfs/util/ui.go
+1
@@ -1,3 +1,4 @@
1 +// GUI detection stub. Windows has its own implementation.
2 //go:build !windows
3
4 package util
cmd/ipfs/util/ulimit_freebsd.go
+1
@@ -1,3 +1,4 @@
1 +// FreeBSD ulimit handling via sysctl.
2 //go:build freebsd
3
4 package util
cmd/ipfs/util/ulimit_test.go
+1
@@ -1,3 +1,4 @@
1 +// Ulimit tests. Skipped on windows and plan9 (no getrlimit).
2 //go:build !windows && !plan9
3
4 package util
cmd/ipfs/util/ulimit_unix.go
+1
@@ -1,3 +1,4 @@
1 +// Unix ulimit handling via getrlimit/setrlimit.
2 //go:build darwin || linux || netbsd || openbsd
3
4 package util
cmd/ipfs/util/ulimit_windows.go
+1
@@ -1,3 +1,4 @@
1 +// Windows ulimit handling via SetHandleInformation.
2 //go:build windows
3
4 package util
cmd/ipfswatch/ipfswatch_test.go
+1
@@ -1,3 +1,4 @@
1 +// Excluded from plan9 (no fsnotify support).
2 //go:build !plan9
3
4 package main
cmd/ipfswatch/main.go
+1
@@ -1,3 +1,4 @@
1 +// Excluded from plan9 (no fsnotify support).
2 //go:build !plan9
3
4 package main
config/mounts.go
+35 -5
@@ -1,9 +1,39 @@
1 package config
2
3 -// Mounts stores the (string) mount points.
3 +const (
4 + DefaultFuseAllowOther = false
5 + DefaultStoreMtime = false
6 + DefaultStoreMode = false
7 +)
8 +
9 +// Mounts stores FUSE mount point configuration.
10 type Mounts struct {
5 - IPFS string
6 - IPNS string
7 - MFS string
8 - FuseAllowOther bool
11 + // IPFS is the mountpoint for the read-only /ipfs/ namespace.
12 + IPFS string
13 +
14 + // IPNS is the mountpoint for the /ipns/ namespace. Directories backed
15 + // by keys this node holds are writable; all other names resolve through
16 + // IPNS to read-only symlinks into the /ipfs mount.
17 + IPNS string
18 +
19 + // MFS is the mountpoint for the Mutable File System (ipfs files API).
20 + MFS string
21 +
22 + // FuseAllowOther sets the FUSE allow_other mount option, letting
23 + // users other than the mounter access the mounted filesystem.
24 + FuseAllowOther Flag
25 +
26 + // StoreMtime controls whether writable mounts (/ipns and /mfs) persist
27 + // the current time as mtime in UnixFS metadata when creating a file or
28 + // opening it for writing. This changes the resulting CID even when file
29 + // content is identical.
30 + //
31 + // Reading mtime from UnixFS is always enabled on all mounts.
32 + StoreMtime Flag
33 +
34 + // StoreMode controls whether writable mounts (/ipns and /mfs) persist
35 + // POSIX permission bits in UnixFS metadata when a chmod request is made.
36 + //
37 + // Reading mode from UnixFS is always enabled on all mounts.
38 + StoreMode Flag
39 }
core/commands/mount_nofuse.go
+4 -1
@@ -1,4 +1,7 @@
1 -//go:build !windows && nofuse
1 +// Stub for non-FUSE builds: the complement of mount_unix.go's
2 +// (linux || darwin || freebsd) && !nofuse, excluding windows
3 +// which has its own stub in mount_windows.go.
4 +//go:build !windows && (nofuse || !(linux || darwin || freebsd))
5
6 package commands
7
core/commands/mount_unix.go
+5 -2
@@ -1,4 +1,5 @@
1 -//go:build !windows && !nofuse
1 +// Real mount command. go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3
4 package commands
5
@@ -130,9 +131,11 @@ baz
131 Type: config.Mounts{},
132 Encoders: cmds.EncoderMap{
133 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, mounts *config.Mounts) error {
134 + // Extra space after "MFS" so "mounted at:" lines up with
135 + // IPFS and IPNS in the column above. Matches LongDescription.
136 fmt.Fprintf(w, "IPFS mounted at: %s\n", cmdenv.EscNonPrint(mounts.IPFS))
137 fmt.Fprintf(w, "IPNS mounted at: %s\n", cmdenv.EscNonPrint(mounts.IPNS))
135 - fmt.Fprintf(w, "MFS mounted at: %s\n", cmdenv.EscNonPrint(mounts.MFS))
138 + fmt.Fprintf(w, "MFS mounted at: %s\n", cmdenv.EscNonPrint(mounts.MFS))
139
140 return nil
141 }),
core/commands/repo_verify_test.go
+1
@@ -1,3 +1,4 @@
1 +// Requires Go 1.25+ for testing/synctest.
2 //go:build go1.25
3
4 package commands
core/node/libp2p/fd/sys_not_unix.go
+1
@@ -1,3 +1,4 @@
1 +// Stub returning zero on platforms without /proc or Handle APIs.
2 //go:build !linux && !darwin && !windows
3
4 package fd
core/node/libp2p/fd/sys_unix.go
+1
@@ -1,3 +1,4 @@
1 +// File descriptor counting via /proc/self/fd (linux) or lsof (darwin).
2 //go:build linux || darwin
3
4 package fd
core/node/libp2p/fd/sys_windows.go
+1
@@ -1,3 +1,4 @@
1 +// File descriptor counting via Windows Handle API.
2 //go:build windows
3
4 package fd
coverage/main/main.go
+1
@@ -1,3 +1,4 @@
1 +// Only built when collecting coverage via "go test -tags testrunmain".
2 //go:build testrunmain
3
4 package main
docs/changelogs/v0.41.md
+23 -10
@@ -16,7 +16,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
16 - [🔧 Correct provider addresses for custom HTTP routing](#-correct-provider-addresses-for-custom-http-routing)
17 - [🛡️ `ipfs object patch` validates UnixFS node types](#-ipfs-object-patch-validates-unixfs-node-types)
18 - [🔗 MFS: fixed CidBuilder preservation](#-mfs-fixed-cidbuilder-preservation)
19 - - [📂 FUSE Mount Fixes](#-fuse-mount-fixes)
19 + - [📂 FUSE Mount Improvements](#-fuse-mount-improvements)
20 - [🐹 Go 1.26, Once More with Feeling](#-go-126-once-more-with-feeling)
21 - [📦️ Dependency updates](#-dependency-updates)
22 - [📝 Changelog](#-changelog)
@@ -109,18 +109,31 @@ Additionally, the MFS root directory itself now respects [`Import.CidVersion`](h
109
110 See [boxo#1125](https://github.com/ipfs/boxo/pull/1125) and [kubo#11273](https://github.com/ipfs/kubo/pull/11273).
111
112 -#### 📂 FUSE Mount Fixes
112 +#### 📂 FUSE Mount Improvements
113
114 -FUSE mounts (`/ipfs`, `/ipns`, `/mfs`) now work with editors like VIM that rely on `fsync` and expect standard file ownership. FUSE support is still experimental. If you run into problems, please report them at [kubo/issues](https://github.com/ipfs/kubo/issues).
114 +The FUSE implementation has been rewritten on top of [`hanwen/go-fuse` v2](https://github.com/hanwen/go-fuse), replacing the unmaintained `bazil.org/fuse`. This fixes long-standing architectural limitations and brings FUSE mounts much closer to what standard tools expect. FUSE support is still experimental. See [docs/fuse.md](https://github.com/ipfs/kubo/blob/master/docs/fuse.md) for setup instructions, and report problems at [kubo/issues](https://github.com/ipfs/kubo/issues).
115
116 -- **No more deadlocks on save.** Editors that call `fsync` after writing would hang indefinitely on `/mfs` and `/ipns` mounts. `Fsync` is now a no-op on both; data is flushed when the file descriptor is closed.
117 -- **Files are no longer owned by root.** Mounts now report the uid/gid of the daemon process, so access works without `allow_other`.
118 -- **Offline IPNS writes succeed.** Writing through `/ipns` FUSE mounts no longer fails when the node has no network. IPNS records are stored locally and published when connectivity returns.
119 -- **Smarter kernel caching.** Attribute caching is disabled for mutable mounts (`/ipns`, `/mfs`) to prevent stale reads after writes. Immutable `/ipfs` paths use long-lived caching since content addressed by CID never changes.
116 +- **`fsync` works.** Editors (vim, emacs) and databases that call `fsync` after writing no longer get a silent no-op. Data is flushed through the open file descriptor to the DAG. The full vim save sequence (O_TRUNC + write + fsync + chmod) is tested.
117 +- **`ftruncate` works.** Tools like `rsync --inplace` that shrink or grow files via `ftruncate(fd, size)` no longer get ENOTSUP. Opening existing files with `O_TRUNC` also works correctly.
118 +- **`chmod` and `touch` no longer drop file content.** Setting mode or mtime on a file with `Mounts.StoreMode`/`StoreMtime` enabled previously replaced the DAG node without preserving content links, making the file appear empty.
119 +- **Symlink creation on writable mounts.** `ln -s target link` now works on `/mfs` and `/ipns`. Symlinks are stored as UnixFS TSymlink nodes, the same format used by `ipfs add`.
120 +- **Rename-over-existing works.** Renaming a file onto an existing name (the pattern used by rsync and atomic-save editors) now correctly replaces the target.
121 +- **Faster reads on `/ipfs`.** Files are read sequentially from the block graph instead of re-resolving from the root on every read call.
122 +- **Killing a stuck `cat` works.** Interrupting a read (Ctrl-C, kill) cancels in-flight block fetches instead of hanging.
123 +- **External unmount detected.** Running `fusermount -u` from outside the daemon now correctly marks the mount as inactive.
124 +- **Files are no longer owned by root.** Mounts report the uid/gid of the daemon process, so access works without `allow_other`.
125 +- **Offline IPNS writes succeed.** IPNS records are stored locally and published when connectivity returns.
126 - **Empty directories list correctly.** Listing an empty directory on `/ipfs` or `/ipns` no longer returns an error.
121 -- **Bare file CIDs work on `/ipfs`.** Accessing a file by its CID directly under the `/ipfs` mount (e.g. `/ipfs/<CID>`) no longer returns "not found". This was a [long-standing regression](https://github.com/ipfs/kubo/issues/9044) that only affected files; directories were not affected.
122 -- **Rename works on `/mfs`.** Renaming a file within the same directory no longer leaves the source behind.
123 -- **IPNS FUSE publish works.** Writing files to `/ipns/local/` now correctly publishes the updated DAG to IPNS. Before this fix, IPNS publishing from the FUSE mount was silently blocked, so the DAG would disappear after a daemon restart.
127 +- **Bare file CIDs work on `/ipfs`.** Accessing a file by its CID directly under the `/ipfs` mount now works. This was a [long-standing regression](https://github.com/ipfs/kubo/issues/9044).
128 +- **Rename works on `/mfs` and `/ipns`.** Renaming a file within the same directory no longer leaves the source behind.
129 +- **IPNS FUSE publish works.** Writing files to `/ipns/local/` now correctly publishes the updated DAG. Previously IPNS publishing from the FUSE mount was silently blocked.
130 +- **Concurrent IPNS file operations no longer race.** The `/ipns` file handle serializes Read, Write, Flush, and Release, matching the `/mfs` mount.
131 +- **IPNS directory operations flush immediately.** Remove and Rename on `/ipns` flush changes to the MFS root, preventing data loss on daemon restart.
132 +- **New files use the correct CID version.** Files created on `/ipns` inherit the parent's CID settings instead of falling back to CIDv0.
133 +- **UnixFS mode and mtime visible in stat.** All three mounts show POSIX mode and mtime from [UnixFS](https://specs.ipfs.tech/unixfs/) metadata when present. When absent, sensible POSIX defaults are used (files: `0644`/`0444`, directories: `0755`/`0555`).
134 +- **Opt-in `Mounts.StoreMtime` and `Mounts.StoreMode`.** Writable mounts can persist mtime on file creation/write and POSIX mode on `chmod` for both files and directories. `touch` on directories also works, which tools like `tar` and `rsync` rely on. Both flags are off by default because they change the resulting CID. See [`Mounts.StoreMtime`](https://github.com/ipfs/kubo/blob/master/docs/config.md#mountsstoremtime) and [`Mounts.StoreMode`](https://github.com/ipfs/kubo/blob/master/docs/config.md#mountsstoremode).
135 +- **`ipfs.cid` xattr on all mounts.** All three mounts expose the node's CID via the `ipfs.cid` extended attribute on files and directories. The legacy `ipfs_cid` xattr name (used in earlier versions of `/mfs`) is no longer supported; use `ipfs.cid` instead.
136 +- **Platform compatibility.** macOS detection updated from OSXFUSE 2.x to macFUSE 4.x. Linux no longer needs a `fusermount` symlink; [`hanwen/go-fuse`](https://github.com/hanwen/go-fuse) finds `fusermount3` natively.
137
138 #### 🐹 Go 1.26, Once More with Feeling
139
docs/config.md
+37 -3
@@ -119,6 +119,8 @@ config file at runtime.
119 - [`Mounts.IPNS`](#mountsipns)
120 - [`Mounts.MFS`](#mountsmfs)
121 - [`Mounts.FuseAllowOther`](#mountsfuseallowother)
122 + - [`Mounts.StoreMtime`](#mountsstoremtime)
123 + - [`Mounts.StoreMode`](#mountsstoremode)
124 - [`Pinning`](#pinning)
125 - [`Pinning.RemoteServices`](#pinningremoteservices)
126 - [`Pinning.RemoteServices: API`](#pinningremoteservices-api)
@@ -1900,12 +1902,20 @@ Default: `"cache"`
1902
1903 > [!CAUTION]
1904 > **EXPERIMENTAL:**
1903 -> This feature is disabled by default, requires an explicit opt-in with `ipfs mount` or `ipfs daemon --mount`.
1905 +> This feature is disabled by default, requires an explicit opt-in with `ipfs mount` or `ipfs daemon --mount`.
1906 >
1905 -> Read about current limitations at [fuse.md](./fuse.md).
1907 +> See [fuse.md](./fuse.md) for setup instructions and platform-specific notes.
1908
1909 FUSE mount point configuration options.
1910
1911 +All mounts expose the `ipfs.cid` extended attribute on files and directories, returning the CID of the underlying DAG node:
1912 +
1913 +```console
1914 +$ getfattr -n ipfs.cid /ipfs/bafybeiaysi4s6lnjev27ln5icwm6tueaw2vdykrtjkwiphwekaywqhcjze/wiki/Cat
1915 +# file: ipfs/bafybeiaysi4s6lnjev27ln5icwm6tueaw2vdykrtjkwiphwekaywqhcjze/wiki/Cat
1916 +ipfs.cid="bafybeihxislsmn7b2drh6m3vqz3ctcfae46al7ax3543umeso4f5jgij5e"
1917 +```
1918 +
1919 ### `Mounts.IPFS`
1920
1921 Mountpoint for `/ipfs/`.
@@ -1937,7 +1947,31 @@ Type: `string` (filesystem path)
1947
1948 ### `Mounts.FuseAllowOther`
1949
1940 -Sets the 'FUSE allow-other' option on the mount point.
1950 +Sets the FUSE `allow_other` mount option, letting users other than the mounter access the mounted filesystem.
1951 +
1952 +Default: `false`
1953 +
1954 +Type: `flag`
1955 +
1956 +### `Mounts.StoreMtime`
1957 +
1958 +When `true`, writable mounts (`/ipns` and `/mfs`) store the current time as mtime in [UnixFS](https://specs.ipfs.tech/unixfs/) metadata when creating a file or opening it for writing. Setting mtime explicitly via `touch` works on both files and directories. This changes the resulting CID even when the file content is identical, because mtime is stored in the [root block of the UnixFS DAG](https://specs.ipfs.tech/unixfs/#dag-pb-optional-metadata).
1959 +
1960 +Most data on IPFS does not include mtime. When mtime is present in the UnixFS metadata, it is always shown in stat responses on all mounts, regardless of this flag. When absent, mtime is reported as zero (epoch).
1961 +
1962 +Default: `false`
1963 +
1964 +Type: `flag`
1965 +
1966 +### `Mounts.StoreMode`
1967 +
1968 +When `true`, writable mounts (`/ipns` and `/mfs`) accept `chmod` requests on both files and directories and persist POSIX permission bits in [UnixFS](https://specs.ipfs.tech/unixfs/) metadata. This changes the resulting CID because mode is stored in the [root block of the UnixFS DAG](https://specs.ipfs.tech/unixfs/#dag-pb-optional-metadata).
1969 +
1970 +Most data on IPFS does not include mode. When mode is present in the UnixFS metadata, it is always shown in stat responses on all mounts, regardless of this flag. When absent, a default mode is used (files: `0644` on writable mounts, `0444` on `/ipfs`; directories: `0755` on writable mounts, `0555` on `/ipfs`).
1971 +
1972 +Default: `false`
1973 +
1974 +Type: `flag`
1975
1976 ## `Pinning`
1977
docs/environment-variables.md
+2 -2
@@ -112,9 +112,9 @@ Warning: Enabling tracing will likely affect performance.
112
113 ## `IPFS_FUSE_DEBUG`
114
115 -If SET, enables fuse debug logging.
115 +When set to any non-empty value, enables verbose FUSE debug logging. Every FUSE operation (open, read, write, lookup, getattr, etc.) is logged to stderr with its arguments and return values. Useful for diagnosing mount issues or understanding what the kernel is requesting.
116
117 -Default: false
117 +Default: not set (no debug logging)
118
119 ## `YAMUX_DEBUG`
120
docs/examples/kubo-as-a-library/go.mod
+1 -1
@@ -14,7 +14,6 @@ require (
14 )
15
16 require (
17 - bazil.org/fuse v0.0.0-20200117225306-7b5117fecadc // indirect
17 filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect
18 filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b // indirect
19 github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
@@ -69,6 +68,7 @@ require (
68 github.com/gorilla/websocket v1.5.3 // indirect
69 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
70 github.com/guillaumemichel/reservedpool v0.3.0 // indirect
71 + github.com/hanwen/go-fuse/v2 v2.9.1-0.20260323175136-8b5aa92e8e7c // indirect
72 github.com/hashicorp/golang-lru v1.0.2 // indirect
73 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
74 github.com/huin/goupnp v1.3.0 // indirect
docs/examples/kubo-as-a-library/go.sum
+6 -5
@@ -1,5 +1,3 @@
1 -bazil.org/fuse v0.0.0-20200117225306-7b5117fecadc h1:utDghgcjE8u+EBjHOgYT+dJPcnDF05KqWMBcjuJy510=
2 -bazil.org/fuse v0.0.0-20200117225306-7b5117fecadc/go.mod h1:FbcW6z/2VytnFDhZfumh8Ss8zxHE6qpMP5sHTRe0EaM=
1 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
2 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
3 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -316,6 +314,8 @@ github.com/guillaumemichel/reservedpool v0.3.0 h1:eqqO/QvTllLBrit7LVtVJBqw4cD0Wd
314 github.com/guillaumemichel/reservedpool v0.3.0/go.mod h1:sXSDIaef81TFdAJglsCFCMfgF5E5Z5xK1tFhjDhvbUc=
315 github.com/gxed/hashland/keccakpg v0.0.1/go.mod h1:kRzw3HkwxFU1mpmPP8v1WyQzwdGfmKFJ6tItnhQ67kU=
316 github.com/gxed/hashland/murmur3 v0.0.1/go.mod h1:KjXop02n4/ckmZSnY2+HKcLud/tcmvhST0bie/0lS48=
317 +github.com/hanwen/go-fuse/v2 v2.9.1-0.20260323175136-8b5aa92e8e7c h1:m4bneA0dtaIhyTOJZCvcka670ZwDEiSomj5EARK1Jxc=
318 +github.com/hanwen/go-fuse/v2 v2.9.1-0.20260323175136-8b5aa92e8e7c/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
319 github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
320 github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
321 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -472,6 +472,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
472 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
473 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
474 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
475 +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
476 +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
477 github.com/libdns/libdns v1.0.0-beta.1 h1:KIf4wLfsrEpXpZ3vmc/poM8zCATXT2klbdPe6hyOBjQ=
478 github.com/libdns/libdns v1.0.0-beta.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
479 github.com/libp2p/go-buffer-pool v0.0.1/go.mod h1:xtyIz9PMobb13WaxR6Zo1Pd1zXJKYg0a8KiIvDp3TzQ=
@@ -568,6 +570,8 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu
570 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
571 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
572 github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
573 +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
574 +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
575 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
576 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
577 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
@@ -774,8 +778,6 @@ github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69
778 github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
779 github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs=
780 github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48=
777 -github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ=
778 -github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM=
781 github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb h1:Ywfo8sUltxogBpFuMOFRrrSifO788kAFxmvVw31PtQQ=
782 github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb/go.mod h1:ikPs9bRWicNw3S7XpJ8sK/smGwU9WcSVU3dy9qahYBM=
783 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
@@ -1033,7 +1035,6 @@ golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7w
1035 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1036 golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1037 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1036 -golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1038 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1039 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1040 golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
docs/experimental-features.md
+3 -3
@@ -326,11 +326,11 @@ We also support the use of protocol names of the form /x/$NAME/http where $NAME
326 ## FUSE
327
328 FUSE makes it possible to mount `/ipfs`, `/ipns` and `/mfs` namespaces in your OS,
329 -allowing arbitrary apps access to IPFS using a subset of filesystem abstractions.
329 +allowing arbitrary apps access to IPFS using standard filesystem operations.
330
331 -It is considered EXPERIMENTAL due to limited (and buggy) support on some platforms.
331 +It is considered EXPERIMENTAL due to limited support on some platforms.
332
333 -See [fuse.md](./fuse.md) for more details.
333 +See [fuse.md](./fuse.md) for setup instructions and details.
334
335 ## Plugins
336
docs/fuse.md
+113 -78
@@ -1,110 +1,114 @@
1 # FUSE
2
3 -**EXPERIMENTAL:** FUSE support is limited, YMMV.
3 +**EXPERIMENTAL:** FUSE support is functional but still evolving. Please report issues at [kubo/issues](https://github.com/ipfs/kubo/issues).
4
5 Kubo makes it possible to mount `/ipfs`, `/ipns` and `/mfs` namespaces in your OS,
6 -allowing arbitrary apps access to IPFS.
6 +allowing arbitrary apps access to IPFS using standard filesystem operations.
7 +
8 +The underlying FUSE implementation uses [`hanwen/go-fuse`](https://github.com/hanwen/go-fuse).
9 +
10 +- [Install FUSE](#install-fuse)
11 + - [Linux](#linux)
12 + - [macOS](#macos)
13 + - [FreeBSD](#freebsd)
14 +- [Prepare mountpoints](#prepare-mountpoints)
15 +- [Mounting IPFS](#mounting-ipfs)
16 +- [MFS mountpoint](#mfs-mountpoint)
17 +- [Mode and mtime](#mode-and-mtime)
18 +- [Troubleshooting](#troubleshooting)
19
20 ## Install FUSE
21
10 -You will need to install and configure fuse before you can mount IPFS
22 +You will need to install and configure FUSE before you can mount IPFS.
23
24 #### Linux
25
14 -Note: while this guide should work for most distributions, you may need to refer
15 -to your distribution manual to get things working.
26 +Install `fuse3` with your package manager:
27
17 -Install `fuse` with your favorite package manager:
18 -```
28 +```sh
29 +# Debian / Ubuntu
30 sudo apt-get install fuse3
31 +
32 +# Fedora
33 +sudo dnf install fuse3
34 +
35 +# Arch
36 +sudo pacman -S fuse3
37 ```
38
22 -On some older Linux distributions, you may need to add yourself to the `fuse` group.
23 -(If no such group exists, you can probably skip this step)
39 +On some older Linux distributions, you may need to add yourself to the `fuse` group
40 +for `allow_other` support (if no `fuse` group exists, you can skip this step):
41 +
42 ```sh
43 sudo usermod -a -G fuse <username>
44 ```
45
28 -Restart user session, if active, for the change to apply, either by restarting
29 -ssh connection or by re-logging to the system.
46 +Restart your session for the change to apply.
47
31 -#### Mac OSX -- OSXFUSE
48 +#### macOS
49
33 -It has been discovered that versions of `osxfuse` prior to `2.7.0` will cause a
34 -kernel panic. For everyone's sake, please upgrade (latest at time of writing is
35 -`2.7.4`). The installer can be found at https://osxfuse.github.io/. There is
36 -also a homebrew formula (`brew cask install osxfuse`) but users report best results
37 -installing from the official OSXFUSE installer package.
38 -
39 -Note that `ipfs` attempts an automatic version check on `osxfuse` to prevent you
40 -from shooting yourself in the foot if you have pre `2.7.0`. Since checking the
41 -OSXFUSE version [is more complicated than it should be], running `ipfs mount`
42 -may require you to install another binary:
50 +Install [macFUSE](https://macfuse.github.io/):
51
52 ```sh
45 -go get github.com/jbenet/go-fuse-version/fuse-version
53 +brew install --cask macfuse
54 ```
55
48 -If you run into any problems installing FUSE or mounting IPFS, hop on IRC and
49 -speak with us, or if you figure something new out, please add to this document!
56 +After installation, open **System Settings > Privacy & Security** and allow the macFUSE kernel extension to load. A reboot may be required.
57 +
58 +Kubo automatically sets `volname`, `noapplexattr`, and `noappledouble` mount options on macOS:
59 +
60 +- `volname` shows the filesystem name (ipfs, ipns, mfs) in Finder instead of the generic "macfuse Volume 0"
61 +- `noapplexattr` prevents Finder from probing Apple-private extended attributes on every file access, reducing unnecessary FUSE traffic on network-backed mounts
62 +- `noappledouble` prevents macOS from creating `._` resource fork sidecar files, which would pollute the DAG with macOS-only metadata
63 +
64 +> [!NOTE]
65 +> macOS has known FUSE limitations (frequent STATFS calls, limited notification support) that may affect performance. See the [`hanwen/go-fuse` macOS notes](https://github.com/hanwen/go-fuse#macos-support) for details.
66
67 #### FreeBSD
52 -```sh
53 -sudo pkg install fusefs-ext2
54 -```
68
56 -Load the fuse kernel module:
69 +Load the FUSE kernel module:
70 +
71 ```sh
72 sudo kldload fusefs
73 ```
74
75 To load automatically on boot:
76 +
77 ```sh
63 -sudo echo fusefs_load="YES" >> /boot/loader.conf
78 +echo 'fusefs_load="YES"' | sudo tee -a /boot/loader.conf
79 ```
80
81 ## Prepare mountpoints
82
68 -By default ipfs uses `/ipfs`, `/ipns` and `/mfs` directories for mounting, this can be
69 -changed in config. You will have to create the `/ipfs`, `/ipns` and `/mfs` directories
83 +By default ipfs uses `/ipfs`, `/ipns` and `/mfs` directories for mounting. These can be
84 +changed in config (see [`Mounts`](https://github.com/ipfs/kubo/blob/master/docs/config.md#mounts)). You will have to create the directories
85 explicitly. Note that modifying root requires sudo permissions.
86
87 ```sh
88 # make the directories
74 -sudo mkdir /ipfs
75 -sudo mkdir /ipns
76 -sudo mkdir /mfs
89 +sudo mkdir /ipfs /ipns /mfs
90
91 # chown them so ipfs can use them without root permissions
79 -sudo chown <username> /ipfs
80 -sudo chown <username> /ipns
81 -sudo chown <username> /mfs
92 +sudo chown <username> /ipfs /ipns /mfs
93 ```
94
84 -Depending on whether you are using OSX or Linux, follow the proceeding instructions.
85 -
86 -## Make sure IPFS daemon is not running
87 -
88 -You'll need to stop the IPFS daemon if you have it started, otherwise the mount will complain.
89 -
90 -```
91 -# Check to see if IPFS daemon is running
92 -ps aux | grep ipfs
95 +## Mounting IPFS
96
94 -# Kill the IPFS daemon
95 -pkill -f ipfs
97 +Make sure no other IPFS daemon is already running, then start the daemon with FUSE mounts enabled:
98
97 -# Verify that it has been killed
99 +```sh
100 +ipfs daemon --mount
101 ```
102
100 -## Mounting IPFS
103 +Or, if the daemon is already running:
104
105 ```sh
103 -ipfs daemon --mount
106 +ipfs mount
107 ```
108
109 If you wish to allow other users to use the mount points, edit `/etc/fuse.conf`
107 -to enable non-root users, i.e.:
110 +to enable non-root users:
111 +
112 ```sh
113 # /etc/fuse.conf - Configuration file for Filesystem in Userspace (FUSE)
114
@@ -117,44 +121,69 @@ user_allow_other
121 ```
122
123 Next set `Mounts.FuseAllowOther` config option to `true`:
124 +
125 ```sh
126 ipfs config --json Mounts.FuseAllowOther true
127 ipfs daemon --mount
128 ```
129
125 -If using FreeBSD, it is necessary to run `ipfs` as root:
130 +## MFS mountpoint
131 +
132 +The `/mfs` mount exposes the MFS (Mutable File System) root as a FUSE filesystem.
133 +This is the same virtual mutable filesystem as the one behind `ipfs files` commands
134 +(see `ipfs files --help`), enabling manipulation of content-addressed data like regular files.
135 +
136 +Standard tools like `vim`, `rsync`, and `tar` work on writable mounts (`/mfs` and `/ipns`).
137 +Operations like `fsync`, `ftruncate`, `chmod`, `touch`, and rename-over-existing are all supported.
138 +
139 +The CID for any file or directory is retrievable via the `ipfs.cid`
140 +extended attribute:
141 +
142 ```sh
127 -sudo HOME=$HOME ipfs daemon --mount
143 +$ getfattr -n ipfs.cid /mfs/hello.txt
144 +# file: mfs/hello.txt
145 +ipfs.cid="bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"
146 ```
147
130 -## MFS mountpoint
148 +> [!TIP]
149 +> New IPFS nodes should run `ipfs config profile apply unixfs-v1-2025` to use CIDv1 with modern defaults. Without this, files default to CIDv0 (base58 `Qm...` hashes).
150 +
151 +## Mode and mtime
152
132 -Kubo v0.35.0 and later supports mounting the MFS (Mutable File System) root as
133 -a FUSE filesystem, enabling manipulation of content-addressed data like regular
134 -files. The CID for any file or directory is retrievable via the `ipfs_cid`
135 -extended attribute.
153 +By default, IPFS does not persist POSIX file mode or modification time. Most content on IPFS
154 +does not include this metadata.
155 +
156 +When mode or mtime is absent, FUSE mounts use sensible defaults:
157 +
158 +- Read-only mounts (`/ipfs`): files `0444`, directories `0555`
159 +- Writable mounts (`/ipns`, `/mfs`): files `0644`, directories `0755`
160 +
161 +When UnixFS metadata is present in the DAG (e.g. content added with mode/mtime preservation),
162 +all three mounts show the stored values in `stat` responses regardless of config flags.
163 +
164 +To persist mode and mtime when writing through FUSE, enable the opt-in config flags:
165
166 ```sh
138 -getfattr -n ipfs_cid /mfs/welcome-to-IPFS.jpg
139 -getfattr: Removing leading '/' from absolute path names
140 -# file: mfs/welcome-to-IPFS.jpg
141 -ipfs_cid="QmaeXDdwpUeKQcMy7d5SFBfVB4y7LtREbhm5KizawPsBSH"
167 +ipfs config --json Mounts.StoreMtime true
168 +ipfs config --json Mounts.StoreMode true
169 ```
170
144 -Please note that the operations supported by the MFS FUSE mountpoint are
145 -limited. Since the MFS wasn't designed to store file attributes like ownership
146 -information, permissions and creation date, some applications like `vim` and
147 -`sed` may misbehave due to missing functionality.
171 +These flags change the resulting CID even when file content is identical, because mode and mtime
172 +are stored in the UnixFS DAG node metadata.
173 +
174 +See [`Mounts.StoreMtime`](https://github.com/ipfs/kubo/blob/master/docs/config.md#mountsstoremtime) and [`Mounts.StoreMode`](https://github.com/ipfs/kubo/blob/master/docs/config.md#mountsstoremode).
175
176 ## Troubleshooting
177
178 #### `Permission denied` or `fusermount: user has no write access to mountpoint` error in Linux
179
180 Verify that the config file can be read by your user:
181 +
182 ```sh
183 sudo ls -l /etc/fuse.conf
184 -rw-r----- 1 root fuse 216 Jan 2 2013 /etc/fuse.conf
185 ```
186 +
187 In most distributions, the group named `fuse` will be created during fuse
188 installation. You can check this with:
189
@@ -163,19 +192,18 @@ sudo grep -q fuse /etc/group && echo fuse_group_present || echo fuse_group_missi
192 ```
193
194 If the group is present, just add your regular user to the `fuse` group:
195 +
196 ```sh
197 sudo usermod -G fuse -a <username>
198 ```
199
200 If the group didn't exist, create `fuse` group (add your regular user to it) and
201 set necessary permissions, for example:
202 +
203 ```sh
204 sudo chgrp fuse /etc/fuse.conf
205 sudo chmod g+r /etc/fuse.conf
206 ```
176 -<!--
177 -TODO: udev rules for /dev/fuse?
178 --->
207
208 Note that the use of `fuse` group is optional and may depend on your operating
209 system. It is okay to use a different group as long as proper permissions are
@@ -183,7 +211,7 @@ set for user running `ipfs mount` command.
211
212 #### Mount command crashes and mountpoint gets stuck
213
186 -```
214 +```sh
215 sudo umount /ipfs
216 sudo umount /ipns
217 sudo umount /mfs
@@ -192,12 +220,19 @@ sudo umount /mfs
220 #### Mounting fails with "error mounting: could not resolve name"
221
222 Make sure your node's IPNS address has a directory published:
195 -```
196 -$ mkdir hello/; echo 'hello' > hello/hello.txt; ipfs add -rQ ./hello/
197 -QmU5PLEGqjetW4RAmXgHpEFL7nVCL3vFnEyrCKUfRk4MSq
223
199 -$ ipfs name publish QmU5PLEGqjetW4RAmXgHpEFL7nVCL3vFnEyrCKUfRk4MSq
224 +```sh
225 +$ mkdir hello/; echo 'hello world' > hello/hello.txt
226 +$ ipfs add -rQ ./hello/
227 +bafybeidhkumeonuwkebh2i4fc7o7lguehauradvlk57gzake6ggjsy372a
228 +
229 +$ ipfs name publish bafybeidhkumeonuwkebh2i4fc7o7lguehauradvlk57gzake6ggjsy372a
230 ```
231
202 -If you manage to mount on other systems (or followed an alternative path to one
203 -above), please contribute to these docs :D
232 +#### Enabling debug logging
233 +
234 +Set the `IPFS_FUSE_DEBUG` environment variable before starting the daemon to log all FUSE operations to stderr:
235 +
236 +```sh
237 +IPFS_FUSE_DEBUG=1 ipfs daemon --mount
238 +```
fuse/fusetest/detect.go
+9 -8
@@ -1,4 +1,5 @@
1 -//go:build !nofuse
1 +// FUSE availability detection. go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3
4 package fusetest
5
@@ -25,28 +26,28 @@ func fuseFlagFromEnv() string {
26 // fuseAvailable checks whether FUSE is likely to work on this system
27 // and skips with a helpful message if not.
28 //
28 -// On Linux, bazil.org/fuse requires "fusermount" (not "fusermount3") in
29 -// PATH. Systems with only fuse3 installed need a symlink:
30 -//
31 -// sudo ln -s /usr/bin/fusermount3 /usr/local/bin/fusermount
29 +// hanwen/go-fuse supports Linux, macOS, and FreeBSD. NetBSD and OpenBSD
30 +// are not supported: NetBSD uses PUFFS (a different protocol) and
31 +// OpenBSD's FUSE support is not compatible with go-fuse's mount mechanism.
32 func fuseAvailable(t *testing.T) bool {
33 t.Helper()
34
35 switch runtime.GOOS {
36 - case "linux", "darwin", "freebsd", "netbsd", "openbsd":
36 + case "linux", "darwin", "freebsd":
37 default:
38 t.Skip("FUSE not supported on", runtime.GOOS)
39 return false
40 }
41
42 if runtime.GOOS == "linux" {
43 + // go-fuse tries fusermount3 first, then fusermount.
44 if _, err := exec.LookPath("fusermount"); err == nil {
45 return true
46 }
47 if _, err := exec.LookPath("fusermount3"); err == nil {
47 - t.Skip("fusermount3 found but bazil.org/fuse needs \"fusermount\"; create a symlink: sudo ln -s /usr/bin/fusermount3 /usr/local/bin/fusermount")
48 + return true
49 }
49 - t.Skip("fusermount not found in PATH")
50 + t.Skip("neither fusermount nor fusermount3 found in PATH")
51 return false
52 }
53
fuse/fusetest/fusetest.go
+27 -1
@@ -1,10 +1,13 @@
1 -//go:build !nofuse
1 +//go:build (linux || darwin || freebsd) && !nofuse
2
3 // Package fusetest provides test helpers shared across FUSE test packages.
4 package fusetest
5
6 import (
7 + "os"
8 "testing"
9 +
10 + "github.com/hanwen/go-fuse/v2/fs"
11 )
12
13 // SkipUnlessFUSE skips the test when FUSE is not available.
@@ -27,6 +30,29 @@ func SkipUnlessFUSE(t *testing.T) {
30 fuseAvailable(t) // skips with a helpful message if not available
31 }
32
33 +// TestMount mounts root at a temp directory with the given options and
34 +// registers an unmount cleanup. Returns the mount directory path.
35 +// Callers set mount-specific options (timeouts, MaxReadAhead, etc.)
36 +// before calling; this helper adds NullPermissions, UID, and GID.
37 +func TestMount(t *testing.T, root fs.InodeEmbedder, opts *fs.Options) string {
38 + t.Helper()
39 + SkipUnlessFUSE(t)
40 + mntDir := t.TempDir()
41 + if opts == nil {
42 + opts = &fs.Options{}
43 + }
44 + opts.NullPermissions = true
45 + opts.UID = uint32(os.Getuid())
46 + opts.GID = uint32(os.Getgid())
47 + if opts.MountOptions.FsName == "" {
48 + opts.MountOptions.FsName = "kubo-test"
49 + }
50 + server, err := fs.Mount(mntDir, root, opts)
51 + MountError(t, err)
52 + t.Cleanup(func() { _ = server.Unmount() })
53 + return mntDir
54 +}
55 +
56 // MountError handles a FUSE mount error. When TEST_FUSE=1 (CI), a mount
57 // failure is fatal because the environment is expected to have working FUSE.
58 // When auto-detecting (no TEST_FUSE set), mount failures cause a skip.
fuse/fusetest/writablesuite.go new
+914
@@ -0,0 +1,914 @@
1 +// Reusable test suite for writable FUSE mounts.
2 +//
3 +// RunWritableSuite exercises all filesystem operations shared by
4 +// /mfs and /ipns. Each mount provides a MountFunc that creates a
5 +// fresh writable mount.
6 +//
7 +//go:build (linux || darwin || freebsd) && !nofuse
8 +
9 +package fusetest
10 +
11 +import (
12 + "bytes"
13 + "crypto/rand"
14 + "errors"
15 + "fmt"
16 + "io"
17 + mrand "math/rand"
18 + "os"
19 + "path/filepath"
20 + "strconv"
21 + "sync"
22 + "syscall"
23 + "testing"
24 + "time"
25 +
26 + racedet "github.com/ipfs/go-detect-race"
27 + "github.com/ipfs/kubo/fuse/writable"
28 + "github.com/stretchr/testify/require"
29 + "golang.org/x/sys/unix"
30 +)
31 +
32 +// MountFunc creates a fresh writable FUSE mount and returns the root
33 +// directory path. Cleanup is handled via t.Cleanup.
34 +type MountFunc func(t *testing.T, cfg writable.Config) string
35 +
36 +// RunWritableSuite runs generic writable filesystem tests against
37 +// the mount produced by mount.
38 +func RunWritableSuite(t *testing.T, mount MountFunc) {
39 + t.Run("ReadWrite", func(t *testing.T) {
40 + dir := mount(t, writable.Config{})
41 + data := WriteFileOrFail(t, 500, filepath.Join(dir, "testfile"))
42 + VerifyFile(t, filepath.Join(dir, "testfile"), data)
43 + })
44 +
45 + t.Run("AppendFile", func(t *testing.T) {
46 + dir := mount(t, writable.Config{})
47 + path := filepath.Join(dir, "appendme")
48 +
49 + part1 := RandBytes(200)
50 + require.NoError(t, os.WriteFile(path, part1, 0o644))
51 +
52 + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
53 + require.NoError(t, err)
54 + part2 := RandBytes(300)
55 + _, err = f.Write(part2)
56 + require.NoError(t, err)
57 + require.NoError(t, f.Close())
58 +
59 + VerifyFile(t, path, append(part1, part2...))
60 + })
61 +
62 + t.Run("MultiWrite", func(t *testing.T) {
63 + dir := mount(t, writable.Config{})
64 + path := filepath.Join(dir, "multiwrite")
65 +
66 + f, err := os.Create(path)
67 + require.NoError(t, err)
68 + var want []byte
69 + for range 1001 {
70 + b := []byte{byte(mrand.Intn(256))}
71 + _, err := f.Write(b)
72 + require.NoError(t, err)
73 + want = append(want, b...)
74 + }
75 + require.NoError(t, f.Close())
76 + VerifyFile(t, path, want)
77 + })
78 +
79 + t.Run("EmptyDirListing", func(t *testing.T) {
80 + dir := mount(t, writable.Config{})
81 + emptyDir := filepath.Join(dir, "emptydir")
82 + require.NoError(t, os.Mkdir(emptyDir, 0o755))
83 +
84 + entries, err := os.ReadDir(emptyDir)
85 + require.NoError(t, err)
86 + require.Empty(t, entries)
87 + })
88 +
89 + t.Run("Mkdir", func(t *testing.T) {
90 + dir := mount(t, writable.Config{})
91 + nested := filepath.Join(dir, "a", "b", "c")
92 + require.NoError(t, os.MkdirAll(nested, 0o755))
93 +
94 + info, err := os.Stat(nested)
95 + require.NoError(t, err)
96 + require.True(t, info.IsDir())
97 + })
98 +
99 + // Both fstat (on the open handle) and path-based stat must return
100 + // the correct mode and size for a freshly created file. The kernel
101 + // caches attrs from the Create response for AttrTimeout: if
102 + // Dir.Create returns an empty EntryOut.Attr, fstat sees the cached
103 + // zero values. A path-based stat does a fresh Lookup, which has its
104 + // own attr-fill path; covering both shapes guards against future
105 + // regressions on either side.
106 + t.Run("CreateAttrsImmediate", func(t *testing.T) {
107 + dir := mount(t, writable.Config{})
108 + path := filepath.Join(dir, "freshfile")
109 +
110 + f, err := os.Create(path)
111 + require.NoError(t, err)
112 + defer f.Close()
113 +
114 + // fstat on the open handle: exercises the Create response cache.
115 + fstatInfo, err := f.Stat()
116 + require.NoError(t, err)
117 + require.Equal(t, int64(0), fstatInfo.Size())
118 + require.Equal(t, os.FileMode(0o644), fstatInfo.Mode().Perm(),
119 + "fstat on new file should report default mode, not cached zero")
120 +
121 + // Path-based stat: exercises Dir.Lookup → FileInode.fillAttr.
122 + statInfo, err := os.Stat(path)
123 + require.NoError(t, err)
124 + require.Equal(t, int64(0), statInfo.Size())
125 + require.Equal(t, os.FileMode(0o644), statInfo.Mode().Perm(),
126 + "stat on new file should report default mode, not cached zero")
127 + })
128 +
129 + // Same as CreateAttrsImmediate, but for mkdir. Mkdir does not return
130 + // a file handle, so we open the directory afterwards and fstat its
131 + // fd to exercise the inode-level path. Path-based stat exercises
132 + // Lookup. Both must report the directory mode.
133 + t.Run("MkdirAttrsImmediate", func(t *testing.T) {
134 + dir := mount(t, writable.Config{})
135 + path := filepath.Join(dir, "freshdir")
136 +
137 + require.NoError(t, os.Mkdir(path, 0o755))
138 +
139 + // Path-based stat: exercises Dir.Lookup → Dir.fillAttr.
140 + statInfo, err := os.Stat(path)
141 + require.NoError(t, err)
142 + require.True(t, statInfo.IsDir())
143 + require.Equal(t, os.FileMode(0o755), statInfo.Mode().Perm(),
144 + "stat on new directory should report default mode, not cached zero")
145 +
146 + // fstat on an open directory fd: exercises Dir.Getattr.
147 + f, err := os.Open(path)
148 + require.NoError(t, err)
149 + defer f.Close()
150 + fstatInfo, err := f.Stat()
151 + require.NoError(t, err)
152 + require.True(t, fstatInfo.IsDir())
153 + require.Equal(t, os.FileMode(0o755), fstatInfo.Mode().Perm(),
154 + "fstat on new directory should report default mode, not cached zero")
155 + })
156 +
157 + t.Run("RenameFile", func(t *testing.T) {
158 + dir := mount(t, writable.Config{})
159 + src := filepath.Join(dir, "oldname")
160 + dst := filepath.Join(dir, "newname")
161 +
162 + data := WriteFileOrFail(t, 300, src)
163 + require.NoError(t, os.Rename(src, dst))
164 +
165 + _, err := os.Stat(src)
166 + require.True(t, os.IsNotExist(err))
167 + VerifyFile(t, dst, data)
168 + })
169 +
170 + t.Run("CrossDirRename", func(t *testing.T) {
171 + dir := mount(t, writable.Config{})
172 + require.NoError(t, os.Mkdir(filepath.Join(dir, "src"), 0o755))
173 + require.NoError(t, os.Mkdir(filepath.Join(dir, "dst"), 0o755))
174 +
175 + data := WriteFileOrFail(t, 200, filepath.Join(dir, "src", "file"))
176 + require.NoError(t, os.Rename(filepath.Join(dir, "src", "file"), filepath.Join(dir, "dst", "file")))
177 +
178 + _, err := os.Stat(filepath.Join(dir, "src", "file"))
179 + require.True(t, os.IsNotExist(err))
180 + VerifyFile(t, filepath.Join(dir, "dst", "file"), data)
181 + })
182 +
183 + // Renaming a directory (not just a file inside it). The contained
184 + // file must still be readable under the new path.
185 + t.Run("DirRename", func(t *testing.T) {
186 + dir := mount(t, writable.Config{})
187 + oldDir := filepath.Join(dir, "olddir")
188 + newDir := filepath.Join(dir, "newdir")
189 +
190 + require.NoError(t, os.Mkdir(oldDir, 0o755))
191 + data := WriteFileOrFail(t, 200, filepath.Join(oldDir, "child"))
192 +
193 + require.NoError(t, os.Rename(oldDir, newDir))
194 +
195 + _, err := os.Stat(oldDir)
196 + require.True(t, os.IsNotExist(err))
197 + VerifyFile(t, filepath.Join(newDir, "child"), data)
198 + })
199 +
200 + t.Run("RemoveFile", func(t *testing.T) {
201 + dir := mount(t, writable.Config{})
202 + path := filepath.Join(dir, "removeme")
203 + WriteFileOrFail(t, 100, path)
204 + require.NoError(t, os.Remove(path))
205 +
206 + _, err := os.Stat(path)
207 + require.True(t, os.IsNotExist(err))
208 + })
209 +
210 + t.Run("Rmdir", func(t *testing.T) {
211 + dir := mount(t, writable.Config{})
212 + sub := filepath.Join(dir, "rmdir_target")
213 + require.NoError(t, os.Mkdir(sub, 0o755))
214 + require.NoError(t, os.Remove(sub))
215 +
216 + _, err := os.Stat(sub)
217 + require.True(t, os.IsNotExist(err))
218 + })
219 +
220 + t.Run("RemoveNonEmptyDirectory", func(t *testing.T) {
221 + dir := mount(t, writable.Config{})
222 + sub := filepath.Join(dir, "nonempty")
223 + require.NoError(t, os.Mkdir(sub, 0o755))
224 + WriteFileOrFail(t, 50, filepath.Join(sub, "child"))
225 +
226 + err := syscall.Rmdir(sub)
227 + require.Error(t, err, "expected error removing non-empty directory")
228 +
229 + // After removing the child, rmdir succeeds.
230 + require.NoError(t, os.Remove(filepath.Join(sub, "child")))
231 + require.NoError(t, os.Remove(sub))
232 + })
233 +
234 + t.Run("DoubleEntryFailure", func(t *testing.T) {
235 + dir := mount(t, writable.Config{})
236 + sub := filepath.Join(dir, "dupdir")
237 + require.NoError(t, os.Mkdir(sub, 0o755))
238 + require.Error(t, os.Mkdir(sub, 0o755))
239 + })
240 +
241 + t.Run("Fsync", func(t *testing.T) {
242 + dir := mount(t, writable.Config{})
243 + path := filepath.Join(dir, "fsyncme")
244 +
245 + f, err := os.Create(path)
246 + require.NoError(t, err)
247 + _, err = f.Write(RandBytes(500))
248 + require.NoError(t, err)
249 + require.NoError(t, f.Sync())
250 + require.NoError(t, f.Close())
251 + })
252 +
253 + // After fsync on the writer handle, a fresh reader on a different
254 + // fd must see the synced data. This is the "vim wrote and called
255 + // fsync; my other process should see it immediately" scenario.
256 + t.Run("FsyncCrossHandle", func(t *testing.T) {
257 + dir := mount(t, writable.Config{})
258 + path := filepath.Join(dir, "fsynccross")
259 +
260 + want := RandBytes(500)
261 + w, err := os.Create(path)
262 + require.NoError(t, err)
263 + _, err = w.Write(want)
264 + require.NoError(t, err)
265 + require.NoError(t, w.Sync())
266 + // w is intentionally still open: the cross-handle reader must
267 + // see the data after fsync, not just after close.
268 +
269 + got, err := os.ReadFile(path)
270 + require.NoError(t, err)
271 + require.Equal(t, len(want), len(got),
272 + "reader on fresh handle should see all bytes after fsync")
273 + require.Equal(t, want, got,
274 + "reader on a fresh handle should see data flushed by fsync")
275 +
276 + require.NoError(t, w.Close())
277 + })
278 +
279 + t.Run("Ftruncate", func(t *testing.T) {
280 + dir := mount(t, writable.Config{})
281 + path := filepath.Join(dir, "truncme")
282 +
283 + f, err := os.Create(path)
284 + require.NoError(t, err)
285 + _, err = f.Write(RandBytes(1000))
286 + require.NoError(t, err)
287 + require.NoError(t, f.Truncate(500))
288 + require.NoError(t, f.Close())
289 +
290 + info, err := os.Stat(path)
291 + require.NoError(t, err)
292 + require.Equal(t, int64(500), info.Size())
293 + })
294 +
295 + // truncate(path, size) without an open fd: uses a temporary
296 + // write descriptor inside Setattr instead of ftruncate on an
297 + // existing handle.
298 + t.Run("TruncatePath", func(t *testing.T) {
299 + dir := mount(t, writable.Config{})
300 + path := filepath.Join(dir, "pathtrunc")
301 +
302 + WriteFileOrFail(t, 1000, path)
303 + require.NoError(t, syscall.Truncate(path, 500))
304 +
305 + info, err := os.Stat(path)
306 + require.NoError(t, err)
307 + require.Equal(t, int64(500), info.Size())
308 + })
309 +
310 + t.Run("LargeFile", func(t *testing.T) {
311 + dir := mount(t, writable.Config{})
312 + path := filepath.Join(dir, "largefile")
313 + size := 1024*1024 + 1 // 1 MiB + 1 byte
314 + data := WriteFileOrFail(t, size, path)
315 + VerifyFile(t, path, data)
316 + })
317 +
318 + t.Run("OpenTrunc", func(t *testing.T) {
319 + dir := mount(t, writable.Config{})
320 + path := filepath.Join(dir, "truncopen")
321 +
322 + WriteFileOrFail(t, 500, path)
323 +
324 + f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o644)
325 + require.NoError(t, err)
326 + newData := RandBytes(200)
327 + _, err = f.Write(newData)
328 + require.NoError(t, err)
329 + require.NoError(t, f.Close())
330 +
331 + VerifyFile(t, path, newData)
332 + })
333 +
334 + t.Run("TempFileRename", func(t *testing.T) {
335 + dir := mount(t, writable.Config{})
336 + target := filepath.Join(dir, "target")
337 + tmp := filepath.Join(dir, ".target.tmp")
338 +
339 + WriteFileOrFail(t, 100, target)
340 + newData := WriteFileOrFail(t, 200, tmp)
341 + require.NoError(t, os.Rename(tmp, target))
342 +
343 + VerifyFile(t, target, newData)
344 + })
345 +
346 + t.Run("SeekAndWrite", func(t *testing.T) {
347 + dir := mount(t, writable.Config{})
348 + path := filepath.Join(dir, "seekwrite")
349 + data := WriteFileOrFail(t, 100, path)
350 +
351 + f, err := os.OpenFile(path, os.O_WRONLY, 0o644)
352 + require.NoError(t, err)
353 + patch := []byte("PATCHED")
354 + _, err = f.WriteAt(patch, 10)
355 + require.NoError(t, err)
356 + require.NoError(t, f.Close())
357 +
358 + copy(data[10:], patch)
359 + VerifyFile(t, path, data)
360 + })
361 +
362 + // Writing past the end of an empty file. UnixFS may not store true
363 + // sparse holes, but the visible read must report the requested
364 + // offset and the data we wrote, with zero bytes filling the gap.
365 + t.Run("SparseWrite", func(t *testing.T) {
366 + dir := mount(t, writable.Config{})
367 + path := filepath.Join(dir, "sparse")
368 +
369 + f, err := os.Create(path)
370 + require.NoError(t, err)
371 + payload := RandBytes(100)
372 + _, err = f.WriteAt(payload, 1000)
373 + require.NoError(t, err)
374 + require.NoError(t, f.Close())
375 +
376 + got, err := os.ReadFile(path)
377 + require.NoError(t, err)
378 + require.Equal(t, 1100, len(got), "size should include the gap before the written bytes")
379 + require.True(t, bytes.Equal(payload, got[1000:]), "tail bytes should match the written payload")
380 + // Bytes [0:1000] should read as zero. Don't assert byte-for-byte
381 + // equality with a zero slice (would catch the same thing twice);
382 + // require.NotContains over a sample is enough.
383 + for _, b := range got[:1000] {
384 + if b != 0 {
385 + t.Fatalf("expected zero gap fill, got byte %d", b)
386 + }
387 + }
388 + })
389 +
390 + // O_EXCL: the second create on the same path must fail with an
391 + // error that satisfies os.IsExist. Lock files, ssh-agent, and
392 + // atomic file creation patterns rely on this.
393 + t.Run("OExcl", func(t *testing.T) {
394 + dir := mount(t, writable.Config{})
395 + path := filepath.Join(dir, "exclfile")
396 +
397 + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
398 + require.NoError(t, err)
399 + require.NoError(t, f.Close())
400 +
401 + _, err = os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
402 + require.Error(t, err)
403 + require.True(t, os.IsExist(err), "second O_EXCL create should fail with EEXIST, got %v", err)
404 + })
405 +
406 + t.Run("OverwriteExisting", func(t *testing.T) {
407 + dir := mount(t, writable.Config{})
408 + path := filepath.Join(dir, "overwrite")
409 +
410 + WriteFileOrFail(t, 500, path)
411 +
412 + f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o644)
413 + require.NoError(t, err)
414 + newData := RandBytes(300)
415 + _, err = f.Write(newData)
416 + require.NoError(t, err)
417 + require.NoError(t, f.Close())
418 +
419 + VerifyFile(t, path, newData)
420 + })
421 +
422 + // Vim (with backupcopy=yes) save sequence: open O_TRUNC, write, fsync, chmod.
423 + t.Run("VimSavePattern", func(t *testing.T) {
424 + dir := mount(t, writable.Config{StoreMode: true})
425 + path := filepath.Join(dir, "vimsave")
426 +
427 + WriteFileOrFail(t, 200, path)
428 +
429 + f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o644)
430 + require.NoError(t, err)
431 + newData := RandBytes(300)
432 + _, err = f.Write(newData)
433 + require.NoError(t, err)
434 + require.NoError(t, f.Sync())
435 + require.NoError(t, f.Chmod(0o644))
436 + require.NoError(t, f.Close())
437 +
438 + VerifyFile(t, path, newData)
439 + })
440 +
441 + // rsync default save: create temp file, write, rename over target.
442 + t.Run("RsyncPattern", func(t *testing.T) {
443 + dir := mount(t, writable.Config{})
444 + target := filepath.Join(dir, "rsync_target")
445 + tmp := filepath.Join(dir, ".rsync_target.XXXXXX")
446 +
447 + WriteFileOrFail(t, 100, target)
448 + newData := WriteFileOrFail(t, 200, tmp)
449 + require.NoError(t, os.Rename(tmp, target))
450 +
451 + VerifyFile(t, target, newData)
452 + })
453 +
454 + t.Run("Symlink", func(t *testing.T) {
455 + dir := mount(t, writable.Config{})
456 + link := filepath.Join(dir, "mylink")
457 + require.NoError(t, os.Symlink("/some/target", link))
458 +
459 + got, err := os.Readlink(link)
460 + require.NoError(t, err)
461 + require.Equal(t, "/some/target", got)
462 + })
463 +
464 + // Verify that readdir reports symlinks with ModeSymlink so that
465 + // tools like ls -l and find -type l see the correct file type.
466 + t.Run("SymlinkReaddir", func(t *testing.T) {
467 + dir := mount(t, writable.Config{})
468 +
469 + // Create a regular file and a symlink in the same directory.
470 + WriteFileOrFail(t, 100, filepath.Join(dir, "regular"))
471 + require.NoError(t, os.Symlink("/some/target", filepath.Join(dir, "mylink")))
472 +
473 + entries, err := os.ReadDir(dir)
474 + require.NoError(t, err)
475 +
476 + found := false
477 + for _, e := range entries {
478 + if e.Name() == "mylink" {
479 + require.Equal(t, os.ModeSymlink, e.Type()&os.ModeSymlink,
480 + "readdir should report symlink type for mylink")
481 + found = true
482 + }
483 + if e.Name() == "regular" {
484 + require.Equal(t, os.FileMode(0), e.Type()&os.ModeSymlink,
485 + "readdir should not report symlink type for regular file")
486 + }
487 + }
488 + require.True(t, found, "symlink entry not found in readdir")
489 + })
490 +
491 + t.Run("SymlinkSetattr", func(t *testing.T) {
492 + dir := mount(t, writable.Config{StoreMtime: true})
493 + link := filepath.Join(dir, "mtimelink")
494 + require.NoError(t, os.Symlink("/some/target", link))
495 +
496 + mtime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
497 + require.NoError(t, Lchtimes(link, mtime))
498 +
499 + var stat unix.Stat_t
500 + require.NoError(t, unix.Lstat(link, &stat))
501 + gotMtime := time.Unix(stat.Mtim.Sec, stat.Mtim.Nsec)
502 + require.WithinDuration(t, mtime, gotMtime, time.Second)
503 + })
504 +
505 + t.Run("FileSizeReporting", func(t *testing.T) {
506 + dir := mount(t, writable.Config{})
507 + path := filepath.Join(dir, "sizecheck")
508 + data := WriteFileOrFail(t, 5555, path)
509 +
510 + info, err := os.Stat(path)
511 + require.NoError(t, err)
512 + require.Equal(t, int64(len(data)), info.Size())
513 + })
514 +
515 + t.Run("FileAttributes", func(t *testing.T) {
516 + dir := mount(t, writable.Config{})
517 + path := filepath.Join(dir, "attrcheck")
518 + WriteFileOrFail(t, 100, path)
519 +
520 + info, err := os.Stat(path)
521 + require.NoError(t, err)
522 + require.False(t, info.IsDir())
523 + require.Equal(t, "attrcheck", info.Name())
524 + require.Equal(t, int64(100), info.Size())
525 + })
526 +
527 + t.Run("DefaultDirMode", func(t *testing.T) {
528 + dir := mount(t, writable.Config{})
529 + sub := filepath.Join(dir, "modedir")
530 + require.NoError(t, os.Mkdir(sub, 0o755))
531 +
532 + info, err := os.Stat(sub)
533 + require.NoError(t, err)
534 + require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
535 + })
536 +
537 + // StoreMtime tests.
538 + t.Run("StoreMtime/disabled", func(t *testing.T) {
539 + dir := mount(t, writable.Config{StoreMtime: false})
540 + path := filepath.Join(dir, "nomtime")
541 + WriteFileOrFail(t, 100, path)
542 +
543 + // Without StoreMtime, Getattr returns mtime=0 which the
544 + // kernel reports as Unix epoch start.
545 + info, err := os.Stat(path)
546 + require.NoError(t, err)
547 + require.Equal(t, time.Unix(0, 0), info.ModTime())
548 + })
549 +
550 + t.Run("StoreMtime/enabled", func(t *testing.T) {
551 + dir := mount(t, writable.Config{StoreMtime: true})
552 + path := filepath.Join(dir, "withmtime")
553 + WriteFileOrFail(t, 100, path)
554 +
555 + info, err := os.Stat(path)
556 + require.NoError(t, err)
557 + require.False(t, info.ModTime().IsZero(), "mtime should be set when StoreMtime is on")
558 + require.WithinDuration(t, time.Now(), info.ModTime(), 30*time.Second)
559 + })
560 +
561 + // StoreMode tests.
562 + t.Run("StoreMode/disabled", func(t *testing.T) {
563 + dir := mount(t, writable.Config{StoreMode: false})
564 + path := filepath.Join(dir, "nomode")
565 + WriteFileOrFail(t, 100, path)
566 + // chmod should not fail, even when not persisting
567 + require.NoError(t, os.Chmod(path, 0o600))
568 +
569 + info, err := os.Stat(path)
570 + require.NoError(t, err)
571 + // With StoreMode off, mode stays at default 0644.
572 + require.Equal(t, os.FileMode(0o644), info.Mode().Perm())
573 + })
574 +
575 + t.Run("StoreMode/enabled", func(t *testing.T) {
576 + dir := mount(t, writable.Config{StoreMode: true})
577 + path := filepath.Join(dir, "withmode")
578 + WriteFileOrFail(t, 100, path)
579 + require.NoError(t, os.Chmod(path, 0o600))
580 +
581 + info, err := os.Stat(path)
582 + require.NoError(t, err)
583 + require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
584 + })
585 +
586 + t.Run("SetuidBitsStripped", func(t *testing.T) {
587 + dir := mount(t, writable.Config{StoreMode: true})
588 + path := filepath.Join(dir, "setuid")
589 + WriteFileOrFail(t, 100, path)
590 +
591 + // Setuid, setgid, and sticky bits should be silently stripped
592 + // because boxo's MFS exposes only the lower 9 permission bits.
593 + require.NoError(t, os.Chmod(path, 0o4755))
594 + info, err := os.Stat(path)
595 + require.NoError(t, err)
596 + require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
597 + })
598 +
599 + t.Run("DirMtime", func(t *testing.T) {
600 + dir := mount(t, writable.Config{StoreMtime: true})
601 + sub := filepath.Join(dir, "dirmtime")
602 + require.NoError(t, os.Mkdir(sub, 0o755))
603 +
604 + mtime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
605 + require.NoError(t, os.Chtimes(sub, mtime, mtime))
606 +
607 + info, err := os.Stat(sub)
608 + require.NoError(t, err)
609 + require.WithinDuration(t, mtime, info.ModTime(), time.Second)
610 + })
611 +
612 + t.Run("DirChmod", func(t *testing.T) {
613 + dir := mount(t, writable.Config{StoreMode: true})
614 + sub := filepath.Join(dir, "dirchmod")
615 + require.NoError(t, os.Mkdir(sub, 0o755))
616 + require.NoError(t, os.Chmod(sub, 0o700))
617 +
618 + info, err := os.Stat(sub)
619 + require.NoError(t, err)
620 + require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
621 + })
622 +
623 + t.Run("XattrCID", func(t *testing.T) {
624 + dir := mount(t, writable.Config{})
625 + path := filepath.Join(dir, "xattrfile")
626 + WriteFileOrFail(t, 100, path)
627 +
628 + buf := make([]byte, 256)
629 + n, err := unix.Getxattr(path, "ipfs.cid", buf)
630 + require.NoError(t, err)
631 + require.NotEmpty(t, string(buf[:n]))
632 + })
633 +
634 + t.Run("UnknownXattr", func(t *testing.T) {
635 + dir := mount(t, writable.Config{})
636 + path := filepath.Join(dir, "xattrunk")
637 + WriteFileOrFail(t, 50, path)
638 +
639 + buf := make([]byte, 256)
640 + _, err := unix.Getxattr(path, "user.nonexistent", buf)
641 + require.Error(t, err)
642 + })
643 +
644 + t.Run("ConcurrentWrites", func(t *testing.T) {
645 + dir := mount(t, writable.Config{})
646 + nactors := 4
647 + filesPerActor := 400
648 + fileSize := 2000
649 +
650 + if racedet.WithRace() {
651 + nactors = 2
652 + filesPerActor = 50
653 + }
654 +
655 + data := make([][][]byte, nactors)
656 + var wg sync.WaitGroup
657 + for i := range nactors {
658 + data[i] = make([][]byte, filesPerActor)
659 + wg.Add(1)
660 + go func(n int) {
661 + defer wg.Done()
662 + for j := range filesPerActor {
663 + out, err := WriteFile(fileSize, filepath.Join(dir, fmt.Sprintf("%dFILE%d", n, j)))
664 + if err != nil {
665 + t.Error(err)
666 + continue
667 + }
668 + data[n][j] = out
669 + }
670 + }(i)
671 + }
672 + wg.Wait()
673 +
674 + for i := range nactors {
675 + for j := range filesPerActor {
676 + if data[i][j] == nil {
677 + continue
678 + }
679 + VerifyFile(t, filepath.Join(dir, fmt.Sprintf("%dFILE%d", i, j)), data[i][j])
680 + }
681 + }
682 + })
683 +
684 + t.Run("ConcurrentRW", func(t *testing.T) {
685 + dir := mount(t, writable.Config{})
686 + nfiles := 5
687 + readers := 5
688 +
689 + content := make([][]byte, nfiles)
690 + for i := range content {
691 + content[i] = RandBytes(8196)
692 + }
693 +
694 + // Write phase.
695 + var wg sync.WaitGroup
696 + for i := range nfiles {
697 + wg.Go(func() {
698 + if err := os.WriteFile(filepath.Join(dir, strconv.Itoa(i)), content[i], 0o644); err != nil {
699 + t.Error(err)
700 + }
701 + })
702 + }
703 + wg.Wait()
704 +
705 + // Read phase.
706 + for i := range nfiles * readers {
707 + wg.Go(func() {
708 + got, err := os.ReadFile(filepath.Join(dir, strconv.Itoa(i/readers)))
709 + if err != nil {
710 + t.Error(err)
711 + return
712 + }
713 + if !bytes.Equal(content[i/readers], got) {
714 + t.Error("read and write not equal")
715 + }
716 + })
717 + }
718 + wg.Wait()
719 + })
720 +
721 + // Large file concurrent reads: the kernel sends multiple Read
722 + // requests via readahead on files bigger than max_read (128 KB).
723 + // Without proper mutex serialization on the file handle, concurrent
724 + // reads corrupt the DagReader's internal state.
725 + t.Run("LargeFileConcurrentRead", func(t *testing.T) {
726 + dir := mount(t, writable.Config{})
727 + path := filepath.Join(dir, "largeconcurrent")
728 +
729 + size := 1024*1024 + 1 // 1 MiB + 1 byte
730 + data := WriteFileOrFail(t, size, path)
731 +
732 + var wg sync.WaitGroup
733 + for range 8 {
734 + wg.Go(func() {
735 + got, err := os.ReadFile(path)
736 + if err != nil {
737 + t.Errorf("ReadFile: %v", err)
738 + return
739 + }
740 + if !bytes.Equal(got, data) {
741 + t.Errorf("data mismatch: got %d bytes, want %d", len(got), len(data))
742 + }
743 + })
744 + }
745 + wg.Wait()
746 + })
747 +
748 + // Simulate the rsync --inplace pattern: one goroutine holds a
749 + // file open for reading while another opens it for writing.
750 + // MFS's desclock blocks a write-open while a read descriptor
751 + // exists. The FUSE layer avoids this by creating a DagReader
752 + // for read-only opens instead of going through MFS.
753 + t.Run("ConcurrentReadWrite", func(t *testing.T) {
754 + dir := mount(t, writable.Config{})
755 + path := filepath.Join(dir, "concurrent_rw")
756 +
757 + data := WriteFileOrFail(t, 50000, path)
758 +
759 + // Hold the file open for reading (like rsync's generator).
760 + reader, err := os.Open(path)
761 + require.NoError(t, err)
762 + defer reader.Close()
763 +
764 + // Overwrite the file while the reader is still open
765 + // (like rsync's receiver).
766 + newData := RandBytes(60000)
767 + require.NoError(t, os.WriteFile(path, newData, 0o644))
768 +
769 + // The reader should still see the original snapshot.
770 + got, err := io.ReadAll(reader)
771 + require.NoError(t, err)
772 + require.True(t, bytes.Equal(data, got), "reader should see original data")
773 +
774 + // A fresh read should see the new data.
775 + got2, err := os.ReadFile(path)
776 + require.NoError(t, err)
777 + require.True(t, bytes.Equal(newData, got2), "new reader should see updated data")
778 + })
779 +
780 + t.Run("FSThrash", func(t *testing.T) {
781 + dir := mount(t, writable.Config{})
782 + dirs := []string{dir}
783 + dirlock := sync.RWMutex{}
784 + filelock := sync.Mutex{}
785 + files := make(map[string][]byte)
786 +
787 + ndirWorkers := 2
788 + nfileWorkers := 2
789 + ndirs := 100
790 + nfiles := 200
791 +
792 + var wg sync.WaitGroup
793 +
794 + for i := range ndirWorkers {
795 + wg.Add(1)
796 + go func(worker int) {
797 + defer wg.Done()
798 + for j := range ndirs {
799 + dirlock.RLock()
800 + n := mrand.Intn(len(dirs))
801 + d := dirs[n]
802 + dirlock.RUnlock()
803 +
804 + newDir := fmt.Sprintf("%s/dir%d-%d", d, worker, j)
805 + if err := os.Mkdir(newDir, os.ModeDir); err != nil {
806 + t.Error(err)
807 + continue
808 + }
809 + dirlock.Lock()
810 + dirs = append(dirs, newDir)
811 + dirlock.Unlock()
812 + }
813 + }(i)
814 + }
815 +
816 + for i := range nfileWorkers {
817 + wg.Add(1)
818 + go func(worker int) {
819 + defer wg.Done()
820 + for j := range nfiles {
821 + dirlock.RLock()
822 + n := mrand.Intn(len(dirs))
823 + d := dirs[n]
824 + dirlock.RUnlock()
825 +
826 + name := fmt.Sprintf("%s/file%d-%d", d, worker, j)
827 + data, err := WriteFile(2000+mrand.Intn(5000), name)
828 + if err != nil {
829 + t.Error(err)
830 + continue
831 + }
832 + filelock.Lock()
833 + files[name] = data
834 + filelock.Unlock()
835 + }
836 + }(i)
837 + }
838 +
839 + wg.Wait()
840 + for name, data := range files {
841 + got, err := os.ReadFile(name)
842 + if err != nil {
843 + t.Errorf("reading %s: %v", name, err)
844 + continue
845 + }
846 + if !bytes.Equal(data, got) {
847 + t.Errorf("data mismatch in %s", name)
848 + }
849 + }
850 + })
851 +}
852 +
853 +// Test helpers exported for use by mount-specific tests.
854 +
855 +// RandBytes returns size random bytes.
856 +func RandBytes(size int) []byte {
857 + b := make([]byte, size)
858 + if _, err := io.ReadFull(rand.Reader, b); err != nil {
859 + panic(err)
860 + }
861 + return b
862 +}
863 +
864 +// WriteFile writes size random bytes to path and returns the data.
865 +func WriteFile(size int, path string) ([]byte, error) {
866 + data := RandBytes(size)
867 + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o666)
868 + if err != nil {
869 + return nil, err
870 + }
871 + _, err = f.Write(data)
872 + if err != nil {
873 + f.Close()
874 + return nil, err
875 + }
876 + // Go's goroutine preemption (SIGURG) can interrupt the FUSE FLUSH
877 + // inside close(), returning EINTR. This is not data loss: the write
878 + // already succeeded and the kernel will still send RELEASE.
879 + if err := f.Close(); err != nil && !errors.Is(err, syscall.EINTR) {
880 + return nil, err
881 + }
882 + return data, nil
883 +}
884 +
885 +// WriteFileOrFail calls WriteFile and fails the test on error.
886 +func WriteFileOrFail(t *testing.T, size int, path string) []byte {
887 + t.Helper()
888 + data, err := WriteFile(size, path)
889 + require.NoError(t, err)
890 + return data
891 +}
892 +
893 +// VerifyFile reads the file at path and asserts its contents match want.
894 +func VerifyFile(t *testing.T, path string, want []byte) {
895 + t.Helper()
896 + got, err := os.ReadFile(path)
897 + require.NoError(t, err)
898 + require.Equal(t, len(want), len(got), "file size mismatch")
899 + require.True(t, bytes.Equal(want, got), "file content mismatch")
900 +}
901 +
902 +// CheckExists asserts that path exists.
903 +func CheckExists(t *testing.T, path string) {
904 + t.Helper()
905 + _, err := os.Stat(path)
906 + require.NoError(t, err)
907 +}
908 +
909 +// Lchtimes sets mtime on a symlink without following it (lutimes).
910 +// Go's os package has no Lchtimes, so we call utimensat directly.
911 +func Lchtimes(path string, mtime time.Time) error {
912 + ts := unix.NsecToTimespec(mtime.UnixNano())
913 + return unix.UtimesNanoAt(unix.AT_FDCWD, path, []unix.Timespec{ts, ts}, unix.AT_SYMLINK_NOFOLLOW)
914 +}
fuse/ipns/ipns_test.go
+110 -495
@@ -1,114 +1,47 @@
1 -//go:build !nofuse && !openbsd && !netbsd && !plan9
1 +//go:build (linux || darwin || freebsd) && !nofuse
2 +
3 +// Unit tests for the /ipns FUSE mount.
4 +// Generic writable operations are exercised by the shared suite in
5 +// fusetest.RunWritableSuite. This file contains the mount factory
6 +// and IPNS-specific tests only.
7
8 package ipns
9
10 import (
11 "bytes"
12 "context"
8 - "errors"
9 - "fmt"
10 - "io"
11 - mrand "math/rand"
13 "os"
13 - "sync"
14 - "syscall"
14 "testing"
15
17 - core "github.com/ipfs/kubo/core"
18 - coreapi "github.com/ipfs/kubo/core/coreapi"
16 + "github.com/hanwen/go-fuse/v2/fs"
17 + "github.com/hanwen/go-fuse/v2/fuse"
18 + "github.com/stretchr/testify/require"
19
20 - fstest "bazil.org/fuse/fs/fstestutil"
21 - racedet "github.com/ipfs/go-detect-race"
22 - "github.com/ipfs/go-test/random"
20 + "github.com/ipfs/kubo/config"
21 + "github.com/ipfs/kubo/core"
22 + coreapi "github.com/ipfs/kubo/core/coreapi"
23 + iface "github.com/ipfs/kubo/core/coreiface"
24 "github.com/ipfs/kubo/fuse/fusetest"
25 + fusemnt "github.com/ipfs/kubo/fuse/mount"
26 + "github.com/ipfs/kubo/fuse/writable"
27 )
28
26 -func randBytes(size int) []byte {
27 - b := make([]byte, size)
28 - _, err := io.ReadFull(random.NewRand(), b)
29 - if err != nil {
30 - panic(err)
31 - }
32 - return b
33 -}
34 -
35 -func mkdir(t *testing.T, path string) {
36 - err := os.Mkdir(path, os.ModeDir)
37 - if err != nil {
38 - t.Fatal(err)
39 - }
40 -}
41 -
42 -func writeFileOrFail(t *testing.T, size int, path string) []byte {
43 - data, err := writeFile(size, path)
44 - if err != nil {
45 - t.Fatal(err)
46 - }
47 - return data
48 -}
49 -
50 -func writeFile(size int, path string) ([]byte, error) {
51 - data := randBytes(size)
52 - // Same flags as os.WriteFile: write-only, create if missing, truncate if exists.
53 - // We open manually instead of using os.WriteFile so we can handle EINTR on close.
54 - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o666)
55 - if err != nil {
56 - return nil, err
57 - }
58 - _, err = f.Write(data)
59 - if err != nil {
60 - f.Close()
61 - return nil, err
62 - }
63 - // Go's goroutine preemption (SIGURG) can interrupt the FUSE FLUSH
64 - // inside close(), returning EINTR. This is not a data loss: the write
65 - // already succeeded and the kernel will still send RELEASE to the FUSE
66 - // daemon. Go intentionally does not retry close() on EINTR because the
67 - // fd is already closed on Linux and its state is undefined on other
68 - // platforms, making retry unsafe.
69 - if err := f.Close(); err != nil && !errors.Is(err, syscall.EINTR) {
70 - return nil, err
71 - }
72 - return data, nil
73 -}
74 -
75 -func verifyFile(t *testing.T, path string, wantData []byte) {
76 - isData, err := os.ReadFile(path)
77 - if err != nil {
78 - t.Fatal(err)
79 - }
80 - if len(isData) != len(wantData) {
81 - t.Fatal("Data not equal - length check failed")
82 - }
83 - if !bytes.Equal(isData, wantData) {
84 - t.Fatal("Data not equal")
85 - }
29 +type mountWrap struct {
30 + Dir string
31 + Root *Root
32 + server *fuse.Server
33 + closed bool
34 }
35
88 -func checkExists(t *testing.T, path string) {
89 - _, err := os.Stat(path)
90 - if err != nil {
91 - t.Fatal(err)
36 +func (m *mountWrap) Close() {
37 + if m.closed {
38 + return
39 }
93 -}
94 -
95 -func closeMount(mnt *mountWrap) {
96 - if err := recover(); err != nil {
97 - log.Error("Recovered panic")
98 - log.Error(err)
40 + m.closed = true
41 + if m.server != nil {
42 + _ = m.server.Unmount()
43 }
100 - mnt.Close()
101 -}
102 -
103 -type mountWrap struct {
104 - *fstest.Mount
105 - Fs *FileSystem
106 -}
107 -
108 -func (m *mountWrap) Close() error {
109 - m.Fs.Destroy()
110 - m.Mount.Close()
111 - return nil
44 + _ = m.Root.Close()
45 }
46
47 // fakeMount is a minimal mount.Mount that reports itself as active.
@@ -121,439 +54,121 @@ func (fakeMount) MountPoint() string { return "/fake/ipns" }
54 func (fakeMount) Unmount() error { return nil }
55 func (fakeMount) IsActive() bool { return true }
56
124 -func setupIpnsTest(t *testing.T, node *core.IpfsNode) (*core.IpfsNode, *mountWrap) {
57 +func setupIpnsTest(t *testing.T, nd *core.IpfsNode, cfgs ...config.Mounts) (*core.IpfsNode, *mountWrap) {
58 t.Helper()
59 fusetest.SkipUnlessFUSE(t)
60
128 - var err error
129 - if node == nil {
130 - node, err = core.NewNode(context.Background(), &core.BuildCfg{})
131 - if err != nil {
132 - t.Fatal(err)
133 - }
134 -
135 - err = InitializeKeyspace(node, node.PrivateKey)
136 - if err != nil {
137 - t.Fatal(err)
138 - }
139 - }
140 -
141 - coreAPI, err := coreapi.NewCoreAPI(node)
142 - if err != nil {
143 - t.Fatal(err)
61 + var cfg config.Mounts
62 + if len(cfgs) > 0 {
63 + cfg = cfgs[0]
64 }
65
146 - fs, err := NewFileSystem(node.Context(), coreAPI, "", "")
147 - if err != nil {
148 - t.Fatal(err)
149 - }
150 - mnt, err := fstest.MountedT(t, fs, nil)
66 + var err error
67 + if nd == nil {
68 + nd, err = core.NewNode(context.Background(), &core.BuildCfg{})
69 + require.NoError(t, err)
70 +
71 + err = InitializeKeyspace(nd, nd.PrivateKey)
72 + require.NoError(t, err)
73 + }
74 +
75 + coreAPI, err := coreapi.NewCoreAPI(nd)
76 + require.NoError(t, err)
77 +
78 + key, err := coreAPI.Key().Self(nd.Context())
79 + require.NoError(t, err)
80 +
81 + root, err := CreateRoot(nd.Context(), coreAPI, map[string]iface.Key{"local": key}, "", "", cfg)
82 + require.NoError(t, err)
83 +
84 + mntDir := t.TempDir()
85 + server, err := fs.Mount(mntDir, root, &fs.Options{
86 + NullPermissions: true,
87 + UID: uint32(os.Getuid()),
88 + GID: uint32(os.Getgid()),
89 + EntryTimeout: &mutableCacheTime,
90 + AttrTimeout: &mutableCacheTime,
91 + MountOptions: fuse.MountOptions{
92 + FsName: "kubo-test",
93 + MaxReadAhead: fusemnt.MaxReadAhead,
94 + ExtraCapabilities: fusemnt.WritableMountCapabilities,
95 + },
96 + })
97 fusetest.MountError(t, err)
98
153 - // Simulate the real daemon: set node.Mounts.Ipns so that
154 - // checkPublishAllowed sees an active IPNS mount. Before the
155 - // context key fix (issue #2168), this would cause the MFS
156 - // republisher's publishes to be silently rejected.
157 - node.Mounts.Ipns = fakeMount{}
99 + mnt := &mountWrap{Dir: mntDir, Root: root, server: server}
100 + t.Cleanup(mnt.Close)
101
159 - return node, &mountWrap{
160 - Mount: mnt,
161 - Fs: fs,
162 - }
102 + nd.Mounts.Ipns = fakeMount{}
103 + return nd, mnt
104 }
105
165 -func TestIpnsLocalLink(t *testing.T) {
166 - nd, mnt := setupIpnsTest(t, nil)
167 - defer mnt.Close()
168 - name := mnt.Dir + "/local"
169 -
170 - checkExists(t, name)
171 -
172 - linksto, err := os.Readlink(name)
173 - if err != nil {
174 - t.Fatal(err)
106 +// newIpnsMount is the factory for the shared writable suite. It creates
107 +// an IPNS mount and returns the writable /local directory path.
108 +func newIpnsMount(t *testing.T, cfg writable.Config) string {
109 + t.Helper()
110 + mountsCfg := config.Mounts{}
111 + if cfg.StoreMtime {
112 + mountsCfg.StoreMtime = config.True
113 }
176 -
177 - if linksto != nd.Identity.String() {
178 - t.Fatal("Link invalid")
114 + if cfg.StoreMode {
115 + mountsCfg.StoreMode = config.True
116 }
117 + _, mnt := setupIpnsTest(t, nil, mountsCfg)
118 + return mnt.Dir + "/local"
119 }
120
182 -// Test that empty directories can be listed without errors.
183 -func TestEmptyDirListing(t *testing.T) {
184 - nd, mnt := setupIpnsTest(t, nil)
185 - defer mnt.Close()
186 -
187 - // The peer's IPNS directory starts empty.
188 - peerDir := mnt.Dir + "/" + nd.Identity.String()
189 - entries, err := os.ReadDir(peerDir)
190 - if err != nil {
191 - t.Fatal(err)
192 - }
193 - if len(entries) != 0 {
194 - t.Fatalf("expected empty peer dir, got %d entries", len(entries))
195 - }
196 -
197 - // Create a subdirectory and list it while still empty.
198 - subdir := peerDir + "/emptydir"
199 - if err := os.Mkdir(subdir, os.ModeDir); err != nil {
200 - t.Fatal(err)
201 - }
202 - entries, err = os.ReadDir(subdir)
203 - if err != nil {
204 - t.Fatal(err)
205 - }
206 - if len(entries) != 0 {
207 - t.Fatalf("expected empty subdirectory, got %d entries", len(entries))
208 - }
121 +func TestWritableSuite(t *testing.T) {
122 + fusetest.RunWritableSuite(t, newIpnsMount)
123 }
124
211 -// Test writing a file and reading it back.
212 -func TestIpnsBasicIO(t *testing.T) {
125 +// TestIpnsLocalLink verifies that /ipns/local is a symlink to the
126 +// node's own peer ID directory.
127 +func TestIpnsLocalLink(t *testing.T) {
128 nd, mnt := setupIpnsTest(t, nil)
214 - defer closeMount(mnt)
215 -
216 - fname := mnt.Dir + "/local/testfile"
217 - data := writeFileOrFail(t, 10, fname)
218 -
219 - rbuf, err := os.ReadFile(fname)
220 - if err != nil {
221 - t.Fatal(err)
222 - }
223 -
224 - if !bytes.Equal(rbuf, data) {
225 - t.Fatal("Incorrect Read!")
226 - }
227 -
228 - fname2 := mnt.Dir + "/" + nd.Identity.String() + "/testfile"
229 - rbuf, err = os.ReadFile(fname2)
230 - if err != nil {
231 - t.Fatal(err)
232 - }
129
234 - if !bytes.Equal(rbuf, data) {
235 - t.Fatal("Incorrect Read!")
236 - }
130 + target, err := os.Readlink(mnt.Dir + "/local")
131 + require.NoError(t, err)
132 + require.Equal(t, nd.Identity.String(), target)
133 }
134
239 -// Test renaming a file within the same IPNS directory.
240 -func TestRenameFile(t *testing.T) {
241 - nd, mnt := setupIpnsTest(t, nil)
242 - defer closeMount(mnt)
243 -
244 - peerDir := mnt.Dir + "/" + nd.Identity.String()
245 - src := peerDir + "/before.txt"
246 - dst := peerDir + "/after.txt"
247 -
248 - data := writeFileOrFail(t, 500, src)
249 -
250 - if err := os.Rename(src, dst); err != nil {
251 - t.Fatal(err)
252 - }
253 -
254 - // Source must be gone.
255 - if _, err := os.Stat(src); !os.IsNotExist(err) {
256 - t.Fatalf("source still exists after rename: %v", err)
257 - }
135 +// TestNamespaceRootMode verifies that the /ipns root has execute-only
136 +// mode (not listable, only traversable).
137 +func TestNamespaceRootMode(t *testing.T) {
138 + _, mnt := setupIpnsTest(t, nil)
139
259 - // Destination must have the original content.
260 - got, err := os.ReadFile(dst)
261 - if err != nil {
262 - t.Fatal(err)
263 - }
264 - if !bytes.Equal(got, data) {
265 - t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(data))
266 - }
140 + info, err := os.Stat(mnt.Dir)
141 + require.NoError(t, err)
142 + require.Equal(t, os.FileMode(0o111), info.Mode().Perm())
143 }
144
269 -// Test to make sure file changes persist over mounts of ipns.
145 +// TestFilePersistence verifies that file data survives unmount and remount.
146 func TestFilePersistence(t *testing.T) {
271 - node, mnt := setupIpnsTest(t, nil)
272 -
273 - fname := "/local/atestfile"
274 - data := writeFileOrFail(t, 127, mnt.Dir+fname)
147 + nd, mnt := setupIpnsTest(t, nil)
148
149 + data := fusetest.RandBytes(4000)
150 + require.NoError(t, os.WriteFile(mnt.Dir+"/local/persist", data, 0o644))
151 mnt.Close()
152
278 - t.Log("Closed, opening new fs")
279 - _, mnt = setupIpnsTest(t, node)
280 - defer mnt.Close()
281 -
282 - rbuf, err := os.ReadFile(mnt.Dir + fname)
283 - if err != nil {
284 - t.Fatal(err)
285 - }
286 -
287 - if !bytes.Equal(rbuf, data) {
288 - t.Fatalf("File data changed between mounts! sizes differ: %d != %d", len(data), len(rbuf))
289 - }
153 + _, mnt = setupIpnsTest(t, nd)
154 + got, err := os.ReadFile(mnt.Dir + "/local/persist")
155 + require.NoError(t, err)
156 + require.True(t, bytes.Equal(data, got))
157 }
158
159 +// TestMultipleDirs verifies nested directories persist across remount.
160 func TestMultipleDirs(t *testing.T) {
293 - node, mnt := setupIpnsTest(t, nil)
294 -
295 - t.Log("make a top level dir")
296 - dir1 := "/local/test1"
297 - mkdir(t, mnt.Dir+dir1)
298 -
299 - checkExists(t, mnt.Dir+dir1)
300 -
301 - t.Log("write a file in it")
302 - data1 := writeFileOrFail(t, 4000, mnt.Dir+dir1+"/file1")
303 -
304 - verifyFile(t, mnt.Dir+dir1+"/file1", data1)
305 -
306 - t.Log("sub directory")
307 - mkdir(t, mnt.Dir+dir1+"/dir2")
308 -
309 - checkExists(t, mnt.Dir+dir1+"/dir2")
310 -
311 - t.Log("file in that subdirectory")
312 - data2 := writeFileOrFail(t, 5000, mnt.Dir+dir1+"/dir2/file2")
313 -
314 - verifyFile(t, mnt.Dir+dir1+"/dir2/file2", data2)
315 -
316 - mnt.Close()
317 - t.Log("closing mount, then restarting")
318 -
319 - _, mnt = setupIpnsTest(t, node)
320 -
321 - checkExists(t, mnt.Dir+dir1)
161 + nd, mnt := setupIpnsTest(t, nil)
162
323 - verifyFile(t, mnt.Dir+dir1+"/file1", data1)
163 + require.NoError(t, os.Mkdir(mnt.Dir+"/local/test1", 0o755))
164 + data1 := fusetest.WriteFileOrFail(t, 4000, mnt.Dir+"/local/test1/file1")
165 + require.NoError(t, os.Mkdir(mnt.Dir+"/local/test1/dir2", 0o755))
166 + data2 := fusetest.WriteFileOrFail(t, 5000, mnt.Dir+"/local/test1/dir2/file2")
167
325 - verifyFile(t, mnt.Dir+dir1+"/dir2/file2", data2)
168 mnt.Close()
327 -}
328 -
329 -// Test to make sure the filesystem reports file sizes correctly.
330 -func TestFileSizeReporting(t *testing.T) {
331 - _, mnt := setupIpnsTest(t, nil)
332 - defer mnt.Close()
333 -
334 - fname := mnt.Dir + "/local/sizecheck"
335 - data := writeFileOrFail(t, 5555, fname)
336 -
337 - finfo, err := os.Stat(fname)
338 - if err != nil {
339 - t.Fatal(err)
340 - }
341 -
342 - if finfo.Size() != int64(len(data)) {
343 - t.Fatal("Read incorrect size from stat!")
344 - }
345 -}
346 -
347 -// Test to make sure you can't create multiple entries with the same name.
348 -func TestDoubleEntryFailure(t *testing.T) {
349 - _, mnt := setupIpnsTest(t, nil)
350 - defer mnt.Close()
351 -
352 - dname := mnt.Dir + "/local/thisisadir"
353 - err := os.Mkdir(dname, 0o777)
354 - if err != nil {
355 - t.Fatal(err)
356 - }
357 -
358 - err = os.Mkdir(dname, 0o777)
359 - if err == nil {
360 - t.Fatal("Should have gotten error one creating new directory.")
361 - }
362 -}
363 -
364 -func TestAppendFile(t *testing.T) {
365 - _, mnt := setupIpnsTest(t, nil)
366 - defer mnt.Close()
367 -
368 - fname := mnt.Dir + "/local/file"
369 - data := writeFileOrFail(t, 1300, fname)
370 -
371 - fi, err := os.OpenFile(fname, os.O_RDWR|os.O_APPEND, 0o666)
372 - if err != nil {
373 - t.Fatal(err)
374 - }
375 -
376 - nudata := randBytes(500)
377 -
378 - n, err := fi.Write(nudata)
379 - if err != nil {
380 - t.Fatal(err)
381 - }
382 - err = fi.Close()
383 - if err != nil {
384 - t.Fatal(err)
385 - }
169 + _, mnt = setupIpnsTest(t, nd)
170
387 - if n != len(nudata) {
388 - t.Fatal("Failed to write enough bytes.")
389 - }
390 -
391 - data = append(data, nudata...)
392 -
393 - rbuf, err := os.ReadFile(fname)
394 - if err != nil {
395 - t.Fatal(err)
396 - }
397 - if !bytes.Equal(rbuf, data) {
398 - t.Fatal("Data inconsistent!")
399 - }
400 -}
401 -
402 -func TestConcurrentWrites(t *testing.T) {
403 - _, mnt := setupIpnsTest(t, nil)
404 - defer mnt.Close()
405 -
406 - nactors := 4
407 - filesPerActor := 400
408 - fileSize := 2000
409 -
410 - data := make([][][]byte, nactors)
411 -
412 - if racedet.WithRace() {
413 - nactors = 2
414 - filesPerActor = 50
415 - }
416 -
417 - wg := sync.WaitGroup{}
418 - for i := 0; i < nactors; i++ {
419 - data[i] = make([][]byte, filesPerActor)
420 - wg.Add(1)
421 - go func(n int) {
422 - defer wg.Done()
423 - for j := 0; j < filesPerActor; j++ {
424 - out, err := writeFile(fileSize, mnt.Dir+fmt.Sprintf("/local/%dFILE%d", n, j))
425 - if err != nil {
426 - t.Error(err)
427 - continue
428 - }
429 - data[n][j] = out
430 - }
431 - }(i)
432 - }
433 - wg.Wait()
434 -
435 - for i := 0; i < nactors; i++ {
436 - for j := 0; j < filesPerActor; j++ {
437 - if data[i][j] == nil {
438 - // Error already reported.
439 - continue
440 - }
441 - verifyFile(t, mnt.Dir+fmt.Sprintf("/local/%dFILE%d", i, j), data[i][j])
442 - }
443 - }
444 -}
445 -
446 -func TestFSThrash(t *testing.T) {
447 - files := make(map[string][]byte)
448 -
449 - _, mnt := setupIpnsTest(t, nil)
450 - defer mnt.Close()
451 -
452 - base := mnt.Dir + "/local"
453 - dirs := []string{base}
454 - dirlock := sync.RWMutex{}
455 - filelock := sync.Mutex{}
456 -
457 - ndirWorkers := 2
458 - nfileWorkers := 2
459 -
460 - ndirs := 100
461 - nfiles := 200
462 -
463 - wg := sync.WaitGroup{}
464 -
465 - // Spawn off workers to make directories
466 - for i := range ndirWorkers {
467 - wg.Add(1)
468 - go func(worker int) {
469 - defer wg.Done()
470 - for j := range ndirs {
471 - dirlock.RLock()
472 - n := mrand.Intn(len(dirs))
473 - dir := dirs[n]
474 - dirlock.RUnlock()
475 -
476 - newDir := fmt.Sprintf("%s/dir%d-%d", dir, worker, j)
477 - err := os.Mkdir(newDir, os.ModeDir)
478 - if err != nil {
479 - t.Error(err)
480 - continue
481 - }
482 - dirlock.Lock()
483 - dirs = append(dirs, newDir)
484 - dirlock.Unlock()
485 - }
486 - }(i)
487 - }
488 -
489 - // Spawn off workers to make files
490 - for i := range nfileWorkers {
491 - wg.Add(1)
492 - go func(worker int) {
493 - defer wg.Done()
494 - for j := range nfiles {
495 - dirlock.RLock()
496 - n := mrand.Intn(len(dirs))
497 - dir := dirs[n]
498 - dirlock.RUnlock()
499 -
500 - newFileName := fmt.Sprintf("%s/file%d-%d", dir, worker, j)
501 -
502 - data, err := writeFile(2000+mrand.Intn(5000), newFileName)
503 - if err != nil {
504 - t.Error(err)
505 - continue
506 - }
507 - filelock.Lock()
508 - files[newFileName] = data
509 - filelock.Unlock()
510 - }
511 - }(i)
512 - }
513 -
514 - wg.Wait()
515 - for name, data := range files {
516 - out, err := os.ReadFile(name)
517 - if err != nil {
518 - t.Error(err)
519 - }
520 -
521 - if !bytes.Equal(data, out) {
522 - t.Errorf("Data didn't match in %s: expected %v, got %v", name, data, out)
523 - }
524 - }
525 -}
526 -
527 -// Test writing a medium sized file one byte at a time.
528 -func TestMultiWrite(t *testing.T) {
529 -
530 - _, mnt := setupIpnsTest(t, nil)
531 - defer mnt.Close()
532 -
533 - fpath := mnt.Dir + "/local/file"
534 - fi, err := os.Create(fpath)
535 - if err != nil {
536 - t.Fatal(err)
537 - }
538 -
539 - data := randBytes(1001)
540 - for i := range data {
541 - n, err := fi.Write(data[i : i+1])
542 - if err != nil {
543 - t.Fatal(err)
544 - }
545 - if n != 1 {
546 - t.Fatal("Somehow wrote the wrong number of bytes! (n != 1)")
547 - }
548 - }
549 - fi.Close()
550 -
551 - rbuf, err := os.ReadFile(fpath)
552 - if err != nil {
553 - t.Fatal(err)
554 - }
555 -
556 - if !bytes.Equal(rbuf, data) {
557 - t.Fatal("File on disk did not match bytes written")
558 - }
171 + fusetest.CheckExists(t, mnt.Dir+"/local/test1")
172 + fusetest.VerifyFile(t, mnt.Dir+"/local/test1/file1", data1)
173 + fusetest.VerifyFile(t, mnt.Dir+"/local/test1/dir2/file2", data2)
174 }
fuse/ipns/ipns_unix.go
+57 -457
@@ -1,85 +1,45 @@
1 -//go:build !nofuse && !openbsd && !netbsd && !plan9
1 +//go:build (linux || darwin || freebsd) && !nofuse
2
3 -// package fuse/ipns implements a fuse filesystem that interfaces
4 -// with ipns, the naming system for ipfs.
3 +// Package ipns implements a FUSE filesystem that interfaces with IPNS,
4 +// the naming system for IPFS. Only names for which the node holds
5 +// private keys are writable; all other names resolve to read-only
6 +// symlinks pointing at the /ipfs mount.
7 package ipns
8
9 import (
10 "context"
9 - "errors"
10 - "fmt"
11 - "io"
12 - "os"
11 "strings"
12 "syscall"
13
14 + "github.com/hanwen/go-fuse/v2/fs"
15 + "github.com/hanwen/go-fuse/v2/fuse"
16 dag "github.com/ipfs/boxo/ipld/merkledag"
17 ft "github.com/ipfs/boxo/ipld/unixfs"
18 + mfs "github.com/ipfs/boxo/mfs"
19 "github.com/ipfs/boxo/namesys"
20 "github.com/ipfs/boxo/path"
20 -
21 - fuse "bazil.org/fuse"
22 - fs "bazil.org/fuse/fs"
23 - mfs "github.com/ipfs/boxo/mfs"
21 cid "github.com/ipfs/go-cid"
22 logging "github.com/ipfs/go-log/v2"
23 + "github.com/ipfs/kubo/config"
24 iface "github.com/ipfs/kubo/core/coreiface"
25 options "github.com/ipfs/kubo/core/coreiface/options"
26 + fusemnt "github.com/ipfs/kubo/fuse/mount"
27 + "github.com/ipfs/kubo/fuse/writable"
28 "github.com/ipfs/kubo/internal/fusemount"
29 )
30
31 -func init() {
32 - if os.Getenv("IPFS_FUSE_DEBUG") != "" {
33 - fuse.Debug = func(msg any) {
34 - fmt.Println(msg)
35 - }
36 - }
37 -}
38 -
31 var log = logging.Logger("fuse/ipns")
32
41 -// FileSystem is the readwrite IPNS Fuse Filesystem.
42 -type FileSystem struct {
43 - Ipfs iface.CoreAPI
44 - RootNode *Root
45 -}
46 -
47 -// NewFileSystem constructs new fs using given core.IpfsNode instance.
48 -func NewFileSystem(ctx context.Context, ipfs iface.CoreAPI, ipfspath, ipnspath string, mfsOpts ...mfs.Option) (*FileSystem, error) {
49 - key, err := ipfs.Key().Self(ctx)
50 - if err != nil {
51 - return nil, err
52 - }
53 - root, err := CreateRoot(ctx, ipfs, map[string]iface.Key{"local": key}, ipfspath, ipnspath, mfsOpts...)
54 - if err != nil {
55 - return nil, err
56 - }
57 -
58 - return &FileSystem{Ipfs: ipfs, RootNode: root}, nil
59 -}
60 -
61 -// Root constructs the Root of the filesystem, a Root object.
62 -func (f *FileSystem) Root() (fs.Node, error) {
63 - log.Debug("filesystem, get root")
64 - return f.RootNode, nil
65 -}
66 -
67 -func (f *FileSystem) Destroy() {
68 - err := f.RootNode.Close()
69 - if err != nil {
70 - log.Errorf("Error Shutting Down Filesystem: %s\n", err)
71 - }
72 -}
73 -
74 -// Root is the root object of the filesystem tree.
33 +// Root is the root object of the /ipns filesystem tree.
34 type Root struct {
35 + fs.Inode
36 Ipfs iface.CoreAPI
37 Keys map[string]iface.Key
38
39 // Used for symlinking into ipfs
40 IpfsRoot string
41 IpnsRoot string
82 - LocalDirs map[string]fs.Node
42 + LocalDirs map[string]*writable.Dir
43 Roots map[string]*mfs.Root
44
45 LocalLinks map[string]*Link
@@ -96,7 +56,7 @@ func ipnsPubFunc(ipfs iface.CoreAPI, key iface.Key) mfs.PubFunc {
56 }
57 }
58
99 -func loadRoot(ctx context.Context, ipfs iface.CoreAPI, key iface.Key, mfsOpts ...mfs.Option) (*mfs.Root, fs.Node, error) {
59 +func loadRoot(ctx context.Context, ipfs iface.CoreAPI, key iface.Key, cfg *writable.Config, mfsOpts ...mfs.Option) (*mfs.Root, *writable.Dir, error) {
60 node, err := ipfs.ResolveNode(ctx, key.Path())
61 switch err {
62 case nil:
@@ -112,36 +72,35 @@ func loadRoot(ctx context.Context, ipfs iface.CoreAPI, key iface.Key, mfsOpts ..
72 return nil, nil, dag.ErrNotProtobuf
73 }
74
115 - // We have no access to provider.System from the CoreAPI. The Routing
116 - // part offers Provide through the router so it may be slow/risky
117 - // to give that here to MFS. Therefore we leave as nil.
75 root, err := mfs.NewRoot(ctx, ipfs.Dag(), pbnode, ipnsPubFunc(ipfs, key), nil, mfsOpts...)
76 if err != nil {
77 return nil, nil, err
78 }
79
123 - return root, &Directory{dir: root.GetDirectory()}, nil
80 + return root, writable.NewDir(root.GetDirectory(), cfg), nil
81 }
82
126 -func CreateRoot(ctx context.Context, ipfs iface.CoreAPI, keys map[string]iface.Key, ipfspath, ipnspath string, mfsOpts ...mfs.Option) (*Root, error) {
127 - ldirs := make(map[string]fs.Node)
83 +// CreateRoot creates the IPNS FUSE root with one writable directory per key.
84 +func CreateRoot(ctx context.Context, ipfs iface.CoreAPI, keys map[string]iface.Key, ipfspath, ipnspath string, mountsCfg config.Mounts, mfsOpts ...mfs.Option) (*Root, error) {
85 + cfg := &writable.Config{
86 + StoreMtime: mountsCfg.StoreMtime.WithDefault(config.DefaultStoreMtime),
87 + StoreMode: mountsCfg.StoreMode.WithDefault(config.DefaultStoreMode),
88 + DAG: ipfs.Dag(),
89 + }
90 +
91 + ldirs := make(map[string]*writable.Dir)
92 roots := make(map[string]*mfs.Root)
93 links := make(map[string]*Link)
94 for alias, k := range keys {
131 - root, fsn, err := loadRoot(ctx, ipfs, k, mfsOpts...)
95 + root, dir, err := loadRoot(ctx, ipfs, k, cfg, mfsOpts...)
96 if err != nil {
97 return nil, err
98 }
99
100 name := k.ID().String()
137 -
101 roots[name] = root
139 - ldirs[name] = fsn
140 -
141 - // set up alias symlink
142 - links[alias] = &Link{
143 - Target: name,
144 - }
102 + ldirs[name] = dir
103 + links[alias] = &Link{Target: name}
104 }
105
106 return &Root{
@@ -155,423 +114,64 @@ func CreateRoot(ctx context.Context, ipfs iface.CoreAPI, keys map[string]iface.K
114 }, nil
115 }
116
158 -// Attr returns file attributes.
159 -func (r *Root) Attr(ctx context.Context, a *fuse.Attr) error {
160 - log.Debug("Root Attr")
161 - // TODO: wire TTL from IPNS record (capped at Ipns.MaxCacheTTL) here instead of 0
162 - a.Valid = 0
163 - a.Mode = os.ModeDir | 0o111 // -rw+x
164 - a.Uid = uint32(os.Getuid())
165 - a.Gid = uint32(os.Getgid())
166 - return nil
117 +// Getattr returns the root directory attributes.
118 +func (r *Root) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
119 + out.Attr.Mode = uint32(fusemnt.NamespaceRootMode.Perm())
120 + return 0
121 }
122
169 -// Lookup performs a lookup under this node.
170 -func (r *Root) Lookup(ctx context.Context, name string) (fs.Node, error) {
123 +func (r *Root) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
124 switch name {
125 case "mach_kernel", ".hidden", "._.":
173 - // Just quiet some log noise on OS X.
174 - return nil, syscall.Errno(syscall.ENOENT)
126 + return nil, syscall.ENOENT
127 }
128
129 if lnk, ok := r.LocalLinks[name]; ok {
178 - return lnk, nil
130 + return r.NewInode(ctx, lnk, fs.StableAttr{Mode: syscall.S_IFLNK}), 0
131 }
132
181 - nd, ok := r.LocalDirs[name]
182 - if ok {
183 - switch nd := nd.(type) {
184 - case *Directory:
185 - return nd, nil
186 - case *FileNode:
187 - return nd, nil
188 - default:
189 - return nil, syscall.Errno(syscall.EIO)
190 - }
133 + if dir, ok := r.LocalDirs[name]; ok {
134 + return r.NewInode(ctx, dir, fs.StableAttr{Mode: syscall.S_IFDIR}), 0
135 }
136
193 - // other links go through ipns resolution and are symlinked into the ipfs mountpoint
194 - ipnsName := "/ipns/" + name
195 - resolved, err := r.Ipfs.Name().Resolve(ctx, ipnsName)
137 + // Other links go through IPNS resolution and are symlinked into the /ipfs mount.
138 + resolved, err := r.Ipfs.Name().Resolve(ctx, "/ipns/"+name)
139 if err != nil {
140 log.Warnf("ipns: namesys resolve error: %s", err)
198 - return nil, syscall.Errno(syscall.ENOENT)
141 + return nil, syscall.ENOENT
142 }
143
144 if resolved.Namespace() != path.IPFSNamespace {
202 - return nil, errors.New("invalid path from ipns record")
145 + return nil, syscall.ENOENT
146 }
147
205 - return &Link{r.IpfsRoot + "/" + strings.TrimPrefix(resolved.String(), "/ipfs/")}, nil
148 + lnk := &Link{Target: r.IpfsRoot + "/" + strings.TrimPrefix(resolved.String(), "/ipfs/")}
149 + return r.NewInode(ctx, lnk, fs.StableAttr{Mode: syscall.S_IFLNK}), 0
150 }
151
208 -func (r *Root) Close() error {
209 - for _, mr := range r.Roots {
210 - err := mr.Close()
211 - if err != nil {
212 - return err
213 - }
214 - }
215 - return nil
216 -}
217 -
218 -// Forget is called when the filesystem is unmounted. probably.
219 -// see comments here: http://godoc.org/bazil.org/fuse/fs#FSDestroyer
220 -func (r *Root) Forget() {
221 - err := r.Close()
222 - if err != nil {
223 - log.Error(err)
224 - }
225 -}
226 -
227 -// ReadDirAll reads a particular directory. Will show locally available keys
228 -// as well as a symlink to the peerID key.
229 -func (r *Root) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
230 - log.Debug("Root ReadDirAll")
231 -
232 - listing := make([]fuse.Dirent, 0, len(r.Keys)*2)
152 +func (r *Root) Readdir(_ context.Context) (fs.DirStream, syscall.Errno) {
153 + entries := make([]fuse.DirEntry, 0, len(r.Keys)*2)
154 for alias, k := range r.Keys {
234 - ent := fuse.Dirent{
235 - Name: k.ID().String(),
236 - Type: fuse.DT_Dir,
237 - }
238 - link := fuse.Dirent{
239 - Name: alias,
240 - Type: fuse.DT_Link,
241 - }
242 - listing = append(listing, ent, link)
155 + entries = append(entries,
156 + fuse.DirEntry{Name: k.ID().String(), Mode: syscall.S_IFDIR},
157 + fuse.DirEntry{Name: alias, Mode: syscall.S_IFLNK},
158 + )
159 }
244 - return listing, nil
245 -}
246 -
247 -// Directory is wrapper over an mfs directory to satisfy the fuse fs interface.
248 -type Directory struct {
249 - dir *mfs.Directory
160 + return fs.NewListDirStream(entries), 0
161 }
162
252 -type FileNode struct {
253 - fi *mfs.File
254 -}
255 -
256 -// File is wrapper over an mfs file to satisfy the fuse fs interface.
257 -type File struct {
258 - fi mfs.FileDescriptor
259 -}
260 -
261 -// Attr returns the attributes of a given node.
262 -func (d *Directory) Attr(ctx context.Context, a *fuse.Attr) error {
263 - log.Debug("Directory Attr")
264 - // TODO: wire TTL from IPNS record (capped at Ipns.MaxCacheTTL) here instead of 0
265 - a.Valid = 0
266 - // TODO: use Mode from UnixFS record if present
267 - a.Mode = os.ModeDir | 0o555
268 - a.Uid = uint32(os.Getuid())
269 - a.Gid = uint32(os.Getgid())
270 - return nil
271 -}
272 -
273 -// Attr returns the attributes of a given node.
274 -func (fi *FileNode) Attr(ctx context.Context, a *fuse.Attr) error {
275 - log.Debug("File Attr")
276 - // TODO: wire TTL from IPNS record (capped at Ipns.MaxCacheTTL) here instead of 0
277 - a.Valid = 0
278 - size, err := fi.fi.Size()
279 - if err != nil {
280 - // In this case, the dag node in question may not be unixfs
281 - return fmt.Errorf("fuse/ipns: failed to get file.Size(): %s", err)
282 - }
283 - // TODO: use Mode and Mtime from UnixFS record if present
284 - a.Mode = os.FileMode(0o666)
285 - a.Size = uint64(size)
286 - a.Uid = uint32(os.Getuid())
287 - a.Gid = uint32(os.Getgid())
288 - return nil
289 -}
290 -
291 -// Lookup performs a lookup under this node.
292 -func (d *Directory) Lookup(ctx context.Context, name string) (fs.Node, error) {
293 - child, err := d.dir.Child(name)
294 - if err != nil {
295 - // todo: make this error more versatile.
296 - return nil, syscall.Errno(syscall.ENOENT)
297 - }
298 -
299 - switch child := child.(type) {
300 - case *mfs.Directory:
301 - return &Directory{dir: child}, nil
302 - case *mfs.File:
303 - return &FileNode{fi: child}, nil
304 - default:
305 - // NB: if this happens, we do not want to continue, unpredictable behaviour
306 - // may occur.
307 - panic("invalid type found under directory. programmer error.")
308 - }
309 -}
310 -
311 -// ReadDirAll reads the link structure as directory entries.
312 -func (d *Directory) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
313 - listing, err := d.dir.List(ctx)
314 - if err != nil {
315 - return nil, err
316 - }
317 - entries := make([]fuse.Dirent, len(listing))
318 - for i, entry := range listing {
319 - dirent := fuse.Dirent{Name: entry.Name}
320 -
321 - switch mfs.NodeType(entry.Type) {
322 - case mfs.TDir:
323 - dirent.Type = fuse.DT_Dir
324 - case mfs.TFile:
325 - dirent.Type = fuse.DT_File
326 - }
327 -
328 - entries[i] = dirent
329 - }
330 -
331 - return entries, nil
332 -}
333 -
334 -func (fi *File) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
335 - _, err := fi.fi.Seek(req.Offset, io.SeekStart)
336 - if err != nil {
337 - return err
338 - }
339 -
340 - fisize, err := fi.fi.Size()
341 - if err != nil {
342 - return err
343 - }
344 -
345 - select {
346 - case <-ctx.Done():
347 - return ctx.Err()
348 - default:
349 - }
350 -
351 - readsize := min(req.Size, int(fisize-req.Offset))
352 - n, err := fi.fi.CtxReadFull(ctx, resp.Data[:readsize])
353 - resp.Data = resp.Data[:n]
354 - return err
355 -}
356 -
357 -func (fi *File) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error {
358 - // TODO: at some point, ensure that WriteAt here respects the context
359 - wrote, err := fi.fi.WriteAt(req.Data, req.Offset)
360 - if err != nil {
361 - return err
362 - }
363 - resp.Size = wrote
364 - return nil
365 -}
366 -
367 -func (fi *File) Flush(ctx context.Context, req *fuse.FlushRequest) error {
368 - errs := make(chan error, 1)
369 - go func() {
370 - errs <- fi.fi.Flush()
371 - }()
372 - select {
373 - case err := <-errs:
374 - return err
375 - case <-ctx.Done():
376 - return ctx.Err()
377 - }
378 -}
379 -
380 -func (fi *File) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
381 - if req.Valid.Size() {
382 - cursize, err := fi.fi.Size()
383 - if err != nil {
384 - return err
385 - }
386 - if cursize != int64(req.Size) {
387 - err := fi.fi.Truncate(int64(req.Size))
388 - if err != nil {
389 - return err
390 - }
391 - }
392 - }
393 - return nil
394 -}
395 -
396 -// Fsync is a no-op. We can't flush here because mfs.File.Flush opens a new
397 -// write descriptor, which needs an exclusive lock (desclock) that the caller
398 -// already holds from Open. Attempting it deadlocks until the FUSE timeout,
399 -// then panics on Release. Data is flushed when the file is closed instead.
400 -// TODO: a proper fix needs changes in boxo/mfs to allow flushing from an
401 -// existing descriptor. Ideas welcome, but for now this is the best we can do.
402 -func (fi *FileNode) Fsync(ctx context.Context, req *fuse.FsyncRequest) error {
403 - return nil
404 -}
405 -
406 -func (fi *File) Forget() {
407 - // TODO(steb): this seems like a place where we should be *uncaching*, not flushing.
408 - err := fi.fi.Flush()
409 - if err != nil {
410 - log.Debug("forget file error: ", err)
411 - }
412 -}
413 -
414 -func (d *Directory) Mkdir(ctx context.Context, req *fuse.MkdirRequest) (fs.Node, error) {
415 - child, err := d.dir.Mkdir(req.Name)
416 - if err != nil {
417 - return nil, err
418 - }
419 -
420 - return &Directory{dir: child}, nil
421 -}
422 -
423 -func (fi *FileNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (fs.Handle, error) {
424 - fd, err := fi.fi.Open(mfs.Flags{
425 - Read: req.Flags.IsReadOnly() || req.Flags.IsReadWrite(),
426 - Write: req.Flags.IsWriteOnly() || req.Flags.IsReadWrite(),
427 - Sync: true,
428 - })
429 - if err != nil {
430 - return nil, err
431 - }
432 -
433 - if req.Flags&fuse.OpenTruncate != 0 {
434 - if req.Flags.IsReadOnly() {
435 - log.Error("tried to open a readonly file with truncate")
436 - return nil, syscall.Errno(syscall.ENOTSUP)
437 - }
438 - log.Info("Need to truncate file!")
439 - err := fd.Truncate(0)
440 - if err != nil {
441 - return nil, err
442 - }
443 - } else if req.Flags&fuse.OpenAppend != 0 {
444 - log.Info("Need to append to file!")
445 - if req.Flags.IsReadOnly() {
446 - log.Error("tried to open a readonly file with append")
447 - return nil, syscall.Errno(syscall.ENOTSUP)
448 - }
449 -
450 - _, err := fd.Seek(0, io.SeekEnd)
451 - if err != nil {
452 - log.Error("seek reset failed: ", err)
453 - return nil, err
454 - }
455 - }
456 -
457 - return &File{fi: fd}, nil
458 -}
459 -
460 -func (fi *File) Release(ctx context.Context, req *fuse.ReleaseRequest) error {
461 - return fi.fi.Close()
462 -}
463 -
464 -func (d *Directory) Create(ctx context.Context, req *fuse.CreateRequest, resp *fuse.CreateResponse) (fs.Node, fs.Handle, error) {
465 - // New 'empty' file
466 - nd := dag.NodeWithData(ft.FilePBData(nil, 0))
467 - err := d.dir.AddChild(req.Name, nd)
468 - if err != nil {
469 - return nil, nil, err
470 - }
471 -
472 - child, err := d.dir.Child(req.Name)
473 - if err != nil {
474 - return nil, nil, err
475 - }
476 -
477 - fi, ok := child.(*mfs.File)
478 - if !ok {
479 - return nil, nil, errors.New("child creation failed")
480 - }
481 -
482 - nodechild := &FileNode{fi: fi}
483 -
484 - fd, err := fi.Open(mfs.Flags{
485 - Read: req.Flags.IsReadOnly() || req.Flags.IsReadWrite(),
486 - Write: req.Flags.IsWriteOnly() || req.Flags.IsReadWrite(),
487 - Sync: true,
488 - })
489 - if err != nil {
490 - return nil, nil, err
491 - }
492 -
493 - return nodechild, &File{fi: fd}, nil
494 -}
495 -
496 -func (d *Directory) Remove(ctx context.Context, req *fuse.RemoveRequest) error {
497 - err := d.dir.Unlink(req.Name)
498 - if err != nil {
499 - return syscall.Errno(syscall.ENOENT)
500 - }
501 - return nil
502 -}
503 -
504 -// Rename implements NodeRenamer.
505 -func (d *Directory) Rename(ctx context.Context, req *fuse.RenameRequest, newDir fs.Node) error {
506 - cur, err := d.dir.Child(req.OldName)
507 - if err != nil {
508 - return err
509 - }
510 -
511 - nd, err := cur.GetNode()
512 - if err != nil {
513 - return err
514 - }
515 -
516 - // Unlink the source before adding to the destination. For
517 - // same-directory renames, this clears the old name from the
518 - // directory's entry cache before AddChild repopulates it.
519 - err = d.dir.Unlink(req.OldName)
520 - if err != nil {
521 - return err
522 - }
523 -
524 - switch newDir := newDir.(type) {
525 - case *Directory:
526 - err = newDir.dir.AddChild(req.NewName, nd)
527 - if err != nil {
163 +func (r *Root) Close() error {
164 + for _, mr := range r.Roots {
165 + if err := mr.Close(); err != nil {
166 return err
167 }
530 - case *FileNode:
531 - log.Error("Cannot move node into a file!")
532 - return syscall.Errno(syscall.EPERM)
533 - default:
534 - log.Error("Unknown node type for rename target dir!")
535 - return errors.New("unknown fs node type")
168 }
169 return nil
170 }
171
540 -// to check that out Node implements all the interfaces we want.
541 -type ipnsRoot interface {
542 - fs.Node
543 - fs.HandleReadDirAller
544 - fs.NodeStringLookuper
545 -}
546 -
547 -var _ ipnsRoot = (*Root)(nil)
548 -
549 -type ipnsDirectory interface {
550 - fs.HandleReadDirAller
551 - fs.Node
552 - fs.NodeCreater
553 - fs.NodeMkdirer
554 - fs.NodeRemover
555 - fs.NodeRenamer
556 - fs.NodeStringLookuper
557 -}
558 -
559 -var _ ipnsDirectory = (*Directory)(nil)
560 -
561 -type ipnsFile interface {
562 - fs.HandleFlusher
563 - fs.HandleReader
564 - fs.HandleWriter
565 - fs.HandleReleaser
566 -}
567 -
568 -type ipnsFileNode interface {
569 - fs.Node
570 - fs.NodeFsyncer
571 - fs.NodeOpener
572 -}
573 -
172 +// Interface compliance checks for Root.
173 var (
575 - _ ipnsFileNode = (*FileNode)(nil)
576 - _ ipnsFile = (*File)(nil)
174 + _ fs.NodeGetattrer = (*Root)(nil)
175 + _ fs.NodeLookuper = (*Root)(nil)
176 + _ fs.NodeReaddirer = (*Root)(nil)
177 )
fuse/ipns/link_unix.go
+15 -14
@@ -1,32 +1,33 @@
1 -//go:build !nofuse && !openbsd && !netbsd && !plan9
1 +// Symlink node for the /ipns FUSE mount. go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3
4 package ipns
5
6 import (
7 "context"
7 - "os"
8 + "syscall"
9
9 - "bazil.org/fuse"
10 - "bazil.org/fuse/fs"
10 + "github.com/hanwen/go-fuse/v2/fs"
11 + "github.com/hanwen/go-fuse/v2/fuse"
12 )
13
14 type Link struct {
15 + fs.Inode
16 Target string
17 }
18
17 -func (l *Link) Attr(ctx context.Context, a *fuse.Attr) error {
19 +func (l *Link) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
20 log.Debug("Link attr.")
19 - // TODO: wire TTL from IPNS record (capped at Ipns.MaxCacheTTL) here instead of 0
20 - a.Valid = 0
21 - a.Mode = os.ModeSymlink | 0o555
22 - a.Uid = uint32(os.Getuid())
23 - a.Gid = uint32(os.Getgid())
24 - return nil
21 + out.Attr.Mode = 0o555
22 + return 0
23 }
24
27 -func (l *Link) Readlink(ctx context.Context, req *fuse.ReadlinkRequest) (string, error) {
25 +func (l *Link) Readlink(_ context.Context) ([]byte, syscall.Errno) {
26 log.Debugf("ReadLink: %s", l.Target)
29 - return l.Target, nil
27 + return []byte(l.Target), 0
28 }
29
32 -var _ fs.NodeReadlinker = (*Link)(nil)
30 +var (
31 + _ fs.NodeGetattrer = (*Link)(nil)
32 + _ fs.NodeReadlinker = (*Link)(nil)
33 +)
fuse/ipns/mount_unix.go
+59 -7
@@ -1,15 +1,30 @@
1 -//go:build (linux || darwin || freebsd || netbsd || openbsd) && !nofuse
1 +// Mount/unmount helpers for the /ipns FUSE mount. go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3
4 package ipns
5
6 import (
7 + "os"
8 + "time"
9 +
10 + "github.com/hanwen/go-fuse/v2/fs"
11 + "github.com/hanwen/go-fuse/v2/fuse"
12 + "github.com/ipfs/kubo/config"
13 core "github.com/ipfs/kubo/core"
14 coreapi "github.com/ipfs/kubo/core/coreapi"
8 - mount "github.com/ipfs/kubo/fuse/mount"
15 + iface "github.com/ipfs/kubo/core/coreiface"
16 + fusemnt "github.com/ipfs/kubo/fuse/mount"
17 )
18
19 +// How long the kernel caches Lookup and Getattr results. 1 second
20 +// matches the go-fuse default and what gocryptfs/rclone use.
21 +// TODO: for resolved IPNS names, use the record's cache TTL (capped
22 +// at Ipns.MaxCacheTTL) instead of a fixed 1 second.
23 +// var (not const) because fs.Options needs a *time.Duration.
24 +var mutableCacheTime = time.Second
25 +
26 // Mount mounts ipns at a given location, and returns a mount.Mount instance.
12 -func Mount(ipfs *core.IpfsNode, ipnsmp, ipfsmp string) (mount.Mount, error) {
27 +func Mount(ipfs *core.IpfsNode, ipnsmp, ipfsmp string) (fusemnt.Mount, error) {
28 coreAPI, err := coreapi.NewCoreAPI(ipfs)
29 if err != nil {
30 return nil, err
@@ -20,17 +35,54 @@ func Mount(ipfs *core.IpfsNode, ipnsmp, ipfsmp string) (mount.Mount, error) {
35 return nil, err
36 }
37
23 - allowOther := cfg.Mounts.FuseAllowOther
24 -
38 mfsOpts, err := cfg.Import.MFSRootOptions()
39 if err != nil {
40 return nil, err
41 }
42
30 - fsys, err := NewFileSystem(ipfs.Context(), coreAPI, ipfsmp, ipnsmp, mfsOpts...)
43 + key, err := coreAPI.Key().Self(ipfs.Context())
44 if err != nil {
45 return nil, err
46 }
47
35 - return mount.NewMount(fsys, ipnsmp, allowOther)
48 + root, err := CreateRoot(ipfs.Context(), coreAPI, map[string]iface.Key{"local": key}, ipfsmp, ipnsmp, cfg.Mounts, mfsOpts...)
49 + if err != nil {
50 + return nil, err
51 + }
52 +
53 + opts := &fs.Options{
54 + NullPermissions: true,
55 + UID: uint32(os.Getuid()),
56 + GID: uint32(os.Getgid()),
57 + EntryTimeout: &mutableCacheTime,
58 + AttrTimeout: &mutableCacheTime,
59 + MountOptions: fuse.MountOptions{
60 + AllowOther: cfg.Mounts.FuseAllowOther.WithDefault(config.DefaultFuseAllowOther),
61 + FsName: "ipns",
62 + MaxReadAhead: fusemnt.MaxReadAhead,
63 + Debug: os.Getenv("IPFS_FUSE_DEBUG") != "",
64 + ExtraCapabilities: fusemnt.WritableMountCapabilities,
65 + },
66 + }
67 +
68 + m, err := fusemnt.NewMount(root, ipnsmp, opts)
69 + if err != nil {
70 + _ = root.Close()
71 + return nil, err
72 + }
73 +
74 + return &ipnsMount{Mount: m, root: root}, nil
75 +}
76 +
77 +// ipnsMount wraps mount.Mount to call Root.Close() on unmount,
78 +// which flushes and publishes all MFS roots.
79 +type ipnsMount struct {
80 + fusemnt.Mount
81 + root *Root
82 +}
83 +
84 +func (m *ipnsMount) Unmount() error {
85 + err := m.Mount.Unmount()
86 + _ = m.root.Close()
87 + return err
88 }
fuse/mfs/mfs_test.go
+50 -442
@@ -1,4 +1,9 @@
1 -//go:build !nofuse && !openbsd && !netbsd && !plan9
1 +//go:build (linux || darwin || freebsd) && !nofuse
2 +
3 +// Unit tests for the /mfs FUSE mount.
4 +// Generic writable operations are exercised by the shared suite in
5 +// fusetest.RunWritableSuite. This file contains the mount factory
6 +// and MFS-specific tests only.
7
8 package mfs
9
@@ -6,476 +11,79 @@ import (
11 "bytes"
12 "context"
13 "crypto/rand"
9 - "errors"
10 - iofs "io/fs"
14 "os"
12 - "slices"
13 - "strconv"
15 "testing"
15 - "time"
16
17 - "bazil.org/fuse"
18 - "bazil.org/fuse/fs"
19 - "bazil.org/fuse/fs/fstestutil"
17 + "github.com/hanwen/go-fuse/v2/fs"
18 + "github.com/hanwen/go-fuse/v2/fuse"
19 + "github.com/stretchr/testify/require"
20 +
21 + "github.com/ipfs/kubo/config"
22 "github.com/ipfs/kubo/core"
23 "github.com/ipfs/kubo/core/node"
24 "github.com/ipfs/kubo/fuse/fusetest"
25 + fusemnt "github.com/ipfs/kubo/fuse/mount"
26 + "github.com/ipfs/kubo/fuse/writable"
27 )
28
25 -// Create an Ipfs.Node, a filesystem and a mount point.
26 -func setUp(t *testing.T, ipfs *core.IpfsNode) (fs.FS, *fstestutil.Mount) {
27 - fusetest.SkipUnlessFUSE(t)
28 -
29 - if ipfs == nil {
30 - var err error
31 - ipfs, err = core.NewNode(context.Background(), &node.BuildCfg{})
32 - if err != nil {
33 - t.Fatal(err)
34 - }
35 - }
36 -
37 - fs := NewFileSystem(ipfs)
38 - mnt, err := fstestutil.MountedT(t, fs, nil)
39 - fusetest.MountError(t, err)
40 -
41 - return fs, mnt
42 -}
43 -
44 -// Test reading and writing a file.
45 -func TestReadWrite(t *testing.T) {
46 - _, mnt := setUp(t, nil)
47 - defer mnt.Close()
48 -
49 - path := mnt.Dir + "/testrw"
50 - content := make([]byte, 8196)
51 - _, err := rand.Read(content)
52 - if err != nil {
53 - t.Fatal(err)
54 - }
55 -
56 - t.Run("write", func(t *testing.T) {
57 - f, err := os.Create(path)
58 - if err != nil {
59 - t.Fatal(err)
60 - }
61 - defer f.Close()
62 -
63 - _, err = f.Write(content)
64 - if err != nil {
65 - t.Fatal(err)
66 - }
67 - })
68 - t.Run("read", func(t *testing.T) {
69 - f, err := os.Open(path)
70 - if err != nil {
71 - t.Fatal(err)
72 - }
73 - defer f.Close()
74 -
75 - buf := make([]byte, 8196)
76 - l, err := f.Read(buf)
77 - if err != nil {
78 - t.Fatal(err)
79 - }
80 - if bytes.Equal(content, buf[:l]) != true {
81 - t.Fatal("read and write not equal")
82 - }
29 +func testMount(t *testing.T, root fs.InodeEmbedder) string {
30 + t.Helper()
31 + return fusetest.TestMount(t, root, &fs.Options{
32 + EntryTimeout: &mutableCacheTime,
33 + AttrTimeout: &mutableCacheTime,
34 + MountOptions: fuse.MountOptions{
35 + MaxReadAhead: fusemnt.MaxReadAhead,
36 + ExtraCapabilities: fusemnt.WritableMountCapabilities,
37 + },
38 })
39 }
40
86 -// Test that empty directories can be listed without errors.
87 -func TestEmptyDirListing(t *testing.T) {
88 - _, mnt := setUp(t, nil)
89 - defer mnt.Close()
90 -
91 - // The MFS root starts empty.
92 - entries, err := os.ReadDir(mnt.Dir)
93 - if err != nil {
94 - t.Fatal(err)
95 - }
96 - if len(entries) != 0 {
97 - t.Fatalf("expected empty root, got %d entries", len(entries))
98 - }
41 +func mfsMount(t *testing.T, cfg writable.Config) string {
42 + t.Helper()
43 + ipfs, err := core.NewNode(context.Background(), &node.BuildCfg{})
44 + require.NoError(t, err)
45
100 - // Create a directory and list it while still empty.
101 - dir := mnt.Dir + "/emptydir"
102 - if err := os.Mkdir(dir, os.ModeDir); err != nil {
103 - t.Fatal(err)
46 + mountsCfg := config.Mounts{}
47 + if cfg.StoreMtime {
48 + mountsCfg.StoreMtime = config.True
49 }
105 - entries, err = os.ReadDir(dir)
106 - if err != nil {
107 - t.Fatal(err)
108 - }
109 - if len(entries) != 0 {
110 - t.Fatalf("expected empty directory, got %d entries", len(entries))
50 + if cfg.StoreMode {
51 + mountsCfg.StoreMode = config.True
52 }
53 + root := NewFileSystem(ipfs, mountsCfg)
54 + return testMount(t, root)
55 }
56
114 -// Test creating a directory.
115 -func TestMkdir(t *testing.T) {
116 - _, mnt := setUp(t, nil)
117 - defer mnt.Close()
118 -
119 - path := mnt.Dir + "/foo/bar/baz/qux/quux"
120 -
121 - t.Run("write", func(t *testing.T) {
122 - err := os.MkdirAll(path, iofs.ModeDir)
123 - if err != nil {
124 - t.Fatal(err)
125 - }
126 - })
127 - t.Run("read", func(t *testing.T) {
128 - stat, err := os.Stat(path)
129 - if err != nil {
130 - t.Fatal(err)
131 - }
132 - if !stat.IsDir() {
133 - t.Fatal("not dir")
134 - }
135 - })
57 +func TestWritableSuite(t *testing.T) {
58 + fusetest.RunWritableSuite(t, mfsMount)
59 }
60
138 -// Test file persistence across mounts.
61 +// TestPersistence verifies that file data survives unmount and remount
62 +// on the same IpfsNode.
63 func TestPersistence(t *testing.T) {
64 ipfs, err := core.NewNode(context.Background(), &node.BuildCfg{})
141 - if err != nil {
142 - t.Fatal(err)
143 - }
65 + require.NoError(t, err)
66
67 content := make([]byte, 8196)
68 _, err = rand.Read(content)
147 - if err != nil {
148 - t.Fatal(err)
149 - }
69 + require.NoError(t, err)
70
71 t.Run("write", func(t *testing.T) {
152 - _, mnt := setUp(t, ipfs)
153 - defer mnt.Close()
154 - path := mnt.Dir + "/testpersistence"
155 -
156 - f, err := os.Create(path)
157 - if err != nil {
158 - t.Fatal(err)
159 - }
160 - defer f.Close()
161 -
162 - _, err = f.Write(content)
163 - if err != nil {
164 - t.Fatal(err)
165 - }
166 - })
167 - t.Run("read", func(t *testing.T) {
168 - _, mnt := setUp(t, ipfs)
169 - defer mnt.Close()
170 - path := mnt.Dir + "/testpersistence"
171 -
172 - f, err := os.Open(path)
173 - if err != nil {
174 - t.Fatal(err)
175 - }
176 - defer f.Close()
177 -
178 - buf := make([]byte, 8196)
179 - l, err := f.Read(buf)
180 - if err != nil {
181 - t.Fatal(err)
182 - }
183 - if bytes.Equal(content, buf[:l]) != true {
184 - t.Fatal("read and write not equal")
185 - }
186 - })
187 -}
188 -
189 -// Test getting the file attributes.
190 -func TestAttr(t *testing.T) {
191 - _, mnt := setUp(t, nil)
192 - defer mnt.Close()
193 -
194 - path := mnt.Dir + "/testattr"
195 - content := make([]byte, 8196)
196 - _, err := rand.Read(content)
197 - if err != nil {
198 - t.Fatal(err)
199 - }
200 -
201 - t.Run("write", func(t *testing.T) {
202 - f, err := os.Create(path)
203 - if err != nil {
204 - t.Fatal(err)
205 - }
206 - defer f.Close()
72 + root := NewFileSystem(ipfs, config.Mounts{})
73 + mntDir := testMount(t, root)
74
75 + f, err := os.Create(mntDir + "/testpersistence")
76 + require.NoError(t, err)
77 _, err = f.Write(content)
209 - if err != nil {
210 - t.Fatal(err)
211 - }
212 - })
213 - t.Run("read", func(t *testing.T) {
214 - fi, err := os.Stat(path)
215 - if err != nil {
216 - t.Fatal(err)
217 - }
218 -
219 - if fi.IsDir() {
220 - t.Fatal("file is a directory")
221 - }
222 -
223 - if fi.ModTime().After(time.Now()) {
224 - t.Fatal("future modtime")
225 - }
226 - if time.Since(fi.ModTime()) > time.Second {
227 - t.Fatal("past modtime")
228 - }
229 -
230 - if fi.Name() != "testattr" {
231 - t.Fatal("invalid filename")
232 - }
233 -
234 - if fi.Size() != 8196 {
235 - t.Fatal("invalid size")
236 - }
237 - })
238 -}
239 -
240 -// Test concurrent access to the filesystem.
241 -func TestConcurrentRW(t *testing.T) {
242 - _, mnt := setUp(t, nil)
243 - defer mnt.Close()
244 -
245 - files := 5
246 - fileWorkers := 5
247 -
248 - path := mnt.Dir + "/testconcurrent"
249 - content := make([][]byte, files)
250 -
251 - for i := range content {
252 - content[i] = make([]byte, 8196)
253 - _, err := rand.Read(content[i])
254 - if err != nil {
255 - t.Fatal(err)
256 - }
257 - }
258 -
259 - t.Run("write", func(t *testing.T) {
260 - errs := make(chan (error), 1)
261 - for i := range files {
262 - go func() {
263 - var err error
264 - defer func() { errs <- err }()
265 -
266 - f, err := os.Create(path + strconv.Itoa(i))
267 - if err != nil {
268 - return
269 - }
270 - defer f.Close()
271 -
272 - _, err = f.Write(content[i])
273 - if err != nil {
274 - return
275 - }
276 - }()
277 - }
278 - for range files {
279 - err := <-errs
280 - if err != nil {
281 - t.Fatal(err)
282 - }
283 - }
78 + require.NoError(t, err)
79 + require.NoError(t, f.Close())
80 })
81 t.Run("read", func(t *testing.T) {
286 - errs := make(chan (error), 1)
287 - for i := 0; i < files*fileWorkers; i++ {
288 - go func() {
289 - var err error
290 - defer func() { errs <- err }()
291 -
292 - f, err := os.Open(path + strconv.Itoa(i/fileWorkers))
293 - if err != nil {
294 - return
295 - }
296 - defer f.Close()
82 + root := NewFileSystem(ipfs, config.Mounts{})
83 + mntDir := testMount(t, root)
84
298 - buf := make([]byte, 8196)
299 - l, err := f.Read(buf)
300 - if err != nil {
301 - return
302 - }
303 - if bytes.Equal(content[i/fileWorkers], buf[:l]) != true {
304 - err = errors.New("read and write not equal")
305 - return
306 - }
307 - }()
308 - }
309 - for range files {
310 - err := <-errs
311 - if err != nil {
312 - t.Fatal(err)
313 - }
314 - }
85 + got, err := os.ReadFile(mntDir + "/testpersistence")
86 + require.NoError(t, err)
87 + require.True(t, bytes.Equal(content, got))
88 })
89 }
317 -
318 -// Test appending data to an existing file.
319 -func TestAppendFile(t *testing.T) {
320 - _, mnt := setUp(t, nil)
321 - defer mnt.Close()
322 -
323 - path := mnt.Dir + "/appendfile"
324 -
325 - initial := make([]byte, 1300)
326 - if _, err := rand.Read(initial); err != nil {
327 - t.Fatal(err)
328 - }
329 - if err := os.WriteFile(path, initial, 0o644); err != nil {
330 - t.Fatal(err)
331 - }
332 -
333 - fi, err := os.OpenFile(path, os.O_RDWR|os.O_APPEND, 0o644)
334 - if err != nil {
335 - t.Fatal(err)
336 - }
337 -
338 - extra := make([]byte, 500)
339 - if _, err := rand.Read(extra); err != nil {
340 - t.Fatal(err)
341 - }
342 -
343 - n, err := fi.Write(extra)
344 - if err != nil {
345 - t.Fatal(err)
346 - }
347 - if n != len(extra) {
348 - t.Fatalf("short write: %d != %d", n, len(extra))
349 - }
350 - if err := fi.Close(); err != nil {
351 - t.Fatal(err)
352 - }
353 -
354 - got, err := os.ReadFile(path)
355 - if err != nil {
356 - t.Fatal(err)
357 - }
358 - want := append(initial, extra...)
359 - if !bytes.Equal(got, want) {
360 - t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(want))
361 - }
362 -}
363 -
364 -// Test writing a file one byte at a time.
365 -func TestMultiWrite(t *testing.T) {
366 - _, mnt := setUp(t, nil)
367 - defer mnt.Close()
368 -
369 - path := mnt.Dir + "/multiwrite"
370 - fi, err := os.Create(path)
371 - if err != nil {
372 - t.Fatal(err)
373 - }
374 -
375 - data := make([]byte, 1001)
376 - if _, err := rand.Read(data); err != nil {
377 - t.Fatal(err)
378 - }
379 -
380 - for i := range data {
381 - n, err := fi.Write(data[i : i+1])
382 - if err != nil {
383 - t.Fatal(err)
384 - }
385 - if n != 1 {
386 - t.Fatal("short write")
387 - }
388 - }
389 - if err := fi.Close(); err != nil {
390 - t.Fatal(err)
391 - }
392 -
393 - got, err := os.ReadFile(path)
394 - if err != nil {
395 - t.Fatal(err)
396 - }
397 - if !bytes.Equal(got, data) {
398 - t.Fatal("content mismatch")
399 - }
400 -}
401 -
402 -// Test renaming a file within the same directory.
403 -func TestRenameFile(t *testing.T) {
404 - _, mnt := setUp(t, nil)
405 - defer mnt.Close()
406 -
407 - src := mnt.Dir + "/before.txt"
408 - dst := mnt.Dir + "/after.txt"
409 -
410 - data := make([]byte, 500)
411 - if _, err := rand.Read(data); err != nil {
412 - t.Fatal(err)
413 - }
414 - if err := os.WriteFile(src, data, 0o644); err != nil {
415 - t.Fatal(err)
416 - }
417 -
418 - if err := os.Rename(src, dst); err != nil {
419 - t.Fatal(err)
420 - }
421 -
422 - // Source must be gone.
423 - if _, err := os.Stat(src); !os.IsNotExist(err) {
424 - t.Fatalf("source still exists after rename: %v", err)
425 - }
426 -
427 - // Destination must have the original content.
428 - got, err := os.ReadFile(dst)
429 - if err != nil {
430 - t.Fatal(err)
431 - }
432 - if !bytes.Equal(got, data) {
433 - t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(data))
434 - }
435 -}
436 -
437 -// Test ipfs_cid extended attribute
438 -func TestMFSRootXattr(t *testing.T) {
439 - ipfs, err := core.NewNode(context.Background(), &node.BuildCfg{})
440 - if err != nil {
441 - t.Fatal(err)
442 - }
443 -
444 - fs, mnt := setUp(t, ipfs)
445 - defer mnt.Close()
446 -
447 - node, err := fs.Root()
448 - if err != nil {
449 - t.Fatal(err)
450 - }
451 -
452 - root := node.(*Dir)
453 -
454 - listReq := fuse.ListxattrRequest{}
455 - listRes := fuse.ListxattrResponse{}
456 - err = root.Listxattr(context.Background(), &listReq, &listRes)
457 - if err != nil {
458 - t.Fatal(err)
459 - }
460 - if slices.Compare(listRes.Xattr, []byte("ipfs_cid\x00")) != 0 {
461 - t.Fatal("list xattr returns invalid value")
462 - }
463 -
464 - getReq := fuse.GetxattrRequest{
465 - Name: "ipfs_cid",
466 - }
467 - getRes := fuse.GetxattrResponse{}
468 - err = root.Getxattr(context.Background(), &getReq, &getRes)
469 - if err != nil {
470 - t.Fatal(err)
471 - }
472 -
473 - ipldNode, err := ipfs.FilesRoot.GetDirectory().GetNode()
474 - if err != nil {
475 - t.Fatal(err)
476 - }
477 -
478 - if slices.Compare(getRes.Xattr, []byte(ipldNode.Cid().String())) != 0 {
479 - t.Fatal("xattr cid not equal to mfs root cid")
480 - }
481 -}
fuse/mfs/mfs_unix.go
+12 -420
@@ -1,428 +1,20 @@
1 -//go:build (linux || darwin || freebsd || netbsd || openbsd) && !nofuse
1 +// FUSE filesystem for the /mfs mount.
2 +//
3 +//go:build (linux || darwin || freebsd) && !nofuse
4
5 package mfs
6
7 import (
6 - "context"
7 - "io"
8 - "os"
9 - "sync"
10 - "syscall"
11 - "time"
12 -
13 - "bazil.org/fuse"
14 - "bazil.org/fuse/fs"
15 -
16 - dag "github.com/ipfs/boxo/ipld/merkledag"
17 - ft "github.com/ipfs/boxo/ipld/unixfs"
18 - "github.com/ipfs/boxo/mfs"
8 + "github.com/ipfs/kubo/config"
9 "github.com/ipfs/kubo/core"
10 + "github.com/ipfs/kubo/fuse/writable"
11 )
12
22 -const (
23 - ipfsCIDXattr = "ipfs_cid"
24 - mfsDirMode = os.ModeDir | 0755
25 - mfsFileMode = 0644
26 - blockSize = 512
27 - dirSize = 8
28 -)
29 -
30 -// FUSE filesystem mounted at /mfs.
31 -type FileSystem struct {
32 - root Dir
33 -}
34 -
35 -// Get filesystem root.
36 -func (fs *FileSystem) Root() (fs.Node, error) {
37 - return &fs.root, nil
38 -}
39 -
40 -// FUSE Adapter for MFS directories.
41 -type Dir struct {
42 - mfsDir *mfs.Directory
43 -}
44 -
45 -// Directory attributes (stat).
46 -func (dir *Dir) Attr(ctx context.Context, attr *fuse.Attr) error {
47 - attr.Valid = 0
48 - // TODO: use Mode from UnixFS record if present
49 - attr.Mode = mfsDirMode
50 - attr.Size = dirSize * blockSize
51 - attr.Blocks = dirSize
52 - attr.Uid = uint32(os.Getuid())
53 - attr.Gid = uint32(os.Getgid())
54 - return nil
55 -}
56 -
57 -// Access files in a directory.
58 -func (dir *Dir) Lookup(ctx context.Context, req *fuse.LookupRequest, resp *fuse.LookupResponse) (fs.Node, error) {
59 - mfsNode, err := dir.mfsDir.Child(req.Name)
60 - switch err {
61 - case os.ErrNotExist:
62 - return nil, syscall.Errno(syscall.ENOENT)
63 - case nil:
64 - default:
65 - return nil, err
66 - }
67 -
68 - resp.EntryValid = 0
69 -
70 - switch mfsNode.Type() {
71 - case mfs.TDir:
72 - result := Dir{
73 - mfsDir: mfsNode.(*mfs.Directory),
74 - }
75 - return &result, nil
76 - case mfs.TFile:
77 - result := File{
78 - mfsFile: mfsNode.(*mfs.File),
79 - }
80 - return &result, nil
81 - }
82 -
83 - return nil, syscall.Errno(syscall.ENOENT)
84 -}
85 -
86 -// List (ls) MFS directory.
87 -func (dir *Dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
88 - var res []fuse.Dirent
89 - nodes, err := dir.mfsDir.List(ctx)
90 - if err != nil {
91 - return nil, err
92 - }
93 -
94 - for _, node := range nodes {
95 - nodeType := fuse.DT_File
96 - if node.Type == 1 {
97 - nodeType = fuse.DT_Dir
98 - }
99 - res = append(res, fuse.Dirent{
100 - Type: nodeType,
101 - Name: node.Name,
102 - })
103 - }
104 - return res, nil
105 -}
106 -
107 -// Mkdir (mkdir) in MFS.
108 -func (dir *Dir) Mkdir(ctx context.Context, req *fuse.MkdirRequest) (fs.Node, error) {
109 - mfsDir, err := dir.mfsDir.Mkdir(req.Name)
110 - if err != nil {
111 - return nil, err
112 - }
113 - return &Dir{
114 - mfsDir: mfsDir,
115 - }, nil
116 -}
117 -
118 -// Remove (rm/rmdir) an MFS file.
119 -func (dir *Dir) Remove(ctx context.Context, req *fuse.RemoveRequest) error {
120 - // Check for empty directory.
121 - if req.Dir {
122 - targetNode, err := dir.mfsDir.Child(req.Name)
123 - if err != nil {
124 - return err
125 - }
126 - target := targetNode.(*mfs.Directory)
127 -
128 - children, err := target.ListNames(ctx)
129 - if err != nil {
130 - return err
131 - }
132 - if len(children) > 0 {
133 - return os.ErrExist
134 - }
135 - }
136 - err := dir.mfsDir.Unlink(req.Name)
137 - if err != nil {
138 - return err
139 - }
140 - return dir.mfsDir.Flush()
141 -}
142 -
143 -// Move (mv) an MFS file.
144 -func (dir *Dir) Rename(ctx context.Context, req *fuse.RenameRequest, newDir fs.Node) error {
145 - child, err := dir.mfsDir.Child(req.OldName)
146 - if err != nil {
147 - return err
148 - }
149 -
150 - nd, err := child.GetNode()
151 - if err != nil {
152 - return err
153 - }
154 -
155 - // Unlink the source first. For same-directory renames, this clears
156 - // the old name from the directory's entry cache before AddChild
157 - // repopulates it with the new name. Without this ordering, Flush
158 - // would sync the stale cache entry back into the DAG.
159 - if err := dir.mfsDir.Unlink(req.OldName); err != nil {
160 - return err
161 - }
162 -
163 - targetDir := newDir.(*Dir)
164 - if err := targetDir.mfsDir.Unlink(req.NewName); err != nil && err != os.ErrNotExist {
165 - return err
166 - }
167 - if err := targetDir.mfsDir.AddChild(req.NewName, nd); err != nil {
168 - return err
169 - }
170 -
171 - return dir.mfsDir.Flush()
172 -}
173 -
174 -// Create (touch) an MFS file.
175 -func (dir *Dir) Create(ctx context.Context, req *fuse.CreateRequest, resp *fuse.CreateResponse) (fs.Node, fs.Handle, error) {
176 - node := dag.NodeWithData(ft.FilePBData(nil, 0))
177 - if err := node.SetCidBuilder(dir.mfsDir.GetCidBuilder()); err != nil {
178 - return nil, nil, err
179 - }
180 -
181 - if err := dir.mfsDir.AddChild(req.Name, node); err != nil {
182 - return nil, nil, err
183 - }
184 -
185 - if err := dir.mfsDir.Flush(); err != nil {
186 - return nil, nil, err
187 - }
188 -
189 - mfsNode, err := dir.mfsDir.Child(req.Name)
190 - if err != nil {
191 - return nil, nil, err
192 - }
193 - if err := mfsNode.SetModTime(time.Now()); err != nil {
194 - return nil, nil, err
195 - }
196 -
197 - mfsFile := mfsNode.(*mfs.File)
198 -
199 - file := File{
200 - mfsFile: mfsFile,
201 - }
202 -
203 - // Read access flags and create a handler.
204 - accessMode := req.Flags & fuse.OpenAccessModeMask
205 - flags := mfs.Flags{
206 - Read: accessMode == fuse.OpenReadOnly || accessMode == fuse.OpenReadWrite,
207 - Write: accessMode == fuse.OpenWriteOnly || accessMode == fuse.OpenReadWrite,
208 - Sync: true, // FUSE writes must propagate to the MFS root on close
209 - }
210 -
211 - fd, err := mfsFile.Open(flags)
212 - if err != nil {
213 - return nil, nil, err
214 - }
215 - handler := FileHandler{
216 - mfsFD: fd,
217 - }
218 -
219 - return &file, &handler, nil
220 -}
221 -
222 -// List dir xattr.
223 -func (dir *Dir) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error {
224 - resp.Append(ipfsCIDXattr)
225 - return nil
226 -}
227 -
228 -// Get dir xattr.
229 -func (dir *Dir) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error {
230 - switch req.Name {
231 - case ipfsCIDXattr:
232 - node, err := dir.mfsDir.GetNode()
233 - if err != nil {
234 - return err
235 - }
236 - resp.Xattr = []byte(node.Cid().String())
237 - return nil
238 - default:
239 - return fuse.ErrNoXattr
240 - }
241 -}
242 -
243 -// FUSE adapter for MFS files.
244 -type File struct {
245 - mfsFile *mfs.File
13 +// NewFileSystem creates a new MFS FUSE root node.
14 +func NewFileSystem(ipfs *core.IpfsNode, cfg config.Mounts) *writable.Dir {
15 + return writable.NewDir(ipfs.FilesRoot.GetDirectory(), &writable.Config{
16 + StoreMtime: cfg.StoreMtime.WithDefault(config.DefaultStoreMtime),
17 + StoreMode: cfg.StoreMode.WithDefault(config.DefaultStoreMode),
18 + DAG: ipfs.DAG,
19 + })
20 }
247 -
248 -// File attributes.
249 -func (file *File) Attr(ctx context.Context, attr *fuse.Attr) error {
250 - attr.Valid = 0
251 -
252 - size, _ := file.mfsFile.Size()
253 -
254 - attr.Size = uint64(size)
255 - if size%blockSize == 0 {
256 - attr.Blocks = uint64(size / blockSize)
257 - } else {
258 - attr.Blocks = uint64(size/blockSize + 1)
259 - }
260 -
261 - mtime, _ := file.mfsFile.ModTime()
262 - attr.Mtime = mtime
263 -
264 - // TODO: use Mode from UnixFS record if present
265 - attr.Mode = mfsFileMode
266 - attr.Uid = uint32(os.Getuid())
267 - attr.Gid = uint32(os.Getgid())
268 - return nil
269 -}
270 -
271 -// Open an MFS file.
272 -func (file *File) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (fs.Handle, error) {
273 - accessMode := req.Flags & fuse.OpenAccessModeMask
274 - flags := mfs.Flags{
275 - Read: accessMode == fuse.OpenReadOnly || accessMode == fuse.OpenReadWrite,
276 - Write: accessMode == fuse.OpenWriteOnly || accessMode == fuse.OpenReadWrite,
277 - Sync: true, // FUSE writes must propagate to the MFS root on close
278 - }
279 - fd, err := file.mfsFile.Open(flags)
280 - if err != nil {
281 - return nil, err
282 - }
283 -
284 - if flags.Write {
285 - if err := file.mfsFile.SetModTime(time.Now()); err != nil {
286 - return nil, err
287 - }
288 - }
289 -
290 - return &FileHandler{
291 - mfsFD: fd,
292 - }, nil
293 -}
294 -
295 -// Fsync is a no-op. We can't flush here because mfs.File.Flush opens a new
296 -// write descriptor, which needs an exclusive lock (desclock) that the caller
297 -// already holds from Open. Attempting it deadlocks until the FUSE timeout.
298 -// Data is flushed when the file is closed instead.
299 -// TODO: a proper fix needs changes in boxo/mfs to allow flushing from an
300 -// existing descriptor. Ideas welcome, but for now this is the best we can do.
301 -func (file *File) Fsync(ctx context.Context, req *fuse.FsyncRequest) error {
302 - return nil
303 -}
304 -
305 -// List file xattr.
306 -func (file *File) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error {
307 - resp.Append(ipfsCIDXattr)
308 - return nil
309 -}
310 -
311 -// Get file xattr.
312 -func (file *File) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error {
313 - switch req.Name {
314 - case ipfsCIDXattr:
315 - node, err := file.mfsFile.GetNode()
316 - if err != nil {
317 - return err
318 - }
319 - resp.Xattr = []byte(node.Cid().String())
320 - return nil
321 - default:
322 - return fuse.ErrNoXattr
323 - }
324 -}
325 -
326 -// Wrapper for MFS's file descriptor that conforms to the FUSE fs.Handler
327 -// interface.
328 -type FileHandler struct {
329 - mfsFD mfs.FileDescriptor
330 - mu sync.Mutex
331 -}
332 -
333 -// Read a opened MFS file.
334 -func (fh *FileHandler) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
335 - fh.mu.Lock()
336 - defer fh.mu.Unlock()
337 -
338 - _, err := fh.mfsFD.Seek(req.Offset, io.SeekStart)
339 - if err != nil {
340 - return err
341 - }
342 -
343 - buf := make([]byte, req.Size)
344 - l, err := fh.mfsFD.Read(buf)
345 -
346 - resp.Data = buf[:l]
347 -
348 - switch err {
349 - case nil, io.EOF, io.ErrUnexpectedEOF:
350 - return nil
351 - default:
352 - return err
353 - }
354 -}
355 -
356 -// Write writes to an opened MFS file.
357 -func (fh *FileHandler) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error {
358 - fh.mu.Lock()
359 - defer fh.mu.Unlock()
360 -
361 - l, err := fh.mfsFD.WriteAt(req.Data, req.Offset)
362 - if err != nil {
363 - return err
364 - }
365 - resp.Size = l
366 -
367 - return nil
368 -}
369 -
370 -// Flushes the file's buffer.
371 -func (fh *FileHandler) Flush(ctx context.Context, req *fuse.FlushRequest) error {
372 - fh.mu.Lock()
373 - defer fh.mu.Unlock()
374 -
375 - return fh.mfsFD.Flush()
376 -}
377 -
378 -// Closes the file.
379 -func (fh *FileHandler) Release(ctx context.Context, req *fuse.ReleaseRequest) error {
380 - fh.mu.Lock()
381 - defer fh.mu.Unlock()
382 -
383 - return fh.mfsFD.Close()
384 -}
385 -
386 -// Create new filesystem.
387 -func NewFileSystem(ipfs *core.IpfsNode) fs.FS {
388 - return &FileSystem{
389 - root: Dir{
390 - mfsDir: ipfs.FilesRoot.GetDirectory(),
391 - },
392 - }
393 -}
394 -
395 -// Check that our structs implement all the interfaces we want.
396 -type mfsDir interface {
397 - fs.Node
398 - fs.NodeGetxattrer
399 - fs.NodeListxattrer
400 - fs.HandleReadDirAller
401 - fs.NodeRequestLookuper
402 - fs.NodeMkdirer
403 - fs.NodeRenamer
404 - fs.NodeRemover
405 - fs.NodeCreater
406 -}
407 -
408 -var _ mfsDir = (*Dir)(nil)
409 -
410 -type mfsFile interface {
411 - fs.Node
412 - fs.NodeGetxattrer
413 - fs.NodeListxattrer
414 - fs.NodeOpener
415 - fs.NodeFsyncer
416 -}
417 -
418 -var _ mfsFile = (*File)(nil)
419 -
420 -type mfsHandler interface {
421 - fs.Handle
422 - fs.HandleReader
423 - fs.HandleWriter
424 - fs.HandleFlusher
425 - fs.HandleReleaser
426 -}
427 -
428 -var _ mfsHandler = (*FileHandler)(nil)
fuse/mfs/mount_unix.go
+31 -6
@@ -1,19 +1,44 @@
1 -//go:build (linux || darwin || freebsd || netbsd || openbsd) && !nofuse
1 +// Mount/unmount helpers for the /mfs FUSE mount. go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3
4 package mfs
5
6 import (
7 + "os"
8 + "time"
9 +
10 + "github.com/hanwen/go-fuse/v2/fs"
11 + "github.com/hanwen/go-fuse/v2/fuse"
12 + "github.com/ipfs/kubo/config"
13 core "github.com/ipfs/kubo/core"
7 - mount "github.com/ipfs/kubo/fuse/mount"
14 + fusemnt "github.com/ipfs/kubo/fuse/mount"
15 )
16
17 +// How long the kernel caches Lookup and Getattr results. 1 second
18 +// matches the go-fuse default and what gocryptfs/rclone use.
19 +// var (not const) because fs.Options needs a *time.Duration.
20 +var mutableCacheTime = time.Second
21 +
22 // Mount mounts MFS at a given location, and returns a mount.Mount instance.
11 -func Mount(ipfs *core.IpfsNode, mountpoint string) (mount.Mount, error) {
23 +func Mount(ipfs *core.IpfsNode, mountpoint string) (fusemnt.Mount, error) {
24 cfg, err := ipfs.Repo.Config()
25 if err != nil {
26 return nil, err
27 }
16 - allowOther := cfg.Mounts.FuseAllowOther
17 - fsys := NewFileSystem(ipfs)
18 - return mount.NewMount(fsys, mountpoint, allowOther)
28 + root := NewFileSystem(ipfs, cfg.Mounts)
29 + opts := &fs.Options{
30 + NullPermissions: true,
31 + UID: uint32(os.Getuid()),
32 + GID: uint32(os.Getgid()),
33 + EntryTimeout: &mutableCacheTime,
34 + AttrTimeout: &mutableCacheTime,
35 + MountOptions: fuse.MountOptions{
36 + AllowOther: cfg.Mounts.FuseAllowOther.WithDefault(config.DefaultFuseAllowOther),
37 + FsName: "mfs",
38 + MaxReadAhead: fusemnt.MaxReadAhead,
39 + Debug: os.Getenv("IPFS_FUSE_DEBUG") != "",
40 + ExtraCapabilities: fusemnt.WritableMountCapabilities,
41 + },
42 + }
43 + return fusemnt.NewMount(root, mountpoint, opts)
44 }
fuse/mount/caps.go new
+17
@@ -0,0 +1,17 @@
1 +// FUSE mount capabilities. go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3 +
4 +package mount
5 +
6 +import "github.com/hanwen/go-fuse/v2/fuse"
7 +
8 +// WritableMountCapabilities are FUSE capabilities requested for writable
9 +// mounts (/ipns, /mfs).
10 +//
11 +// CAP_ATOMIC_O_TRUNC tells the kernel to pass O_TRUNC to Open instead of
12 +// sending a separate SETATTR(size=0) before Open. Without this, the kernel
13 +// does SETATTR first, which requires opening a write descriptor inside
14 +// Setattr. MFS only allows one write descriptor at a time, so that
15 +// deadlocks. With this capability, O_TRUNC is handled inside Open where
16 +// we already hold the descriptor.
17 +const WritableMountCapabilities = fuse.CAP_ATOMIC_O_TRUNC
fuse/mount/errno.go new
+27
@@ -0,0 +1,27 @@
1 +// FUSE error mapping helpers. go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3 +
4 +package mount
5 +
6 +import (
7 + "context"
8 + "syscall"
9 +
10 + "github.com/hanwen/go-fuse/v2/fs"
11 +)
12 +
13 +// ReadErrno maps an error from a context-aware read or write to a FUSE
14 +// errno. It exists so context cancellation surfaces as EINTR rather than
15 +// the unspecified code that fs.ToErrno produces for context.Canceled.
16 +//
17 +// The kernel sends FUSE_INTERRUPT when a userspace process is killed
18 +// mid-syscall (Ctrl-C, SIGKILL on a stuck `cat`). go-fuse cancels the
19 +// per-request context in response. Returning EINTR tells the kernel to
20 +// abort the syscall with the right errno; without this, fs.ToErrno
21 +// turns context.Canceled into something the caller can't act on.
22 +func ReadErrno(err error) syscall.Errno {
23 + if err == context.Canceled || err == context.DeadlineExceeded {
24 + return syscall.EINTR
25 + }
26 + return fs.ToErrno(err)
27 +}
fuse/mount/fuse.go
+19 -78
@@ -1,4 +1,5 @@
1 -//go:build !nofuse && !windows && !openbsd && !netbsd && !plan9
1 +// FUSE mount/unmount lifecycle. go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3
4 package mount
5
@@ -6,19 +7,17 @@ import (
7 "errors"
8 "fmt"
9 "sync"
9 - "time"
10
11 - "bazil.org/fuse"
12 - "bazil.org/fuse/fs"
11 + "github.com/hanwen/go-fuse/v2/fs"
12 + "github.com/hanwen/go-fuse/v2/fuse"
13 )
14
15 var ErrNotMounted = errors.New("not mounted")
16
17 // mount implements go-ipfs/fuse/mount.
18 type mount struct {
19 - mpoint string
20 - filesys fs.FS
21 - fuseConn *fuse.Conn
19 + mpoint string
20 + server *fuse.Server
21
22 active bool
23 activeLock *sync.RWMutex
@@ -26,101 +25,43 @@ type mount struct {
25 unmountOnce sync.Once
26 }
27
29 -// Mount mounts a fuse fs.FS at a given location, and returns a Mount instance.
30 -// ctx is parent is a ContextGroup to bind the mount's ContextGroup to.
31 -func NewMount(fsys fs.FS, mountpoint string, allowOther bool) (Mount, error) {
32 - var conn *fuse.Conn
33 - var err error
34 -
35 - mountOpts := []fuse.MountOption{
36 - fuse.MaxReadahead(64 * 1024 * 1024),
37 - fuse.AsyncRead(),
38 - }
39 -
40 - if allowOther {
41 - mountOpts = append(mountOpts, fuse.AllowOther())
42 - }
43 - conn, err = fuse.Mount(mountpoint, mountOpts...)
44 -
28 +// NewMount mounts a FUSE filesystem at a given location, and returns a Mount instance.
29 +func NewMount(root fs.InodeEmbedder, mountpoint string, opts *fs.Options) (Mount, error) {
30 + PlatformMountOpts(&opts.MountOptions)
31 + server, err := fs.Mount(mountpoint, root, opts)
32 if err != nil {
46 - return nil, err
33 + return nil, fmt.Errorf("mounting %s: %w", mountpoint, err)
34 }
35
36 m := &mount{
37 mpoint: mountpoint,
51 - fuseConn: conn,
52 - filesys: fsys,
53 - active: false,
38 + server: server,
39 + active: true,
40 activeLock: &sync.RWMutex{},
41 }
42
57 - // launch the mounting process.
58 - if err = m.mount(); err != nil {
59 - _ = m.Unmount() // just in case.
60 - return nil, err
61 - }
62 -
63 - return m, nil
64 -}
65 -
66 -func (m *mount) mount() error {
67 - log.Infof("Mounting %s", m.MountPoint())
68 -
69 - errs := make(chan error, 1)
43 + // Detect external unmount (e.g. fusermount -u) so IsActive
44 + // returns false and Unmount returns ErrNotMounted.
45 go func() {
71 - // fs.Serve blocks until the filesystem is unmounted.
72 - err := fs.Serve(m.fuseConn, m.filesys)
73 - log.Debugf("%s is unmounted", m.MountPoint())
74 - if err != nil {
75 - log.Debugf("fs.Serve returned (%s)", err)
76 - errs <- err
77 - }
46 + server.Wait()
47 m.setActive(false)
48 }()
49
81 - // wait for the mount process to be done, or timed out.
82 - select {
83 - case <-time.After(MountTimeout):
84 - return fmt.Errorf("mounting %s timed out", m.MountPoint())
85 - case err := <-errs:
86 - return err
87 - case <-m.fuseConn.Ready:
88 - }
89 -
90 - // check if the mount process has an error to report
91 - if err := m.fuseConn.MountError; err != nil {
92 - return err
93 - }
94 -
95 - m.setActive(true)
96 -
97 - log.Infof("Mounted %s", m.MountPoint())
98 - return nil
50 + log.Infof("Mounted %s", mountpoint)
51 + return m, nil
52 }
53
54 // unmount is called exactly once to unmount this service.
102 -// note that closing the connection will not always unmount
103 -// properly. If that happens, we bring out the big guns
104 -// (mount.ForceUnmountManyTimes, exec unmount).
55 func (m *mount) unmount() error {
56 log.Infof("Unmounting %s", m.MountPoint())
57
108 - // try unmounting with fuse lib
109 - err := fuse.Unmount(m.MountPoint())
58 + err := m.server.Unmount()
59 if err == nil {
60 m.setActive(false)
61 return nil
62 }
63 log.Warnf("fuse unmount err: %s", err)
64
116 - // try closing the fuseConn
117 - err = m.fuseConn.Close()
118 - if err == nil {
119 - m.setActive(false)
120 - return nil
121 - }
122 - log.Warnf("fuse conn error: %s", err)
123 -
65 // try mount.ForceUnmountManyTimes
66 if err := ForceUnmountManyTimes(m, 10); err != nil {
67 return err
fuse/mount/mode.go new
+50
@@ -0,0 +1,50 @@
1 +package mount
2 +
3 +import "os"
4 +
5 +// Default POSIX modes used by FUSE mounts when the UnixFS DAG node does
6 +// not contain explicit permission metadata. Most data on IPFS does not
7 +// include mode, so these apply to the majority of files and directories.
8 +//
9 +// Per the UnixFS spec, implementations may default to 0755 for directories
10 +// and 0644 for files when mode is absent.
11 +// See https://specs.ipfs.tech/unixfs/#dag-pb-optional-metadata
12 +
13 +// Writable mounts (/ipns, /mfs): standard POSIX defaults matching umask 022.
14 +const (
15 + DefaultFileModeRW = os.FileMode(0o644)
16 + DefaultDirModeRW = os.ModeDir | 0o755
17 +)
18 +
19 +// Read-only mount (/ipfs): no write bits.
20 +const (
21 + DefaultFileModeRO = os.FileMode(0o444)
22 + DefaultDirModeRO = os.ModeDir | 0o555
23 +)
24 +
25 +// NamespaceRootMode is for the /ipfs/ and /ipns/ root directories.
26 +// Execute-only: these are virtual namespaces where users traverse by
27 +// name (CID or IPNS key) but listing the full namespace is not possible.
28 +const NamespaceRootMode = os.ModeDir | 0o111
29 +
30 +// SymlinkMode is the POSIX permission bits for symlinks. Symlink
31 +// permissions are always 0777; access control uses the target's mode.
32 +const SymlinkMode = os.FileMode(0o777)
33 +
34 +// MaxReadAhead tells the kernel how far ahead to read in a single FUSE
35 +// request. 64 MiB works well for sequential access (streaming, file
36 +// copies) because most data is served from the local blockstore after
37 +// the initial fetch. Network-backed reads are already chunked by the
38 +// DAG layer, so oversized readahead does not cause extra round-trips.
39 +const MaxReadAhead = 64 * 1024 * 1024
40 +
41 +// XattrCID is the extended attribute name for the node's CID.
42 +// Follows the convention used by CephFS (ceph.*), Btrfs (btrfs.*),
43 +// and GlusterFS (glusterfs.*) of using a project-specific namespace.
44 +const XattrCID = "ipfs.cid"
45 +
46 +// XattrCIDDeprecated is the old xattr name. Getxattr normalizes it
47 +// to XattrCID and logs a deprecation error so existing tooling keeps
48 +// working while users migrate.
49 +// TODO: remove after 2 releases.
50 +const XattrCIDDeprecated = "ipfs_cid"
fuse/mount/mount.go
+3
@@ -66,6 +66,9 @@ func UnmountCmd(point string) (*exec.Cmd, error) {
66 case "darwin":
67 return exec.Command("diskutil", "umount", "force", point), nil
68 case "linux":
69 + if _, err := exec.LookPath("fusermount3"); err == nil {
70 + return exec.Command("fusermount3", "-u", point), nil
71 + }
72 return exec.Command("fusermount", "-u", point), nil
73 default:
74 return nil, fmt.Errorf("unmount: unimplemented")
fuse/mount/opts_darwin.go new
+26
@@ -0,0 +1,26 @@
1 +//go:build darwin && !nofuse
2 +
3 +package mount
4 +
5 +import "github.com/hanwen/go-fuse/v2/fuse"
6 +
7 +// PlatformMountOpts applies macOS-specific FUSE mount options.
8 +func PlatformMountOpts(opts *fuse.MountOptions) {
9 + // volname: Finder shows this instead of the generic "macfuse Volume 0".
10 + if opts.FsName != "" {
11 + opts.Options = append(opts.Options, "volname="+opts.FsName)
12 + }
13 +
14 + // noapplexattr: prevents Finder from probing com.apple.FinderInfo,
15 + // com.apple.ResourceFork, and other Apple-private xattrs on every
16 + // file access. Without this, each stat triggers multiple Getxattr
17 + // calls that all return ENOATTR, adding latency on network-backed
18 + // mounts.
19 + opts.Options = append(opts.Options, "noapplexattr")
20 +
21 + // noappledouble: prevents macOS from creating ._ resource fork
22 + // sidecar files when copying or editing files on the mount. These
23 + // AppleDouble files pollute the DAG with metadata that only macOS
24 + // understands and inflate the CID tree.
25 + opts.Options = append(opts.Options, "noappledouble")
26 +}
fuse/mount/opts_other.go new
+8
@@ -0,0 +1,8 @@
1 +//go:build (linux || freebsd) && !nofuse
2 +
3 +package mount
4 +
5 +import "github.com/hanwen/go-fuse/v2/fuse"
6 +
7 +// PlatformMountOpts is a no-op on Linux and FreeBSD.
8 +func PlatformMountOpts(_ *fuse.MountOptions) {}
fuse/node/mount_darwin.go
+17 -229
@@ -1,248 +1,36 @@
1 -//go:build !nofuse && darwin
1 +// macFUSE/OSXFUSE availability check. Darwin only.
2 +//go:build darwin && !nofuse
3
4 package node
5
6 import (
6 - "bytes"
7 "fmt"
8 - "os/exec"
9 - "runtime"
10 - "strings"
8 + "os"
9
10 core "github.com/ipfs/kubo/core"
13 -
14 - "github.com/blang/semver/v4"
15 - unix "golang.org/x/sys/unix"
11 )
12
13 func init() {
19 - // this is a hack, but until we need to do it another way, this works.
20 - platformFuseChecks = darwinFuseCheckVersion
14 + platformFuseChecks = darwinFuseCheck
15 }
16
23 -// dontCheckOSXFUSEConfigKey is a key used to let the user tell us to
24 -// skip fuse checks.
25 -const dontCheckOSXFUSEConfigKey = "DontCheckOSXFUSE"
26 -
27 -// fuseVersionPkg is the go pkg url for fuse-version.
28 -const fuseVersionPkg = "github.com/jbenet/go-fuse-version/fuse-version"
29 -
30 -// errStrFuseRequired is returned when we're sure the user does not have fuse.
31 -const errStrFuseRequired = `OSXFUSE not found.
32 -
33 -OSXFUSE is required to mount, please install it.
34 -NOTE: Version 2.7.2 or higher required; prior versions are known to kernel panic!
35 -It is recommended you install it from the OSXFUSE website:
36 -
37 - http://osxfuse.github.io/
38 -
39 -For more help, see:
40 -
41 - https://github.com/ipfs/kubo/issues/177
42 -`
43 -
44 -// errStrNoFuseHeaders is included in the output of `go get <fuseVersionPkg>` if there
45 -// are no fuse headers. this means they don't have OSXFUSE installed.
46 -var errStrNoFuseHeaders = "no such file or directory: '/usr/local/lib/libosxfuse.dylib'"
47 -
48 -var errStrUpgradeFuse = `OSXFUSE version %s not supported.
49 -
50 -OSXFUSE versions <2.7.2 are known to cause kernel panics!
51 -Please upgrade to the latest OSXFUSE version.
52 -It is recommended you install it from the OSXFUSE website:
53 -
54 - http://osxfuse.github.io/
55 -
56 -For more help, see:
57 -
58 - https://github.com/ipfs/kubo/issues/177
59 -`
60 -
61 -type errNeedFuseVersion struct {
62 - cause string
63 -}
64 -
65 -func (me errNeedFuseVersion) Error() string {
66 - return fmt.Sprintf(`unable to check fuse version.
67 -
68 -Dear User,
69 -
70 -Before mounting, we must check your version of OSXFUSE. We are protecting
71 -you from a nasty kernel panic we found in OSXFUSE versions <2.7.2.[1]. To
72 -make matters worse, it's harder than it should be to check whether you have
73 -the right version installed...[2]. We've automated the process with the
74 -help of a little tool. We tried to install it, but something went wrong[3].
75 -Please install it yourself by running:
76 -
77 - go get %s
78 -
79 -You can also stop ipfs from running these checks and use whatever OSXFUSE
80 -version you have by running:
81 -
82 - ipfs config --bool %s true
83 -
84 -[1]: https://github.com/ipfs/kubo/issues/177
85 -[2]: https://github.com/ipfs/kubo/pull/533
86 -[3]: %s
87 -`, fuseVersionPkg, dontCheckOSXFUSEConfigKey, me.cause)
17 +// macFUSE mount helper paths, checked in the same order as go-fuse.
18 +var macFUSEPaths = []string{
19 + "/Library/Filesystems/macfuse.fs/Contents/Resources/mount_macfuse",
20 + "/Library/Filesystems/osxfuse.fs/Contents/Resources/mount_osxfuse",
21 }
22
90 -var errStrFailedToRunFuseVersion = `unable to check fuse version.
91 -
92 -Dear User,
93 -
94 -Before mounting, we must check your version of OSXFUSE. We are protecting
95 -you from a nasty kernel panic we found in OSXFUSE versions <2.7.2.[1]. To
96 -make matters worse, it's harder than it should be to check whether you have
97 -the right version installed...[2]. We've automated the process with the
98 -help of a little tool. We tried to run it, but something went wrong[3].
99 -Please, try to run it yourself with:
100 -
101 - go get %s
102 - fuse-version
103 -
104 -You should see something like this:
105 -
106 - > fuse-version
107 - fuse-version -only agent
108 - OSXFUSE.AgentVersion: 2.7.3
109 -
110 -Just make sure the number is 2.7.2 or higher. You can then stop ipfs from
111 -trying to run these checks with:
112 -
113 - ipfs config --bool %s true
114 -
115 -[1]: https://github.com/ipfs/kubo/issues/177
116 -[2]: https://github.com/ipfs/kubo/pull/533
117 -[3]: %s
118 -`
119 -
120 -var errStrFixConfig = `config key invalid: %s %v
121 -You may be able to get this error to go away by setting it again:
122 -
123 - ipfs config --bool %s true
124 -
125 -Either way, please tell us at: http://github.com/ipfs/kubo/issues
126 -`
127 -
128 -func darwinFuseCheckVersion(node *core.IpfsNode) error {
129 - // on OSX, check FUSE version.
130 - if runtime.GOOS != "darwin" {
131 - return nil
132 - }
133 -
134 - ov, errGFV := tryGFV()
135 - if errGFV != nil {
136 - // if we failed AND the user has told us to ignore the check we
137 - // continue. this is in case fuse-version breaks or the user cannot
138 - // install it, but is sure their fuse version will work.
139 - if skip, err := userAskedToSkipFuseCheck(node); err != nil {
140 - return err
141 - } else if skip {
142 - return nil // user told us not to check version... ok....
23 +func darwinFuseCheck(_ *core.IpfsNode) error {
24 + for _, p := range macFUSEPaths {
25 + if _, err := os.Stat(p); err == nil {
26 + return nil
27 }
144 - return errGFV
145 - }
146 -
147 - log.Debug("mount: osxfuse version:", ov)
148 -
149 - min := semver.MustParse("2.7.2")
150 - curr, err := semver.Make(ov)
151 - if err != nil {
152 - return err
28 }
29 + return fmt.Errorf(`macFUSE not found.
30
155 - if curr.LT(min) {
156 - return fmt.Errorf(errStrUpgradeFuse, ov)
157 - }
158 - return nil
159 -}
160 -
161 -func tryGFV() (string, error) {
162 - // first try sysctl. it may work!
163 - ov, err := trySysctl()
164 - if err == nil {
165 - return ov, nil
166 - }
167 - log.Debug(err)
31 +macFUSE is required to mount FUSE filesystems on macOS.
32 +Install it from https://osxfuse.github.io/ or via Homebrew:
33
169 - return tryGFVFromFuseVersion()
170 -}
171 -
172 -func trySysctl() (string, error) {
173 - v, err := unix.Sysctl("osxfuse.version.number")
174 - if err != nil {
175 - log.Debug("mount: sysctl osxfuse.version.number:", "failed")
176 - return "", err
177 - }
178 - log.Debug("mount: sysctl osxfuse.version.number:", v)
179 - return v, nil
180 -}
181 -
182 -func tryGFVFromFuseVersion() (string, error) {
183 - if err := ensureFuseVersionIsInstalled(); err != nil {
184 - return "", err
185 - }
186 -
187 - cmd := exec.Command("fuse-version", "-q", "-only", "agent", "-s", "OSXFUSE")
188 - out := new(bytes.Buffer)
189 - cmd.Stdout = out
190 - if err := cmd.Run(); err != nil {
191 - return "", fmt.Errorf(errStrFailedToRunFuseVersion, fuseVersionPkg, dontCheckOSXFUSEConfigKey, err)
192 - }
193 -
194 - return out.String(), nil
195 -}
196 -
197 -func ensureFuseVersionIsInstalled() error {
198 - // see if fuse-version is there
199 - if _, err := exec.LookPath("fuse-version"); err == nil {
200 - return nil // got it!
201 - }
202 -
203 - // try installing it...
204 - log.Debug("fuse-version: no fuse-version. attempting to install.")
205 - cmd := exec.Command("go", "install", "github.com/jbenet/go-fuse-version/fuse-version")
206 - cmdout := new(bytes.Buffer)
207 - cmd.Stdout = cmdout
208 - cmd.Stderr = cmdout
209 - if err := cmd.Run(); err != nil {
210 - // Ok, install fuse-version failed. is it they don't have fuse?
211 - cmdoutstr := cmdout.String()
212 - if strings.Contains(cmdoutstr, errStrNoFuseHeaders) {
213 - // yes! it is! they don't have fuse!
214 - return fmt.Errorf(errStrFuseRequired)
215 - }
216 -
217 - log.Debug("fuse-version: failed to install.")
218 - s := err.Error() + "\n" + cmdoutstr
219 - return errNeedFuseVersion{s}
220 - }
221 -
222 - // ok, try again...
223 - if _, err := exec.LookPath("fuse-version"); err != nil {
224 - log.Debug("fuse-version: failed to install?")
225 - return errNeedFuseVersion{err.Error()}
226 - }
227 -
228 - log.Debug("fuse-version: install success")
229 - return nil
230 -}
231 -
232 -func userAskedToSkipFuseCheck(node *core.IpfsNode) (skip bool, err error) {
233 - val, err := node.Repo.GetConfigKey(dontCheckOSXFUSEConfigKey)
234 - if err != nil {
235 - return false, nil // failed to get config value. don't skip check.
236 - }
237 -
238 - switch val := val.(type) {
239 - case string:
240 - return val == "true", nil
241 - case bool:
242 - return val, nil
243 - default:
244 - // got config value, but it's invalid... don't skip check, ask the user to fix it...
245 - return false, fmt.Errorf(errStrFixConfig, dontCheckOSXFUSEConfigKey, val,
246 - dontCheckOSXFUSEConfigKey)
247 - }
34 + brew install macfuse
35 +`)
36 }
fuse/node/mount_nofuse.go
+2
@@ -1,3 +1,5 @@
1 +// Stub when built with "go build -tags nofuse". Excludes windows
2 +// which never has FUSE support regardless of build tags.
3 //go:build !windows && nofuse
4
5 package node
fuse/node/mount_notsupp.go
+4 -1
@@ -1,4 +1,7 @@
1 -//go:build (!nofuse && openbsd) || (!nofuse && netbsd) || (!nofuse && plan9)
1 +// Stub for platforms where go-fuse does not compile but the user
2 +// has not set the nofuse build tag. Returns a clear error instead
3 +// of a build failure. See https://github.com/ipfs/kubo/issues/5334.
4 +//go:build (openbsd || netbsd || plan9) && !nofuse
5
6 package node
7
fuse/node/mount_test.go
+83 -44
@@ -1,14 +1,15 @@
1 -//go:build !openbsd && !nofuse && !netbsd && !plan9
1 +// go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3
4 package node
5
6 import (
6 - "context"
7 "os"
8 "testing"
9 "time"
10
11 core "github.com/ipfs/kubo/core"
12 + coremock "github.com/ipfs/kubo/core/mock"
13 "github.com/ipfs/kubo/fuse/fusetest"
14 ipns "github.com/ipfs/kubo/fuse/ipns"
15 mount "github.com/ipfs/kubo/fuse/mount"
@@ -21,64 +22,102 @@ func mkdir(t *testing.T, path string) {
22 }
23 }
24
24 -// Test externally unmounting, then trying to unmount in code.
25 +// TestExternalUnmount runs an external unmount on each of the three
26 +// FUSE mounts (/ipfs, /ipns, /mfs) and confirms the corresponding
27 +// Mount.IsActive flips to false and Unmount returns ErrNotMounted.
28 +// This exercises the goroutine in fuse/mount/fuse.go that watches
29 +// fuse.Server.Wait() to detect out-of-band unmounts.
30 func TestExternalUnmount(t *testing.T) {
26 -
27 - // TODO: needed?
31 fusetest.SkipUnlessFUSE(t)
32
30 - node, err := core.NewNode(context.Background(), &core.BuildCfg{})
31 - if err != nil {
32 - t.Fatal(err)
33 - }
34 -
35 - err = ipns.InitializeKeyspace(node, node.PrivateKey)
36 - if err != nil {
37 - t.Fatal(err)
33 + cases := []struct {
34 + name string
35 + target func(node *core.IpfsNode, paths mountPaths) (string, mount.Mount)
36 + }{
37 + {
38 + name: "ipfs",
39 + target: func(node *core.IpfsNode, p mountPaths) (string, mount.Mount) {
40 + return p.ipfs, node.Mounts.Ipfs
41 + },
42 + },
43 + {
44 + name: "ipns",
45 + target: func(node *core.IpfsNode, p mountPaths) (string, mount.Mount) {
46 + return p.ipns, node.Mounts.Ipns
47 + },
48 + },
49 + {
50 + name: "mfs",
51 + target: func(node *core.IpfsNode, p mountPaths) (string, mount.Mount) {
52 + return p.mfs, node.Mounts.Mfs
53 + },
54 + },
55 }
56
40 - // get the test dir paths (/tmp/TestExternalUnmount)
41 - dir := t.TempDir()
57 + for _, tc := range cases {
58 + t.Run(tc.name, func(t *testing.T) {
59 + node, paths := setupAllMounts(t)
60 + mountpoint, target := tc.target(node, paths)
61
43 - ipfsDir := dir + "/ipfs"
44 - ipnsDir := dir + "/ipns"
45 - mfsDir := dir + "/mfs"
46 - mkdir(t, ipfsDir)
47 - mkdir(t, ipnsDir)
48 - mkdir(t, mfsDir)
49 -
50 - err = Mount(node, ipfsDir, ipnsDir, mfsDir)
51 - fusetest.MountError(t, err)
52 -
53 - t.Cleanup(func() {
54 - if node.Mounts.Mfs != nil && node.Mounts.Mfs.IsActive() {
55 - if err := node.Mounts.Mfs.Unmount(); err != nil {
62 + // Run shell command to externally unmount the directory.
63 + cmd, err := mount.UnmountCmd(mountpoint)
64 + if err != nil {
65 t.Fatal(err)
66 }
58 - }
59 - if node.Mounts.Ipns != nil && node.Mounts.Ipns.IsActive() {
60 - if err := node.Mounts.Ipns.Unmount(); err != nil {
67 + if err := cmd.Run(); err != nil {
68 t.Fatal(err)
69 }
63 - }
64 - })
70
66 - // Run shell command to externally unmount the directory
67 - cmd, err := mount.UnmountCmd(ipfsDir)
71 + // The goroutine watching fuse.Server.Wait() needs a moment
72 + // to observe the kernel-side unmount and flip IsActive.
73 + time.Sleep(100 * time.Millisecond)
74 +
75 + if target.IsActive() {
76 + t.Fatal("mount should be inactive after external unmount")
77 + }
78 + if err := target.Unmount(); err != mount.ErrNotMounted {
79 + t.Fatalf("expected ErrNotMounted, got %v", err)
80 + }
81 + })
82 + }
83 +}
84 +
85 +type mountPaths struct {
86 + ipfs, ipns, mfs string
87 +}
88 +
89 +// setupAllMounts builds an IpfsNode and mounts all three FUSE filesystems
90 +// under a fresh temp directory. Cleanup unmounts whatever is still active.
91 +//
92 +// The node is built via coremock.NewMockNode so it is online: doMount
93 +// only mounts /ipns when node.IsOnline is true, and the test needs all
94 +// three mounts populated.
95 +func setupAllMounts(t *testing.T) (*core.IpfsNode, mountPaths) {
96 + t.Helper()
97 +
98 + node, err := coremock.NewMockNode()
99 if err != nil {
100 t.Fatal(err)
101 }
71 -
72 - if err := cmd.Run(); err != nil {
102 + if err := ipns.InitializeKeyspace(node, node.PrivateKey); err != nil {
103 t.Fatal(err)
104 }
105
76 - // TODO(noffle): it takes a moment for the goroutine that's running fs.Serve to be notified and do its cleanup.
77 - time.Sleep(time.Millisecond * 100)
78 -
79 - // Attempt to unmount IPFS; it should unmount successfully.
80 - err = node.Mounts.Ipfs.Unmount()
81 - if err != mount.ErrNotMounted {
82 - t.Fatal("Unmount should have failed")
106 + dir := t.TempDir()
107 + paths := mountPaths{
108 + ipfs: dir + "/ipfs",
109 + ipns: dir + "/ipns",
110 + mfs: dir + "/mfs",
111 }
112 + mkdir(t, paths.ipfs)
113 + mkdir(t, paths.ipns)
114 + mkdir(t, paths.mfs)
115 +
116 + err = Mount(node, paths.ipfs, paths.ipns, paths.mfs)
117 + fusetest.MountError(t, err)
118 +
119 + t.Cleanup(func() {
120 + Unmount(node)
121 + })
122 + return node, paths
123 }
fuse/node/mount_unix.go
+5 -4
@@ -1,4 +1,5 @@
1 -//go:build !windows && !openbsd && !netbsd && !plan9 && !nofuse
1 +// Mounts all three FUSE filesystems (/ipfs, /ipns, /mfs). go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3
4 package node
5
@@ -26,7 +27,7 @@ const fuseNoDirectory = "fusermount: failed to access mountpoint"
27 const fuseExitStatus1 = "fusermount: exit status 1"
28
29 // platformFuseChecks can get overridden by arch-specific files
29 -// to run fuse checks (like checking the OSXFUSE version).
30 +// to run pre-mount checks (e.g. verifying macFUSE is installed).
31 var platformFuseChecks = func(*core.IpfsNode) error {
32 return nil
33 }
@@ -69,8 +70,8 @@ func doMount(node *core.IpfsNode, fsdir, nsdir, mfsdir string) error {
70 fmtFuseErr := func(err error, mountpoint string) error {
71 s := err.Error()
72 if strings.Contains(s, fuseNoDirectory) {
72 - s = strings.Replace(s, `fusermount: "fusermount:`, "", -1)
73 - s = strings.Replace(s, `\n", exit status 1`, "", -1)
73 + s = strings.ReplaceAll(s, `fusermount: "fusermount:`, "")
74 + s = strings.ReplaceAll(s, `\n", exit status 1`, "")
75 return errors.New(s)
76 }
77 if s == fuseExitStatus1 {
fuse/readonly/ipfs_test.go
+384 -30
@@ -1,4 +1,9 @@
1 -//go:build !nofuse && !openbsd && !netbsd && !plan9
1 +//go:build (linux || darwin || freebsd) && !nofuse
2 +
3 +// Unit tests for the read-only /ipfs FUSE mount.
4 +// These test the filesystem implementation directly without a daemon.
5 +// End-to-end tests that exercise mount/unmount through a real daemon
6 +// live in test/cli/fuse/.
7
8 package readonly
9
@@ -13,16 +18,21 @@ import (
18 gopath "path"
19 "strings"
20 "sync"
21 + "syscall"
22 "testing"
23 + "time"
24 +
25 + "github.com/hanwen/go-fuse/v2/fs"
26 + "github.com/hanwen/go-fuse/v2/fuse"
27
28 core "github.com/ipfs/kubo/core"
29 coreapi "github.com/ipfs/kubo/core/coreapi"
30 coremock "github.com/ipfs/kubo/core/mock"
31
22 - fstest "bazil.org/fuse/fs/fstestutil"
32 chunker "github.com/ipfs/boxo/chunker"
33 "github.com/ipfs/boxo/files"
34 dag "github.com/ipfs/boxo/ipld/merkledag"
35 + ft "github.com/ipfs/boxo/ipld/unixfs"
36 importer "github.com/ipfs/boxo/ipld/unixfs/importer"
37 uio "github.com/ipfs/boxo/ipld/unixfs/io"
38 "github.com/ipfs/boxo/path"
@@ -30,8 +40,21 @@ import (
40 "github.com/ipfs/go-test/random"
41 options "github.com/ipfs/kubo/core/coreiface/options"
42 "github.com/ipfs/kubo/fuse/fusetest"
43 + fusemnt "github.com/ipfs/kubo/fuse/mount"
44 + "github.com/stretchr/testify/require"
45 )
46
47 +func testMount(t *testing.T, root fs.InodeEmbedder) string {
48 + t.Helper()
49 + return fusetest.TestMount(t, root, &fs.Options{
50 + AttrTimeout: &immutableAttrCacheTime,
51 + EntryTimeout: &immutableAttrCacheTime,
52 + MountOptions: fuse.MountOptions{
53 + MaxReadAhead: fusemnt.MaxReadAhead,
54 + },
55 + })
56 +}
57 +
58 func randObj(t *testing.T, nd *core.IpfsNode, size int64) (ipld.Node, []byte) {
59 buf := make([]byte, size)
60 _, err := io.ReadFull(random.NewRand(), buf)
@@ -47,9 +70,8 @@ func randObj(t *testing.T, nd *core.IpfsNode, size int64) (ipld.Node, []byte) {
70 return obj, buf
71 }
72
50 -func setupIpfsTest(t *testing.T, node *core.IpfsNode) (*core.IpfsNode, *fstest.Mount) {
73 +func setupIpfsTest(t *testing.T, node *core.IpfsNode) (*core.IpfsNode, string) {
74 t.Helper()
52 - fusetest.SkipUnlessFUSE(t)
75
76 var err error
77 if node == nil {
@@ -59,17 +81,15 @@ func setupIpfsTest(t *testing.T, node *core.IpfsNode) (*core.IpfsNode, *fstest.M
81 }
82 }
83
62 - fs := NewFileSystem(node)
63 - mnt, err := fstest.MountedT(t, fs, nil)
64 - fusetest.MountError(t, err)
84 + root := NewRoot(node)
85 + mntDir := testMount(t, root)
86
66 - return node, mnt
87 + return node, mntDir
88 }
89
90 // Test that an empty directory can be listed without errors.
91 func TestEmptyDirListing(t *testing.T) {
71 - nd, mnt := setupIpfsTest(t, nil)
72 - defer mnt.Close()
92 + nd, mntDir := setupIpfsTest(t, nil)
93
94 // Create an empty UnixFS directory and add it to the DAG.
95 db, err := uio.NewDirectory(nd.DAG)
@@ -85,7 +105,7 @@ func TestEmptyDirListing(t *testing.T) {
105 }
106
107 // List it via FUSE.
88 - dirPath := gopath.Join(mnt.Dir, emptyDir.Cid().String())
108 + dirPath := gopath.Join(mntDir, emptyDir.Cid().String())
109 entries, err := os.ReadDir(dirPath)
110 if err != nil {
111 t.Fatal(err)
@@ -97,8 +117,7 @@ func TestEmptyDirListing(t *testing.T) {
117
118 // Test that a bare file CID can be read at the /ipfs mount root.
119 func TestBareFileCID(t *testing.T) {
100 - nd, mnt := setupIpfsTest(t, nil)
101 - defer mnt.Close()
120 + nd, mntDir := setupIpfsTest(t, nil)
121
122 api, err := coreapi.NewCoreAPI(nd)
123 if err != nil {
@@ -116,7 +135,7 @@ func TestBareFileCID(t *testing.T) {
135 t.Fatal(err)
136 }
137 cidStr := resolved.RootCid().String()
119 - got, err := os.ReadFile(gopath.Join(mnt.Dir, cidStr))
138 + got, err := os.ReadFile(gopath.Join(mntDir, cidStr))
139 if err != nil {
140 t.Fatalf("read %s via FUSE: %v", cidStr, err)
141 }
@@ -134,7 +153,7 @@ func TestBareFileCID(t *testing.T) {
153 t.Fatal(err)
154 }
155 cidStr := resolved.RootCid().String()
137 - got, err := os.ReadFile(gopath.Join(mnt.Dir, cidStr))
156 + got, err := os.ReadFile(gopath.Join(mntDir, cidStr))
157 if err != nil {
158 t.Fatalf("read %s via FUSE: %v", cidStr, err)
159 }
@@ -148,8 +167,7 @@ func TestBareFileCID(t *testing.T) {
167 // This is the typical layout produced by `ipfs add --raw-leaves`: the
168 // directory node is dag-pb, while file leaves are raw blocks.
169 func TestMixedDAGDirectory(t *testing.T) {
151 - nd, mnt := setupIpfsTest(t, nil)
152 - defer mnt.Close()
170 + nd, mntDir := setupIpfsTest(t, nil)
171
172 api, err := coreapi.NewCoreAPI(nd)
173 if err != nil {
@@ -172,7 +190,7 @@ func TestMixedDAGDirectory(t *testing.T) {
190 t.Fatal(err)
191 }
192
175 - dirPath := gopath.Join(mnt.Dir, resolved.RootCid().String())
193 + dirPath := gopath.Join(mntDir, resolved.RootCid().String())
194
195 entries, err := os.ReadDir(dirPath)
196 if err != nil {
@@ -201,12 +219,11 @@ func TestMixedDAGDirectory(t *testing.T) {
219
220 // Test writing an object and reading it back through fuse.
221 func TestIpfsBasicRead(t *testing.T) {
204 - nd, mnt := setupIpfsTest(t, nil)
205 - defer mnt.Close()
222 + nd, mntDir := setupIpfsTest(t, nil)
223
224 fi, data := randObj(t, nd, 10000)
225 k := fi.Cid()
209 - fname := gopath.Join(mnt.Dir, k.String())
226 + fname := gopath.Join(mntDir, k.String())
227 rbuf, err := os.ReadFile(fname)
228 if err != nil {
229 t.Fatal(err)
@@ -241,8 +258,7 @@ func getPaths(t *testing.T, ipfs *core.IpfsNode, name string, n *dag.ProtoNode)
258
259 // Perform a large number of concurrent reads to stress the system.
260 func TestIpfsStressRead(t *testing.T) {
244 - nd, mnt := setupIpfsTest(t, nil)
245 - defer mnt.Close()
261 + nd, mntDir := setupIpfsTest(t, nil)
262
263 api, err := coreapi.NewCoreAPI(nd)
264 if err != nil {
@@ -306,11 +322,12 @@ func TestIpfsStressRead(t *testing.T) {
322 }
323
324 relpath := strings.Replace(item.String(), item.Namespace(), "", 1)
309 - fname := gopath.Join(mnt.Dir, relpath)
325 + fname := gopath.Join(mntDir, relpath)
326
327 rbuf, err := os.ReadFile(fname)
328 if err != nil {
329 errs <- err
330 + continue
331 }
332
333 // nd.Context() is never closed which leads to
@@ -319,12 +336,16 @@ func TestIpfsStressRead(t *testing.T) {
336
337 read, err := api.Unixfs().Get(ctx, item)
338 if err != nil {
339 + cancelFunc()
340 errs <- err
341 + continue
342 }
343
344 data, err := io.ReadAll(read.(files.File))
345 if err != nil {
346 + cancelFunc()
347 errs <- err
348 + continue
349 }
350
351 cancelFunc()
@@ -350,8 +371,7 @@ func TestIpfsStressRead(t *testing.T) {
371
372 // Test writing a file and reading it back.
373 func TestIpfsBasicDirRead(t *testing.T) {
353 - nd, mnt := setupIpfsTest(t, nil)
354 - defer mnt.Close()
374 + nd, mntDir := setupIpfsTest(t, nil)
375
376 // Make a 'file'
377 fi, data := randObj(t, nd, 10000)
@@ -376,7 +396,7 @@ func TestIpfsBasicDirRead(t *testing.T) {
396 t.Fatal(err)
397 }
398
379 - dirname := gopath.Join(mnt.Dir, d1nd.Cid().String())
399 + dirname := gopath.Join(mntDir, d1nd.Cid().String())
400 fname := gopath.Join(dirname, "actual")
401 rbuf, err := os.ReadFile(fname)
402 if err != nil {
@@ -401,13 +421,12 @@ func TestIpfsBasicDirRead(t *testing.T) {
421
422 // Test to make sure the filesystem reports file sizes correctly.
423 func TestFileSizeReporting(t *testing.T) {
404 - nd, mnt := setupIpfsTest(t, nil)
405 - defer mnt.Close()
424 + nd, mntDir := setupIpfsTest(t, nil)
425
426 fi, data := randObj(t, nd, 10000)
427 k := fi.Cid()
428
410 - fname := gopath.Join(mnt.Dir, k.String())
429 + fname := gopath.Join(mntDir, k.String())
430
431 finfo, err := os.Stat(fname)
432 if err != nil {
@@ -418,3 +437,338 @@ func TestFileSizeReporting(t *testing.T) {
437 t.Fatal("Read incorrect size from stat!")
438 }
439 }
440 +
441 +// Test that mode and mtime stored in UnixFS metadata are reported in stat.
442 +func TestUnixFSMetadataInStat(t *testing.T) {
443 + nd, mntDir := setupIpfsTest(t, nil)
444 +
445 + storedMode := os.FileMode(0o755)
446 + storedMtime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
447 + content := []byte("file with metadata")
448 +
449 + // Create a UnixFS node with explicit mode and mtime.
450 + pbdata := ft.FilePBDataWithStat(content, uint64(len(content)), storedMode, storedMtime)
451 + node := dag.NodeWithData(pbdata)
452 + if err := nd.DAG.Add(nd.Context(), node); err != nil {
453 + t.Fatal(err)
454 + }
455 +
456 + fpath := gopath.Join(mntDir, node.Cid().String())
457 + fi, err := os.Stat(fpath)
458 + if err != nil {
459 + t.Fatal(err)
460 + }
461 +
462 + if fi.Mode().Perm() != storedMode.Perm() {
463 + t.Fatalf("expected mode %04o, got %04o", storedMode.Perm(), fi.Mode().Perm())
464 + }
465 + if !fi.ModTime().Equal(storedMtime) {
466 + t.Fatalf("expected mtime %v, got %v", storedMtime, fi.ModTime())
467 + }
468 +}
469 +
470 +// Test that files without UnixFS metadata get the read-only defaults.
471 +func TestDefaultModeReadonly(t *testing.T) {
472 + nd, mntDir := setupIpfsTest(t, nil)
473 +
474 + // Create a plain UnixFS file (no mode/mtime metadata).
475 + fi, _ := randObj(t, nd, 100)
476 + fpath := gopath.Join(mntDir, fi.Cid().String())
477 +
478 + finfo, err := os.Stat(fpath)
479 + if err != nil {
480 + t.Fatal(err)
481 + }
482 + if finfo.Mode().Perm() != fusemnt.DefaultFileModeRO.Perm() {
483 + t.Fatalf("expected default mode %04o, got %04o", fusemnt.DefaultFileModeRO.Perm(), finfo.Mode().Perm())
484 + }
485 +}
486 +
487 +// Test that ipfs.cid xattr returns the correct CID for files and directories.
488 +func TestXattrCID(t *testing.T) {
489 + nd, _ := setupIpfsTest(t, nil)
490 +
491 + t.Run("file", func(t *testing.T) {
492 + obj, _ := randObj(t, nd, 100)
493 + node := &Node{ipfs: nd, nd: obj}
494 +
495 + dest := make([]byte, 256)
496 + sz, errno := node.Listxattr(t.Context(), dest)
497 + if errno != 0 {
498 + t.Fatalf("Listxattr: %v", errno)
499 + }
500 + if !bytes.Contains(dest[:sz], []byte(fusemnt.XattrCID)) {
501 + t.Fatal("ipfs.cid not listed")
502 + }
503 +
504 + sz, errno = node.Getxattr(t.Context(), fusemnt.XattrCID, dest)
505 + if errno != 0 {
506 + t.Fatalf("Getxattr: %v", errno)
507 + }
508 + if string(dest[:sz]) != obj.Cid().String() {
509 + t.Fatalf("expected CID %s, got %s", obj.Cid().String(), string(dest[:sz]))
510 + }
511 + })
512 +
513 + t.Run("directory", func(t *testing.T) {
514 + db, err := uio.NewDirectory(nd.DAG)
515 + if err != nil {
516 + t.Fatal(err)
517 + }
518 + dirNode, err := db.GetNode()
519 + if err != nil {
520 + t.Fatal(err)
521 + }
522 + if err := nd.DAG.Add(nd.Context(), dirNode); err != nil {
523 + t.Fatal(err)
524 + }
525 + node := &Node{ipfs: nd, nd: dirNode}
526 +
527 + dest := make([]byte, 256)
528 + sz, errno := node.Listxattr(t.Context(), dest)
529 + if errno != 0 {
530 + t.Fatalf("Listxattr: %v", errno)
531 + }
532 + if !bytes.Contains(dest[:sz], []byte(fusemnt.XattrCID)) {
533 + t.Fatal("ipfs.cid not listed")
534 + }
535 +
536 + sz, errno = node.Getxattr(t.Context(), fusemnt.XattrCID, dest)
537 + if errno != 0 {
538 + t.Fatalf("Getxattr: %v", errno)
539 + }
540 + if string(dest[:sz]) != dirNode.Cid().String() {
541 + t.Fatalf("expected CID %s, got %s", dirNode.Cid().String(), string(dest[:sz]))
542 + }
543 + })
544 +
545 +}
546 +
547 +// Test that symlinks in UnixFS are rendered via Readlink.
548 +func TestReadlink(t *testing.T) {
549 + nd, mntDir := setupIpfsTest(t, nil)
550 +
551 + // Build a directory containing a symlink.
552 + db, err := uio.NewDirectory(nd.DAG)
553 + if err != nil {
554 + t.Fatal(err)
555 + }
556 +
557 + target := "hello.txt"
558 + slData, err := ft.SymlinkData(target)
559 + if err != nil {
560 + t.Fatal(err)
561 + }
562 + symlinkNode := dag.NodeWithData(slData)
563 + if err := nd.DAG.Add(nd.Context(), symlinkNode); err != nil {
564 + t.Fatal(err)
565 + }
566 + if err := db.AddChild(nd.Context(), "link", symlinkNode); err != nil {
567 + t.Fatal(err)
568 + }
569 +
570 + dirNode, err := db.GetNode()
571 + if err != nil {
572 + t.Fatal(err)
573 + }
574 + if err := nd.DAG.Add(nd.Context(), dirNode); err != nil {
575 + t.Fatal(err)
576 + }
577 +
578 + linkPath := gopath.Join(mntDir, dirNode.Cid().String(), "link")
579 + got, err := os.Readlink(linkPath)
580 + if err != nil {
581 + t.Fatal(err)
582 + }
583 + if got != target {
584 + t.Fatalf("expected readlink %q, got %q", target, got)
585 + }
586 +}
587 +
588 +// Test that readdir reports symlinks with ModeSymlink so that
589 +// tools like ls -l and find -type l see the correct file type.
590 +func TestReaddirSymlink(t *testing.T) {
591 + nd, mntDir := setupIpfsTest(t, nil)
592 +
593 + db, err := uio.NewDirectory(nd.DAG)
594 + require.NoError(t, err)
595 +
596 + // Regular file child.
597 + fileData := []byte("hello")
598 + fileNode := dag.NodeWithData(ft.FilePBData(fileData, uint64(len(fileData))))
599 + require.NoError(t, nd.DAG.Add(nd.Context(), fileNode))
600 + require.NoError(t, db.AddChild(nd.Context(), "regular", fileNode))
601 +
602 + // Symlink child.
603 + slData, err := ft.SymlinkData("hello")
604 + require.NoError(t, err)
605 + symlinkNode := dag.NodeWithData(slData)
606 + require.NoError(t, nd.DAG.Add(nd.Context(), symlinkNode))
607 + require.NoError(t, db.AddChild(nd.Context(), "link", symlinkNode))
608 +
609 + dirNode, err := db.GetNode()
610 + require.NoError(t, err)
611 + require.NoError(t, nd.DAG.Add(nd.Context(), dirNode))
612 +
613 + entries, err := os.ReadDir(gopath.Join(mntDir, dirNode.Cid().String()))
614 + require.NoError(t, err)
615 +
616 + found := false
617 + for _, e := range entries {
618 + if e.Name() == "link" {
619 + require.NotZero(t, e.Type()&os.ModeSymlink, "readdir should report symlink type")
620 + found = true
621 + }
622 + if e.Name() == "regular" {
623 + require.Zero(t, e.Type()&os.ModeSymlink, "regular file should not have symlink type")
624 + }
625 + }
626 + require.True(t, found, "symlink entry not found in readdir")
627 +}
628 +
629 +// Test reading a slice from the middle of a file, skipping both
630 +// the beginning and the end.
631 +func TestSeekRead(t *testing.T) {
632 + nd, mntDir := setupIpfsTest(t, nil)
633 +
634 + obj, data := randObj(t, nd, 10000)
635 + fpath := gopath.Join(mntDir, obj.Cid().String())
636 +
637 + f, err := os.Open(fpath)
638 + if err != nil {
639 + t.Fatal(err)
640 + }
641 + defer f.Close()
642 +
643 + off := int64(3000)
644 + readLen := 2000
645 + if _, err := f.Seek(off, io.SeekStart); err != nil {
646 + t.Fatal(err)
647 + }
648 +
649 + buf := make([]byte, readLen)
650 + n, err := io.ReadFull(f, buf)
651 + if err != nil {
652 + t.Fatal(err)
653 + }
654 + if n != readLen {
655 + t.Fatalf("short read: got %d, want %d", n, readLen)
656 + }
657 + if !bytes.Equal(buf, data[off:off+int64(readLen)]) {
658 + t.Fatal("content mismatch for middle slice")
659 + }
660 +}
661 +
662 +// Test that concurrent reads of the same large file produce correct data.
663 +// The kernel sends multiple Read requests concurrently via readahead;
664 +// without a mutex on roFileHandle the DagReader's internal state
665 +// corrupts, causing data mismatches or panics.
666 +func TestConcurrentLargeFileRead(t *testing.T) {
667 + nd, mntDir := setupIpfsTest(t, nil)
668 +
669 + // 1 MiB + 1 byte: large enough to span multiple DAG nodes and
670 + // trigger concurrent kernel readahead requests.
671 + fi, data := randObj(t, nd, 1024*1024+1)
672 + fpath := gopath.Join(mntDir, fi.Cid().String())
673 +
674 + // Multiple goroutines opening and reading the same file exercises
675 + // both per-handle serialization (Seek+Read within one handle) and
676 + // independent handle isolation (separate DagReaders).
677 + var wg sync.WaitGroup
678 + for range 8 {
679 + wg.Go(func() {
680 + got, err := os.ReadFile(fpath)
681 + if err != nil {
682 + t.Errorf("ReadFile: %v", err)
683 + return
684 + }
685 + if !bytes.Equal(got, data) {
686 + t.Errorf("data mismatch: got %d bytes, want %d", len(got), len(data))
687 + }
688 + })
689 + }
690 + wg.Wait()
691 +}
692 +
693 +// blockingDagReader is a uio.DagReader that blocks in CtxReadFull until
694 +// the supplied context is cancelled. Used to verify that roFileHandle
695 +// propagates cancellation from FUSE down to the underlying reader.
696 +type blockingDagReader struct {
697 + entered chan struct{} // closed when CtxReadFull begins blocking
698 +}
699 +
700 +func (b *blockingDagReader) CtxReadFull(ctx context.Context, _ []byte) (int, error) {
701 + close(b.entered)
702 + <-ctx.Done()
703 + return 0, ctx.Err()
704 +}
705 +
706 +// Stub uio.DagReader methods that the test does not exercise. Returning
707 +// zero values keeps roFileHandle.Read on the CtxReadFull path.
708 +func (*blockingDagReader) Seek(int64, int) (int64, error) { return 0, nil }
709 +func (*blockingDagReader) Read([]byte) (int, error) { return 0, io.EOF }
710 +func (*blockingDagReader) Close() error { return nil }
711 +func (*blockingDagReader) WriteTo(io.Writer) (int64, error) { return 0, nil }
712 +func (*blockingDagReader) Size() uint64 { return 0 }
713 +func (*blockingDagReader) Mode() os.FileMode { return 0 }
714 +func (*blockingDagReader) ModTime() time.Time { return time.Time{} }
715 +
716 +var _ uio.DagReader = (*blockingDagReader)(nil)
717 +
718 +// TestReadCancellationUnblocks confirms that cancelling the context
719 +// passed to roFileHandle.Read returns promptly with EINTR. This guards
720 +// the "killing a stuck cat works" fix: the kernel sends FUSE_INTERRUPT
721 +// when a userspace process is killed mid-read, go-fuse cancels the
722 +// per-request context, and the read handler must propagate cancellation
723 +// down to the DagReader instead of blocking forever on a stuck fetch.
724 +func TestReadCancellationUnblocks(t *testing.T) {
725 + fake := &blockingDagReader{entered: make(chan struct{})}
726 + fh := &roFileHandle{r: fake}
727 +
728 + ctx, cancel := context.WithCancel(t.Context())
729 + defer cancel()
730 +
731 + type result struct {
732 + errno syscall.Errno
733 + }
734 + done := make(chan result, 1)
735 + go func() {
736 + buf := make([]byte, 4096)
737 + _, errno := fh.Read(ctx, buf, 0)
738 + done <- result{errno}
739 + }()
740 +
741 + // Wait for the fake reader to actually block on ctx.Done() before
742 + // cancelling, so the test exercises mid-read cancellation rather
743 + // than racing the goroutine start.
744 + select {
745 + case <-fake.entered:
746 + case <-time.After(5 * time.Second):
747 + t.Fatal("CtxReadFull never entered; cancellation path unreachable")
748 + }
749 +
750 + cancel() // simulates FUSE_INTERRUPT from the kernel
751 +
752 + select {
753 + case r := <-done:
754 + if r.errno != syscall.EINTR {
755 + t.Fatalf("expected EINTR after cancel, got errno %v", r.errno)
756 + }
757 + case <-time.After(5 * time.Second):
758 + t.Fatal("roFileHandle.Read did not return after ctx cancel; cancellation is not propagated")
759 + }
760 +}
761 +
762 +// Test that getxattr on an unknown attribute returns ENODATA (Linux) / ENOATTR.
763 +func TestUnknownXattr(t *testing.T) {
764 + nd, _ := setupIpfsTest(t, nil)
765 +
766 + obj, _ := randObj(t, nd, 100)
767 + node := &Node{ipfs: nd, nd: obj}
768 +
769 + dest := make([]byte, 256)
770 + _, errno := node.Getxattr(t.Context(), "user.bogus", dest)
771 + if errno == 0 {
772 + t.Fatal("expected error for unknown xattr, got success")
773 + }
774 +}
fuse/readonly/mount_unix.go
+23 -5
@@ -1,19 +1,37 @@
1 +// Mount/unmount helpers for the /ipfs FUSE mount. go-fuse only builds on linux, darwin, and freebsd.
2 //go:build (linux || darwin || freebsd) && !nofuse
3
4 package readonly
5
6 import (
7 + "os"
8 +
9 + "github.com/hanwen/go-fuse/v2/fs"
10 + "github.com/hanwen/go-fuse/v2/fuse"
11 + "github.com/ipfs/kubo/config"
12 core "github.com/ipfs/kubo/core"
7 - mount "github.com/ipfs/kubo/fuse/mount"
13 + fusemnt "github.com/ipfs/kubo/fuse/mount"
14 )
15
16 // Mount mounts IPFS at a given location, and returns a mount.Mount instance.
11 -func Mount(ipfs *core.IpfsNode, mountpoint string) (mount.Mount, error) {
17 +func Mount(ipfs *core.IpfsNode, mountpoint string) (fusemnt.Mount, error) {
18 cfg, err := ipfs.Repo.Config()
19 if err != nil {
20 return nil, err
21 }
16 - allowOther := cfg.Mounts.FuseAllowOther
17 - fsys := NewFileSystem(ipfs)
18 - return mount.NewMount(fsys, mountpoint, allowOther)
22 + root := NewRoot(ipfs)
23 + opts := &fs.Options{
24 + NullPermissions: true,
25 + UID: uint32(os.Getuid()),
26 + GID: uint32(os.Getgid()),
27 + AttrTimeout: &immutableAttrCacheTime,
28 + EntryTimeout: &immutableAttrCacheTime,
29 + MountOptions: fuse.MountOptions{
30 + AllowOther: cfg.Mounts.FuseAllowOther.WithDefault(config.DefaultFuseAllowOther),
31 + FsName: "ipfs",
32 + MaxReadAhead: fusemnt.MaxReadAhead,
33 + Debug: os.Getenv("IPFS_FUSE_DEBUG") != "",
34 + },
35 + }
36 + return fusemnt.NewMount(root, mountpoint, opts)
37 }
fuse/readonly/readonly_unix.go
+212 -147
@@ -1,17 +1,19 @@
1 +// FUSE filesystem for the read-only /ipfs mount. go-fuse only builds on linux, darwin, and freebsd.
2 //go:build (linux || darwin || freebsd) && !nofuse
3
4 package readonly
5
6 import (
7 "context"
7 - "fmt"
8 "io"
9 "os"
10 + "sync"
11 "syscall"
12 "time"
13
13 - fuse "bazil.org/fuse"
14 - fs "bazil.org/fuse/fs"
14 + "github.com/hanwen/go-fuse/v2/fs"
15 + "github.com/hanwen/go-fuse/v2/fuse"
16 + "github.com/ipfs/boxo/files"
17 mdag "github.com/ipfs/boxo/ipld/merkledag"
18 ft "github.com/ipfs/boxo/ipld/unixfs"
19 uio "github.com/ipfs/boxo/ipld/unixfs/io"
@@ -20,6 +22,7 @@ import (
22 ipld "github.com/ipfs/go-ipld-format"
23 logging "github.com/ipfs/go-log/v2"
24 core "github.com/ipfs/kubo/core"
25 + fusemnt "github.com/ipfs/kubo/fuse/mount"
26 cidlink "github.com/ipld/go-ipld-prime/linking/cid"
27 )
28
@@ -27,223 +30,244 @@ var log = logging.Logger("fuse/ipfs")
30
31 // /ipfs paths are immutable (content-addressed by CID), so the kernel
32 // can cache attributes and directory entries for as long as it wants.
30 -const immutableAttrCacheTime = 365 * 24 * time.Hour
33 +// var (not const) because fs.Options needs a *time.Duration.
34 +var immutableAttrCacheTime = 365 * 24 * time.Hour
35
32 -// FileSystem is the readonly IPFS Fuse Filesystem.
33 -type FileSystem struct {
34 - Ipfs *core.IpfsNode
35 -}
36 -
37 -// NewFileSystem constructs new fs using given core.IpfsNode instance.
38 -func NewFileSystem(ipfs *core.IpfsNode) *FileSystem {
39 - return &FileSystem{Ipfs: ipfs}
36 +// Root is the root object of the /ipfs filesystem tree.
37 +type Root struct {
38 + fs.Inode
39 + ipfs *core.IpfsNode
40 }
41
42 -// Root constructs the Root of the filesystem, a Root object.
43 -func (f FileSystem) Root() (fs.Node, error) {
44 - return &Root{Ipfs: f.Ipfs}, nil
42 +// NewRoot constructs a new readonly root node.
43 +func NewRoot(ipfs *core.IpfsNode) *Root {
44 + return &Root{ipfs: ipfs}
45 }
46
47 -// Root is the root object of the filesystem tree.
48 -type Root struct {
49 - Ipfs *core.IpfsNode
47 +func (*Root) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
48 + out.Attr.Mode = uint32(fusemnt.NamespaceRootMode.Perm())
49 + out.SetTimeout(immutableAttrCacheTime)
50 + return 0
51 }
52
52 -// Attr returns file attributes.
53 -func (*Root) Attr(ctx context.Context, a *fuse.Attr) error {
54 - a.Valid = immutableAttrCacheTime
55 - a.Mode = os.ModeDir | 0o111 // -rw+x
56 - a.Uid = uint32(os.Getuid())
57 - a.Gid = uint32(os.Getgid())
58 - return nil
59 -}
60 -
61 -// Lookup performs a lookup under this node.
62 -func (s *Root) Lookup(ctx context.Context, name string) (fs.Node, error) {
53 +func (r *Root) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
54 log.Debugf("Root Lookup: '%s'", name)
55 switch name {
56 case "mach_kernel", ".hidden", "._.":
66 - // Just quiet some log noise on OS X.
67 - return nil, syscall.Errno(syscall.ENOENT)
57 + return nil, syscall.ENOENT
58 }
59
60 p, err := path.NewPath("/ipfs/" + name)
61 if err != nil {
62 log.Debugf("fuse failed to parse path: %q: %s", name, err)
73 - return nil, syscall.Errno(syscall.ENOENT)
63 + return nil, syscall.ENOENT
64 }
65
66 imPath, err := path.NewImmutablePath(p)
67 if err != nil {
68 log.Debugf("fuse failed to convert path: %q: %s", name, err)
79 - return nil, syscall.Errno(syscall.ENOENT)
69 + return nil, syscall.ENOENT
70 }
71
82 - nd, ndLnk, err := s.Ipfs.UnixFSPathResolver.ResolvePath(ctx, imPath)
72 + nd, ndLnk, err := r.ipfs.UnixFSPathResolver.ResolvePath(ctx, imPath)
73 if err != nil {
84 - // todo: make this error more versatile.
85 - return nil, syscall.Errno(syscall.ENOENT)
74 + return nil, syscall.ENOENT
75 }
76
77 cidLnk, ok := ndLnk.(cidlink.Link)
78 if !ok {
79 log.Debugf("non-cidlink returned from ResolvePath: %v", ndLnk)
91 - return nil, syscall.Errno(syscall.ENOENT)
80 + return nil, syscall.ENOENT
81 }
82
94 - // Build a legacy ipld.Node from the raw block so the rest of the
95 - // FUSE code (Attr, ReadDirAll, Read) can work with it.
96 - blk, err := s.Ipfs.Blockstore.Get(ctx, cidLnk.Cid)
83 + blk, err := r.ipfs.Blockstore.Get(ctx, cidLnk.Cid)
84 if err != nil {
85 log.Debugf("fuse failed to retrieve block: %v: %s", cidLnk, err)
99 - return nil, syscall.Errno(syscall.ENOENT)
86 + return nil, syscall.ENOENT
87 }
88
89 var fnd ipld.Node
90 switch cidLnk.Cid.Prefix().Codec {
91 case cid.DagProtobuf:
105 - // Decode directly from block bytes. UnixFS files are
106 - // represented as ADLs in ipld-prime, and their substrate
107 - // type is not a dagpb.PBNode, so ProtoNodeConverter fails
108 - // for them. Decoding from bytes works for all dag-pb blocks
109 - // (files, directories, HAMT shards, symlinks, raw).
92 fnd, err = mdag.DecodeProtobuf(blk.RawData())
93 case cid.Raw:
94 fnd, err = mdag.RawNodeConverter(blk, nd)
95 default:
96 log.Error("fuse node was not a supported type")
115 - return nil, syscall.Errno(syscall.ENOTSUP)
97 + return nil, syscall.ENOTSUP
98 }
99 if err != nil {
100 log.Errorf("could not decode block as protobuf or raw node: %s", err)
119 - return nil, syscall.Errno(syscall.ENOENT)
101 + return nil, syscall.ENOENT
102 }
103
122 - return &Node{Ipfs: s.Ipfs, Nd: fnd}, nil
104 + child := &Node{ipfs: r.ipfs, nd: fnd}
105 + stable := stableAttrFor(child)
106 +
107 + // Fill attrs in the lookup response so the kernel doesn't cache zeros.
108 + child.fillAttr(&out.Attr)
109 + out.SetEntryTimeout(immutableAttrCacheTime)
110 + out.SetAttrTimeout(immutableAttrCacheTime)
111 + return r.NewInode(ctx, child, stable), 0
112 }
113
125 -// ReadDirAll reads a particular directory. Disallowed for root.
126 -func (*Root) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
127 - log.Debug("read Root")
128 - return nil, syscall.Errno(syscall.EPERM)
114 +// Readdir on the namespace root is not allowed (execute-only).
115 +func (*Root) Readdir(_ context.Context) (fs.DirStream, syscall.Errno) {
116 + return nil, syscall.EPERM
117 }
118
119 // Node is the core object representing a filesystem tree node.
120 type Node struct {
133 - Ipfs *core.IpfsNode
134 - Nd ipld.Node
121 + fs.Inode
122 + ipfs *core.IpfsNode
123 + nd ipld.Node
124 cached *ft.FSNode
125 }
126
138 -func (s *Node) loadData() error {
139 - if pbnd, ok := s.Nd.(*mdag.ProtoNode); ok {
127 +func (n *Node) loadData() error {
128 + if pbnd, ok := n.nd.(*mdag.ProtoNode); ok {
129 fsn, err := ft.FSNodeFromBytes(pbnd.Data())
130 if err != nil {
131 return err
132 }
144 - s.cached = fsn
133 + n.cached = fsn
134 }
135 return nil
136 }
137
149 -// Attr returns the attributes of a given node.
150 -// TODO: use Mode and Mtime from UnixFS record if present
151 -func (s *Node) Attr(ctx context.Context, a *fuse.Attr) error {
138 +func (n *Node) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
139 log.Debug("Node attr")
153 - a.Valid = immutableAttrCacheTime
154 - a.Uid = uint32(os.Getuid())
155 - a.Gid = uint32(os.Getgid())
156 - if rawnd, ok := s.Nd.(*mdag.RawNode); ok {
157 - a.Mode = 0o444
140 + out.SetTimeout(immutableAttrCacheTime)
141 + n.fillAttr(&out.Attr)
142 + return 0
143 +}
144 +
145 +// Open creates a DagReader that is reused across sequential Read
146 +// calls, avoiding re-traversal of the DAG from the root on each read.
147 +func (n *Node) Open(ctx context.Context, _ uint32) (fs.FileHandle, uint32, syscall.Errno) {
148 + r, err := uio.NewDagReader(ctx, n.nd, n.ipfs.DAG)
149 + if err != nil {
150 + return nil, 0, fusemnt.ReadErrno(err)
151 + }
152 + return &roFileHandle{r: r}, fuse.FOPEN_KEEP_CACHE, 0
153 +}
154 +
155 +// roFileHandle holds a DagReader for the lifetime of an open file.
156 +// All methods are serialized by mu because the FUSE server dispatches
157 +// each request in its own goroutine and the underlying DagReader is
158 +// not safe for concurrent use.
159 +type roFileHandle struct {
160 + r uio.DagReader
161 + mu sync.Mutex
162 +}
163 +
164 +// fillAttr populates a fuse.Attr from this node's UnixFS metadata.
165 +// Used by both Getattr and Lookup (to fill EntryOut.Attr so the kernel
166 +// doesn't cache zero values for the entry timeout duration).
167 +func (n *Node) fillAttr(a *fuse.Attr) {
168 + if rawnd, ok := n.nd.(*mdag.RawNode); ok {
169 + a.Mode = uint32(fusemnt.DefaultFileModeRO.Perm())
170 a.Size = uint64(len(rawnd.RawData()))
171 a.Blocks = 1
160 - return nil
172 + return
173 }
174
163 - if s.cached == nil {
164 - if err := s.loadData(); err != nil {
165 - return fmt.Errorf("readonly: loadData() failed: %s", err)
175 + if n.cached == nil {
176 + if err := n.loadData(); err != nil {
177 + log.Errorf("readonly: loadData() failed: %s", err)
178 + return
179 }
180 }
168 - switch s.cached.Type() {
181 +
182 + switch n.cached.Type() {
183 case ft.TDirectory, ft.THAMTShard:
170 - a.Mode = os.ModeDir | 0o555
184 + a.Mode = uint32(fusemnt.DefaultDirModeRO.Perm())
185 case ft.TFile:
172 - size := s.cached.FileSize()
173 - a.Mode = 0o444
174 - a.Size = uint64(size)
175 - a.Blocks = uint64(len(s.Nd.Links()))
186 + a.Mode = uint32(fusemnt.DefaultFileModeRO.Perm())
187 + a.Size = n.cached.FileSize()
188 + a.Blocks = uint64(len(n.nd.Links()))
189 case ft.TRaw:
177 - a.Mode = 0o444
178 - a.Size = uint64(len(s.cached.Data()))
179 - a.Blocks = uint64(len(s.Nd.Links()))
190 + a.Mode = uint32(fusemnt.DefaultFileModeRO.Perm())
191 + a.Size = uint64(len(n.cached.Data()))
192 + a.Blocks = uint64(len(n.nd.Links()))
193 case ft.TSymlink:
181 - a.Mode = 0o777 | os.ModeSymlink
182 - a.Size = uint64(len(s.cached.Data()))
194 + a.Mode = uint32(fusemnt.SymlinkMode.Perm())
195 + a.Size = uint64(len(n.cached.Data()))
196 default:
184 - return fmt.Errorf("invalid data type - %s", s.cached.Type())
197 + log.Errorf("invalid data type: %s", n.cached.Type())
198 + return
199 + }
200 +
201 + // Use mode and mtime from UnixFS metadata when present.
202 + if m := n.cached.Mode(); m != 0 {
203 + a.Mode = files.ModePermsToUnixPerms(m)
204 + }
205 + if t := n.cached.ModTime(); !t.IsZero() {
206 + a.SetTimes(nil, &t, nil)
207 }
186 - return nil
208 }
209
189 -// Lookup performs a lookup under this node.
190 -func (s *Node) Lookup(ctx context.Context, name string) (fs.Node, error) {
210 +func (n *Node) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
211 log.Debugf("Lookup '%s'", name)
192 - link, _, err := uio.ResolveUnixfsOnce(ctx, s.Ipfs.DAG, s.Nd, []string{name})
212 + link, _, err := uio.ResolveUnixfsOnce(ctx, n.ipfs.DAG, n.nd, []string{name})
213 switch err {
214 case os.ErrNotExist, mdag.ErrLinkNotFound:
195 - // todo: make this error more versatile.
196 - return nil, syscall.Errno(syscall.ENOENT)
215 + return nil, syscall.ENOENT
216 case nil:
198 - // noop
217 default:
218 log.Errorf("fuse lookup %q: %s", name, err)
201 - return nil, syscall.Errno(syscall.EIO)
219 + return nil, syscall.EIO
220 }
221
204 - nd, err := s.Ipfs.DAG.Get(ctx, link.Cid)
222 + nd, err := n.ipfs.DAG.Get(ctx, link.Cid)
223 if err != nil && !ipld.IsNotFound(err) {
224 log.Errorf("fuse lookup %q: %s", name, err)
207 - return nil, err
225 + return nil, syscall.EIO
226 }
227
210 - return &Node{Ipfs: s.Ipfs, Nd: nd}, nil
228 + child := &Node{ipfs: n.ipfs, nd: nd}
229 + stable := stableAttrFor(child)
230 +
231 + child.fillAttr(&out.Attr)
232 + out.SetEntryTimeout(immutableAttrCacheTime)
233 + out.SetAttrTimeout(immutableAttrCacheTime)
234 + return n.NewInode(ctx, child, stable), 0
235 }
236
213 -// ReadDirAll reads the link structure as directory entries.
214 -func (s *Node) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
237 +func (n *Node) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) {
238 log.Debug("Node ReadDir")
216 - dir, err := uio.NewDirectoryFromNode(s.Ipfs.DAG, s.Nd)
239 + dir, err := uio.NewDirectoryFromNode(n.ipfs.DAG, n.nd)
240 if err != nil {
218 - return nil, err
241 + return nil, fusemnt.ReadErrno(err)
242 }
243
221 - var entries []fuse.Dirent
244 + var entries []fuse.DirEntry
245 err = dir.ForEachLink(ctx, func(lnk *ipld.Link) error {
223 - n := lnk.Name
224 - if len(n) == 0 {
225 - n = lnk.Cid.String()
246 + name := lnk.Name
247 + if len(name) == 0 {
248 + name = lnk.Cid.String()
249 }
227 - nd, err := s.Ipfs.DAG.Get(ctx, lnk.Cid)
250 + nd, err := n.ipfs.DAG.Get(ctx, lnk.Cid)
251 if err != nil {
252 log.Warn("error fetching directory child node: ", err)
253 + return err
254 }
255
232 - t := fuse.DT_Unknown
256 + var mode uint32
257 switch nd := nd.(type) {
258 case *mdag.RawNode:
235 - t = fuse.DT_File
259 + // regular file (mode 0 = S_IFREG)
260 case *mdag.ProtoNode:
261 if fsn, err := ft.FSNodeFromBytes(nd.Data()); err != nil {
262 log.Warn("failed to unmarshal protonode data field:", err)
263 } else {
264 switch fsn.Type() {
265 case ft.TDirectory, ft.THAMTShard:
242 - t = fuse.DT_Dir
266 + mode = syscall.S_IFDIR
267 case ft.TFile, ft.TRaw:
244 - t = fuse.DT_File
268 + // regular file
269 case ft.TSymlink:
246 - t = fuse.DT_Link
270 + mode = syscall.S_IFLNK
271 case ft.TMetadata:
272 log.Error("metadata object in fuse should contain its wrapped type")
273 default:
@@ -251,67 +275,108 @@ func (s *Node) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
275 }
276 }
277 }
254 - entries = append(entries, fuse.Dirent{Name: n, Type: t})
278 + entries = append(entries, fuse.DirEntry{Name: name, Mode: mode})
279 return nil
280 })
281 if err != nil {
258 - return nil, err
282 + return nil, fusemnt.ReadErrno(err)
283 }
284
261 - return entries, nil
285 + return fs.NewListDirStream(entries), 0
286 }
287
264 -func (s *Node) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error {
265 - // TODO: is nil the right response for 'bug off, we ain't got none' ?
266 - resp.Xattr = nil
267 - return nil
288 +func (n *Node) Listxattr(_ context.Context, dest []byte) (uint32, syscall.Errno) {
289 + // Null-terminated list of attribute names.
290 + data := []byte(fusemnt.XattrCID + "\x00")
291 + if len(dest) == 0 {
292 + return uint32(len(data)), 0
293 + }
294 + if len(dest) < len(data) {
295 + return 0, syscall.ERANGE
296 + }
297 + return uint32(copy(dest, data)), 0
298 }
299
270 -func (s *Node) Readlink(ctx context.Context, req *fuse.ReadlinkRequest) (string, error) {
271 - if s.cached == nil || s.cached.Type() != ft.TSymlink {
272 - return "", fuse.Errno(syscall.EINVAL)
300 +func (n *Node) Getxattr(_ context.Context, attr string, dest []byte) (uint32, syscall.Errno) {
301 + if attr == fusemnt.XattrCIDDeprecated {
302 + log.Errorf("xattr %q is deprecated, use %q instead", fusemnt.XattrCIDDeprecated, fusemnt.XattrCID)
303 + attr = fusemnt.XattrCID
304 }
274 - return string(s.cached.Data()), nil
305 + if attr != fusemnt.XattrCID {
306 + return 0, fs.ENOATTR
307 + }
308 + data := []byte(n.nd.Cid().String())
309 + if len(dest) == 0 {
310 + return uint32(len(data)), 0
311 + }
312 + if len(dest) < len(data) {
313 + return 0, syscall.ERANGE
314 + }
315 + return uint32(copy(dest, data)), 0
316 }
317
277 -func (s *Node) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
278 - r, err := uio.NewDagReader(ctx, s.Nd, s.Ipfs.DAG)
279 - if err != nil {
280 - return err
318 +func (n *Node) Readlink(_ context.Context) ([]byte, syscall.Errno) {
319 + if n.cached == nil || n.cached.Type() != ft.TSymlink {
320 + return nil, syscall.EINVAL
321 }
282 - _, err = r.Seek(req.Offset, io.SeekStart)
283 - if err != nil {
284 - return err
322 + return n.cached.Data(), 0
323 +}
324 +
325 +func (fh *roFileHandle) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
326 + fh.mu.Lock()
327 + defer fh.mu.Unlock()
328 +
329 + if _, err := fh.r.Seek(off, io.SeekStart); err != nil {
330 + return nil, fusemnt.ReadErrno(err)
331 }
286 - // Data has a capacity of Size
287 - buf := resp.Data[:int(req.Size)]
288 - n, err := io.ReadFull(r, buf)
289 - resp.Data = buf[:n]
332 + n, err := fh.r.CtxReadFull(ctx, dest)
333 switch err {
334 case nil, io.EOF, io.ErrUnexpectedEOF:
335 default:
293 - return err
336 + return nil, fusemnt.ReadErrno(err)
337 }
295 - resp.Data = resp.Data[:n]
296 - return nil // may be non-nil / not succeeded
338 + return fuse.ReadResultData(dest[:n]), 0
339 }
340
299 -// to check that our Node implements all the interfaces we want.
300 -type roRoot interface {
301 - fs.Node
302 - fs.HandleReadDirAller
303 - fs.NodeStringLookuper
304 -}
341 +func (fh *roFileHandle) Release(_ context.Context) syscall.Errno {
342 + fh.mu.Lock()
343 + defer fh.mu.Unlock()
344
306 -var _ roRoot = (*Root)(nil)
345 + return fs.ToErrno(fh.r.Close())
346 +}
347
308 -type roNode interface {
309 - fs.HandleReadDirAller
310 - fs.HandleReader
311 - fs.Node
312 - fs.NodeStringLookuper
313 - fs.NodeReadlinker
314 - fs.NodeGetxattrer
348 +// stableAttrFor returns the StableAttr (file type bits) for a Node.
349 +func stableAttrFor(n *Node) fs.StableAttr {
350 + if _, ok := n.nd.(*mdag.RawNode); ok {
351 + return fs.StableAttr{} // S_IFREG
352 + }
353 + if n.cached == nil {
354 + _ = n.loadData()
355 + }
356 + if n.cached != nil {
357 + switch n.cached.Type() {
358 + case ft.TDirectory, ft.THAMTShard:
359 + return fs.StableAttr{Mode: syscall.S_IFDIR}
360 + case ft.TSymlink:
361 + return fs.StableAttr{Mode: syscall.S_IFLNK}
362 + }
363 + }
364 + return fs.StableAttr{} // S_IFREG
365 }
366
317 -var _ roNode = (*Node)(nil)
367 +// Interface checks.
368 +var (
369 + _ fs.NodeGetattrer = (*Root)(nil)
370 + _ fs.NodeLookuper = (*Root)(nil)
371 + _ fs.NodeReaddirer = (*Root)(nil)
372 + _ fs.NodeGetattrer = (*Node)(nil)
373 + _ fs.NodeLookuper = (*Node)(nil)
374 + _ fs.NodeOpener = (*Node)(nil)
375 + _ fs.NodeReaddirer = (*Node)(nil)
376 + _ fs.NodeReadlinker = (*Node)(nil)
377 + _ fs.NodeGetxattrer = (*Node)(nil)
378 + _ fs.NodeListxattrer = (*Node)(nil)
379 +
380 + _ fs.FileReader = (*roFileHandle)(nil)
381 + _ fs.FileReleaser = (*roFileHandle)(nil)
382 +)
fuse/writable/writable.go new
+770
@@ -0,0 +1,770 @@
1 +// Package writable implements FUSE filesystem types shared by the
2 +// mutable /mfs and /ipns mounts. Both mounts expose MFS directories
3 +// as writable POSIX filesystems; the only differences are how the
4 +// root is created and how xattr names are published.
5 +//
6 +//go:build (linux || darwin || freebsd) && !nofuse
7 +
8 +package writable
9 +
10 +import (
11 + "context"
12 + "io"
13 + "os"
14 + "sync"
15 + "syscall"
16 + "time"
17 +
18 + "github.com/hanwen/go-fuse/v2/fs"
19 + "github.com/hanwen/go-fuse/v2/fuse"
20 +
21 + "github.com/ipfs/boxo/files"
22 + dag "github.com/ipfs/boxo/ipld/merkledag"
23 + ft "github.com/ipfs/boxo/ipld/unixfs"
24 + uio "github.com/ipfs/boxo/ipld/unixfs/io"
25 + "github.com/ipfs/boxo/mfs"
26 + ipld "github.com/ipfs/go-ipld-format"
27 + logging "github.com/ipfs/go-log/v2"
28 + fusemnt "github.com/ipfs/kubo/fuse/mount"
29 +)
30 +
31 +var log = logging.Logger("fuse/writable")
32 +
33 +// Config controls write-side behavior for writable mounts.
34 +type Config struct {
35 + StoreMtime bool // persist mtime on create and open-for-write
36 + StoreMode bool // persist mode on chmod
37 + DAG ipld.DAGService // required: read-only opens use it to bypass MFS desclock
38 +}
39 +
40 +// NewDir creates a Dir node backed by the given MFS directory.
41 +// cfg.DAG is required: read-only file opens build a DagReader directly
42 +// from it to avoid MFS's desclock (see FileInode.Open). Passing a nil
43 +// DAG would silently re-introduce the rsync --inplace deadlock, so we
44 +// fail loudly at construction time instead.
45 +func NewDir(d *mfs.Directory, cfg *Config) *Dir {
46 + if cfg == nil || cfg.DAG == nil {
47 + panic("fuse/writable: Config.DAG is required")
48 + }
49 + return &Dir{MFSDir: d, Cfg: cfg}
50 +}
51 +
52 +// Dir is the FUSE adapter for MFS directories.
53 +type Dir struct {
54 + fs.Inode
55 + MFSDir *mfs.Directory
56 + Cfg *Config
57 +}
58 +
59 +func (d *Dir) fillAttr(a *fuse.Attr) {
60 + a.Mode = uint32(fusemnt.DefaultDirModeRW.Perm())
61 + if m, err := d.MFSDir.Mode(); err == nil && m != 0 {
62 + a.Mode = files.ModePermsToUnixPerms(m)
63 + }
64 + if t, err := d.MFSDir.ModTime(); err == nil && !t.IsZero() {
65 + a.SetTimes(nil, &t, nil)
66 + }
67 +}
68 +
69 +func (d *Dir) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
70 + d.fillAttr(&out.Attr)
71 + return 0
72 +}
73 +
74 +// Setattr handles chmod and mtime changes on directories.
75 +// Tools like tar and rsync set directory timestamps after extraction.
76 +//
77 +// Mode and mtime are stored as UnixFS optional metadata.
78 +// The UnixFS spec supports all 12 permission bits, but boxo's MFS
79 +// layer exposes only the lower 9 (ugo-rwx); setuid/setgid/sticky
80 +// are silently dropped. FUSE mounts are always nosuid so these
81 +// bits would have no execution effect anyway.
82 +// See https://specs.ipfs.tech/unixfs/#dag-pb-optional-metadata
83 +func (d *Dir) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
84 + if mode, ok := in.GetMode(); ok && d.Cfg.StoreMode {
85 + if err := d.MFSDir.SetMode(files.UnixPermsToModePerms(mode)); err != nil {
86 + return fs.ToErrno(err)
87 + }
88 + }
89 + if mtime, ok := in.GetMTime(); ok && d.Cfg.StoreMtime {
90 + if err := d.MFSDir.SetModTime(mtime); err != nil {
91 + return fs.ToErrno(err)
92 + }
93 + }
94 + d.fillAttr(&out.Attr)
95 + return 0
96 +}
97 +
98 +func (d *Dir) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
99 + mfsNode, err := d.MFSDir.Child(name)
100 + if err != nil {
101 + return nil, syscall.ENOENT
102 + }
103 +
104 + switch mfsNode.Type() {
105 + case mfs.TDir:
106 + child := &Dir{MFSDir: mfsNode.(*mfs.Directory), Cfg: d.Cfg}
107 + child.fillAttr(&out.Attr)
108 + return d.NewInode(ctx, child, fs.StableAttr{Mode: syscall.S_IFDIR}), 0
109 + case mfs.TFile:
110 + mfsFile := mfsNode.(*mfs.File)
111 + if target := SymlinkTarget(mfsFile); target != "" {
112 + child := &Symlink{Target: target, MFSFile: mfsFile, Cfg: d.Cfg}
113 + child.fillAttr(&out.Attr)
114 + return d.NewInode(ctx, child, fs.StableAttr{Mode: syscall.S_IFLNK}), 0
115 + }
116 + child := &FileInode{MFSFile: mfsFile, Cfg: d.Cfg}
117 + child.fillAttr(&out.Attr)
118 + return d.NewInode(ctx, child, fs.StableAttr{}), 0
119 + default:
120 + log.Errorf("unexpected MFS node type %d under directory", mfsNode.Type())
121 + return nil, syscall.EIO
122 + }
123 +}
124 +
125 +func (d *Dir) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) {
126 + nodes, err := d.MFSDir.List(ctx)
127 + if err != nil {
128 + return nil, fs.ToErrno(err)
129 + }
130 +
131 + entries := make([]fuse.DirEntry, len(nodes))
132 + for i, node := range nodes {
133 + var mode uint32
134 + switch {
135 + case node.Type == int(mfs.TDir):
136 + mode = syscall.S_IFDIR
137 + case node.Type == int(mfs.TFile):
138 + // MFS represents symlinks as TFile; check the DAG node.
139 + if child, err := d.MFSDir.Child(node.Name); err == nil {
140 + if f, ok := child.(*mfs.File); ok && SymlinkTarget(f) != "" {
141 + mode = syscall.S_IFLNK
142 + }
143 + }
144 + }
145 + entries[i] = fuse.DirEntry{Name: node.Name, Mode: mode}
146 + }
147 + return fs.NewListDirStream(entries), 0
148 +}
149 +
150 +// Mkdir creates a new directory under d.
151 +//
152 +// TODO: boxo's mfs.Directory.Mkdir(name string) accepts no mode
153 +// argument, so the caller's mode is silently dropped here. Tools
154 +// that mkdir then chown without a follow-up chmod (some tar/rsync
155 +// flows) see the default 0755 instead of the requested mode.
156 +// Fixing this requires a boxo MFS API change.
157 +func (d *Dir) Mkdir(ctx context.Context, name string, _ uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
158 + mfsDir, err := d.MFSDir.Mkdir(name)
159 + if err != nil {
160 + return nil, fs.ToErrno(err)
161 + }
162 + child := &Dir{MFSDir: mfsDir, Cfg: d.Cfg}
163 + // Fill the response attrs so the kernel doesn't cache zero values
164 + // until AttrTimeout expires. Matches Dir.Create and FileInode.Setattr.
165 + child.fillAttr(&out.Attr)
166 + return d.NewInode(ctx, child, fs.StableAttr{Mode: syscall.S_IFDIR}), 0
167 +}
168 +
169 +func (d *Dir) Unlink(_ context.Context, name string) syscall.Errno {
170 + if err := d.MFSDir.Unlink(name); err != nil {
171 + return fs.ToErrno(err)
172 + }
173 + return fs.ToErrno(d.MFSDir.Flush())
174 +}
175 +
176 +func (d *Dir) Rmdir(ctx context.Context, name string) syscall.Errno {
177 + child, err := d.MFSDir.Child(name)
178 + if err != nil {
179 + return fs.ToErrno(err)
180 + }
181 + target, ok := child.(*mfs.Directory)
182 + if !ok {
183 + return syscall.ENOTDIR
184 + }
185 +
186 + children, err := target.ListNames(ctx)
187 + if err != nil {
188 + return fs.ToErrno(err)
189 + }
190 + if len(children) > 0 {
191 + return syscall.ENOTEMPTY
192 + }
193 +
194 + if err := d.MFSDir.Unlink(name); err != nil {
195 + return fs.ToErrno(err)
196 + }
197 + return fs.ToErrno(d.MFSDir.Flush())
198 +}
199 +
200 +// Rename moves an entry across MFS directories.
201 +//
202 +// TODO: this is not atomic. The source is unlinked before the
203 +// destination is added, so any failure between the two steps loses
204 +// the source entry. Making it atomic requires changes to MFS rename
205 +// semantics (boxo/mfs does not currently expose an atomic rename).
206 +func (d *Dir) Rename(_ context.Context, oldName string, newParent fs.InodeEmbedder, newName string, _ uint32) syscall.Errno {
207 + child, err := d.MFSDir.Child(oldName)
208 + if err != nil {
209 + return fs.ToErrno(err)
210 + }
211 +
212 + nd, err := child.GetNode()
213 + if err != nil {
214 + return fs.ToErrno(err)
215 + }
216 +
217 + // Unlink the source first. For same-directory renames, this clears
218 + // the old name from the directory's entry cache before AddChild
219 + // repopulates it with the new name. Without this ordering, Flush
220 + // would sync the stale cache entry back into the DAG.
221 + if err := d.MFSDir.Unlink(oldName); err != nil {
222 + return fs.ToErrno(err)
223 + }
224 +
225 + targetDir, ok := newParent.EmbeddedInode().Operations().(*Dir)
226 + if !ok {
227 + return syscall.EINVAL
228 + }
229 + if err := targetDir.MFSDir.Unlink(newName); err != nil && err != os.ErrNotExist {
230 + return fs.ToErrno(err)
231 + }
232 + if err := targetDir.MFSDir.AddChild(newName, nd); err != nil {
233 + return fs.ToErrno(err)
234 + }
235 +
236 + return fs.ToErrno(d.MFSDir.Flush())
237 +}
238 +
239 +func (d *Dir) Create(ctx context.Context, name string, flags uint32, _ uint32, out *fuse.EntryOut) (*fs.Inode, fs.FileHandle, uint32, syscall.Errno) {
240 + node := dag.NodeWithData(ft.FilePBData(nil, 0))
241 + if err := node.SetCidBuilder(d.MFSDir.GetCidBuilder()); err != nil {
242 + return nil, nil, 0, fs.ToErrno(err)
243 + }
244 +
245 + if err := d.MFSDir.AddChild(name, node); err != nil {
246 + return nil, nil, 0, fs.ToErrno(err)
247 + }
248 +
249 + if err := d.MFSDir.Flush(); err != nil {
250 + return nil, nil, 0, fs.ToErrno(err)
251 + }
252 +
253 + mfsNode, err := d.MFSDir.Child(name)
254 + if err != nil {
255 + return nil, nil, 0, fs.ToErrno(err)
256 + }
257 + if d.Cfg.StoreMtime {
258 + if err := mfsNode.SetModTime(time.Now()); err != nil {
259 + return nil, nil, 0, fs.ToErrno(err)
260 + }
261 + }
262 +
263 + mfsFile, ok := mfsNode.(*mfs.File)
264 + if !ok {
265 + return nil, nil, 0, syscall.EIO
266 + }
267 + fileInode := &FileInode{MFSFile: mfsFile, Cfg: d.Cfg}
268 +
269 + accessMode := flags & syscall.O_ACCMODE
270 + fd, err := mfsFile.Open(mfs.Flags{
271 + Read: accessMode == syscall.O_RDONLY || accessMode == syscall.O_RDWR,
272 + Write: accessMode == syscall.O_WRONLY || accessMode == syscall.O_RDWR,
273 + Sync: true,
274 + })
275 + if err != nil {
276 + return nil, nil, 0, fs.ToErrno(err)
277 + }
278 +
279 + // Fill the response attrs so the kernel doesn't cache zero values
280 + // (mode 0, size 0) for the new inode until AttrTimeout expires.
281 + // fstat on the open file handle returned to the caller hits this
282 + // cache, so leaving it empty makes f.Stat() report mode 0 right
283 + // after open. Matches FileInode.Setattr and Dir.Mkdir.
284 + fileInode.fillAttr(&out.Attr)
285 +
286 + inode := d.NewInode(ctx, fileInode, fs.StableAttr{})
287 + return inode, &FileHandle{inode: inode, fd: fd}, 0, 0
288 +}
289 +
290 +func (d *Dir) Listxattr(_ context.Context, dest []byte) (uint32, syscall.Errno) {
291 + data := []byte(fusemnt.XattrCID + "\x00")
292 + if len(dest) == 0 {
293 + return uint32(len(data)), 0
294 + }
295 + if len(dest) < len(data) {
296 + return 0, syscall.ERANGE
297 + }
298 + return uint32(copy(dest, data)), 0
299 +}
300 +
301 +func (d *Dir) Getxattr(_ context.Context, attr string, dest []byte) (uint32, syscall.Errno) {
302 + if attr == fusemnt.XattrCIDDeprecated {
303 + log.Errorf("xattr %q is deprecated, use %q instead", fusemnt.XattrCIDDeprecated, fusemnt.XattrCID)
304 + attr = fusemnt.XattrCID
305 + }
306 + if attr != fusemnt.XattrCID {
307 + return 0, fs.ENOATTR
308 + }
309 + nd, err := d.MFSDir.GetNode()
310 + if err != nil {
311 + return 0, fs.ToErrno(err)
312 + }
313 + data := []byte(nd.Cid().String())
314 + if len(dest) == 0 {
315 + return uint32(len(data)), 0
316 + }
317 + if len(dest) < len(data) {
318 + return 0, syscall.ERANGE
319 + }
320 + return uint32(copy(dest, data)), 0
321 +}
322 +
323 +// Symlink creates a new symlink in this directory.
324 +func (d *Dir) Symlink(ctx context.Context, target, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
325 + data, err := ft.SymlinkData(target)
326 + if err != nil {
327 + return nil, fs.ToErrno(err)
328 + }
329 + nd := dag.NodeWithData(data)
330 + if err := nd.SetCidBuilder(d.MFSDir.GetCidBuilder()); err != nil {
331 + return nil, fs.ToErrno(err)
332 + }
333 + if err := d.MFSDir.AddChild(name, nd); err != nil {
334 + return nil, fs.ToErrno(err)
335 + }
336 + if err := d.MFSDir.Flush(); err != nil {
337 + return nil, fs.ToErrno(err)
338 + }
339 +
340 + // Retrieve the mfs.File so Setattr can persist mtime.
341 + mfsNode, err := d.MFSDir.Child(name)
342 + if err != nil {
343 + return nil, fs.ToErrno(err)
344 + }
345 + mfsFile, _ := mfsNode.(*mfs.File)
346 +
347 + sym := &Symlink{Target: target, MFSFile: mfsFile, Cfg: d.Cfg}
348 + sym.fillAttr(&out.Attr)
349 + return d.NewInode(ctx, sym, fs.StableAttr{Mode: syscall.S_IFLNK}), 0
350 +}
351 +
352 +// FileInode is the FUSE adapter for MFS file inodes.
353 +type FileInode struct {
354 + fs.Inode
355 + MFSFile *mfs.File
356 + Cfg *Config
357 +}
358 +
359 +func (fi *FileInode) fillAttr(a *fuse.Attr) {
360 + size, _ := fi.MFSFile.Size()
361 + a.Size = uint64(size)
362 + a.Mode = uint32(fusemnt.DefaultFileModeRW.Perm())
363 + if m, err := fi.MFSFile.Mode(); err == nil && m != 0 {
364 + a.Mode = files.ModePermsToUnixPerms(m)
365 + }
366 + if t, _ := fi.MFSFile.ModTime(); !t.IsZero() {
367 + a.SetTimes(nil, &t, nil)
368 + }
369 +}
370 +
371 +func (fi *FileInode) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
372 + fi.fillAttr(&out.Attr)
373 + return 0
374 +}
375 +
376 +func (fi *FileInode) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
377 + accessMode := flags & syscall.O_ACCMODE
378 +
379 + // Read-only opens bypass MFS's desclock by creating a DagReader
380 + // directly from the current DAG node. MFS holds desclock.RLock
381 + // for the lifetime of a read descriptor, which blocks any
382 + // concurrent write open on the same file (desclock.Lock). Tools
383 + // like rsync --inplace open the destination for reading and
384 + // writing simultaneously, deadlocking on MFS's lock. Creating
385 + // a DagReader here avoids the lock entirely: the reader gets a
386 + // snapshot of the file at open time, and writers proceed through
387 + // MFS independently. Cfg.DAG is required by NewDir.
388 + if accessMode == syscall.O_RDONLY {
389 + nd, err := fi.MFSFile.GetNode()
390 + if err != nil {
391 + return nil, 0, fs.ToErrno(err)
392 + }
393 + r, err := uio.NewDagReader(ctx, nd, fi.Cfg.DAG)
394 + if err != nil {
395 + return nil, 0, fusemnt.ReadErrno(err)
396 + }
397 + return &roFileHandle{r: r}, fuse.FOPEN_KEEP_CACHE, 0
398 + }
399 +
400 + mfsFlags := mfs.Flags{
401 + Read: accessMode == syscall.O_RDONLY || accessMode == syscall.O_RDWR,
402 + Write: accessMode == syscall.O_WRONLY || accessMode == syscall.O_RDWR,
403 + Sync: true,
404 + }
405 + fd, err := fi.MFSFile.Open(mfsFlags)
406 + if err != nil {
407 + return nil, 0, fs.ToErrno(err)
408 + }
409 +
410 + if flags&syscall.O_TRUNC != 0 {
411 + if !mfsFlags.Write {
412 + fd.Close()
413 + log.Error("tried to open a readonly file with truncate")
414 + return nil, 0, syscall.ENOTSUP
415 + }
416 + if err := fd.Truncate(0); err != nil {
417 + fd.Close()
418 + return nil, 0, fs.ToErrno(err)
419 + }
420 + }
421 + // O_APPEND is handled in FileHandle.Write by seeking to end.
422 +
423 + if mfsFlags.Write && fi.Cfg.StoreMtime {
424 + if err := fi.MFSFile.SetModTime(time.Now()); err != nil {
425 + fd.Close()
426 + return nil, 0, fs.ToErrno(err)
427 + }
428 + }
429 +
430 + return &FileHandle{inode: fi.EmbeddedInode(), fd: fd, appendMode: flags&syscall.O_APPEND != 0}, 0, 0
431 +}
432 +
433 +// Setattr handles chmod, mtime changes (touch), and ftruncate.
434 +//
435 +// Mode and mtime are stored as UnixFS optional metadata.
436 +// The UnixFS spec supports all 12 permission bits, but boxo's MFS
437 +// layer exposes only the lower 9 (ugo-rwx); setuid/setgid/sticky
438 +// are silently dropped. FUSE mounts are always nosuid so these
439 +// bits would have no execution effect anyway.
440 +// See https://specs.ipfs.tech/unixfs/#dag-pb-optional-metadata
441 +//
442 +// With hanwen/go-fuse, the kernel passes the open file handle (fh) when
443 +// the caller uses ftruncate(fd, size). This lets us truncate through
444 +// the existing write descriptor without opening a second one. For
445 +// truncate(path, size) without a handle, a temporary descriptor is
446 +// opened; this may block if another writer holds MFS's desclock.
447 +func (fi *FileInode) Setattr(_ context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
448 + if sz, ok := in.GetSize(); ok {
449 + if f, ok := fh.(*FileHandle); ok {
450 + // ftruncate(fd, size): use the existing write descriptor.
451 + f.mu.Lock()
452 + err := f.fd.Truncate(int64(sz))
453 + f.mu.Unlock()
454 + if err != nil {
455 + return fs.ToErrno(err)
456 + }
457 + } else {
458 + // truncate(path, size) without an open file descriptor.
459 + // Open a temporary write descriptor, truncate, flush, and
460 + // close. This may block if another writer holds MFS's
461 + // desclock; the FUSE kernel timeout (30s) bounds the wait.
462 + fd, err := fi.MFSFile.Open(mfs.Flags{Write: true, Sync: true})
463 + if err != nil {
464 + return fs.ToErrno(err)
465 + }
466 + if err := fd.Truncate(int64(sz)); err != nil {
467 + fd.Close()
468 + return fs.ToErrno(err)
469 + }
470 + if err := fd.Flush(); err != nil {
471 + fd.Close()
472 + return fs.ToErrno(err)
473 + }
474 + if err := fd.Close(); err != nil {
475 + return fs.ToErrno(err)
476 + }
477 + }
478 + }
479 + if mode, ok := in.GetMode(); ok && fi.Cfg.StoreMode {
480 + if err := fi.MFSFile.SetMode(files.UnixPermsToModePerms(mode)); err != nil {
481 + return fs.ToErrno(err)
482 + }
483 + }
484 + if mtime, ok := in.GetMTime(); ok && fi.Cfg.StoreMtime {
485 + if err := fi.MFSFile.SetModTime(mtime); err != nil {
486 + return fs.ToErrno(err)
487 + }
488 + }
489 + // Fill the response attrs so the kernel doesn't cache stale zero
490 + // values until AttrTimeout expires. Matches Dir.Setattr behavior.
491 + fi.fillAttr(&out.Attr)
492 + return 0
493 +}
494 +
495 +func (fi *FileInode) Listxattr(_ context.Context, dest []byte) (uint32, syscall.Errno) {
496 + data := []byte(fusemnt.XattrCID + "\x00")
497 + if len(dest) == 0 {
498 + return uint32(len(data)), 0
499 + }
500 + if len(dest) < len(data) {
501 + return 0, syscall.ERANGE
502 + }
503 + return uint32(copy(dest, data)), 0
504 +}
505 +
506 +func (fi *FileInode) Getxattr(_ context.Context, attr string, dest []byte) (uint32, syscall.Errno) {
507 + if attr == fusemnt.XattrCIDDeprecated {
508 + log.Errorf("xattr %q is deprecated, use %q instead", fusemnt.XattrCIDDeprecated, fusemnt.XattrCID)
509 + attr = fusemnt.XattrCID
510 + }
511 + if attr != fusemnt.XattrCID {
512 + return 0, fs.ENOATTR
513 + }
514 + nd, err := fi.MFSFile.GetNode()
515 + if err != nil {
516 + return 0, fs.ToErrno(err)
517 + }
518 + data := []byte(nd.Cid().String())
519 + if len(dest) == 0 {
520 + return uint32(len(data)), 0
521 + }
522 + if len(dest) < len(data) {
523 + return 0, syscall.ERANGE
524 + }
525 + return uint32(copy(dest, data)), 0
526 +}
527 +
528 +// FileHandle wraps an MFS file descriptor for FUSE operations.
529 +// All methods are serialized by mu because the FUSE server dispatches
530 +// each request in its own goroutine and the underlying DagModifier
531 +// is not safe for concurrent use.
532 +type FileHandle struct {
533 + inode *fs.Inode // back-pointer for kernel cache invalidation
534 + fd mfs.FileDescriptor
535 + mu sync.Mutex
536 + appendMode bool // O_APPEND: writes always go to end of file
537 +}
538 +
539 +func (fh *FileHandle) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
540 + fh.mu.Lock()
541 + defer fh.mu.Unlock()
542 +
543 + if _, err := fh.fd.Seek(off, io.SeekStart); err != nil {
544 + return nil, fs.ToErrno(err)
545 + }
546 +
547 + size, err := fh.fd.Size()
548 + if err != nil {
549 + return nil, fs.ToErrno(err)
550 + }
551 +
552 + n := min(len(dest), int(size-off))
553 + if n <= 0 {
554 + return fuse.ReadResultData(nil), 0
555 + }
556 + got, err := fh.fd.CtxReadFull(ctx, dest[:n])
557 + if err != nil {
558 + return nil, fusemnt.ReadErrno(err)
559 + }
560 + return fuse.ReadResultData(dest[:got]), 0
561 +}
562 +
563 +func (fh *FileHandle) Write(_ context.Context, data []byte, off int64) (uint32, syscall.Errno) {
564 + fh.mu.Lock()
565 + defer fh.mu.Unlock()
566 +
567 + if fh.appendMode {
568 + // O_APPEND: the kernel may send offset 0, but POSIX says
569 + // writes must go to the end of the file.
570 + if _, err := fh.fd.Seek(0, io.SeekEnd); err != nil {
571 + return 0, fs.ToErrno(err)
572 + }
573 + n, err := fh.fd.Write(data)
574 + if err != nil {
575 + return 0, fs.ToErrno(err)
576 + }
577 + return uint32(n), 0
578 + }
579 +
580 + n, err := fh.fd.WriteAt(data, off)
581 + if err != nil {
582 + return 0, fs.ToErrno(err)
583 + }
584 + return uint32(n), 0
585 +}
586 +
587 +// Flush persists buffered writes to the DAG and invalidates the
588 +// kernel's cached attrs so the next stat sees the updated size.
589 +//
590 +// We intentionally ignore ctx: the underlying MFS flush cannot be
591 +// safely canceled mid-operation, and abandoning it would leak a
592 +// background goroutine that races with the subsequent Release.
593 +//
594 +// Cache invalidation happens here (in addition to Release) because
595 +// the kernel calls Flush synchronously inside close() but sends
596 +// Release asynchronously after close() returns. Without this, a
597 +// stat() immediately after close() could see stale cached attrs.
598 +func (fh *FileHandle) Flush(_ context.Context) syscall.Errno {
599 + fh.mu.Lock()
600 + defer fh.mu.Unlock()
601 +
602 + err := fh.fd.Flush()
603 + if fh.inode != nil {
604 + _ = fh.inode.NotifyContent(0, 0)
605 + }
606 + return fs.ToErrno(err)
607 +}
608 +
609 +// Release closes the descriptor and invalidates the kernel's cached
610 +// content and attrs so readers opening the same path see the new data.
611 +// Invalidation happens here (not in Flush) because fd.Close commits
612 +// the final DAG node; Flush alone may not have the final size yet.
613 +func (fh *FileHandle) Release(_ context.Context) syscall.Errno {
614 + fh.mu.Lock()
615 + defer fh.mu.Unlock()
616 +
617 + err := fh.fd.Close()
618 + if fh.inode != nil {
619 + _ = fh.inode.NotifyContent(0, 0)
620 + }
621 + return fs.ToErrno(err)
622 +}
623 +
624 +// Fsync flushes the write buffer through the open file descriptor and
625 +// invalidates the kernel's cached attrs and content for this inode.
626 +// Editors (vim, emacs) and databases call fsync after writing to
627 +// ensure data reaches persistent storage; a fresh reader on the same
628 +// path must see the synced bytes immediately, not the size the kernel
629 +// cached from the initial Create response.
630 +func (fh *FileHandle) Fsync(_ context.Context, _ uint32) syscall.Errno {
631 + fh.mu.Lock()
632 + defer fh.mu.Unlock()
633 +
634 + err := fh.fd.Flush()
635 + if fh.inode != nil {
636 + _ = fh.inode.NotifyContent(0, 0)
637 + }
638 + return fs.ToErrno(err)
639 +}
640 +
641 +// Symlink is the FUSE adapter for UnixFS TSymlink nodes on writable mounts.
642 +// Target is resolved once at Lookup/Create time and never changes
643 +// (POSIX symlinks are immutable; changing the target requires unlink + symlink).
644 +type Symlink struct {
645 + fs.Inode
646 + Target string
647 + MFSFile *mfs.File // backing MFS node for mtime persistence
648 + Cfg *Config
649 +}
650 +
651 +func (s *Symlink) Readlink(_ context.Context) ([]byte, syscall.Errno) {
652 + return []byte(s.Target), 0
653 +}
654 +
655 +func (s *Symlink) fillAttr(a *fuse.Attr) {
656 + a.Mode = uint32(fusemnt.SymlinkMode.Perm())
657 + a.Size = uint64(len(s.Target))
658 + if s.MFSFile != nil {
659 + if t, err := s.MFSFile.ModTime(); err == nil && !t.IsZero() {
660 + a.SetTimes(nil, &t, nil)
661 + }
662 + }
663 +}
664 +
665 +func (s *Symlink) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
666 + s.fillAttr(&out.Attr)
667 + return 0
668 +}
669 +
670 +// Setattr handles mtime changes on symlinks.
671 +// Tools like rsync call lutimes on symlinks after creating them and
672 +// treat ENOTSUP as an error. Every major FUSE filesystem (gocryptfs,
673 +// rclone, sshfs, s3fs) implements Setattr on symlinks for this reason.
674 +//
675 +// Mode is always 0777 per POSIX convention (access control uses the
676 +// target's mode), so chmod requests are silently accepted but not stored.
677 +func (s *Symlink) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
678 + if s.MFSFile != nil {
679 + if mtime, ok := in.GetMTime(); ok && s.Cfg.StoreMtime {
680 + if err := s.MFSFile.SetModTime(mtime); err != nil {
681 + return fs.ToErrno(err)
682 + }
683 + }
684 + }
685 + s.fillAttr(&out.Attr)
686 + return 0
687 +}
688 +
689 +// roFileHandle is a read-only file handle backed by a DagReader.
690 +// Used for O_RDONLY opens to bypass MFS's desclock (see FileInode.Open).
691 +type roFileHandle struct {
692 + r uio.DagReader
693 + mu sync.Mutex
694 +}
695 +
696 +func (fh *roFileHandle) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
697 + fh.mu.Lock()
698 + defer fh.mu.Unlock()
699 +
700 + if _, err := fh.r.Seek(off, io.SeekStart); err != nil {
701 + return nil, fs.ToErrno(err)
702 + }
703 + n, err := fh.r.CtxReadFull(ctx, dest)
704 + switch err {
705 + case nil, io.EOF, io.ErrUnexpectedEOF:
706 + default:
707 + return nil, fusemnt.ReadErrno(err)
708 + }
709 + return fuse.ReadResultData(dest[:n]), 0
710 +}
711 +
712 +func (fh *roFileHandle) Release(_ context.Context) syscall.Errno {
713 + fh.mu.Lock()
714 + defer fh.mu.Unlock()
715 +
716 + return fs.ToErrno(fh.r.Close())
717 +}
718 +
719 +// SymlinkTarget extracts the symlink target from an MFS file, or
720 +// returns "" if the file is not a TSymlink node. MFS represents
721 +// symlinks as *mfs.File, so the DAG node's UnixFS type must be checked.
722 +func SymlinkTarget(f *mfs.File) string {
723 + nd, err := f.GetNode()
724 + if err != nil {
725 + return ""
726 + }
727 + fsn, err := ft.ExtractFSNode(nd)
728 + if err != nil {
729 + return ""
730 + }
731 + if fsn.Type() != ft.TSymlink {
732 + return ""
733 + }
734 + return string(fsn.Data())
735 +}
736 +
737 +// Interface compliance checks.
738 +var (
739 + _ fs.NodeGetattrer = (*Dir)(nil)
740 + _ fs.NodeSetattrer = (*Dir)(nil)
741 + _ fs.NodeLookuper = (*Dir)(nil)
742 + _ fs.NodeReaddirer = (*Dir)(nil)
743 + _ fs.NodeMkdirer = (*Dir)(nil)
744 + _ fs.NodeUnlinker = (*Dir)(nil)
745 + _ fs.NodeRmdirer = (*Dir)(nil)
746 + _ fs.NodeRenamer = (*Dir)(nil)
747 + _ fs.NodeCreater = (*Dir)(nil)
748 + _ fs.NodeSymlinker = (*Dir)(nil)
749 + _ fs.NodeGetxattrer = (*Dir)(nil)
750 + _ fs.NodeListxattrer = (*Dir)(nil)
751 +
752 + _ fs.NodeGetattrer = (*FileInode)(nil)
753 + _ fs.NodeOpener = (*FileInode)(nil)
754 + _ fs.NodeSetattrer = (*FileInode)(nil)
755 + _ fs.NodeGetxattrer = (*FileInode)(nil)
756 + _ fs.NodeListxattrer = (*FileInode)(nil)
757 +
758 + _ fs.NodeGetattrer = (*Symlink)(nil)
759 + _ fs.NodeSetattrer = (*Symlink)(nil)
760 + _ fs.NodeReadlinker = (*Symlink)(nil)
761 +
762 + _ fs.FileReader = (*FileHandle)(nil)
763 + _ fs.FileWriter = (*FileHandle)(nil)
764 + _ fs.FileFlusher = (*FileHandle)(nil)
765 + _ fs.FileReleaser = (*FileHandle)(nil)
766 + _ fs.FileFsyncer = (*FileHandle)(nil)
767 +
768 + _ fs.FileReader = (*roFileHandle)(nil)
769 + _ fs.FileReleaser = (*roFileHandle)(nil)
770 +)
fuse/writable/writable_test.go new
+45
@@ -0,0 +1,45 @@
1 +//go:build (linux || darwin || freebsd) && !nofuse
2 +
3 +package writable
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/hanwen/go-fuse/v2/fuse"
9 +)
10 +
11 +// TestSymlinkSetattrChmodNoError verifies that Setattr on a symlink
12 +// with only a mode change is silently accepted. POSIX symlinks have no
13 +// meaningful permission bits (access control uses the target's mode),
14 +// so handlers must not return an error when the kernel forwards a
15 +// chmod-on-symlink request (e.g. via BSD lchmod or fchmodat with
16 +// AT_SYMLINK_NOFOLLOW). Tools like rsync depend on this contract.
17 +//
18 +// This is a unit test rather than an integration test because Linux
19 +// usually rejects fchmodat(AT_SYMLINK_NOFOLLOW) at the VFS layer with
20 +// EOPNOTSUPP and never forwards it to the FUSE filesystem, so a
21 +// userspace test would not actually exercise this code path.
22 +func TestSymlinkSetattrChmodNoError(t *testing.T) {
23 + // MFSFile is nil: Setattr must still succeed without dereferencing
24 + // it. StoreMode is true to confirm that even when persistence is
25 + // enabled, mode changes on symlinks are silently dropped.
26 + s := &Symlink{
27 + Target: "/some/target",
28 + Cfg: &Config{StoreMode: true},
29 + }
30 +
31 + in := &fuse.SetAttrIn{}
32 + in.Valid = fuse.FATTR_MODE
33 + in.Mode = 0o600
34 +
35 + out := &fuse.AttrOut{}
36 + if errno := s.Setattr(t.Context(), nil, in, out); errno != 0 {
37 + t.Fatalf("Symlink.Setattr returned errno %v, want 0", errno)
38 + }
39 +
40 + // fillAttr must report the POSIX symlink mode (0o777), not the
41 + // caller-supplied value, because the request is not stored.
42 + if got := out.Attr.Mode & 0o777; got != 0o777 {
43 + t.Fatalf("Symlink mode = 0o%o, want 0o777", got)
44 + }
45 +}
go.mod
+1 -1
@@ -3,7 +3,6 @@ module github.com/ipfs/kubo
3 go 1.26.2
4
5 require (
6 - bazil.org/fuse v0.0.0-20200117225306-7b5117fecadc
6 contrib.go.opencensus.io/exporter/prometheus v0.4.2
7 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239
8 github.com/blang/semver/v4 v4.0.0
@@ -18,6 +17,7 @@ require (
17 github.com/facebookgo/atomicfile v0.0.0-20151019160806-2de1f203e7d5
18 github.com/fsnotify/fsnotify v1.9.0
19 github.com/google/uuid v1.6.0
20 + github.com/hanwen/go-fuse/v2 v2.9.1-0.20260323175136-8b5aa92e8e7c
21 github.com/hashicorp/go-version v1.9.0
22 github.com/ipfs-shipyard/nopfs v0.0.14
23 github.com/ipfs-shipyard/nopfs/ipfs v0.25.0
go.sum
+4 -5
@@ -1,5 +1,3 @@
1 -bazil.org/fuse v0.0.0-20200117225306-7b5117fecadc h1:utDghgcjE8u+EBjHOgYT+dJPcnDF05KqWMBcjuJy510=
2 -bazil.org/fuse v0.0.0-20200117225306-7b5117fecadc/go.mod h1:FbcW6z/2VytnFDhZfumh8Ss8zxHE6qpMP5sHTRe0EaM=
1 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
2 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
3 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -354,6 +352,8 @@ github.com/guillaumemichel/reservedpool v0.3.0 h1:eqqO/QvTllLBrit7LVtVJBqw4cD0Wd
352 github.com/guillaumemichel/reservedpool v0.3.0/go.mod h1:sXSDIaef81TFdAJglsCFCMfgF5E5Z5xK1tFhjDhvbUc=
353 github.com/gxed/hashland/keccakpg v0.0.1/go.mod h1:kRzw3HkwxFU1mpmPP8v1WyQzwdGfmKFJ6tItnhQ67kU=
354 github.com/gxed/hashland/murmur3 v0.0.1/go.mod h1:KjXop02n4/ckmZSnY2+HKcLud/tcmvhST0bie/0lS48=
355 +github.com/hanwen/go-fuse/v2 v2.9.1-0.20260323175136-8b5aa92e8e7c h1:m4bneA0dtaIhyTOJZCvcka670ZwDEiSomj5EARK1Jxc=
356 +github.com/hanwen/go-fuse/v2 v2.9.1-0.20260323175136-8b5aa92e8e7c/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
357 github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
358 github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
359 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -638,6 +638,8 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu
638 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
639 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
640 github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
641 +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
642 +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
643 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
644 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
645 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
@@ -896,8 +898,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
898 github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
899 github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
900 github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
899 -github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ=
900 -github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM=
901 github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb h1:Ywfo8sUltxogBpFuMOFRrrSifO788kAFxmvVw31PtQQ=
902 github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb/go.mod h1:ikPs9bRWicNw3S7XpJ8sK/smGwU9WcSVU3dy9qahYBM=
903 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
@@ -1180,7 +1180,6 @@ golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7w
1180 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1181 golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1182 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1183 -golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1183 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1184 golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
1185 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
mk/golang.mk
+22 -7
@@ -66,16 +66,31 @@ TEST_CLI_TIMEOUT ?= 10m
66 test_cli: cmd/ipfs/ipfs test/bin/gotestsum $$(DEPS_GO)
67 mkdir -p test/cli
68 rm -f test/cli/cli-tests.json
69 - PATH="$(CURDIR)/cmd/ipfs:$(CURDIR)/test/bin:$$PATH" gotestsum $(GOTESTSUM_NOCOLOR) --jsonfile test/cli/cli-tests.json -- -v -timeout=$(TEST_CLI_TIMEOUT) ./test/cli/... ./test/integration/... ./client/rpc/...
69 + TEST_FUSE=0 PATH="$(CURDIR)/cmd/ipfs:$(CURDIR)/test/bin:$$PATH" gotestsum $(GOTESTSUM_NOCOLOR) --jsonfile test/cli/cli-tests.json -- -v -timeout=$(TEST_CLI_TIMEOUT) ./test/cli/... ./test/integration/... ./client/rpc/...
70 .PHONY: test_cli
71
72 -# FUSE unit tests (requires /dev/fuse and fusermount in PATH)
73 -# Set TEST_FUSE=1 to make mount failures fatal instead of skipping
74 -TEST_FUSE_TIMEOUT ?= 5m
75 -test_fuse: test/bin/gotestsum $$(DEPS_GO)
72 +# FUSE tests (requires /dev/fuse and fusermount in PATH)
73 +# TEST_FUSE=1 makes mount failures fatal instead of skipping
74 +# Keep this shorter than the CI job timeout so a hang trips Go's panic
75 +# (and prints stack traces) instead of getting silently killed by CI.
76 +TEST_FUSE_TIMEOUT ?= 4m
77 +
78 +# FUSE unit tests (./fuse/...)
79 +test_fuse_unit: test/bin/gotestsum $$(DEPS_GO)
80 + mkdir -p test/fuse
81 + rm -f test/fuse/fuse-unit-tests.json
82 + TEST_FUSE=1 gotestsum $(GOTESTSUM_NOCOLOR) --jsonfile test/fuse/fuse-unit-tests.json -- -v -timeout=$(TEST_FUSE_TIMEOUT) ./fuse/...
83 +.PHONY: test_fuse_unit
84 +
85 +# FUSE CLI integration tests (test/cli/fuse/)
86 +test_fuse_cli: cmd/ipfs/ipfs test/bin/gotestsum $$(DEPS_GO)
87 mkdir -p test/fuse
77 - rm -f test/fuse/fuse-tests.json
78 - TEST_FUSE=1 gotestsum $(GOTESTSUM_NOCOLOR) --jsonfile test/fuse/fuse-tests.json -- -v -timeout=$(TEST_FUSE_TIMEOUT) ./fuse/...
88 + rm -f test/fuse/fuse-cli-tests.json
89 + TEST_FUSE=1 PATH="$(CURDIR)/cmd/ipfs:$(CURDIR)/test/bin:$$PATH" gotestsum $(GOTESTSUM_NOCOLOR) --jsonfile test/fuse/fuse-cli-tests.json -- -v -timeout=$(TEST_FUSE_TIMEOUT) ./test/cli/fuse/...
90 +.PHONY: test_fuse_cli
91 +
92 +# Combined: run all FUSE tests
93 +test_fuse: test_fuse_unit test_fuse_cli
94 .PHONY: test_fuse
95
96 # Example tests (docs/examples/kubo-as-a-library)
plugin/loader/load_nocgo.go
+2 -1
@@ -1,4 +1,5 @@
1 -//go:build !cgo && !noplugin && (linux || darwin || freebsd)
1 +// Plugin preloading without cgo (no dlopen, plugins are compiled in).
2 +//go:build (linux || darwin || freebsd) && !cgo && !noplugin
3
4 package loader
5
plugin/loader/load_noplugin.go
+1
@@ -1,3 +1,4 @@
1 +// No-op plugin loader when built with "go build -tags noplugin".
2 //go:build noplugin
3
4 package loader
plugin/loader/load_unix.go
+2 -1
@@ -1,4 +1,5 @@
1 -//go:build cgo && !noplugin && (linux || darwin || freebsd)
1 +// Plugin loading with cgo (uses dlopen to load .so plugins at runtime).
2 +//go:build (linux || darwin || freebsd) && cgo && !noplugin
3
4 package loader
5
test/cli/fuse/fuse_test.go new
+500
@@ -0,0 +1,500 @@
1 +// Package fuse contains end-to-end FUSE integration tests that exercise
2 +// mount/unmount and filesystem operations through a real ipfs daemon.
3 +//
4 +// These tests complement the unit tests in fuse/readonly/, fuse/ipns/,
5 +// and fuse/mfs/ which test the FUSE filesystem implementations directly
6 +// (without a daemon) via fusetest.TestMount.
7 +//
8 +// All tests here are gated by testutils.RequiresFUSE (TEST_FUSE env var).
9 +// CI runs them via `make test_fuse_cli` inside the fuse-tests job.
10 +package fuse
11 +
12 +import (
13 + "bytes"
14 + "crypto/rand"
15 + "os"
16 + "os/exec"
17 + "path/filepath"
18 + "runtime"
19 + "sort"
20 + "strings"
21 + "syscall"
22 + "testing"
23 +
24 + "github.com/ipfs/kubo/config"
25 + "github.com/ipfs/kubo/test/cli/harness"
26 + "github.com/ipfs/kubo/test/cli/testutils"
27 + "github.com/stretchr/testify/require"
28 +)
29 +
30 +func TestFUSE(t *testing.T) {
31 + testutils.RequiresFUSE(t)
32 + t.Parallel()
33 +
34 + t.Run("mount and unmount work correctly", func(t *testing.T) {
35 + t.Parallel()
36 +
37 + node := harness.NewT(t).NewNode().Init()
38 + node.StartDaemon()
39 +
40 + ipfsMount, ipnsMount, mfsMount := mountAll(t, node)
41 +
42 + // Test basic MFS functionality via FUSE mount
43 + testFile := filepath.Join(mfsMount, "testfile")
44 + testContent := "hello fuse world"
45 +
46 + err := os.WriteFile(testFile, []byte(testContent), 0644)
47 + require.NoError(t, err)
48 +
49 + // Verify file appears in MFS via IPFS commands
50 + result := node.IPFS("files", "ls", "/")
51 + require.Contains(t, result.Stdout.String(), "testfile")
52 +
53 + // Read content back via MFS FUSE mount
54 + readContent, err := os.ReadFile(testFile)
55 + require.NoError(t, err)
56 + require.Equal(t, testContent, string(readContent))
57 +
58 + // Get the CID of the MFS file
59 + result = node.IPFS("files", "stat", "/testfile", "--format=<hash>")
60 + fileCID := strings.TrimSpace(result.Stdout.String())
61 + require.NotEmpty(t, fileCID, "should have a CID for the MFS file")
62 +
63 + // Read the same content via IPFS FUSE mount using the CID
64 + ipfsFile := filepath.Join(ipfsMount, fileCID)
65 + ipfsContent, err := os.ReadFile(ipfsFile)
66 + require.NoError(t, err)
67 + require.Equal(t, testContent, string(ipfsContent), "content should match between MFS and IPFS mounts")
68 +
69 + // Verify both FUSE mounts return identical data
70 + require.Equal(t, readContent, ipfsContent, "MFS and IPFS FUSE mounts should return identical data")
71 +
72 + // Test that mount directories cannot be removed while mounted
73 + err = os.Remove(ipfsMount)
74 + require.Error(t, err, "should not be able to remove mounted directory")
75 +
76 + // Stop daemon, which should trigger automatic unmount
77 + node.StopDaemon()
78 +
79 + // Verify directories can now be removed (indicating successful unmount)
80 + require.NoError(t, os.Remove(ipfsMount))
81 + require.NoError(t, os.Remove(ipnsMount))
82 + require.NoError(t, os.Remove(mfsMount))
83 + })
84 +
85 + t.Run("explicit unmount works", func(t *testing.T) {
86 + t.Parallel()
87 +
88 + node := harness.NewT(t).NewNode().Init()
89 + node.StartDaemon()
90 +
91 + ipfsMount, ipnsMount, mfsMount := mountAll(t, node)
92 +
93 + doUnmount(t, ipfsMount, true)
94 + doUnmount(t, ipnsMount, true)
95 + doUnmount(t, mfsMount, true)
96 +
97 + // Verify directories can be removed after explicit unmount
98 + require.NoError(t, os.Remove(ipfsMount))
99 + require.NoError(t, os.Remove(ipnsMount))
100 + require.NoError(t, os.Remove(mfsMount))
101 +
102 + node.StopDaemon()
103 + })
104 +
105 + t.Run("mount fails when dirs missing", func(t *testing.T) {
106 + t.Parallel()
107 +
108 + node := harness.NewT(t).NewNode().Init()
109 + node.StartDaemon()
110 +
111 + res := node.RunIPFS("mount", "-f=not_ipfs", "-n=not_ipns", "-m=not_mfs")
112 + require.Error(t, res.Err)
113 + require.Empty(t, res.Stdout.String())
114 + stderr := res.Stderr.String()
115 + require.True(t,
116 + strings.Contains(stderr, "not_ipfs") ||
117 + strings.Contains(stderr, "not_ipns") ||
118 + strings.Contains(stderr, "not_mfs"),
119 + "error should mention missing mount dir, got: %s", stderr)
120 +
121 + node.StopDaemon()
122 + })
123 +
124 + t.Run("IPNS local symlink", func(t *testing.T) {
125 + t.Parallel()
126 +
127 + node := harness.NewT(t).NewNode().Init()
128 + node.StartDaemon()
129 +
130 + _, ipnsMount, _ := mountAll(t, node)
131 +
132 + target, err := os.Readlink(filepath.Join(ipnsMount, "local"))
133 + require.NoError(t, err)
134 + require.Equal(t, node.PeerID().String(), filepath.Base(target))
135 +
136 + node.StopDaemon()
137 + })
138 +
139 + t.Run("IPNS name resolution via NS map", func(t *testing.T) {
140 + t.Parallel()
141 +
142 + node := harness.NewT(t).NewNode().Init()
143 +
144 + // Add content offline (before daemon starts)
145 + expectedFile := filepath.Join(node.Dir, "expected")
146 + require.NoError(t, os.WriteFile(expectedFile, []byte("ipfs"), 0644))
147 + wrappedCID := node.IPFS("add", "--cid-version", "1", "-Q", "-w", expectedFile).Stdout.Trimmed()
148 +
149 + // Set IPFS_NS_MAP so the daemon resolves welcome.example.com
150 + node.Runner.Env["IPFS_NS_MAP"] = "welcome.example.com:/ipfs/" + wrappedCID
151 +
152 + node.StartDaemon()
153 + _, ipnsMount, _ := mountAll(t, node)
154 +
155 + // Read the file through IPNS FUSE mount using the DNS name
156 + content, err := os.ReadFile(filepath.Join(ipnsMount, "welcome.example.com", "expected"))
157 + require.NoError(t, err)
158 + require.Equal(t, "ipfs", string(content))
159 +
160 + node.StopDaemon()
161 + })
162 +
163 + t.Run("MFS file and dir creation", func(t *testing.T) {
164 + t.Parallel()
165 +
166 + node := harness.NewT(t).NewNode().Init()
167 + node.StartDaemon()
168 +
169 + _, _, mfsMount := mountAll(t, node)
170 +
171 + // Create file via FUSE
172 + require.NoError(t, os.WriteFile(filepath.Join(mfsMount, "testfile"), []byte("content"), 0644))
173 + result := node.IPFS("files", "ls", "/")
174 + require.Contains(t, result.Stdout.String(), "testfile")
175 +
176 + // Create dir via FUSE
177 + require.NoError(t, os.Mkdir(filepath.Join(mfsMount, "testdir"), 0755))
178 + result = node.IPFS("files", "ls", "/")
179 + require.Contains(t, result.Stdout.String(), "testdir")
180 +
181 + node.StopDaemon()
182 + })
183 +
184 + t.Run("MFS xattr", func(t *testing.T) {
185 + t.Parallel()
186 + if runtime.GOOS != "linux" {
187 + t.Skip("xattr requires Linux")
188 + }
189 +
190 + node := harness.NewT(t).NewNode().Init()
191 + node.StartDaemon()
192 +
193 + _, _, mfsMount := mountAll(t, node)
194 +
195 + testFile := filepath.Join(mfsMount, "testfile")
196 + require.NoError(t, os.WriteFile(testFile, []byte("content"), 0644))
197 +
198 + cid, err := getXattr(testFile, "ipfs.cid")
199 + require.NoError(t, err)
200 + require.NotEmpty(t, cid)
201 +
202 + node.StopDaemon()
203 + })
204 +
205 + t.Run("files write then read via FUSE", func(t *testing.T) {
206 + t.Parallel()
207 +
208 + node := harness.NewT(t).NewNode().Init()
209 + node.StartDaemon()
210 +
211 + _, _, mfsMount := mountAll(t, node)
212 +
213 + // Write via ipfs files write -e, read back via FUSE
214 + node.PipeStrToIPFS("content3", "files", "write", "-e", "/testfile3")
215 +
216 + got, err := os.ReadFile(filepath.Join(mfsMount, "testfile3"))
217 + require.NoError(t, err)
218 + require.Equal(t, "content3", string(got))
219 +
220 + node.StopDaemon()
221 + })
222 +
223 + t.Run("add --to-files then read via FUSE", func(t *testing.T) {
224 + t.Parallel()
225 +
226 + node := harness.NewT(t).NewNode().Init()
227 + node.StartDaemon()
228 +
229 + _, _, mfsMount := mountAll(t, node)
230 +
231 + // Create a temp file to add
232 + tmpFile := filepath.Join(node.Dir, "testfile2")
233 + require.NoError(t, os.WriteFile(tmpFile, []byte("content"), 0644))
234 +
235 + node.IPFS("add", "--to-files", "/testfile2", tmpFile)
236 +
237 + got, err := os.ReadFile(filepath.Join(mfsMount, "testfile2"))
238 + require.NoError(t, err)
239 + require.Equal(t, "content", string(got))
240 +
241 + node.StopDaemon()
242 + })
243 +
244 + t.Run("file removal via FUSE", func(t *testing.T) {
245 + t.Parallel()
246 +
247 + node := harness.NewT(t).NewNode().Init()
248 + node.StartDaemon()
249 +
250 + _, _, mfsMount := mountAll(t, node)
251 +
252 + testFile := filepath.Join(mfsMount, "testfile")
253 + require.NoError(t, os.WriteFile(testFile, []byte("content"), 0644))
254 +
255 + result := node.IPFS("files", "ls", "/")
256 + require.Contains(t, result.Stdout.String(), "testfile")
257 +
258 + require.NoError(t, os.Remove(testFile))
259 +
260 + result = node.IPFS("files", "ls", "/")
261 + require.NotContains(t, result.Stdout.String(), "testfile")
262 +
263 + node.StopDaemon()
264 + })
265 +
266 + t.Run("nested dirs via FUSE", func(t *testing.T) {
267 + t.Parallel()
268 +
269 + node := harness.NewT(t).NewNode().Init()
270 + node.StartDaemon()
271 +
272 + _, _, mfsMount := mountAll(t, node)
273 +
274 + nested := filepath.Join(mfsMount, "foo", "bar", "baz", "qux")
275 + require.NoError(t, os.MkdirAll(nested, 0755))
276 + require.NoError(t, os.WriteFile(filepath.Join(nested, "quux"), []byte("content"), 0644))
277 +
278 + result := node.IPFS("files", "stat", "/foo/bar/baz/qux/quux")
279 + require.NoError(t, result.Err)
280 +
281 + node.StopDaemon()
282 + })
283 +
284 + t.Run("publish blocked while IPNS mounted", func(t *testing.T) {
285 + t.Parallel()
286 +
287 + node := harness.NewT(t).NewNode().Init()
288 + node.StartDaemon()
289 +
290 + // Add content and publish before mount
291 + hash := node.PipeStrToIPFS("hello warld", "add", "-Q", "-w", "--stdin-name", "file").Stdout.Trimmed()
292 + node.IPFS("name", "publish", hash)
293 +
294 + // Mount all
295 + _, ipnsMount, _ := mountAll(t, node)
296 +
297 + // Publish should fail while IPNS is mounted
298 + res := node.RunIPFS("name", "publish", hash)
299 + require.Error(t, res.Err)
300 + require.Contains(t, res.Stderr.String(), "cannot manually publish while IPNS is mounted")
301 +
302 + // Unmount IPNS out-of-band
303 + doUnmount(t, ipnsMount, true)
304 +
305 + // Publish should work again
306 + node.IPFS("name", "publish", hash)
307 +
308 + node.StopDaemon()
309 + })
310 +
311 + // Exercises both ftruncate(fd, size) and truncate(path, size).
312 + // ftruncate uses the open file handle in Setattr; truncate opens
313 + // a temporary write descriptor. Both must leave the file with
314 + // correct content visible via the FUSE mount and via ipfs files.
315 + t.Run("truncation via FUSE", func(t *testing.T) {
316 + t.Parallel()
317 +
318 + node := harness.NewT(t).NewNode().Init()
319 + node.StartDaemon()
320 +
321 + _, _, mfsMount := mountAll(t, node)
322 +
323 + original := make([]byte, 2000)
324 + _, err := rand.Read(original)
325 + require.NoError(t, err)
326 +
327 + path := filepath.Join(mfsMount, "trunctest")
328 + require.NoError(t, os.WriteFile(path, original, 0644))
329 +
330 + // ftruncate(fd, 500): open, truncate via fd, close.
331 + t.Run("ftruncate via fd", func(t *testing.T) {
332 + f, err := os.OpenFile(path, os.O_WRONLY, 0644)
333 + require.NoError(t, err)
334 + require.NoError(t, f.Truncate(500))
335 + require.NoError(t, f.Close())
336 +
337 + info, err := os.Stat(path)
338 + require.NoError(t, err)
339 + require.Equal(t, int64(500), info.Size())
340 +
341 + got, err := os.ReadFile(path)
342 + require.NoError(t, err)
343 + require.True(t, bytes.Equal(original[:500], got),
344 + "ftruncated content should match first 500 bytes of original")
345 +
346 + // Verify via ipfs files stat
347 + stat := node.IPFS("files", "stat", "/trunctest", "--format=<size>")
348 + require.Equal(t, "500", strings.TrimSpace(stat.Stdout.String()))
349 + })
350 +
351 + // truncate(path, 200): no open fd, Setattr opens a temporary
352 + // write descriptor.
353 + t.Run("truncate via path", func(t *testing.T) {
354 + require.NoError(t, syscall.Truncate(path, 200))
355 +
356 + info, err := os.Stat(path)
357 + require.NoError(t, err)
358 + require.Equal(t, int64(200), info.Size())
359 +
360 + got, err := os.ReadFile(path)
361 + require.NoError(t, err)
362 + require.True(t, bytes.Equal(original[:200], got),
363 + "path-truncated content should match first 200 bytes of original")
364 +
365 + stat := node.IPFS("files", "stat", "/trunctest", "--format=<size>")
366 + require.Equal(t, "200", strings.TrimSpace(stat.Stdout.String()))
367 + })
368 +
369 + // Truncate to zero and rewrite: the common open(O_TRUNC) pattern.
370 + t.Run("truncate to zero and rewrite", func(t *testing.T) {
371 + newContent := []byte("brand new content")
372 + f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
373 + require.NoError(t, err)
374 + _, err = f.Write(newContent)
375 + require.NoError(t, err)
376 + require.NoError(t, f.Close())
377 +
378 + got, err := os.ReadFile(path)
379 + require.NoError(t, err)
380 + require.Equal(t, newContent, got)
381 + })
382 +
383 + node.StopDaemon()
384 + })
385 +
386 + t.Run("sharded directory read via FUSE", func(t *testing.T) {
387 + t.Parallel()
388 +
389 + node := harness.NewT(t).NewNode().Init()
390 +
391 + // Force sharding with 1B threshold
392 + node.UpdateConfig(func(cfg *config.Config) {
393 + cfg.Import.UnixFSHAMTDirectorySizeThreshold = *config.NewOptionalBytes("1B")
394 + })
395 +
396 + node.StartDaemon()
397 + ipfsMount, _, _ := mountAll(t, node)
398 +
399 + // Create test data directory
400 + testdataDir := filepath.Join(node.Dir, "testdata")
401 + require.NoError(t, os.MkdirAll(filepath.Join(testdataDir, "subdir"), 0755))
402 + require.NoError(t, os.WriteFile(filepath.Join(testdataDir, "a"), []byte("a\n"), 0644))
403 + require.NoError(t, os.WriteFile(filepath.Join(testdataDir, "subdir", "b"), []byte("b\n"), 0644))
404 +
405 + // Add sharded directory
406 + hash := node.IPFS("add", "-r", "-Q", testdataDir).Stdout.Trimmed()
407 +
408 + // Read files via FUSE /ipfs mount
409 + contentA, err := os.ReadFile(filepath.Join(ipfsMount, hash, "a"))
410 + require.NoError(t, err)
411 + require.Equal(t, "a\n", string(contentA))
412 +
413 + contentB, err := os.ReadFile(filepath.Join(ipfsMount, hash, "subdir", "b"))
414 + require.NoError(t, err)
415 + require.Equal(t, "b\n", string(contentB))
416 +
417 + // List directories via FUSE
418 + entries, err := os.ReadDir(filepath.Join(ipfsMount, hash))
419 + require.NoError(t, err)
420 + names := make([]string, len(entries))
421 + for i, e := range entries {
422 + names[i] = e.Name()
423 + }
424 + sort.Strings(names)
425 + require.Equal(t, []string{"a", "subdir"}, names)
426 +
427 + subEntries, err := os.ReadDir(filepath.Join(ipfsMount, hash, "subdir"))
428 + require.NoError(t, err)
429 + require.Len(t, subEntries, 1)
430 + require.Equal(t, "b", subEntries[0].Name())
431 +
432 + node.StopDaemon()
433 + })
434 +}
435 +
436 +// mountAll creates mount directories and mounts IPFS, IPNS, and MFS.
437 +func mountAll(t *testing.T, node *harness.Node) (ipfsMount, ipnsMount, mfsMount string) {
438 + t.Helper()
439 + ipfsMount = filepath.Join(node.Dir, "ipfs")
440 + ipnsMount = filepath.Join(node.Dir, "ipns")
441 + mfsMount = filepath.Join(node.Dir, "mfs")
442 +
443 + require.NoError(t, os.MkdirAll(ipfsMount, 0755))
444 + require.NoError(t, os.MkdirAll(ipnsMount, 0755))
445 + require.NoError(t, os.MkdirAll(mfsMount, 0755))
446 +
447 + // Lazy-unmount any stale mounts from a previous crashed run so
448 + // the mountpoint is free. Non-fatal: the dir may not be mounted.
449 + lazyUnmount(ipfsMount)
450 + lazyUnmount(ipnsMount)
451 + lazyUnmount(mfsMount)
452 +
453 + result := node.IPFS("mount", "-f", ipfsMount, "-n", ipnsMount, "-m", mfsMount)
454 +
455 + // Extra space after "MFS" matches the column-aligned output produced
456 + // by MountCmd in core/commands/mount_unix.go.
457 + expectedOutput := "IPFS mounted at: " + ipfsMount + "\n" +
458 + "IPNS mounted at: " + ipnsMount + "\n" +
459 + "MFS mounted at: " + mfsMount + "\n"
460 + require.Equal(t, expectedOutput, result.Stdout.String())
461 +
462 + return
463 +}
464 +
465 +// doUnmount performs platform-specific unmount, similar to sharness do_umount.
466 +// If failOnError is true, unmount errors cause test failure; otherwise errors are ignored.
467 +func doUnmount(t *testing.T, mountPoint string, failOnError bool) {
468 + t.Helper()
469 + var cmd *exec.Cmd
470 + switch runtime.GOOS {
471 + case "linux":
472 + if _, err := exec.LookPath("fusermount3"); err == nil {
473 + cmd = exec.Command("fusermount3", "-u", mountPoint)
474 + } else {
475 + cmd = exec.Command("fusermount", "-u", mountPoint)
476 + }
477 + default:
478 + cmd = exec.Command("umount", mountPoint)
479 + }
480 +
481 + err := cmd.Run()
482 + if err != nil && failOnError {
483 + t.Fatalf("failed to unmount %s: %v", mountPoint, err)
484 + }
485 +}
486 +
487 +// lazyUnmount detaches a mount point without waiting for open files
488 +// to close. Used to clean up stale mounts from crashed test runs.
489 +func lazyUnmount(mountPoint string) {
490 + switch runtime.GOOS {
491 + case "linux":
492 + if _, err := exec.LookPath("fusermount3"); err == nil {
493 + _ = exec.Command("fusermount3", "-uz", mountPoint).Run()
494 + } else {
495 + _ = exec.Command("fusermount", "-uz", mountPoint).Run()
496 + }
497 + default:
498 + _ = exec.Command("umount", "-l", mountPoint).Run()
499 + }
500 +}
test/cli/fuse/realworld_test.go new
+567
@@ -0,0 +1,567 @@
1 +// End-to-end FUSE coverage with real POSIX tools.
2 +//
3 +// TestFUSERealWorld spins up one ipfs daemon, mounts /ipfs, /ipns, and
4 +// /mfs, and exercises the writable mount through the actual binaries
5 +// users invoke (cat, ls, cp, mv, rm, ln, find, dd, sha256sum, tar,
6 +// rsync, vim, sh, wc). Each subtest verifies the result both via the
7 +// FUSE filesystem and via the daemon's `ipfs files` view.
8 +//
9 +// All external tools are required: a missing binary fails the test
10 +// instead of skipping, so a CI image change cannot silently turn this
11 +// suite green. The whole-suite TEST_FUSE gate is the only place a
12 +// developer is allowed to skip.
13 +//
14 +// Synthetic file payloads default to 1 MiB + 1 byte so multi-chunk
15 +// read/write paths and chunk-boundary off-by-ones are exercised.
16 +
17 +package fuse
18 +
19 +import (
20 + "bytes"
21 + "crypto/rand"
22 + "crypto/sha256"
23 + "encoding/hex"
24 + "os"
25 + "os/exec"
26 + "path/filepath"
27 + "strconv"
28 + "strings"
29 + "testing"
30 + "time"
31 +
32 + "github.com/ipfs/kubo/config"
33 + "github.com/ipfs/kubo/test/cli/harness"
34 + "github.com/ipfs/kubo/test/cli/testutils"
35 + "github.com/stretchr/testify/require"
36 +)
37 +
38 +// payloadSize is the default test payload size: 1 MiB + 1 byte.
39 +// Forces multi-chunk DAG construction so single-chunk fast paths
40 +// cannot mask cross-block bugs.
41 +const payloadSize = 1024*1024 + 1
42 +
43 +func TestFUSERealWorld(t *testing.T) {
44 + testutils.RequiresFUSE(t)
45 +
46 + node := harness.NewT(t).NewNode().Init()
47 + // StoreMtime/StoreMode on so rsync -a, tar -p, vim's chmod, and
48 + // any other tool that round-trips POSIX metadata see consistent
49 + // behaviour. The flags only affect the writable mounts.
50 + node.UpdateConfig(func(cfg *config.Config) {
51 + cfg.Mounts.StoreMtime = config.True
52 + cfg.Mounts.StoreMode = config.True
53 + })
54 + node.StartDaemon()
55 + defer node.StopDaemon()
56 +
57 + _, _, mfsMount := mountAll(t, node)
58 +
59 + // requireTool fails the current subtest if bin is not in PATH.
60 + // External tools are part of the test contract: a missing binary
61 + // is a hidden coverage gap and we want a loud failure.
62 + requireTool := func(t *testing.T, bins ...string) {
63 + t.Helper()
64 + for _, bin := range bins {
65 + if _, err := exec.LookPath(bin); err != nil {
66 + t.Fatalf("%s not in PATH; required for end-to-end FUSE tests", bin)
67 + }
68 + }
69 + }
70 +
71 + // workdir creates a unique subdirectory under the mount for the
72 + // current subtest. Subtests share one daemon and one mount; using
73 + // disjoint subdirectories keeps them from colliding.
74 + workdir := func(t *testing.T, name string) string {
75 + t.Helper()
76 + d := filepath.Join(mfsMount, name)
77 + require.NoError(t, os.Mkdir(d, 0o755))
78 + return d
79 + }
80 +
81 + // runCmd runs an external binary and fails the test on error,
82 + // printing both stdout and stderr in the failure message.
83 + //
84 + // LC_ALL=C forces the C locale so any locale-sensitive output
85 + // (date formats in `ls -l`, decimal separators in `wc` output on
86 + // some locales, localized error messages, collation order from
87 + // `find` and `ls`) is deterministic regardless of how the runner
88 + // is configured. Without this the same test could pass on a US
89 + // runner and fail on one with LC_ALL=de_DE.UTF-8.
90 + runCmd := func(t *testing.T, name string, args ...string) string {
91 + t.Helper()
92 + cmd := exec.Command(name, args...)
93 + cmd.Env = append(os.Environ(), "LC_ALL=C")
94 + var stdout, stderr bytes.Buffer
95 + cmd.Stdout = &stdout
96 + cmd.Stderr = &stderr
97 + if err := cmd.Run(); err != nil {
98 + t.Fatalf("%s %v failed: %v\nstdout: %s\nstderr: %s",
99 + name, args, err, stdout.String(), stderr.String())
100 + }
101 + return stdout.String()
102 + }
103 +
104 + // randBytes returns n cryptographically random bytes.
105 + randBytes := func(t *testing.T, n int) []byte {
106 + t.Helper()
107 + b := make([]byte, n)
108 + _, err := rand.Read(b)
109 + require.NoError(t, err)
110 + return b
111 + }
112 +
113 + // ----- Shell and core POSIX -----
114 +
115 + t.Run("echo_redirect_and_cat", func(t *testing.T) {
116 + requireTool(t, "sh", "cat")
117 + dir := workdir(t, "echo_redirect_and_cat")
118 + path := filepath.Join(dir, "greeting")
119 +
120 + runCmd(t, "sh", "-c", "echo 'hello fuse' > "+path)
121 +
122 + got := runCmd(t, "cat", path)
123 + require.Equal(t, "hello fuse\n", got, "cat output via FUSE")
124 +
125 + // Cross-verify via daemon's MFS view (bypasses FUSE).
126 + ipfsView := node.IPFS("files", "read", "/echo_redirect_and_cat/greeting").Stdout.String()
127 + require.Equal(t, "hello fuse\n", ipfsView, "ipfs files read view")
128 + })
129 +
130 + t.Run("seq_pipe_to_file_and_wc", func(t *testing.T) {
131 + requireTool(t, "sh", "seq", "wc")
132 + dir := workdir(t, "seq_pipe_to_file_and_wc")
133 + path := filepath.Join(dir, "lines")
134 +
135 + // 200000 lines: about 1.3 MB of text, comfortably more than
136 + // one UnixFS chunk under the default chunker.
137 + runCmd(t, "sh", "-c", "seq 1 200000 > "+path)
138 +
139 + lineCount := strings.Fields(runCmd(t, "wc", "-l", path))[0]
140 + require.Equal(t, "200000", lineCount)
141 +
142 + // File size should match: digits + newline per line.
143 + // sum_{i=1..9} i*9*1 + sum_{i=10..99} i*90*2 + ... easier to
144 + // just stat the file and compare against wc -c.
145 + byteCount := strings.Fields(runCmd(t, "wc", "-c", path))[0]
146 + info, err := os.Stat(path)
147 + require.NoError(t, err)
148 + require.Equal(t, strconv.FormatInt(info.Size(), 10), byteCount,
149 + "wc -c and stat agree on the multi-chunk file size")
150 + require.Greater(t, info.Size(), int64(payloadSize),
151 + "file should be larger than one chunk")
152 + })
153 +
154 + t.Run("ls_l_shows_mode_and_size", func(t *testing.T) {
155 + requireTool(t, "ls")
156 + dir := workdir(t, "ls_l_shows_mode_and_size")
157 + path := filepath.Join(dir, "file")
158 +
159 + data := randBytes(t, payloadSize)
160 + require.NoError(t, os.WriteFile(path, data, 0o644))
161 +
162 + // `ls -l` line layout: <mode> <links> <user> <group> <size> <date> <name>
163 + out := runCmd(t, "ls", "-l", path)
164 + fields := strings.Fields(out)
165 + require.GreaterOrEqual(t, len(fields), 8, "ls -l output: %q", out)
166 +
167 + require.True(t, strings.HasPrefix(fields[0], "-rw-r--r--"),
168 + "mode field %q should be -rw-r--r--", fields[0])
169 + require.Equal(t, strconv.Itoa(payloadSize), fields[4],
170 + "size field should match payload size")
171 + })
172 +
173 + t.Run("stat_reports_default_mode", func(t *testing.T) {
174 + requireTool(t, "stat")
175 + dir := workdir(t, "stat_reports_default_mode")
176 + path := filepath.Join(dir, "file")
177 +
178 + f, err := os.Create(path)
179 + require.NoError(t, err)
180 + require.NoError(t, f.Close())
181 +
182 + out := strings.TrimSpace(runCmd(t, "stat", "-c", "%a %s", path))
183 + require.Equal(t, "644 0", out, "stat -c '%%a %%s' on a fresh file")
184 + })
185 +
186 + t.Run("cp_file_in", func(t *testing.T) {
187 + requireTool(t, "cp")
188 + dir := workdir(t, "cp_file_in")
189 +
190 + src := filepath.Join(node.Dir, "cp_file_in_src")
191 + want := randBytes(t, payloadSize)
192 + require.NoError(t, os.WriteFile(src, want, 0o644))
193 +
194 + dst := filepath.Join(dir, "cp-in")
195 + runCmd(t, "cp", src, dst)
196 +
197 + got, err := os.ReadFile(dst)
198 + require.NoError(t, err)
199 + require.True(t, bytes.Equal(want, got), "FUSE read-back differs")
200 +
201 + // Cross-verify via daemon. ipfs files read can return huge
202 + // blobs; compare lengths first to fail fast.
203 + daemonView := node.IPFS("files", "read", "/cp_file_in/cp-in").Stdout.Bytes()
204 + require.Equal(t, len(want), len(daemonView), "daemon view length")
205 + require.True(t, bytes.Equal(want, daemonView), "daemon view content")
206 + })
207 +
208 + t.Run("cp_r_tree_in", func(t *testing.T) {
209 + requireTool(t, "cp")
210 + dir := workdir(t, "cp_r_tree_in")
211 +
212 + // Build the source tree under node.Dir.
213 + srcRoot := filepath.Join(node.Dir, "cp_r_tree_in_src")
214 + require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "a", "b", "c"), 0o755))
215 +
216 + topData := randBytes(t, payloadSize)
217 + leafData := randBytes(t, payloadSize)
218 + require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "top.bin"), topData, 0o644))
219 + require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "a", "b", "c", "leaf.bin"), leafData, 0o644))
220 +
221 + runCmd(t, "cp", "-r", srcRoot, dir+"/")
222 +
223 + // Walk the FUSE side and assert both files match.
224 + gotTop, err := os.ReadFile(filepath.Join(dir, "cp_r_tree_in_src", "top.bin"))
225 + require.NoError(t, err)
226 + require.True(t, bytes.Equal(topData, gotTop), "top file content")
227 +
228 + gotLeaf, err := os.ReadFile(filepath.Join(dir, "cp_r_tree_in_src", "a", "b", "c", "leaf.bin"))
229 + require.NoError(t, err)
230 + require.True(t, bytes.Equal(leafData, gotLeaf), "leaf file content")
231 +
232 + // Cross-verify the deepest file via the daemon.
233 + daemonView := node.IPFS("files", "read",
234 + "/cp_r_tree_in/cp_r_tree_in_src/a/b/c/leaf.bin").Stdout.Bytes()
235 + require.True(t, bytes.Equal(leafData, daemonView), "daemon view of deepest leaf")
236 + })
237 +
238 + t.Run("cp_file_out", func(t *testing.T) {
239 + requireTool(t, "cp")
240 + dir := workdir(t, "cp_file_out")
241 +
242 + want := randBytes(t, payloadSize)
243 + src := filepath.Join(dir, "payload")
244 + require.NoError(t, os.WriteFile(src, want, 0o644))
245 +
246 + dst := filepath.Join(node.Dir, "cp_file_out_dst")
247 + runCmd(t, "cp", src, dst)
248 +
249 + got, err := os.ReadFile(dst)
250 + require.NoError(t, err)
251 + require.True(t, bytes.Equal(want, got), "exported file content")
252 + })
253 +
254 + t.Run("mv_atomic_save", func(t *testing.T) {
255 + requireTool(t, "mv")
256 + dir := workdir(t, "mv_atomic_save")
257 +
258 + oldData := randBytes(t, payloadSize)
259 + newData := randBytes(t, payloadSize)
260 +
261 + target := filepath.Join(dir, "target")
262 + tmp := filepath.Join(dir, ".target.tmp")
263 +
264 + require.NoError(t, os.WriteFile(target, oldData, 0o644))
265 + require.NoError(t, os.WriteFile(tmp, newData, 0o644))
266 +
267 + runCmd(t, "mv", tmp, target)
268 +
269 + got, err := os.ReadFile(target)
270 + require.NoError(t, err)
271 + require.True(t, bytes.Equal(newData, got), "target should now hold new data")
272 +
273 + _, err = os.Stat(tmp)
274 + require.True(t, os.IsNotExist(err), "tmp should be gone after mv")
275 + })
276 +
277 + t.Run("rm_rf_tree", func(t *testing.T) {
278 + requireTool(t, "cp", "rm")
279 + dir := workdir(t, "rm_rf_tree")
280 +
281 + // Build a tree the same shape as cp_r_tree_in.
282 + srcRoot := filepath.Join(node.Dir, "rm_rf_tree_src")
283 + require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "a", "b", "c"), 0o755))
284 + require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "top.bin"), randBytes(t, payloadSize), 0o644))
285 + require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "a", "b", "c", "leaf.bin"), randBytes(t, payloadSize), 0o644))
286 +
287 + runCmd(t, "cp", "-r", srcRoot, dir+"/")
288 + copied := filepath.Join(dir, "rm_rf_tree_src")
289 +
290 + // Sanity: tree exists.
291 + _, err := os.Stat(filepath.Join(copied, "a", "b", "c", "leaf.bin"))
292 + require.NoError(t, err)
293 +
294 + runCmd(t, "rm", "-rf", copied)
295 +
296 + _, err = os.Stat(copied)
297 + require.True(t, os.IsNotExist(err), "copied tree should be gone")
298 +
299 + // Cross-verify the daemon no longer lists the subtree.
300 + listing := node.IPFS("files", "ls", "/rm_rf_tree").Stdout.String()
301 + require.NotContains(t, listing, "rm_rf_tree_src",
302 + "ipfs files ls should not see the removed subtree")
303 + })
304 +
305 + t.Run("ln_s_and_readlink", func(t *testing.T) {
306 + requireTool(t, "ln", "readlink", "ls")
307 + dir := workdir(t, "ln_s_and_readlink")
308 + link := filepath.Join(dir, "link")
309 +
310 + runCmd(t, "ln", "-s", "/tmp/some/target", link)
311 +
312 + target := strings.TrimSpace(runCmd(t, "readlink", link))
313 + require.Equal(t, "/tmp/some/target", target)
314 +
315 + // ls -l on a symlink starts with 'l'.
316 + lsOut := runCmd(t, "ls", "-l", link)
317 + require.True(t, strings.HasPrefix(lsOut, "l"),
318 + "ls -l output should start with 'l' for a symlink, got: %q", lsOut)
319 +
320 + // Daemon view: ipfs files stat reports symlinks via the Mode
321 + // field (lrwxrwxrwx). The Type field is "file" because MFS
322 + // stores symlinks as TFile/TSymlink under the hood.
323 + stat := node.IPFS("files", "stat", "/ln_s_and_readlink/link").Stdout.String()
324 + require.Contains(t, stat, "lrwxrwxrwx",
325 + "ipfs files stat mode should be lrwxrwxrwx for a symlink, got: %s", stat)
326 + })
327 +
328 + t.Run("find_traversal", func(t *testing.T) {
329 + requireTool(t, "find", "ln")
330 + dir := workdir(t, "find_traversal")
331 +
332 + require.NoError(t, os.WriteFile(filepath.Join(dir, "regular"), randBytes(t, payloadSize), 0o644))
333 + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0o755))
334 + runCmd(t, "ln", "-s", "regular", filepath.Join(dir, "link"))
335 +
336 + // strings.Fields splits on any whitespace; this is safe here
337 + // because every test filename is ASCII with no spaces. If a
338 + // future maintainer adds a filename with whitespace, switch
339 + // to `find -print0` and split on '\x00' instead.
340 +
341 + // -type f should find exactly the regular file.
342 + files := strings.Fields(runCmd(t, "find", dir, "-type", "f"))
343 + require.Equal(t, []string{filepath.Join(dir, "regular")}, files)
344 +
345 + // -type d should find dir itself plus subdir.
346 + dirs := strings.Fields(runCmd(t, "find", dir, "-type", "d"))
347 + require.ElementsMatch(t, []string{dir, filepath.Join(dir, "subdir")}, dirs)
348 +
349 + // -type l should find exactly the symlink.
350 + links := strings.Fields(runCmd(t, "find", dir, "-type", "l"))
351 + require.Equal(t, []string{filepath.Join(dir, "link")}, links)
352 + })
353 +
354 + t.Run("dd_block_write", func(t *testing.T) {
355 + requireTool(t, "dd")
356 + dir := workdir(t, "dd_block_write")
357 + path := filepath.Join(dir, "blob")
358 +
359 + // 4096 * 257 = 1052672 bytes, just past the 1 MiB chunk
360 + // boundary. Uses /dev/urandom to avoid pulling all-zero
361 + // pages from the kernel cache.
362 + runCmd(t, "dd",
363 + "if=/dev/urandom",
364 + "of="+path,
365 + "bs=4096",
366 + "count=257",
367 + "status=none",
368 + )
369 +
370 + info, err := os.Stat(path)
371 + require.NoError(t, err)
372 + require.Equal(t, int64(4096*257), info.Size())
373 + })
374 +
375 + t.Run("sha256sum_roundtrip", func(t *testing.T) {
376 + requireTool(t, "sha256sum")
377 + dir := workdir(t, "sha256sum_roundtrip")
378 + path := filepath.Join(dir, "blob")
379 +
380 + want := randBytes(t, payloadSize)
381 + require.NoError(t, os.WriteFile(path, want, 0o644))
382 +
383 + hash := sha256.Sum256(want)
384 + wantHex := hex.EncodeToString(hash[:])
385 +
386 + out := runCmd(t, "sha256sum", path)
387 + // `sha256sum` prints "<hex> <path>".
388 + gotHex := strings.Fields(out)[0]
389 + require.Equal(t, wantHex, gotHex,
390 + "sha256sum on FUSE-read bytes should match the bytes we wrote")
391 + })
392 +
393 + // ----- Archives -----
394 +
395 + t.Run("tar_extract_into_mfs", func(t *testing.T) {
396 + requireTool(t, "tar")
397 + dir := workdir(t, "tar_extract_into_mfs")
398 +
399 + // Build the source tree and tar it up under node.Dir.
400 + srcRoot := filepath.Join(node.Dir, "tar_extract_src")
401 + require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "sub"), 0o755))
402 + oneData := randBytes(t, payloadSize)
403 + twoData := randBytes(t, payloadSize)
404 + require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "one.bin"), oneData, 0o644))
405 + require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "sub", "two.bin"), twoData, 0o644))
406 +
407 + tarPath := filepath.Join(node.Dir, "tar_extract.tar")
408 + runCmd(t, "tar", "-cf", tarPath, "-C", node.Dir, "tar_extract_src")
409 +
410 + // Extract into the FUSE mount.
411 + runCmd(t, "tar", "-xf", tarPath, "-C", dir)
412 +
413 + extracted := filepath.Join(dir, "tar_extract_src")
414 + gotOne, err := os.ReadFile(filepath.Join(extracted, "one.bin"))
415 + require.NoError(t, err)
416 + require.True(t, bytes.Equal(oneData, gotOne), "one.bin content")
417 +
418 + gotTwo, err := os.ReadFile(filepath.Join(extracted, "sub", "two.bin"))
419 + require.NoError(t, err)
420 + require.True(t, bytes.Equal(twoData, gotTwo), "two.bin content")
421 + })
422 +
423 + t.Run("tar_create_from_mfs", func(t *testing.T) {
424 + requireTool(t, "tar")
425 + dir := workdir(t, "tar_create_from_mfs")
426 +
427 + // Populate a small tree under the FUSE mount.
428 + srcRoot := filepath.Join(dir, "src")
429 + require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "sub"), 0o755))
430 + oneData := randBytes(t, payloadSize)
431 + twoData := randBytes(t, payloadSize)
432 + require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "one.bin"), oneData, 0o644))
433 + require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "sub", "two.bin"), twoData, 0o644))
434 +
435 + // tar it up *from* the mount.
436 + tarPath := filepath.Join(node.Dir, "tar_create.tar")
437 + runCmd(t, "tar", "-cf", tarPath, "-C", dir, "src")
438 +
439 + // tar listing should include both leaves.
440 + listing := runCmd(t, "tar", "-tf", tarPath)
441 + require.Contains(t, listing, "src/one.bin")
442 + require.Contains(t, listing, "src/sub/two.bin")
443 +
444 + // Extract back to a fresh dir off the mount and byte-compare.
445 + extractDir := filepath.Join(node.Dir, "tar_create_extract")
446 + require.NoError(t, os.MkdirAll(extractDir, 0o755))
447 + runCmd(t, "tar", "-xf", tarPath, "-C", extractDir)
448 +
449 + gotOne, err := os.ReadFile(filepath.Join(extractDir, "src", "one.bin"))
450 + require.NoError(t, err)
451 + require.True(t, bytes.Equal(oneData, gotOne), "one.bin survives tar round-trip")
452 +
453 + gotTwo, err := os.ReadFile(filepath.Join(extractDir, "src", "sub", "two.bin"))
454 + require.NoError(t, err)
455 + require.True(t, bytes.Equal(twoData, gotTwo), "two.bin survives tar round-trip")
456 + })
457 +
458 + // ----- Rsync -----
459 +
460 + t.Run("rsync_archive_in", func(t *testing.T) {
461 + requireTool(t, "rsync")
462 + dir := workdir(t, "rsync_archive_in")
463 +
464 + // Build a tree under node.Dir with a known mode and mtime.
465 + srcRoot := filepath.Join(node.Dir, "rsync_archive_src")
466 + require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "sub"), 0o755))
467 +
468 + oneData := randBytes(t, payloadSize)
469 + twoData := randBytes(t, payloadSize)
470 + onePath := filepath.Join(srcRoot, "one.bin")
471 + twoPath := filepath.Join(srcRoot, "sub", "two.bin")
472 + require.NoError(t, os.WriteFile(onePath, oneData, 0o640))
473 + require.NoError(t, os.WriteFile(twoPath, twoData, 0o640))
474 +
475 + mtime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
476 + require.NoError(t, os.Chtimes(onePath, mtime, mtime))
477 + require.NoError(t, os.Chtimes(twoPath, mtime, mtime))
478 +
479 + // Trailing slash on source: copy the contents of srcRoot,
480 + // not the directory itself. Mirrors typical rsync usage.
481 + runCmd(t, "rsync", "-a", srcRoot+"/", dir+"/copy/")
482 +
483 + gotOne, err := os.ReadFile(filepath.Join(dir, "copy", "one.bin"))
484 + require.NoError(t, err)
485 + require.True(t, bytes.Equal(oneData, gotOne), "one.bin content")
486 +
487 + gotTwo, err := os.ReadFile(filepath.Join(dir, "copy", "sub", "two.bin"))
488 + require.NoError(t, err)
489 + require.True(t, bytes.Equal(twoData, gotTwo), "two.bin content")
490 +
491 + // Mode preserved (StoreMode is enabled at the daemon level).
492 + oneInfo, err := os.Stat(filepath.Join(dir, "copy", "one.bin"))
493 + require.NoError(t, err)
494 + require.Equal(t, os.FileMode(0o640), oneInfo.Mode().Perm(),
495 + "mode should be preserved through rsync -a")
496 +
497 + // Mtime preserved (StoreMtime is enabled at the daemon level).
498 + require.WithinDuration(t, mtime, oneInfo.ModTime(), time.Second,
499 + "mtime should be preserved through rsync -a")
500 + })
501 +
502 + t.Run("rsync_inplace_overwrite", func(t *testing.T) {
503 + requireTool(t, "rsync")
504 + dir := workdir(t, "rsync_inplace_overwrite")
505 +
506 + // Initial file is larger than the replacement so the inplace
507 + // path has to truncate the tail.
508 + initial := randBytes(t, payloadSize+4096)
509 + dst := filepath.Join(dir, "inplace")
510 + require.NoError(t, os.WriteFile(dst, initial, 0o644))
511 +
512 + replacement := randBytes(t, payloadSize)
513 + src := filepath.Join(node.Dir, "rsync_inplace_replacement")
514 + require.NoError(t, os.WriteFile(src, replacement, 0o644))
515 +
516 + runCmd(t, "rsync", "--inplace", src, dst)
517 +
518 + got, err := os.ReadFile(dst)
519 + require.NoError(t, err)
520 + require.Equal(t, len(replacement), len(got),
521 + "file size should shrink to replacement size after --inplace")
522 + require.True(t, bytes.Equal(replacement, got),
523 + "content should match the replacement after --inplace")
524 + })
525 +
526 + // ----- Editor -----
527 +
528 + t.Run("vim_edit_file", func(t *testing.T) {
529 + requireTool(t, "vim")
530 + dir := workdir(t, "vim_edit_file")
531 + path := filepath.Join(dir, "edit.txt")
532 +
533 + // Build a multi-chunk file: a header line followed by enough
534 + // "world" repeats to push the total size past one UnixFS chunk.
535 + const word = "world\n"
536 + repeats := payloadSize/len(word) + 1
537 + var buf bytes.Buffer
538 + buf.WriteString("header\n")
539 + for range repeats {
540 + buf.WriteString(word)
541 + }
542 + original := buf.Bytes()
543 + require.NoError(t, os.WriteFile(path, original, 0o644))
544 + require.Greater(t, len(original), payloadSize, "file should span multiple chunks")
545 +
546 + // Vim in headless ex mode: substitute world->fuse globally,
547 + // write, quit. -E selects ex mode, -s suppresses prompts.
548 + runCmd(t, "vim", "-E", "-s",
549 + "-c", "%s/world/fuse/g",
550 + "-c", "wq",
551 + path,
552 + )
553 +
554 + got, err := os.ReadFile(path)
555 + require.NoError(t, err)
556 + require.NotContains(t, string(got), "world",
557 + "after :%%s/world/fuse/g the file should contain no 'world'")
558 + gotFuses := bytes.Count(got, []byte("fuse"))
559 + require.Equal(t, repeats, gotFuses,
560 + "the substitution should have replaced exactly %d occurrences", repeats)
561 +
562 + // Cross-verify via daemon.
563 + daemonView := node.IPFS("files", "read", "/vim_edit_file/edit.txt").Stdout.Bytes()
564 + require.True(t, bytes.Equal(got, daemonView),
565 + "daemon view should match FUSE view after vim save")
566 + })
567 +}
test/cli/fuse/xattr_linux_test.go new
+15
@@ -0,0 +1,15 @@
1 +// Uses unix.Getxattr which is only available on Linux.
2 +//go:build linux
3 +
4 +package fuse
5 +
6 +import "golang.org/x/sys/unix"
7 +
8 +func getXattr(path, attr string) (string, error) {
9 + buf := make([]byte, 256)
10 + sz, err := unix.Getxattr(path, attr, buf)
11 + if err != nil {
12 + return "", err
13 + }
14 + return string(buf[:sz]), nil
15 +}
test/cli/fuse/xattr_other_test.go new
+13
@@ -0,0 +1,13 @@
1 +// Stub that skips xattr tests on non-Linux platforms.
2 +//go:build !linux
3 +
4 +package fuse
5 +
6 +import (
7 + "fmt"
8 + "runtime"
9 +)
10 +
11 +func getXattr(_, _ string) (string, error) {
12 + return "", fmt.Errorf("xattr not supported on %s", runtime.GOOS)
13 +}
test/cli/fuse_test.go deleted
-166
@@ -1,166 +0,0 @@
1 -package cli
2 -
3 -import (
4 - "os"
5 - "os/exec"
6 - "path/filepath"
7 - "runtime"
8 - "strings"
9 - "testing"
10 -
11 - "github.com/ipfs/kubo/test/cli/harness"
12 - "github.com/ipfs/kubo/test/cli/testutils"
13 - "github.com/stretchr/testify/require"
14 -)
15 -
16 -func TestFUSE(t *testing.T) {
17 - testutils.RequiresFUSE(t)
18 - t.Parallel()
19 -
20 - t.Run("mount and unmount work correctly", func(t *testing.T) {
21 - t.Parallel()
22 -
23 - // Create a node and start daemon
24 - node := harness.NewT(t).NewNode().Init()
25 - node.StartDaemon()
26 -
27 - // Create mount directories in the node's working directory
28 - nodeDir := node.Dir
29 - ipfsMount := filepath.Join(nodeDir, "ipfs")
30 - ipnsMount := filepath.Join(nodeDir, "ipns")
31 - mfsMount := filepath.Join(nodeDir, "mfs")
32 -
33 - err := os.MkdirAll(ipfsMount, 0755)
34 - require.NoError(t, err)
35 - err = os.MkdirAll(ipnsMount, 0755)
36 - require.NoError(t, err)
37 - err = os.MkdirAll(mfsMount, 0755)
38 - require.NoError(t, err)
39 -
40 - // Ensure any existing mounts are cleaned up first
41 - failOnError := false // mount points might not exist from previous runs
42 - doUnmount(t, ipfsMount, failOnError)
43 - doUnmount(t, ipnsMount, failOnError)
44 - doUnmount(t, mfsMount, failOnError)
45 -
46 - // Test mount operation
47 - result := node.IPFS("mount", "-f", ipfsMount, "-n", ipnsMount, "-m", mfsMount)
48 -
49 - // Verify mount output
50 - expectedOutput := "IPFS mounted at: " + ipfsMount + "\n" +
51 - "IPNS mounted at: " + ipnsMount + "\n" +
52 - "MFS mounted at: " + mfsMount + "\n"
53 - require.Equal(t, expectedOutput, result.Stdout.String())
54 -
55 - // Test basic MFS functionality via FUSE mount
56 - testFile := filepath.Join(mfsMount, "testfile")
57 - testContent := "hello fuse world"
58 -
59 - // Create file via FUSE mount
60 - err = os.WriteFile(testFile, []byte(testContent), 0644)
61 - require.NoError(t, err)
62 -
63 - // Verify file appears in MFS via IPFS commands
64 - result = node.IPFS("files", "ls", "/")
65 - require.Contains(t, result.Stdout.String(), "testfile")
66 -
67 - // Read content back via MFS FUSE mount
68 - readContent, err := os.ReadFile(testFile)
69 - require.NoError(t, err)
70 - require.Equal(t, testContent, string(readContent))
71 -
72 - // Get the CID of the MFS file
73 - result = node.IPFS("files", "stat", "/testfile", "--format=<hash>")
74 - fileCID := strings.TrimSpace(result.Stdout.String())
75 - require.NotEmpty(t, fileCID, "should have a CID for the MFS file")
76 -
77 - // Read the same content via IPFS FUSE mount using the CID
78 - ipfsFile := filepath.Join(ipfsMount, fileCID)
79 - ipfsContent, err := os.ReadFile(ipfsFile)
80 - require.NoError(t, err)
81 - require.Equal(t, testContent, string(ipfsContent), "content should match between MFS and IPFS mounts")
82 -
83 - // Verify both FUSE mounts return identical data
84 - require.Equal(t, readContent, ipfsContent, "MFS and IPFS FUSE mounts should return identical data")
85 -
86 - // Test that mount directories cannot be removed while mounted
87 - err = os.Remove(ipfsMount)
88 - require.Error(t, err, "should not be able to remove mounted directory")
89 -
90 - // Stop daemon - this should trigger automatic unmount via context cancellation
91 - node.StopDaemon()
92 -
93 - // Daemon shutdown should handle unmount synchronously via context.AfterFunc
94 -
95 - // Verify directories can now be removed (indicating successful unmount)
96 - err = os.Remove(ipfsMount)
97 - require.NoError(t, err, "should be able to remove directory after unmount")
98 - err = os.Remove(ipnsMount)
99 - require.NoError(t, err, "should be able to remove directory after unmount")
100 - err = os.Remove(mfsMount)
101 - require.NoError(t, err, "should be able to remove directory after unmount")
102 - })
103 -
104 - t.Run("explicit unmount works", func(t *testing.T) {
105 - t.Parallel()
106 -
107 - node := harness.NewT(t).NewNode().Init()
108 - node.StartDaemon()
109 -
110 - // Create mount directories
111 - nodeDir := node.Dir
112 - ipfsMount := filepath.Join(nodeDir, "ipfs")
113 - ipnsMount := filepath.Join(nodeDir, "ipns")
114 - mfsMount := filepath.Join(nodeDir, "mfs")
115 -
116 - err := os.MkdirAll(ipfsMount, 0755)
117 - require.NoError(t, err)
118 - err = os.MkdirAll(ipnsMount, 0755)
119 - require.NoError(t, err)
120 - err = os.MkdirAll(mfsMount, 0755)
121 - require.NoError(t, err)
122 -
123 - // Clean up any existing mounts
124 - failOnError := false // mount points might not exist from previous runs
125 - doUnmount(t, ipfsMount, failOnError)
126 - doUnmount(t, ipnsMount, failOnError)
127 - doUnmount(t, mfsMount, failOnError)
128 -
129 - // Mount
130 - node.IPFS("mount", "-f", ipfsMount, "-n", ipnsMount, "-m", mfsMount)
131 -
132 - // Explicit unmount via platform-specific command
133 - failOnError = true // test that explicit unmount works correctly
134 - doUnmount(t, ipfsMount, failOnError)
135 - doUnmount(t, ipnsMount, failOnError)
136 - doUnmount(t, mfsMount, failOnError)
137 -
138 - // Verify directories can be removed after explicit unmount
139 - err = os.Remove(ipfsMount)
140 - require.NoError(t, err)
141 - err = os.Remove(ipnsMount)
142 - require.NoError(t, err)
143 - err = os.Remove(mfsMount)
144 - require.NoError(t, err)
145 -
146 - node.StopDaemon()
147 - })
148 -}
149 -
150 -// doUnmount performs platform-specific unmount, similar to sharness do_umount
151 -// failOnError: if true, unmount errors cause test failure; if false, errors are ignored (useful for cleanup)
152 -func doUnmount(t *testing.T, mountPoint string, failOnError bool) {
153 - t.Helper()
154 - var cmd *exec.Cmd
155 - if runtime.GOOS == "linux" {
156 - // fusermount -u: unmount filesystem (strict - fails if busy)
157 - cmd = exec.Command("fusermount", "-u", mountPoint)
158 - } else {
159 - cmd = exec.Command("umount", mountPoint)
160 - }
161 -
162 - err := cmd.Run()
163 - if err != nil && failOnError {
164 - t.Fatalf("failed to unmount %s: %v", mountPoint, err)
165 - }
166 -}
test/cli/ipfswatch_test.go
+1
@@ -1,3 +1,4 @@
1 +// Excluded from plan9 (no fsnotify support).
2 //go:build !plan9
3
4 package cli
test/dependencies/dependencies.go
+1
@@ -1,3 +1,4 @@
1 +// Tracks test tool dependencies in go.mod without importing them in production.
2 //go:build tools
3
4 package tools
test/sharness/lib/test-lib.sh
+1 -1
@@ -364,7 +364,7 @@ test_mount_ipfs() {
364 test_expect_success FUSE "'ipfs mount' output looks good" '
365 echo "IPFS mounted at: $(pwd)/ipfs" >expected &&
366 echo "IPNS mounted at: $(pwd)/ipns" >>expected &&
367 - echo "MFS mounted at: $(pwd)/mfs" >>expected &&
367 + echo "MFS mounted at: $(pwd)/mfs" >>expected &&
368 test_cmp expected actual
369 '
370
test/sharness/t0030-mount.sh deleted
-133
@@ -1,133 +0,0 @@
1 -#!/usr/bin/env bash
2 -#
3 -# Copyright (c) 2014 Christian Couder
4 -# MIT Licensed; see the LICENSE file in this repository.
5 -#
6 -
7 -test_description="Test mount command"
8 -
9 -. lib/test-lib.sh
10 -
11 -# if in travis CI, don't test mount (no fuse)
12 -if ! test_have_prereq FUSE; then
13 - skip_all='skipping mount tests, fuse not available'
14 -
15 - test_done
16 -fi
17 -
18 -
19 -# echo -n "ipfs" > expected && ipfs add --cid-version 1 -Q -w expected
20 -export IPFS_NS_MAP="welcome.example.com:/ipfs/bafybeicq7bvn5lz42qlmghaoiwrve74pzi53auqetbantp5kajucsabike"
21 -
22 -# start iptb + wait for peering
23 -NUM_NODES=5
24 -test_expect_success 'init iptb' '
25 - iptb testbed create -type localipfs -count $NUM_NODES -init
26 -'
27 -startup_cluster $NUM_NODES
28 -
29 -# test mount failure before mounting properly.
30 -test_expect_success "'ipfs mount' fails when there is no mount dir" '
31 - tmp_ipfs_mount() { ipfsi 0 mount -f=not_ipfs -n=not_ipns -m=not_mfs >output 2>output.err; } &&
32 - test_must_fail tmp_ipfs_mount
33 -'
34 -
35 -test_expect_success "'ipfs mount' output looks good" '
36 - test_must_be_empty output &&
37 - test_should_contain "not_ipns\|not_ipfs\|not_mfs" output.err
38 -'
39 -
40 -test_expect_success "setup and publish default IPNS value" '
41 - mkdir "$(pwd)/ipfs" "$(pwd)/ipns" "$(pwd)/mfs" &&
42 - ipfsi 0 name publish QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn
43 -'
44 -
45 -# make sure stuff is unmounted first
46 -# then mount properly
47 -test_expect_success FUSE "'ipfs mount' succeeds" '
48 - do_umount "$(pwd)/ipfs" || true &&
49 - do_umount "$(pwd)/ipns" || true &&
50 - do_umount "$(pwd)/mfs" || true &&
51 - ipfsi 0 mount -f "$(pwd)/ipfs" -n "$(pwd)/ipns" -m "$(pwd)/mfs" >actual
52 -'
53 -
54 -test_expect_success FUSE "'ipfs mount' output looks good" '
55 - echo "IPFS mounted at: $(pwd)/ipfs" >expected &&
56 - echo "IPNS mounted at: $(pwd)/ipns" >>expected &&
57 - echo "MFS mounted at: $(pwd)/mfs" >>expected &&
58 - test_cmp expected actual
59 -'
60 -
61 -test_expect_success FUSE "local symlink works" '
62 - ipfsi 0 id -f"<id>\n" > expected &&
63 - basename $(readlink ipns/local) > actual &&
64 - test_cmp expected actual
65 -'
66 -
67 -test_expect_success FUSE "can resolve ipns names" '
68 - echo -n "ipfs" > expected &&
69 - ipfsi 0 add --cid-version 1 -Q -w expected &&
70 - cat ipns/welcome.example.com/expected > actual &&
71 - test_cmp expected actual
72 -'
73 -
74 -test_expect_success FUSE "create mfs file via fuse" '
75 - touch mfs/testfile &&
76 - ipfsi 0 files ls | grep testfile
77 -'
78 -
79 -test_expect_success FUSE "create mfs dir via fuse" '
80 - mkdir mfs/testdir &&
81 - ipfsi 0 files ls | grep testdir
82 -'
83 -
84 -test_expect_success FUSE "read mfs file from fuse" '
85 - echo content > mfs/testfile &&
86 - getfattr -n ipfs_cid mfs/testfile
87 -'
88 -test_expect_success FUSE "ipfs add file and read it back via fuse" '
89 - echo content3 | ipfsi 0 files write -e /testfile3 &&
90 - grep content3 mfs/testfile3
91 -'
92 -
93 -test_expect_success FUSE "ipfs add file and read it back via fuse" '
94 - echo content > testfile2 &&
95 - ipfsi 0 add --to-files /testfile2 testfile2 &&
96 - grep content mfs/testfile2
97 -'
98 -
99 -test_expect_success FUSE "test file xattr" '
100 - echo content > mfs/testfile &&
101 - getfattr -n ipfs_cid mfs/testfile
102 -'
103 -
104 -test_expect_success FUSE "test file removal" '
105 - touch mfs/testfile &&
106 - rm mfs/testfile
107 -'
108 -
109 -test_expect_success FUSE "test nested dirs" '
110 - mkdir -p mfs/foo/bar/baz/qux &&
111 - echo content > mfs/foo/bar/baz/qux/quux &&
112 - ipfsi 0 files stat /foo/bar/baz/qux/quux
113 -'
114 -
115 -test_expect_success "mount directories cannot be removed while active" '
116 - test_must_fail rmdir ipfs ipns mfs 2>/dev/null
117 -'
118 -
119 -test_expect_success "unmount directories" '
120 - do_umount "$(pwd)/ipfs" &&
121 - do_umount "$(pwd)/ipns" &&
122 - do_umount "$(pwd)/mfs"
123 -'
124 -
125 -test_expect_success "mount directories can be removed after shutdown" '
126 - rmdir ipfs ipns mfs
127 -'
128 -
129 -test_expect_success 'stop iptb' '
130 - iptb stop
131 -'
132 -
133 -test_done
test/sharness/t0031-mount-publish.sh deleted
-62
@@ -1,62 +0,0 @@
1 -#!/usr/bin/env bash
2 -
3 -test_description="Test mount command in conjunction with publishing"
4 -
5 -# imports
6 -. lib/test-lib.sh
7 -
8 -# if in travis CI, don't test mount (no fuse)
9 -if ! test_have_prereq FUSE; then
10 - skip_all='skipping mount tests, fuse not available'
11 -
12 - test_done
13 -fi
14 -
15 -test_init_ipfs
16 -
17 -# start iptb + wait for peering
18 -NUM_NODES=3
19 -test_expect_success 'init iptb' '
20 - iptb testbed create -type localipfs -count $NUM_NODES -force -init &&
21 - startup_cluster $NUM_NODES
22 -'
23 -
24 -# pre-mount publish
25 -HASH=$(echo 'hello warld' | ipfsi 0 add -Q -w --stdin-name "file")
26 -test_expect_success "can publish before mounting /ipns" '
27 - ipfsi 0 name publish "$HASH"
28 -'
29 -
30 -# mount
31 -IPFS_MOUNT_DIR="$PWD/ipfs"
32 -IPNS_MOUNT_DIR="$PWD/ipns"
33 -test_expect_success FUSE "'ipfs mount' succeeds" '
34 - ipfsi 0 mount -f "'"$IPFS_MOUNT_DIR"'" -n "'"$IPNS_MOUNT_DIR"'" >actual
35 -'
36 -test_expect_success FUSE "'ipfs mount' output looks good" '
37 - echo "IPFS mounted at: $PWD/ipfs" >expected &&
38 - echo "IPNS mounted at: $PWD/ipns" >>expected &&
39 - test_cmp expected actual
40 -'
41 -
42 -test_expect_success "cannot publish after mounting /ipns" '
43 - echo "Error: cannot manually publish while IPNS is mounted" >expected &&
44 - test_must_fail ipfsi 0 name publish '$HASH' 2>actual &&
45 - test_cmp expected actual
46 -'
47 -
48 -test_expect_success "unmount /ipns out-of-band" '
49 - fusermount -u "'"$IPNS_MOUNT_DIR"'"
50 -'
51 -
52 -test_expect_success "can publish after unmounting /ipns" '
53 - ipfsi 0 name publish '$HASH'
54 -'
55 -
56 -# clean-up ipfs
57 -test_expect_success "unmount /ipfs" '
58 - fusermount -u "'"$IPFS_MOUNT_DIR"'"
59 -'
60 -iptb stop
61 -
62 -test_done
test/sharness/t0032-mount-sharded.sh deleted
-68
@@ -1,68 +0,0 @@
1 -#!/usr/bin/env bash
2 -#
3 -# Copyright (c) 2021 Protocol Labs
4 -# MIT Licensed; see the LICENSE file in this repository.
5 -#
6 -
7 -test_description="Test mount command with sharding enabled"
8 -
9 -. lib/test-lib.sh
10 -
11 -if ! test_have_prereq FUSE; then
12 - skip_all='skipping mount sharded tests, fuse not available'
13 - test_done
14 -fi
15 -
16 -test_init_ipfs
17 -
18 -test_expect_success 'force sharding' '
19 - ipfs config --json Import.UnixFSHAMTDirectorySizeThreshold "\"1B\""
20 -'
21 -
22 -test_launch_ipfs_daemon
23 -test_mount_ipfs
24 -
25 -# we're testing nested subdirs which ensures that IPLD ADLs work
26 -test_expect_success 'setup test data' '
27 - mkdir testdata &&
28 - echo a > testdata/a &&
29 - mkdir testdata/subdir &&
30 - echo b > testdata/subdir/b
31 -'
32 -
33 -HASH=QmY59Ufw8zA2BxGPMTcfXg86JVed81Qbxeq5rDkHWSLN1m
34 -
35 -test_expect_success 'can add the data' '
36 - echo $HASH > expected_hash &&
37 - ipfs add -r -Q testdata > actual_hash &&
38 - test_cmp expected_hash actual_hash
39 -'
40 -
41 -test_expect_success 'can read the data' '
42 - echo a > expected_a &&
43 - cat "ipfs/$HASH/a" > actual_a &&
44 - test_cmp expected_a actual_a &&
45 - echo b > expected_b &&
46 - cat "ipfs/$HASH/subdir/b" > actual_b &&
47 - test_cmp expected_b actual_b
48 -'
49 -
50 -test_expect_success 'can list directories' '
51 - printf "a\nsubdir\n" > expected_ls &&
52 - ls -1 "ipfs/$HASH" > actual_ls &&
53 - test_cmp expected_ls actual_ls &&
54 - printf "b\n" > expected_ls_subdir &&
55 - ls -1 "ipfs/$HASH/subdir" > actual_ls_subdir &&
56 - test_cmp expected_ls_subdir actual_ls_subdir
57 -'
58 -
59 -test_expect_success "unmount" '
60 - do_umount "$(pwd)/ipfs" &&
61 - do_umount "$(pwd)/ipns"
62 -'
63 -
64 -test_expect_success 'cleanup' 'rmdir ipfs ipns'
65 -
66 -test_kill_ipfs_daemon
67 -
68 -test_done