Skip to content

Add guest-internal FUSE transport and minimal VFS#35

Merged
jserv merged 1 commit into
mainfrom
fuse
May 15, 2026
Merged

Add guest-internal FUSE transport and minimal VFS#35
jserv merged 1 commit into
mainfrom
fuse

Conversation

@jserv
Copy link
Copy Markdown
Contributor

@jserv jserv commented May 15, 2026

elfuse runs entirely user-space on macOS without macFUSE, FUSE-T, FSKit, or any host filesystem framework. Guest libfuse programs (sshfs, ntfs-3g, fuse-overlayfs, AppImage runtimes) need the FUSE protocol to live wholly inside the guest VM, which means /dev/fuse, mount(2) fstype="fuse", and VFS dispatch from any guest process opening files under the mountpoint into the daemon's /dev/fuse fd all have to be emulated locally. This change adds that surface as an opt-in P2 feature.

src/syscall/fuse.{c,h} implements:

  • Session, mount, and per-fd state tables protected by a global fuse_lock outer / per-session lock inner. Sessions are refcounted so in-flight reads or writes pin the lock against concurrent daemon exit; mutex and condvar destruction defer until the last ref drops.
  • Per-fd alias bindings so dup(), dup3(), and fcntl(F_DUPFD) on a FUSE fd correctly share the underlying session or file state. Closing one alias unbinds without tearing the session down; only the last alias triggers the full cleanup path.
  • Per-file slot refcount plus io_in_progress / io_cond to serialize read() and getdents64() against the offset field the way Linux f_pos_lock does. lseek() waits on io_in_progress so it cannot clobber an in-flight read's post-update. pread() and getattr-style calls do not advance the offset and skip the serialization to match Linux.
  • Synchronous FUSE_INIT in sys_mount via fuse_send_init_locked so a daemon that rejects INIT (negative hdr.error) propagates the errno back through mount(); on failure the mount slot is uninstalled. FUSE_INIT version negotiation accepts any minor when the major matches and tolerates a short init_out from older libfuse.
  • Mount tombstoning on daemon death: fuse_fd_cleanup sets mount->session = NULL but keeps the slot's path metadata intact so a consumer whose virtual cwd is on this mount still routes lookups into FUSE space and surfaces -LINUX_ENOTCONN instead of silently falling through to host-filesystem resolution. sys_mount reclaims tombstoned slots when a new mount lands at the same path.
  • Absolute-path canonicalization via fuse_canonical_abs and the fuse_join_virtual_path helper so /mnt/fuse/./foo and /mnt/fuse/sub/../foo route identically with /mnt/fuse/foo, and /mnt/fuse/../etc escapes FUSE land deterministically.
  • O_PATH support: fuse_open_path tracks path_only on the file slot, skips FUSE_OPEN at allocation time, and ignores access-mode bits per Linux open(2). Read / readdir / lseek / mmap-refusal all check path_only and follow Linux semantics. fuse_release_common_locked bypasses FUSE_RELEASE for O_PATH fds so a daemon never sees a RELEASE without a prior OPEN.
  • fuse_getdents64 hardened: namelen is bounded to Linux NAME_MAX (255), the reply record is validated against the declared body length, and the destination buffer is fixed at 280 bytes with explicit checks so a malicious daemon cannot trigger a stack overflow.
  • fuse_dev_read peeks the queue head before validating that the daemon buffer fits, so a short read leaves the request on the queue for the next retry instead of silently dropping it.
  • Daemon writes are capped at FUSE_FRAME_CAP (8 MiB) and the negotiated max_write is clamped to the same ceiling at FUSE_INIT time so the read-reply path cannot negotiate larger than the dev-write path accepts.
  • fuse_resolve_at_path joins relative paths against a FUSE_DIR dirfd or a FUSE-rooted virtual cwd via fuse_join_virtual_path, plumbed from path_translate_at when proc-path interception did not fire.
  • fuse_fchdir routes guest fchdir on a FUSE_DIR fd to proc_cwd_set_virtual using the file slot's stored absolute path.
  • procfs integration emits live FUSE entries through fuse_append_mountinfo and fuse_append_mounts so /proc/self/mountinfo and /proc/mounts list the mount, and /proc/filesystems reports fuse.
  • sys_execve materializes FUSE-backed binaries into a temp file via fuse_materialize_path so the host ELF loader can read them; the temp is unlinked on success or failure.

Errors are uniformly in Linux errno space (-LINUX_E*) end to end so guest-visible returns and syscall-boundary translation stay consistent.

Two test surfaces validate the work:

  • tests/test-fuse-basic.c spawns a guest daemon thread plus consumer in one process. It exercises mount + stat + open + read + getdents64 + mmap refusal + dup-shares-offset on both file and directory fds + O_PATH file (read = EBADF) + O_PATH directory (getdents = EBADF, fchdir works) + canonicalization (./hello, sub/../hello) + relative-from-FUSE-cwd open + a bad daemon that rejects FUSE_INIT with EPROTO + daemon-death + post-tombstone routing returning ENOTCONN instead of a host-relative open.
  • tests/test-fuse-alpine.sh wraps the guest binary under the existing Alpine sysroot fixture and is wired into mk/tests.mk so make check runs it as a second stage.

Summary by cubic

Adds a guest-internal FUSE transport and minimal VFS so libfuse apps run fully inside the VM without host FUSE. Implements /dev/fuse, mount("fuse"), and routes file ops under the mount to the guest daemon.

  • New Features

    • Guest-local /dev/fuse, SYS_mount with fstype="fuse", and VFS dispatch; no macFUSE or host frameworks.
    • File ops: open/read/readdir/stat/access and O_PATH; correct dup/lseek/pread semantics; mmap on FUSE fds is refused; xattr on FUSE paths returns -ENOSYS for now.
    • Paths: absolute canonicalization; relative resolution via FUSE dirfds or virtual cwd; fchdir on FUSE dirs.
    • Procfs and exec: /proc/*/mountinfo, /proc/mounts, and /proc/filesystems include fuse; execve materializes FUSE-backed binaries and ELF interpreters to a temp for the host loader.
    • Hardening: INIT negotiation with errno propagation; daemon-death tombstones return -ENOTCONN; capped frame sizes; validated getdents64; short-read-safe device queue; uniform Linux errno.
  • Migration

    • Opt-in feature. Mount with mount("fuse", "/mnt/fuse", "fuse", 0, NULL) and run a guest daemon on /dev/fuse.
    • make check runs an Alpine sysroot FUSE test; a unit test (test-fuse-basic) covers basic operations and edge cases.

Written for commit aa449a7. Summary will update on new commits. Review in cubic

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 25 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/syscall/fs-stat.c">

<violation number="1" location="src/syscall/fs-stat.c:201">
P1: FUSE stat routing drops `AT_SYMLINK_NOFOLLOW` semantics because it calls `fuse_stat_path(...)` without any flag context.</violation>
</file>

<file name="tests/test-fuse-basic.c">

<violation number="1" location="tests/test-fuse-basic.c:480">
P2: This expectation is inconsistent with the fixture: the file is marked 0644 for the current user and the daemon never denies `FUSE_OPEN`, so `O_RDWR` should not be treated as an error here.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic

Comment thread src/syscall/fs-stat.c Outdated
Comment thread tests/test-fuse-basic.c
Comment on lines +480 to +483
if (wfd >= 0 || errno != EACCES) {
fprintf(stderr, "expected O_RDWR FUSE open to fail with EACCES\n");
return 1;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This expectation is inconsistent with the fixture: the file is marked 0644 for the current user and the daemon never denies FUSE_OPEN, so O_RDWR should not be treated as an error here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test-fuse-basic.c, line 480:

<comment>This expectation is inconsistent with the fixture: the file is marked 0644 for the current user and the daemon never denies `FUSE_OPEN`, so `O_RDWR` should not be treated as an error here.</comment>

<file context>
@@ -0,0 +1,643 @@
+        die("open(file)");
+    errno = 0;
+    int wfd = open(hello_path, O_RDWR);
+    if (wfd >= 0 || errno != EACCES) {
+        fprintf(stderr, "expected O_RDWR FUSE open to fail with EACCES\n");
+        return 1;
</file context>
Suggested change
if (wfd >= 0 || errno != EACCES) {
fprintf(stderr, "expected O_RDWR FUSE open to fail with EACCES\n");
return 1;
}
if (wfd < 0)
die("open(file O_RDWR)");
close(wfd);

elfuse runs entirely user-space on macOS without macFUSE, FUSE-T, FSKit,
or any host filesystem framework. Guest libfuse programs (sshfs,
ntfs-3g, fuse-overlayfs, AppImage runtimes) need the FUSE protocol to
live wholly inside the guest VM, which means /dev/fuse, mount(2)
fstype="fuse", and VFS dispatch from any guest process opening files
under the mountpoint into the daemon's /dev/fuse fd all have to be
emulated locally. This change adds that surface as an opt-in P2
feature.

src/syscall/fuse.{c,h} implements:
- Session, mount, and per-fd state tables protected by a global
  fuse_lock outer / per-session lock inner. Sessions are refcounted so
  in-flight reads or writes pin the lock against concurrent daemon
  exit; mutex and condvar destruction defer until the last ref drops.
- Per-fd alias bindings so dup(), dup3(), and fcntl(F_DUPFD) on a FUSE
  fd correctly share the underlying session or file state. Closing one
  alias unbinds without tearing the session down; only the last alias
  triggers the full cleanup path.
- Per-file slot refcount plus io_in_progress / io_cond to serialize
  read() and getdents64() against the offset field the way Linux
  f_pos_lock does. lseek() waits on io_in_progress so it cannot clobber
  an in-flight read's post-update. pread() and getattr-style calls do
  not advance the offset and skip the serialization to match Linux.
- Synchronous FUSE_INIT in sys_mount via fuse_send_init_locked so a
  daemon that rejects INIT (negative hdr.error) propagates the errno
  back through mount(); on failure the mount slot is uninstalled.
  FUSE_INIT version negotiation accepts any minor when the major
  matches and tolerates a short init_out from older libfuse.
- Mount tombstoning on daemon death: fuse_fd_cleanup sets
  mount->session = NULL but keeps the slot's path metadata intact so a
  consumer whose virtual cwd is on this mount still routes lookups
  into FUSE space and surfaces -LINUX_ENOTCONN instead of silently
  falling through to host-filesystem resolution. sys_mount reclaims
  tombstoned slots when a new mount lands at the same path.
- Absolute-path canonicalization via fuse_canonical_abs and the
  fuse_join_virtual_path helper so /mnt/fuse/./foo and
  /mnt/fuse/sub/../foo route identically with /mnt/fuse/foo, and
  /mnt/fuse/../etc escapes FUSE land deterministically.
- O_PATH support: fuse_open_path tracks path_only on the file slot,
  skips FUSE_OPEN at allocation time, and ignores access-mode bits
  per Linux open(2). Read / readdir / lseek / mmap-refusal all check
  path_only and follow Linux semantics. fuse_release_common_locked
  bypasses FUSE_RELEASE for O_PATH fds so a daemon never sees a
  RELEASE without a prior OPEN.
- fuse_getdents64 hardened: namelen is bounded to Linux NAME_MAX (255),
  the reply record is validated against the declared body length, and
  the destination buffer is fixed at 280 bytes with explicit checks so
  a malicious daemon cannot trigger a stack overflow.
- fuse_dev_read peeks the queue head before validating that the daemon
  buffer fits, so a short read leaves the request on the queue for the
  next retry instead of silently dropping it.
- Daemon writes are capped at FUSE_FRAME_CAP (8 MiB) and the negotiated
  max_write is clamped to the same ceiling at FUSE_INIT time so the
  read-reply path cannot negotiate larger than the dev-write path
  accepts.
- fuse_resolve_at_path joins relative paths against a FUSE_DIR dirfd
  or a FUSE-rooted virtual cwd via fuse_join_virtual_path, plumbed
  from path_translate_at when proc-path interception did not fire.
- fuse_fchdir routes guest fchdir on a FUSE_DIR fd to
  proc_cwd_set_virtual using the file slot's stored absolute path.
- procfs integration emits live FUSE entries through
  fuse_append_mountinfo and fuse_append_mounts so /proc/self/mountinfo
  and /proc/mounts list the mount, and /proc/filesystems reports
  fuse.
- sys_execve materializes FUSE-backed binaries into a temp file via
  fuse_materialize_path so the host ELF loader can read them; the temp
  is unlinked on success or failure.

Errors are uniformly in Linux errno space (-LINUX_E*) end to end so
guest-visible returns and syscall-boundary translation stay consistent.
@jserv jserv merged commit 0cfb9cd into main May 15, 2026
4 checks passed
@jserv jserv deleted the fuse branch May 15, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant