diff --git a/docs/internals.md b/docs/internals.md index e6732d48..2735c13e 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -96,7 +96,7 @@ Key files: | `src/syscall/mem.c` | `brk`, `mmap`, `mprotect`, `mremap`, `madvise`, `msync` | | `src/syscall/fs.c`, `fs-stat.c`, `fs-xattr.c` | filesystem syscalls | | `src/syscall/io.c`, `poll.c`, `fd.c`, `fdtable.c` | I/O, polling, FD lifecycle and table | -| `src/syscall/path.c` | centralized guest-to-host path resolution | +| `src/syscall/path.c` | centralized guest-to-host path resolution (see also [sysroot.md](sysroot.md)) | | `src/syscall/sidecar.c` | case-fold sidecar tokens for case-insensitive macOS volumes | | `src/syscall/fuse.c` | guest-internal FUSE transport and minimal VFS | | `src/syscall/inotify.c` | inotify via kqueue `EVFILT_VNODE` | @@ -406,6 +406,8 @@ Socket syscalls are translated in `src/syscall/net.c` and friends: type argument; both bits must be extracted before calling `socket()`. - `SOL_SOCKET` option numbers (`SO_TYPE`, `SO_SNDBUF`, `SO_RCVBUF`, …) differ between platforms and are remapped per option. +- AF_UNIX pathname and abstract socket addresses are translated through the + sysroot; see [sysroot.md](sysroot.md#af_unix-socket-addresses). ### Stack Alignment @@ -806,10 +808,13 @@ Only a non-empty flat leaf is redirected. Bare `/dev/shm` and `/dev/shm/` stay on the sysroot path so the synthetic-directory intercepts keep answering for them, and `statfs` on a shm leaf or on `/dev/shm` reports `TMPFS_MAGIC` synthetically rather than the host filesystem's type. Because the backing path -is absolute, two inline helpers in `src/syscall/path.h` adapt the `*at()` calls: -`path_translation_dirfd()` returns `AT_FDCWD` (POSIX ignores `dirfd` for an -absolute path), and `path_translation_at_flags()` forces the nofollow flag -described next. +is absolute, three inline helpers in `src/syscall/path.h` adapt the calls that +receive it: `path_translation_dirfd()` returns `AT_FDCWD` (POSIX ignores +`dirfd` for an absolute path), `path_translation_at_flags()` forces the +nofollow flag described next onto an `*at()` call, and +`path_translation_oflags()` forces the equivalent open flags onto an `open()`. +Keeping each requirement in one helper is what stops a new call site from +picking up the backing path without also picking up the rules that go with it. ### The Never-Follow Invariant @@ -825,13 +830,21 @@ is spread across the syscall families, one mechanism each: | Operation family | Never-follow mechanism | |------------------|------------------------| | `*at()` metadata (chmod, chown, stat, utimensat, access) | `path_translation_at_flags()` adds `AT_SYMLINK_NOFOLLOW` | -| open for truncate/chdir | `shm_open_leaf()` opens `O_NOFOLLOW` | +| open for truncate/chdir, and `execveat` | `path_translation_oflags()` adds `O_NOFOLLOW` | | proc open | `O_NOFOLLOW` | | xattr get/set/list/remove | `XATTR_NOFOLLOW` | | stat | `lstat`, not `stat` | | linkat | clears `AT_SYMLINK_FOLLOW` | | statfs | nofollow `lstat` existence probe, then a synthetic reply | +The open flavors carry a second requirement for the same reason. A shm leaf that +is a FIFO would block the opening thread until a peer arrives, and that thread is +a vCPU thread, so the whole guest would stall inside one syscall. Every open of a +shm leaf therefore adds `O_NONBLOCK` alongside `O_NOFOLLOW`, which turns the +stall into an immediate return: `truncate` reports `EINVAL`, matching Linux +`truncate(2)` on a FIFO. `path_translation_oflags()` carries both flags together +so a caller cannot take one without the other. + The name gate lives with the resolver. A POSIX shm name is always a single flat component: glibc's `__shm_get_name` (`posix/shm-directory.c`) strips the leading slash and rejects an empty name or any embedded `/` with `EINVAL`. @@ -841,9 +854,10 @@ rejected name (`ENAMETOOLONG` if the backing path overflows). Related implementation: `src/runtime/procemu.c` (`dev_shm_resolve_path`), `src/syscall/path.c` and `path.h` (`path_translate_at`, `is_dev_shm`, -`path_translation_dirfd`, `path_translation_at_flags`), and the metadata -handlers in `src/syscall/fs.c`, `fs-stat.c`, and `fs-xattr.c`. Validation: -`tests/test-dev-shm-paths.c`. +`path_translation_dirfd`, `path_translation_at_flags`, +`path_translation_oflags`), and the metadata handlers in `src/syscall/fs.c`, +`fs-stat.c`, and `fs-xattr.c`. Validation: `tests/test-dev-shm-paths.c` and +`tests/test-execveat-shm.c`. ## Dynamic Linking diff --git a/docs/sysroot.md b/docs/sysroot.md new file mode 100644 index 00000000..33acba82 --- /dev/null +++ b/docs/sysroot.md @@ -0,0 +1,243 @@ +# Sysroot Path Translation + +`--sysroot DIR` resolves absolute guest paths under a Linux root filesystem +staged in DIR while keeping the host filesystem reachable. The sysroot is a +root, not a boundary: elfuse is not a sandbox, and guest access to files in +the real macOS filesystem is a feature. This document describes the dispatch +model that chooses between sysroot and host, the translation pipeline every +path-taking syscall uses and its invariants, case handling, and the special +treatment of AF_UNIX socket addresses. + +## Path Dispatch + +Every absolute guest path is dispatched on existence by +`proc_resolve_sysroot_path_flags` (`src/syscall/proc-state.c`): + +- If the path exists under ``, the guest operates on the + sysroot copy, subject to the containment check below. +- If the path is absent under the sysroot, the resolver returns the guest + path unchanged and the host kernel operates on the literal host file, for + reads and writes alike. This fall-through is what lets guests reach host + resources such as `/etc/resolv.conf` or files under the user's home + directory. + +### Guest-Private Prefixes + +Creates and lookups under `/tmp`, `/var/tmp`, and any `.ccache` directory +(the directories themselves included, not only their contents) always +resolve inside the sysroot. Pinning these prefixes keeps a name from +splitting between host and guest depending on the verb, and keeps a `/tmp` +listing from showing host entries its own child lookups cannot reach. The +rule applies to the lexically normalized path, so non-canonical spellings +such as `//tmp/x` or `/tmp/../tmp/x` resolve inside the sysroot as well. + +### Symlink Containment + +A realpath check stops a symlink inside the sysroot from redirecting an +in-sysroot lookup to an arbitrary host file, and +`path_check_relative_sysroot_containment` applies the same check to +dirfd-relative names. Both checks are check-then-use symlink hardening, not +sandboxing: the guest runs in the host user's trust domain, and no isolation +is provided or intended. + +### Consequences Of Existence Dispatch + +Because dispatch depends on what the image ships, a host binary can shadow a +path the image does not provide (PATH ordering decides which spelling of a +tool wins), and host configuration files are visible to guests that omit +their own. This host visibility follows directly from dispatching on +existence. + +## The Translation Pipeline + +`path_translate_at` (`src/syscall/path.c`) is the single forward resolver. +It applies, in order: `/proc` resolution, FUSE resolution, the sidecar +reserved-name guard, sysroot prefixing (in follow, nofollow, or create +flavor), the relative containment recheck, and the sidecar lookup. It fills +a `path_translation_t` with three views of the same path: + +| View | Meaning | Correct consumer | +|---|---|---| +| `guest_path` | the path in guest namespace | guest-visible bookkeeping | +| `intercept_path` | the intercept-matching path | `/proc`, `/dev`, `/sys`, FUSE handlers | +| `host_path` | the actual macOS path | every host syscall, no exceptions | + +Two rules govern every consumer: + +- After translation, the raw guest string is dead: a host syscall receives + `host_path` (or a host fd) and nothing else. Under a casefold sysroot a + guest-created file exists on disk only under its sidecar token, so any + other spelling misses the file. +- Any host-derived path that becomes guest-visible (host `getcwd`, + `F_GETPATH`, readdir names) goes through `path_host_to_guest`, which + strips the sysroot prefix and reverse-maps token components. Prefix + arithmetic alone would leak token spellings into the guest. +- `path_host_to_guest` inverts the sysroot leg only. The `/dev/shm` redirect + and FUSE materialization are not reversible through it, and a host path + from either leg comes back unchanged, so a caller publishing one of those + to the guest carries the guest spelling forward instead of reversing it. + +Companion helpers keep the shared policies in one place: +`path_translation_relative_fast_path` handles the `AT_FDCWD` relative fast +path, and `path_resolve_empty_at` handles `AT_EMPTY_PATH` resolution with +FUSE and `/proc` interception. AF_UNIX socket addresses carry paths outside +the normal argument convention and have their own entry point in the socket +layer, described in [AF_UNIX Socket Addresses](#af_unix-socket-addresses). + +## Case Handling And The Sidecar + +A Linux guest expects case-sensitive, byte-exact names; the default APFS +volume folds case. At startup `sysroot_probe_case_sensitivity` +(`src/core/sysroot.c`) probes the sysroot volume once, and the resulting +casefold flag decides whether the sidecar (`src/syscall/sidecar.c`), the +subsystem that emulates case-sensitive naming on a case-insensitive volume, +is active: it runs iff a sysroot is set and the volume folds case. Both the +flag and the sidecar state travel to forked children over the fork IPC. + +- Case-insensitive volume (default APFS): guest-created names are stored as + `.ef_<16 hex>` token files, with the real name recorded in the + per-directory `.elfuse_case_index`. Host-staged names keep their real + spelling and are matched byte-exactly, so a wrong-case lookup returns + ENOENT exactly as on Linux. +- Case-sensitive volume (a `--create-sysroot` sparsebundle, or any volume + that probes sensitive): the sidecar is inert and paths are plain + byte-exact host names. + +Guest-visible behavior must not depend on the mode; the mode only changes +the on-disk representation. Paths that fall through to the host live on the +host volume with the host's case semantics; that mix is inherent to the +transparent-overlay model. + +Two rules follow from the index being a mapping rather than a gate. + +A name the index does not describe keeps its on-disk spelling. Lookup falls +back to the literal name whenever no row matches, which is exactly what lets +a host-staged file be opened, and a directory listing passes the same name +through for the same reason. This holds even for a name shaped like a sidecar +token: with no row to map it, `.ef_<16 hex>` is an ordinary file that the +guest can stat and open under that spelling, so hiding it from a listing +would make a reachable file impossible to enumerate. Directory listings and +inotify event names follow this rule identically, so a listing, an event, and +a lookup never disagree about what a directory contains. + +If a directory's index exists but cannot be read, the operation fails instead +of falling back. Treating an unreadable index as an empty one would report +every mapped child under a spelling the guest has never seen, and for a watch +it would diff as though those children had been deleted. A listing therefore +reports the error, and a watch discards the snapshot and keeps its previous +baseline so the next successful snapshot reconciles. + +## AF_UNIX Socket Addresses + +A Unix-domain socket is named by a filesystem path, but that path never +reaches the generic path-taking handlers: it is embedded in +`struct sockaddr_un` at byte offset 2 and measured by the caller's +`addrlen` rather than by a NUL terminator. The socket layer therefore +extracts and translates the path itself, in `net_sockaddr_to_mac` +(`src/syscall/net-absock.c`) on the way to the host and +`net_sockaddr_from_mac` on the way back. Both pipeline rules above still +hold: the host call receives `host_path`, and host-derived paths return to +the guest through `path_host_to_guest`. + +### Pathname Sockets + +A pathname socket (its `sun_path` does not begin with a NUL byte) carries a +real filesystem path and goes through `path_translate_at` like every other +path; passing the raw bytes would name an unrelated host file outside the +sysroot. `bind` takes one extra step: the sidecar lookup is skipped under +create semantics, so translating the whole path would miss a tokenized +parent directory. The handler translates the parent directory (picking up +any sidecar token) and reattaches the final component verbatim, because a +socket keeps its real name and socket names are never tokenized. + +A reattached leaf never passes through the pipeline, so it would also miss +the reserved-name guard listed above. The handler applies that guard to the +leaf itself: binding a sidecar bookkeeping name reports ENOENT, the answer +`stat` and `open(O_CREAT)` already give for it, rather than colliding with +the real index file. + +### Path Length Limits + +`sun_path` holds 108 bytes on Linux but only 104 on macOS, and sysroot +prefixing makes the translated path longer still, so a legal guest path +routinely does not fit the host struct. `absock_shorten_path` absorbs the +difference: it creates a short symlink in a private per-instance directory +pointing at the over-long host path and hands the kernel the symlink +instead. On macOS a `bind` through the symlink creates the socket at the +link target and a `connect` follows it (verified on macOS 15), so the +socket physically lives at the long path while the kernel only ever handles +the short name. + +### Abstract Sockets + +An abstract socket (its `sun_path` begins with a NUL byte) lives in a +Linux-only kernel namespace with no filesystem presence and no macOS +equivalent, so elfuse emulates it with real files. The abstract name is +hex-encoded into a filename inside a per-instance `/tmp/elfuse-absock-` +directory, a fixed table maps each abstract name to its on-disk file, and +the file is unlinked when the owning descriptor closes, matching the way an +abstract socket disappears with its last reference. The directory is +created through `create_private_dir` (`src/utils.h`), which rejects a +pre-planted symlink or a foreign-owned directory in world-writable `/tmp`. + +### Reverse Translation + +Reading an address back (`getsockname`, `getpeername`, `recvmsg`) reverses +each step: `net_sockaddr_from_mac` undoes the shortening symlink with a +`readlink`, maps the host path into the guest namespace through +`path_host_to_guest`, and writes the Linux `sockaddr` directly. Writing the +Linux form directly matters because the guest may have bound a Linux-legal +name longer than the 103 usable bytes of a macOS `sun_path`; rebuilding a +host `sockaddr` first would reject it. + +### Socket Directory Lifecycle + +The `/tmp/elfuse-absock-` directory holds both the abstract-socket +files and the pathname shortening links. Its `` is the root guest's +namespace id, which forked children inherit over the fork IPC, so one +directory is shared across a forked guest tree. + +Exit cleanup is registered once, when the directory is first created, so it +covers every producer of on-disk state, and it is split three ways. + +Every process unlinks its own table-tracked abstract-socket files. A forked +child starts from an empty table, because fork hands over only the namespace +id, so a process never unlinks a sibling's socket this way. + +Only the process whose pid equals the namespace id sweeps the untracked +shortening links, and the sweep covers symlinks alone. Restricting it to the +creator keeps a departing sibling from deleting links a live sibling still +needs; restricting it to symlinks keeps the owner from deleting an +abstract-socket file that a child which outlives it still has bound. A +sibling that loses a shortening link degrades to the raw host link path in +its `getsockname` until the next `bind` or `connect` recreates it, which is +recoverable; losing the socket file itself would not be. + +Any process may remove the directory, not only the owner. `rmdir` succeeds +only once the directory is empty, so the last participant out retires it and +a namespace whose directory was created by a forked child does not leak. + +## Testing + +The `test-path-matrix` suite (`tests/test-path-matrix.c`) enforces the two +translation-pipeline rules. The `test-path-matrix-fold` and +`test-path-matrix-sensitive` lanes run the same expectations on a +case-insensitive and a case-sensitive sysroot volume respectively, holding +the contract that guest-visible behavior is mode-independent, and +`tests/check-sidecar-state.sh` asserts the on-disk index and token contract +from the host after each run. + +Two narrower lanes cover the index rules above, both of which need the host to +stage state the guest cannot produce for itself. `test-sidecar-orphan` stages a +token-shaped file with no index row beside an ordinary host-staged file and +asserts both are listed and both resolve, which is what separates passing an +unmapped name through from hiding whatever the index does not name. +`test-inotify-index-fail` makes a watched directory's index unreadable between +the baseline and the next snapshot, and asserts no child is announced as +deleted; the guest signals readiness on stdout and waits on a host-staged +sentinel, because the failure has to begin after the baseline is taken. + +Known limitations are tracked as expected failures, with the XFAIL ids +listed in the header comment of `tests/test-path-matrix.c`. The qemu lane +runs the same binary in native mode, where each of those cases must pass, +so the expected-failure table stays checked against a real Linux kernel. diff --git a/docs/usage.md b/docs/usage.md index bef8aca2..56fcb8f3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -166,6 +166,10 @@ Practical notes: (default APFS) and the sysroot is being provisioned for the first time; `elfuse` creates a case-sensitive APFS sparsebundle, mounts it at `PATH`, and uses it as the sysroot for this run. +- The sysroot is a root, not a boundary: absolute paths absent from the + sysroot deliberately fall through to the literal host file, and no + isolation is provided. `docs/sysroot.md` defines the dispatch model, + the translation invariants, and the case-handling details. ## Debugging With GDB Or LLDB diff --git a/mk/tests.mk b/mk/tests.mk index 5f07a44c..e1596dc4 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -16,7 +16,11 @@ test-proctitle-host test-proctitle-low-stack \ test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ - test-linkat-symlink-fallback perf + test-sysroot-exec-cwd test-linkat-symlink-fallback \ + test-path-matrix-fold test-path-matrix-sensitive \ + test-execveat-shm test-absock-cleanup test-sidecar-orphan \ + test-inotify-index-fail \ + test-sysroot-interp-fallback test-sysroot-interp-tokenized perf ## Build and run the assembly hello world test test-hello: $(ELFUSE_BIN) $(TEST_HELLO_DEP) @@ -108,6 +112,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-busybox @printf "\n$(BLUE)━━━ sysroot procfs exec validation ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-procfs-exec + @printf "\n$(BLUE)━━━ sysroot fork/exec cwd validation ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-exec-cwd @printf "\n$(BLUE)━━━ getdents64 overlong-UTF-8 dirent skip ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-getdents64-overlong @printf "\n$(BLUE)━━━ sysroot host-fallback validation ━━━$(RESET)\n" @@ -116,6 +122,22 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-sysroot-case-exact @printf "\n$(BLUE)━━━ sysroot relative-dirfd symlink escape validation ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-symlink-escape + @printf "\n$(BLUE)━━━ sysroot path-translation matrix ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-path-matrix-fold + @printf "\n$(BLUE)━━━ sysroot path-translation matrix (case-sensitive) ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-path-matrix-sensitive + @printf "\n$(BLUE)━━━ sysroot PT_INTERP /lib fallback ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-interp-fallback + @printf "\n$(BLUE)━━━ sysroot PT_INTERP tokenized-parent fallback ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-interp-tokenized + @printf "\n$(BLUE)━━━ execveat /dev/shm nofollow ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-execveat-shm + @printf "\n$(BLUE)━━━ absock namespace lifecycle ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-absock-cleanup + @printf "\n$(BLUE)━━━ unmapped sidecar token visibility ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sidecar-orphan + @printf "\n$(BLUE)━━━ inotify index-failure containment ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-inotify-index-fail @printf "\n$(BLUE)━━━ Alpine sysroot FUSE validation ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-fuse-alpine @printf "\n$(BLUE)━━━ timeout=0 validation ━━━$(RESET)\n" @@ -194,6 +216,206 @@ test-sysroot-symlink-escape: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-symlink-esc ln -sf "$${relback}$${secret_dir#/}/secret.txt" "$$tmpdir/d1/rel-link"; \ $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-sysroot-symlink-escape +## Path-translation matrix: one sectioned guest binary covering the +## path-taking syscalls against guest-created mixed-case fixtures, run on +## the default (case-insensitive) volume so the casefold sidecar is active. +## A host fixture staged outside the sysroot exercises the host-literal +## fallback, and tests/check-sidecar-state.sh verifies the on-disk +## index/token contract after the guest exits. +test-path-matrix-fold: $(ELFUSE_BIN) $(BUILD_DIR)/test-path-matrix + @set -e; \ + tmpdir=$$(mktemp -d); \ + hostdir=$$(mktemp -d); \ + shadow="elfuse-pathmx-$$$$-shadow"; \ + trap 'rm -rf "$$tmpdir" "$$hostdir"; rm -f "/tmp/$$shadow"' EXIT; \ + sysroot="$$tmpdir/sysroot"; \ + mkdir -p "$$sysroot/bin" "$$sysroot/tmp" "$$sysroot/data"; \ + cp $(BUILD_DIR)/test-path-matrix "$$sysroot/bin/"; \ + printf 'host-visible\n' > "$$hostdir/hello.txt"; \ + : > "$$sysroot/data/probe"; \ + mode=cs; \ + if [ -e "$$sysroot/data/PROBE" ]; then mode=ci; fi; \ + rm -f "$$sysroot/data/probe"; \ + if [ "$$mode" = ci ]; then \ + printf 'HOST-TMP-SHADOW\n' > "/tmp/$$shadow"; \ + mkdir -p "$$hostdir/.ccache"; \ + printf 'HOST-CCACHE\n' > "$$hostdir/.ccache/host-leaf"; \ + fi; \ + $(ELFUSE_BIN) --sysroot "$$sysroot" "$$sysroot/bin/test-path-matrix" \ + "$$mode" "$$hostdir/hello.txt" "$$shadow"; \ + bash tests/check-sidecar-state.sh "$$sysroot" "$$mode" "$$hostdir" + +## The same matrix binary on a case-sensitive scratch volume: the sidecar is +## inert there, so every Linux-semantics expectation must hold identically +## with plain names, and the sysroot must stay free of sidecar artifacts. +## Skips when hdiutil cannot provide the volume (same idiom as +## test-linkat-symlink-fallback). +test-path-matrix-sensitive: $(ELFUSE_BIN) $(BUILD_DIR)/test-path-matrix + @set -e; dmg=$$(mktemp -u).dmg; mnt=""; hostdir=$$(mktemp -d); \ + trap '{ [ -z "$$mnt" ] || hdiutil detach "$$mnt" -quiet; } >/dev/null 2>&1 || true; rm -f "$$dmg"; rm -rf "$$hostdir"' EXIT; \ + if ! hdiutil create -size 64m -fs "Case-sensitive APFS" \ + -volname elfusepathmx -quiet "$$dmg" >/dev/null 2>&1; then \ + printf "$(YELLOW)SKIP$(RESET) test-path-matrix-sensitive (hdiutil create failed)\n"; \ + exit 0; \ + fi; \ + mnt=$$(hdiutil attach "$$dmg" -nobrowse | awk '/\/Volumes\//{print $$NF}'); \ + if [ -z "$$mnt" ]; then \ + printf "$(YELLOW)SKIP$(RESET) test-path-matrix-sensitive (hdiutil attach failed)\n"; \ + exit 0; \ + fi; \ + sysroot="$$mnt/sysroot"; \ + mkdir -p "$$sysroot/bin" "$$sysroot/tmp"; \ + cp $(BUILD_DIR)/test-path-matrix "$$sysroot/bin/"; \ + printf 'host-visible\n' > "$$hostdir/hello.txt"; \ + $(ELFUSE_BIN) --sysroot "$$sysroot" "$$sysroot/bin/test-path-matrix" \ + cs "$$hostdir/hello.txt"; \ + bash tests/check-sidecar-state.sh "$$sysroot" cs "$$hostdir" + +## PT_INTERP /lib fallback: a store-style interpreter path absent from the +## sysroot must fall back to /lib/. Patches a copy of the dynamic +## echo fixture to interp /nix/ld-musl-aarch64.so.1 (same byte length as the +## original /lib spelling, so no ELF surgery) and expects it to run via the +## sysroot's /lib loader. Skips when the musl fixtures are absent. +test-sysroot-interp-fallback: $(ELFUSE_BIN) + @if [ -z "$(SYSROOT_DIR)" ] || \ + [ ! -f "$(SYSROOT_DIR)/lib/ld-musl-aarch64.so.1" ] || \ + [ ! -f "$(DYNAMIC_COREUTILS_BIN)/echo" ]; then \ + printf "$(YELLOW)SKIP$(RESET) test-sysroot-interp-fallback (musl fixtures missing)\n"; \ + exit 0; \ + fi; \ + set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + cp "$(DYNAMIC_COREUTILS_BIN)/echo" "$$tmpdir/echo"; \ + off=$$(grep -abo '/lib/ld-musl-aarch64.so.1' "$$tmpdir/echo" | head -1 | cut -d: -f1); \ + [ -n "$$off" ]; \ + printf '/nix' | dd of="$$tmpdir/echo" bs=1 seek="$$off" conv=notrunc 2>/dev/null; \ + out=$$($(ELFUSE_BIN) --sysroot $(SYSROOT_DIR) "$$tmpdir/echo" interp-fallback-ok); \ + [ "$$out" = "interp-fallback-ok" ] + +## PT_INTERP under a tokenized parent: when translation resolves the interp +## prefix through the sidecar but its loader suffix is absent, the host path +## differs from the guest spelling yet does not exist, so resolution must fall +## back to /lib/ instead of accepting the missing path. Guest-creates +## (and thus tokenizes) /NiX on a casefold sysroot, patches echo's interp to +## /NiX/ld-musl-aarch64.so.1 (same 4 bytes as /lib, mixed case), and expects +## the /lib loader to run it. Skips when the musl fixtures are absent. +test-sysroot-interp-tokenized: $(ELFUSE_BIN) $(BUILD_DIR)/mkdir-arg + @if [ -z "$(SYSROOT_DIR)" ] || \ + [ ! -f "$(SYSROOT_DIR)/lib/ld-musl-aarch64.so.1" ] || \ + [ ! -f "$(DYNAMIC_COREUTILS_BIN)/echo" ]; then \ + printf "$(YELLOW)SKIP$(RESET) test-sysroot-interp-tokenized (musl fixtures missing)\n"; \ + exit 0; \ + fi; \ + set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + sysroot="$$tmpdir/sysroot"; \ + cp -R "$(SYSROOT_DIR)" "$$sysroot"; \ + mkdir -p "$$sysroot/bin"; \ + cp $(BUILD_DIR)/mkdir-arg "$$sysroot/bin/mkdir-arg"; \ + $(ELFUSE_BIN) --sysroot "$$sysroot" "$$sysroot/bin/mkdir-arg" /NiX; \ + cp "$(DYNAMIC_COREUTILS_BIN)/echo" "$$tmpdir/echo"; \ + off=$$(grep -abo '/lib/ld-musl-aarch64.so.1' "$$tmpdir/echo" | head -1 | cut -d: -f1); \ + [ -n "$$off" ]; \ + printf '/NiX' | dd of="$$tmpdir/echo" bs=1 seek="$$off" conv=notrunc 2>/dev/null; \ + out=$$($(ELFUSE_BIN) --sysroot "$$sysroot" "$$tmpdir/echo" interp-tokenized-ok); \ + [ "$$out" = "interp-tokenized-ok" ] + +## execveat must open a /dev/shm leaf with O_NOFOLLOW like sys_execve: a +## regular binary copied into /dev/shm still execs, but a /dev/shm symlink to a +## binary outside the synthetic namespace fails with ELOOP instead of being +## followed out of it. +test-execveat-shm: $(ELFUSE_BIN) $(BUILD_DIR)/test-execveat-shm + @$(ELFUSE_BIN) $(BUILD_DIR)/test-execveat-shm + +## A directory index that stops being readable between a watch's baseline and +## its next snapshot must fail the snapshot, not silently drop every mapped +## child, which the diff would then report as deletions of files that still +## exist. The failure has to start after the baseline, so the guest announces +## WATCH_READY and blocks on a host-staged sentinel while this recipe makes the +## index unreadable and provokes the next snapshot. +test-inotify-index-fail: $(ELFUSE_BIN) $(BUILD_DIR)/test-inotify-index-fail + @set -e; \ + tmpdir=$$(mktemp -d); \ + out=$$(mktemp); \ + trap 'rm -rf "$$tmpdir" "$$out"' EXIT; \ + printf " %-30s " "index failure spares baseline"; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-inotify-index-fail > "$$out" 2>/dev/null & \ + guest=$$!; \ + ready=0; \ + for i in $$(seq 1 200); do \ + if grep -q WATCH_READY "$$out" 2>/dev/null; then ready=1; break; fi; \ + sleep 0.1; \ + done; \ + if [ "$$ready" = 0 ]; then \ + kill "$$guest" 2>/dev/null || true; \ + printf "FAIL: guest never armed the watch\n"; \ + exit 1; \ + fi; \ + idx=$$(find "$$tmpdir" -path '*/.ef_*/.elfuse_case_index' -print -quit); \ + if [ -z "$$idx" ]; then \ + kill "$$guest" 2>/dev/null || true; \ + printf "SKIP (no tokenized index; volume is case-sensitive)\n"; \ + exit 0; \ + fi; \ + chmod 000 "$$idx"; \ + : > "$$(dirname "$$idx")/HostNew"; \ + : > "$$tmpdir/go"; \ + rc=0; wait "$$guest" || rc=$$?; \ + chmod 644 "$$idx"; \ + verdict=$$(sed -n 's/^INDEX_FAIL=//p' "$$out"); \ + if [ "$$verdict" = ok ]; then \ + printf "OK\n"; \ + else \ + printf "FAIL: %s\n" "$${verdict:-unreported}"; \ + exit 1; \ + fi + +## A sidecar token with no index row is host-private and unreachable by name, +## so a listing must hide it. The orphan token is staged from here because the +## guest cannot create one: every guest-created name is registered in the index +## as it is written. The ordinary sibling staged beside it must still be listed, +## which is what separates hiding an unmapped token from hiding everything the +## index does not name. +test-sidecar-orphan: $(ELFUSE_BIN) $(BUILD_DIR)/test-sidecar-orphan + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + mkdir -p "$$tmpdir/data"; \ + : > "$$tmpdir/data/.ef_0123456789abcdef"; \ + printf 'host\n' > "$$tmpdir/data/Plain.Host"; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-sidecar-orphan + +## absock namespace lifecycle: a forked child that binds its own over-long +## pathname socket must not sweep the shared shortening links its parent still +## uses (checked in-guest via getsockname), and the /tmp/elfuse-absock- +## namespace dir must not leak once the guest exits (checked here). Needs a +## casefold sysroot so the tokenized chain overflows sun_path and the shortening +## link is created. +test-absock-cleanup: $(ELFUSE_BIN) $(BUILD_DIR)/test-absock-cleanup + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + before=$$(ls -d /tmp/elfuse-absock-* 2>/dev/null | wc -l | tr -d ' '); \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-absock-cleanup; \ + printf " %-30s " "owner sweep spares live child"; \ + out=$$($(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-absock-cleanup owner-sweep 2>/dev/null); \ + verdict=$$(printf '%s\n' "$$out" | sed -n 's/^OWNER_SWEEP=//p'); \ + if [ "$$verdict" = ok ]; then \ + printf "OK\n"; \ + else \ + printf "FAIL: child socket %s\n" "$${verdict:-unreported}"; \ + exit 1; \ + fi; \ + after=$$(ls -d /tmp/elfuse-absock-* 2>/dev/null | wc -l | tr -d ' '); \ + if [ "$$after" -gt "$$before" ]; then \ + printf "$(RED)FAIL$(RESET) absock namespace dir leaked ($$before -> $$after)\n"; \ + exit 1; \ + fi + ## sys_linkat() falls back to symlinkat() when the host linkat() rejects a ## hard link to a symlink source. APFS accepts that call directly and would ## never exercise the fallback, so this builds a scratch Case-sensitive HFS+ @@ -339,6 +561,17 @@ test-sysroot-procfs-exec: $(ELFUSE_BIN) $(BUILD_DIR)/test-procfs-exec cp $(BUILD_DIR)/test-procfs-exec "$$tmpdir/bin/test-procfs-exec"; \ $(ELFUSE_BIN) --sysroot "$$tmpdir" "$$tmpdir/bin/test-procfs-exec" +## Guest cwd must not leak sidecar .ef_ token names after chdir into a +## guest-created directory, in the caller and in a forked child after execve; +## needs a plain-dir sysroot on a case-insensitive volume so the casefold +## sidecar is active. +test-sysroot-exec-cwd: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-exec-cwd + @tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + mkdir -p "$$tmpdir/bin"; \ + cp $(BUILD_DIR)/test-sysroot-exec-cwd "$$tmpdir/bin/test-sysroot-exec-cwd"; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" "$$tmpdir/bin/test-sysroot-exec-cwd" + test-timeout-disable: $(ELFUSE_BIN) $(TEST_HELLO_DEP) @$(ELFUSE_BIN) --timeout 0 $(TEST_DIR)/test-hello > /dev/null diff --git a/scripts/check-syscall-coverage.py b/scripts/check-syscall-coverage.py index b0d87a21..d73248b5 100644 --- a/scripts/check-syscall-coverage.py +++ b/scripts/check-syscall-coverage.py @@ -35,7 +35,6 @@ INDIRECT_COVERAGE: dict[str, str] = { "lgetxattr": "Symlink xattr semantics are filesystem-sensitive; audit via fs-xattr code and negative-path tests.", "lsetxattr": "Symlink xattr semantics are filesystem-sensitive; audit via fs-xattr code and negative-path tests.", - "listxattr": "Covered indirectly through xattr plumbing; success-path coverage is filesystem-dependent.", "llistxattr": "Symlink xattr list semantics are filesystem-sensitive; retained as indirect coverage.", "flistxattr": "Covered indirectly through xattr plumbing and fd-based xattr checks.", "fgetxattr": "Covered indirectly through xattr plumbing and fd-based xattr checks.", diff --git a/src/runtime/procemu.c b/src/runtime/procemu.c index ed83fcc7..6c709bc7 100644 --- a/src/runtime/procemu.c +++ b/src/runtime/procemu.c @@ -63,6 +63,7 @@ #include "syscall/fuse.h" #include "syscall/internal.h" #include "syscall/net-identity.h" +#include "syscall/path.h" #include "syscall/proc.h" #include "syscall/sys.h" @@ -3783,24 +3784,27 @@ static int proc_readlink_self_exe(char *buf, size_t bufsiz) } const char *exe = exe_buf; char exe_real[LINUX_PATH_MAX]; - char sysroot_snap[LINUX_PATH_MAX]; - if (proc_sysroot_snapshot(sysroot_snap, sizeof(sysroot_snap))) { - /* proc_set_sysroot stores a realpath()-canonicalized form, so - * canonicalize exe before the prefix check or the strip fails when /var - * -> /private/var (and similar macOS symlinks) make the two strings - * diverge. - */ - const char *exe_cmp = exe; - if (realpath(exe, exe_real)) - exe_cmp = exe_real; - size_t sr_len = strlen(sysroot_snap); - if (sr_len > 0 && !strncmp(exe_cmp, sysroot_snap, sr_len) && - (exe_cmp[sr_len] == '/' || exe_cmp[sr_len] == '\0')) { - exe = exe_cmp + sr_len; - if (*exe == '\0') - exe = "/"; - } - } + char exe_guest[LINUX_PATH_MAX]; + /* proc_set_sysroot stores a realpath()-canonicalized form, so canonicalize + * exe before the reverse map or the sysroot strip fails when /var -> + * /private/var (and similar macOS symlinks) make the two strings diverge. + * path_host_to_guest also rewrites sidecar token components back to guest + * spellings, which a bare prefix strip would leak. Only an actual rewrite + * is adopted: an identity result keeps the original spelling, so a + * host-literal exe is not silently canonicalized (/tmp -> /private/tmp). + * + * With no sysroot configured the reverse map is an identity copy, so the + * canonicalization would be computed and then discarded; skip it there + * rather than pay one lstat per path component on every readlink. Testing + * proc_get_sysroot() for NULL without a snapshot is sanctioned (see + * proc.h). + */ + const char *exe_cmp = exe; + if (proc_get_sysroot() && realpath(exe, exe_real)) + exe_cmp = exe_real; + if (path_host_to_guest(exe_cmp, exe_guest, sizeof(exe_guest)) == 0 && + strcmp(exe_guest, exe_cmp)) + exe = exe_guest; size_t len = strlen(exe); if (len > bufsiz) len = bufsiz; @@ -3863,10 +3867,17 @@ int proc_intercept_readlink(const char *path, char *buf, size_t bufsiz) */ if (proc_rosetta_active() && !strcmp(fdpath, ROSETTA_PATH)) return proc_readlink_self_exe(buf, bufsiz); - size_t len = strlen(fdpath); + /* F_GETPATH reports the raw host path; the guest must see its own + * namespace (sysroot stripped, sidecar tokens reverse-mapped). + */ + char guest_view[LINUX_PATH_MAX]; + const char *report = fdpath; + if (path_host_to_guest(fdpath, guest_view, sizeof(guest_view)) == 0) + report = guest_view; + size_t len = strlen(report); if (len > bufsiz) len = bufsiz; - memcpy(buf, fdpath, len); + memcpy(buf, report, len); return (int) len; } diff --git a/src/syscall/exec.c b/src/syscall/exec.c index 27b71dbb..01a461f9 100644 --- a/src/syscall/exec.c +++ b/src/syscall/exec.c @@ -209,31 +209,67 @@ static int exec_resolve_guest_host_path(const char *guest_path, return 0; } -static int exec_resolve_interp_host_path(const char *sysroot, - const char *interp_guest_path, +/* A translated interpreter path is usable when it is a FUSE temp copy, or when + * translation rewrote the spelling AND the rewritten file actually exists. + * The existence probe is what separates a real sysroot hit from a + * sidecar-mapped prefix whose loader suffix is absent: without it a + * differs-but-missing path would be accepted and the /lib/ fallback + * skipped, so a store-style interpreter (e.g. a /nix/.../ld-musl.so.1 that + * ships the loader under /lib) would fail to launch. + */ +static bool exec_translated_usable(const char *host, + const char *guest, + bool temp) +{ + return temp || (strcmp(host, guest) && access(host, F_OK) == 0); +} + +/* Resolve PT_INTERP through the same translation as every other guest path, + * so a sysroot interpreter is found with sidecar spelling, containment, and + * FUSE materialization applied. The bootstrap loader keeps elf_resolve_interp + * because the core layer cannot call into the syscall translator. + */ +static int exec_resolve_interp_host_path(const char *interp_guest_path, char *interp_host_path, size_t interp_host_path_sz, bool *interp_host_temp, bool *shm_nofollow) { - char interp_candidate[LINUX_PATH_MAX]; - elf_resolve_interp(sysroot, interp_guest_path, interp_candidate, - sizeof(interp_candidate)); *shm_nofollow = false; - if (strcmp(interp_candidate, interp_guest_path) != 0) { - size_t len = str_copy_trunc(interp_host_path, interp_candidate, - interp_host_path_sz); - if (len >= interp_host_path_sz) { - errno = ENAMETOOLONG; - return -1; - } - *interp_host_temp = false; + if (exec_resolve_guest_host_path(interp_guest_path, interp_host_path, + interp_host_path_sz, interp_host_temp, + shm_nofollow) < 0) + return -1; + if (exec_translated_usable(interp_host_path, interp_guest_path, + *interp_host_temp)) return 0; - } - return exec_resolve_guest_host_path(interp_guest_path, interp_host_path, - interp_host_path_sz, interp_host_temp, - shm_nofollow); + /* Literal fallback: before accepting it, try the image's /lib for the + * loader's basename. Store-style interpreter paths such as + * /nix/.../lib/ld-musl-aarch64.so.1 ship the loader under /lib. + */ + const char *base = strrchr(interp_guest_path, '/'); + base = base ? base + 1 : interp_guest_path; + char lib_guest[LINUX_PATH_MAX]; + int n = snprintf(lib_guest, sizeof(lib_guest), "/lib/%s", base); + if (n > 0 && (size_t) n < sizeof(lib_guest)) { + char lib_host[LINUX_PATH_MAX]; + bool lib_temp = false; + bool lib_shm = false; + if (exec_resolve_guest_host_path(lib_guest, lib_host, sizeof(lib_host), + &lib_temp, &lib_shm) == 0 && + exec_translated_usable(lib_host, lib_guest, lib_temp)) { + size_t len = + str_copy_trunc(interp_host_path, lib_host, interp_host_path_sz); + if (len >= interp_host_path_sz) { + errno = ENAMETOOLONG; + return -1; + } + *interp_host_temp = lib_temp; + *shm_nofollow = lib_shm; + } + } + return 0; } /* Read a NULL-terminated pointer array from guest memory. Each pointer in the @@ -386,17 +422,16 @@ int64_t sys_execve(hv_vcpu_t vcpu, bool verbose, const char *host_path) { - /* Copy guest execve inputs before any state-reset point of no return. If - * host_path is provided (from execveat resolution), use it directly instead - * of reading from guest memory. This avoids writing host-resolved paths - * into guest address space. + /* Copy guest execve inputs before any state-reset point of no return. A + * provided host_path (from execveat resolution) is used directly for the + * exec open, but the guest-visible identity in path must carry the guest + * spelling: strip the sysroot and reverse-map sidecar tokens so + * /proc/self/exe never reports the private host namespace. */ char path[LINUX_PATH_MAX]; if (host_path) { - size_t len = strlen(host_path); - if (len >= LINUX_PATH_MAX) + if (path_host_to_guest(host_path, path, sizeof(path)) < 0) return -LINUX_ENAMETOOLONG; - memcpy(path, host_path, len + 1); } else if (guest_read_str(g, path_gva, path, sizeof(path)) < 0) { return -LINUX_EFAULT; } @@ -404,7 +439,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, log_debug("execve(\"%s\")", path); char path_host_buf[LINUX_PATH_MAX]; - const char *path_host = path; + const char *path_host = host_path ? host_path : path; bool path_host_temp = false; /* Whether path_host is a shm redirect leaf (drives O_NOFOLLOW on the exec * open). Re-evaluated whenever path_host is repointed to an interpreter. @@ -762,11 +797,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, */ bool interp_shm = false; if (!target_is_rosetta && elf_info.interp_path[0] != '\0') { - char sysroot_snap[LINUX_PATH_MAX]; - bool have_sr = - proc_sysroot_snapshot(sysroot_snap, sizeof(sysroot_snap)); - if (exec_resolve_interp_host_path(have_sr ? sysroot_snap : NULL, - elf_info.interp_path, interp_resolved, + if (exec_resolve_interp_host_path(elf_info.interp_path, interp_resolved, sizeof(interp_resolved), &interp_host_temp, &interp_shm) < 0) { log_error("execve: failed to resolve interpreter: %s", diff --git a/src/syscall/fs-stat.c b/src/syscall/fs-stat.c index 7b14d174..44fefedc 100644 --- a/src/syscall/fs-stat.c +++ b/src/syscall/fs-stat.c @@ -213,10 +213,9 @@ static int64_t stat_at_path(guest_t *g, return 0; } - if (tx.proc_resolved == 0 && dirfd == LINUX_AT_FDCWD && pathp[0] != '/' && - pathp[0] != '\0' && !proc_get_sysroot()) { + if (path_translation_relative_fast_path(&tx, dirfd)) { int mac_flags = translate_at_flags(flags); - if (fstatat(AT_FDCWD, pathp, mac_st, mac_flags) < 0) + if (fstatat(AT_FDCWD, tx.host_path, mac_st, mac_flags) < 0) return linux_errno(); return 0; } @@ -224,38 +223,35 @@ static int64_t stat_at_path(guest_t *g, int64_t rc = 0; host_fd_ref_t dir_ref = {.fd = -1, .owned = false}; if ((flags & LINUX_AT_EMPTY_PATH) && pathp[0] == '\0') { - /* Linux: AT_EMPTY_PATH with dirfd == AT_FDCWD operates on the current - * working directory. + path_empty_at_t er; + int64_t erc = path_resolve_empty_at(dirfd, &er); + if (erc < 0) + return erc; + /* Stat is a query: serve the FUSE identities that the mutating + * empty-path callers (fchmodat/fchownat) reject with ENOSYS. */ - if (dirfd == LINUX_AT_FDCWD) { - dir_ref.fd = AT_FDCWD; - int mac_flags = translate_at_flags(flags); - if (fstatat(AT_FDCWD, ".", mac_st, mac_flags) < 0) { - rc = linux_errno(); - goto done; - } - } else { - fd_entry_t snap; - dir_ref.fd = fd_snapshot_and_dup(dirfd, &snap); - dir_ref.owned = true; - if (dir_ref.fd < 0) { - rc = -LINUX_EBADF; + if (er.fuse_path[0] != '\0') { + int frc = fuse_stat_path(er.fuse_path, mac_st, flags); + return frc < 0 ? frc : 0; + } + if (er.fuse_fd) { + int frc = fuse_fstat_fd(dirfd, mac_st); + return frc < 0 ? frc : 0; + } + dir_ref = er.ref; + if (er.proc_path[0] != '\0') { + int intercepted = proc_intercept_stat(er.proc_path, mac_st); + if (intercepted == 0) goto done; - } - if (snap.type == FD_PATH && snap.proc_path[0] != '\0') { - int intercepted = proc_intercept_stat(snap.proc_path, mac_st); - if (intercepted == 0) - goto done; - if (intercepted == -1) { - rc = linux_errno(); - goto done; - } - } - if (fstat(dir_ref.fd, mac_st) < 0) { + if (intercepted == -1) { rc = linux_errno(); goto done; } } + if (fstat(dir_ref.fd, mac_st) < 0) { + rc = linux_errno(); + goto done; + } } else { if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; diff --git a/src/syscall/fs.c b/src/syscall/fs.c index e96f954a..dbbd1a7e 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -513,9 +513,8 @@ int64_t sys_openat_path(guest_t *g, return linux_errno(); int flags = translate_open_flags(linux_flags); - if (!tx.fuse_path && tx.proc_resolved == 0 && dirfd == LINUX_AT_FDCWD && - pathp[0] != '/' && !proc_get_sysroot()) { - int host_fd = openat(AT_FDCWD, pathp, flags, mode); + if (path_translation_relative_fast_path(&tx, dirfd)) { + int host_fd = openat(AT_FDCWD, tx.host_path, flags, mode); if (host_fd < 0) return linux_errno(); @@ -1707,13 +1706,15 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) } /* Reach a shm leaf via an fd for truncate/chdir, which have no nofollow path - * variant. O_NOFOLLOW keeps a symlink leaf contained, O_NONBLOCK stops a FIFO - * leaf from blocking the vCPU thread, O_CLOEXEC covers the short-lived fd. See - * dev_shm_resolve_path(). + * variant. path_translation_oflags() supplies the namespace's own requirement + * (O_NOFOLLOW to keep a symlink leaf contained, O_NONBLOCK so a FIFO leaf + * cannot block the vCPU thread) from one place, so the policy cannot drift + * between here and the other translated opens; O_CLOEXEC covers the + * short-lived fd. See dev_shm_resolve_path(). */ static int shm_open_leaf(const path_translation_t *tx, int oflags) { - return open(tx->host_path, oflags | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + return open(tx->host_path, path_translation_oflags(tx, oflags) | O_CLOEXEC); } int64_t sys_chdir(guest_t *g, uint64_t path_gva) @@ -2377,9 +2378,9 @@ int64_t sys_faccessat(guest_t *g, if (tx.fuse_path) return fuse_access_path(tx.intercept_path, mode, flags); - if (tx.proc_resolved == 0 && dirfd == LINUX_AT_FDCWD && path[0] != '/') { + if (path_translation_relative_fast_path(&tx, dirfd)) { int mac_flags = translate_faccessat_flags(flags); - if (faccessat(AT_FDCWD, path, mode, mac_flags) < 0) + if (faccessat(AT_FDCWD, tx.host_path, mode, mac_flags) < 0) return linux_errno(); return 0; } @@ -2512,6 +2513,27 @@ int64_t sys_fchmod(int fd, uint32_t mode) return 0; } +/* Resolve dirfd for an AT_EMPTY_PATH mutation and reject the identities that + * cannot be mutated through one. On success the caller owns er->ref and must + * close it; on failure the descriptor is already released. Shared by fchmodat + * and fchownat so the policy cannot drift between them. Stat-like callers do + * not use this: they serve the FUSE and proc identities instead of refusing + * them, so they read path_resolve_empty_at directly. + */ +static int64_t empty_at_mutable(guest_fd_t dirfd, path_empty_at_t *er) +{ + int64_t erc = path_resolve_empty_at(dirfd, er); + if (erc < 0) + return erc; + if (er->fuse_path[0] != '\0' || er->fuse_fd) + return -LINUX_ENOSYS; + if (er->proc_path[0] != '\0') { + host_fd_ref_close(&er->ref); + return -LINUX_EPERM; + } + return 0; +} + int64_t sys_fchmodat(guest_t *g, int dirfd, uint64_t path_gva, @@ -2525,43 +2547,20 @@ int64_t sys_fchmodat(guest_t *g, if (guest_read_str(g, path_gva, path, sizeof(path)) < 0) return -LINUX_EFAULT; - /* AT_EMPTY_PATH with an empty path chmods dirfd itself; see the identical - * branch in sys_fchownat above for the O_PATH/AT_FDCWD rationale. Neither - * sub-case below goes through path_translate_at, so the FUSE and /proc - * interception normally applied by reject_unsupported_fuse_path_op and - * stat_at_path's proc_intercept_stat has to be checked by hand here. + /* AT_EMPTY_PATH with an empty path chmods dirfd itself; see sys_fchownat + * below for the O_PATH rationale. A FUSE object cannot be mutated this + * way and a synthetic /proc identity cannot be chmodded. */ if ((flags & LINUX_AT_EMPTY_PATH) && path[0] == '\0') { - if (dirfd == LINUX_AT_FDCWD) { - char fuse_buf[LINUX_PATH_MAX]; - int fuse_rc = fuse_resolve_at_path(LINUX_AT_FDCWD, ".", fuse_buf, - sizeof(fuse_buf)); - if (fuse_rc < 0) - return linux_errno(); - if (fuse_rc > 0) - return -LINUX_ENOSYS; - if (fchmodat(AT_FDCWD, ".", mode, translate_at_flags(flags)) < 0) - return linux_errno(); - return 0; - } - - fd_entry_t snap; - if (!fd_snapshot(dirfd, &snap)) - return -LINUX_EBADF; - if (snap.type == FD_FUSE_DEV || snap.type == FD_FUSE_FILE || - snap.type == FD_FUSE_DIR) - return -LINUX_ENOSYS; - if (snap.type == FD_PATH && snap.proc_path[0] != '\0') - return -LINUX_EPERM; - - host_fd_ref_t ref; - if (host_dirfd_ref_open(dirfd, &ref) < 0) - return -LINUX_EBADF; - if (fchmod(ref.fd, mode) < 0) { - host_fd_ref_close(&ref); + path_empty_at_t er; + int64_t erc = empty_at_mutable(dirfd, &er); + if (erc < 0) + return erc; + if (fchmod(er.ref.fd, mode) < 0) { + host_fd_ref_close(&er.ref); return linux_errno(); } - host_fd_ref_close(&ref); + host_fd_ref_close(&er.ref); return 0; } @@ -2658,61 +2657,25 @@ int64_t sys_fchownat(guest_t *g, * beneath it. This is the only way to chown an O_PATH fd (plain fchown() * rejects FD_PATH, matching Linux's EBADF there), so unlike the other * *at() flag validation here it has to actually be handled, not just - * accepted. dirfd == AT_FDCWD resolves to the current directory, mirroring - * stat_at_path's identical AT_EMPTY_PATH branch in fs-stat.c. Neither - * sub-case below goes through path_translate_at, so FUSE and /proc - * interception has to be checked by hand, same as sys_fchmodat above. + * accepted. path_resolve_empty_at supplies one descriptor for both the + * fchown and the follow-up stat, so a concurrent chdir() cannot record + * the overlay against a different directory's dev/ino. A synthetic /proc + * identity cannot be chowned. */ if ((flags & LINUX_AT_EMPTY_PATH) && path[0] == '\0') { - if (dirfd == LINUX_AT_FDCWD) { - char fuse_buf[LINUX_PATH_MAX]; - int fuse_rc = fuse_resolve_at_path(LINUX_AT_FDCWD, ".", fuse_buf, - sizeof(fuse_buf)); - if (fuse_rc < 0) - return linux_errno(); - if (fuse_rc > 0) - return -LINUX_ENOSYS; - - /* Open "." once so fchown and the follow-up stat operate on the - * same descriptor. Resolving "." twice (fchownat then fstatat) - * would let a concurrent chdir() on another thread of this guest - * record the overlay against a different directory's dev/ino. - */ - int fd = open(".", O_RDONLY); - if (fd < 0) - return linux_errno(); - int host_rc = fchown(fd, owner, group); - int saved_errno = errno; - struct stat host_st; - const struct stat *st_ptr = - fstat(fd, &host_st) == 0 ? &host_st : NULL; - errno = saved_errno; - int64_t out = chown_result(host_rc, st_ptr, owner, group); - close_keep_errno(fd); - return out; - } - - fd_entry_t snap; - if (!fd_snapshot(dirfd, &snap)) - return -LINUX_EBADF; - if (snap.type == FD_FUSE_DEV || snap.type == FD_FUSE_FILE || - snap.type == FD_FUSE_DIR) - return -LINUX_ENOSYS; - if (snap.type == FD_PATH && snap.proc_path[0] != '\0') - return -LINUX_EPERM; - - host_fd_ref_t ref; - if (host_dirfd_ref_open(dirfd, &ref) < 0) - return -LINUX_EBADF; + path_empty_at_t er; + int64_t erc = empty_at_mutable(dirfd, &er); + if (erc < 0) + return erc; - int host_rc = fchown(ref.fd, owner, group); + int host_rc = fchown(er.ref.fd, owner, group); int saved_errno = errno; struct stat host_st; const struct stat *st_ptr = - fstat(ref.fd, &host_st) == 0 ? &host_st : NULL; + fstat(er.ref.fd, &host_st) == 0 ? &host_st : NULL; errno = saved_errno; int64_t out = chown_result(host_rc, st_ptr, owner, group); - host_fd_ref_close(&ref); + host_fd_ref_close(&er.ref); return out; } diff --git a/src/syscall/inotify.c b/src/syscall/inotify.c index 3fe0070a..a89719de 100644 --- a/src/syscall/inotify.c +++ b/src/syscall/inotify.c @@ -40,7 +40,9 @@ #include "syscall/abi.h" #include "syscall/inotify.h" #include "syscall/internal.h" +#include "syscall/path.h" #include "syscall/proc.h" /* proc_exit_group_requested */ +#include "syscall/sidecar.h" static void inotify_close(int guest_fd); @@ -333,12 +335,32 @@ static bool dir_snapshot_fd(int dirfd, char ***out, int *n_out) int fd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); if (fd < 0) return false; + DIR *d = fdopendir(fd); if (!d) { close(fd); return false; } + /* Snapshots feed named IN_CREATE/IN_DELETE events, so they must carry + * guest-visible names: inside a casefold sysroot, reverse-map sidecar token + * children through this directory's index and hide the sidecar's own + * bookkeeping files, which would otherwise diff as phantom children. The + * map is held open across the whole pass so the index is read and parsed + * once rather than once per entry. + * + * An unreadable index fails the snapshot here rather than later, matching + * getdents64 on a translation error: the caller keeps the previous baseline + * and the next successful snapshot reconciles. Dropping the entries instead + * would diff as though every mapped child had been deleted. + */ + bool map_names = sidecar_active(); + sidecar_dir_map_t *dir_map = NULL; + if (map_names && sidecar_dir_map_open(fd, &dir_map) < 0) { + closedir(d); + return false; + } + char **names = NULL; int n = 0, cap = 0; bool ok = true; @@ -356,6 +378,32 @@ static bool dir_snapshot_fd(int dirfd, char ***out, int *n_out) } if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue; + if (map_names && sidecar_name_reserved(de->d_name)) + continue; + const char *store = de->d_name; + char mapped[LINUX_PATH_MAX]; + if (map_names && + sidecar_name_is_token(de->d_name, strlen(de->d_name))) { + int map_rc = sidecar_dir_map_name(dir_map, de->d_name, mapped, + sizeof(mapped)); + if (map_rc < 0) { + /* Fail closed on a per-entry translation error, as getdents64 + * does. Only ENAMETOOLONG reaches here, and mapped is a full + * LINUX_PATH_MAX for a single component, so it is a guard + * rather than an expected outcome. + */ + ok = false; + break; + } + /* An empty result means the index carries no row for this name, so + * it keeps its on-disk spelling, exactly as getdents64 passes an + * unmapped name through. Such a name is an ordinary file to the + * guest: a lookup falls back to the literal spelling and reaches + * it, so hiding it here would make a reachable file unlistable. + */ + if (mapped[0] != '\0') + store = mapped; + } if (n == cap) { int ncap = cap ? cap * 2 : 16; char **tmp = realloc(names, (size_t) ncap * sizeof(char *)); @@ -366,7 +414,7 @@ static bool dir_snapshot_fd(int dirfd, char ***out, int *n_out) names = tmp; cap = ncap; } - names[n] = strdup(de->d_name); + names[n] = strdup(store); if (!names[n]) { ok = false; break; @@ -374,6 +422,7 @@ static bool dir_snapshot_fd(int dirfd, char ***out, int *n_out) n++; } closedir(d); + sidecar_dir_map_close(dir_map); if (!ok) { free_dir_snapshot(names, n); @@ -638,11 +687,22 @@ int64_t sys_inotify_add_watch(guest_t *g, if (guest_read_str(g, path_gva, path, sizeof(path)) < 0) return -LINUX_EFAULT; + /* Watches are backed by kqueue on a host fd, which cannot observe FUSE + * or synthetic /proc objects, so refuse those instead of watching an + * unrelated host path. + */ + path_translation_t tx; + if (path_translate_at(LINUX_AT_FDCWD, path, PATH_TR_NONE, &tx) < 0) + return linux_errno(); + if (tx.fuse_path || tx.proc_resolved != 0 || + path_might_use_open_intercept(tx.intercept_path)) + return -LINUX_ENOSYS; + /* Open the path for event monitoring. O_EVTONLY is macOS-specific: opens * for event notification only, does not prevent unmount or require read * access to the file contents. */ - int host_fd = open(path, O_EVTONLY); + int host_fd = open(tx.host_path, O_EVTONLY); if (host_fd < 0) return linux_errno(); diff --git a/src/syscall/net-absock.c b/src/syscall/net-absock.c index 180ee1ad..bbc4c5c8 100644 --- a/src/syscall/net-absock.c +++ b/src/syscall/net-absock.c @@ -5,6 +5,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include +#include #include #include #include @@ -21,11 +23,18 @@ #include "utils.h" +#include "syscall/abi.h" +#include "syscall/internal.h" #include "syscall/net.h" +#include "syscall/net-abi.h" #include "syscall/net-absock.h" +#include "syscall/path.h" +#include "syscall/sidecar.h" #define ABSOCK_MAX_ENTRIES 64 #define ABSOCK_MAX_NAME 107 +/* Linux struct sockaddr_un carries at most 108 sun_path bytes. */ +#define LINUX_UNIX_PATH_MAX 108 typedef struct { int guest_fd; @@ -42,6 +51,8 @@ static bool absock_dir_created; static _Atomic uint64_t absock_namespace_id; static _Atomic uint32_t absock_autobind_counter; +static void absock_cleanup(void); + static int absock_ensure_dir_locked(void) { uint64_t namespace_id = atomic_load(&absock_namespace_id); @@ -61,6 +72,16 @@ static int absock_ensure_dir_locked(void) if (create_private_dir(absock_dir) < 0) return -1; + /* Arm the exit sweep here, the one point where on-disk namespace state + * first appears. Every producer of that state (abstract bind, autobind, + * connect rewrite, and the pathname-socket shortening links) reaches the + * dir through this function, so a single registration covers them all. + * Reaching this line already means absock_dir_created was false and the + * directory was just created, and it is set below and never cleared, so + * this runs exactly once and needs no separate guard. + */ + atexit(absock_cleanup); + absock_dir_created = true; return 0; } @@ -196,6 +217,216 @@ static int absock_build_sun(const char *fs_path, return (int) (offsetof(struct sockaddr_un, sun_path) + path_len + 1); } +/* Point a short symlink in the private absock dir at an over-long translated + * socket path so it fits sun_path. bind(2) through a dangling symlink creates + * the socket at the target and connect(2) follows it (probed on macOS 15). + * Forked guests share the namespace dir, so a losing EEXIST race is accepted + * when the existing link already names the same target. + */ +static int absock_shorten_path(const char *host_path, char *out, size_t out_sz) +{ + pthread_mutex_lock(&absock_lock); + if (absock_ensure_dir_locked() < 0) { + pthread_mutex_unlock(&absock_lock); + return -1; + } + absock_encode_name((const uint8_t *) host_path, + (uint32_t) strlen(host_path), out, out_sz); + /* Create-first, never unlink a matching link: absock_lock is + * per-process, so an unconditional unlink could yank a forked sibling's + * just-created link between its shorten and its bind(2). The encoded + * name is derived from the target, so an existing link with the same + * name almost always already points at the right place. + */ + if (symlink(host_path, out) < 0) { + char existing[LINUX_PATH_MAX]; + ssize_t n = -1; + if (errno == EEXIST) + n = readlink(out, existing, sizeof(existing) - 1); + if (n < 0 || (size_t) n != strlen(host_path) || + /* cppcheck-suppress legacyUninitvar + * Short-circuit || guarantees memcmp only runs when n == + * strlen(host_path) and readlink filled exactly + * existing[0..n-1] on success. + */ + memcmp(existing, host_path, (size_t) n)) { + /* Stale or foreign entry under our name: replace it. */ + (void) unlink(out); + if (symlink(host_path, out) < 0) { + pthread_mutex_unlock(&absock_lock); + return -1; + } + } + } + pthread_mutex_unlock(&absock_lock); + return 0; +} + +/* Reverse-map one returned pathname AF_UNIX address to its guest spelling. + * Returns the Linux sockaddr length when the address was rewritten, or -1 + * when it is not a translated pathname and the generic converter should run. + */ +static int absock_sockaddr_un_from_mac(const struct sockaddr_un *sun, + uint32_t mac_len, + uint8_t *linux_sa, + uint32_t linux_sa_size) +{ + /* Bound every read by mac_len: macOS may fill all 104 sun_path bytes + * with no terminator, and only mac_len bytes of the caller's + * sockaddr_storage are initialized. + */ + size_t sp_max = mac_len - offsetof(struct sockaddr_un, sun_path); + if (sp_max > sizeof(sun->sun_path)) + sp_max = sizeof(sun->sun_path); + size_t sp_len = strnlen(sun->sun_path, sp_max); + if (sp_len == 0) + return -1; + char mac_path[sizeof(sun->sun_path) + 1]; + memcpy(mac_path, sun->sun_path, sp_len); + mac_path[sp_len] = '\0'; + + /* Undo the over-length shortening symlink, then map the host path back + * to the guest namespace so the guest reads back the spelling it bound + * or connected with, not the sysroot-prefixed (and possibly + * token-bearing) host path. + */ + char host_path[LINUX_PATH_MAX]; + str_copy_trunc(host_path, mac_path, sizeof(host_path)); + pthread_mutex_lock(&absock_lock); + bool in_absock_dir = absock_dir_created && + !strncmp(host_path, absock_dir, strlen(absock_dir)); + pthread_mutex_unlock(&absock_lock); + if (in_absock_dir) { + char target[LINUX_PATH_MAX]; + ssize_t n = readlink(host_path, target, sizeof(target) - 1); + if (n > 0) { + target[n] = '\0'; + str_copy_trunc(host_path, target, sizeof(host_path)); + } + } + + char guest_path[LINUX_PATH_MAX]; + if (path_host_to_guest(host_path, guest_path, sizeof(guest_path)) != 0 || + !strcmp(guest_path, mac_path)) + return -1; + + /* Write the Linux sockaddr directly: the guest may have bound a + * Linux-legal name longer than the 103 usable bytes of a macOS + * sun_path, and rebuilding a mac sockaddr first would fail for exactly + * the paths the shortening symlink serves. + */ + size_t glen = strlen(guest_path); + if (glen > LINUX_UNIX_PATH_MAX || linux_sa_size < 2) + return -1; + uint16_t fam16 = LINUX_AF_UNIX; + memcpy(linux_sa, &fam16, 2); + uint32_t avail = linux_sa_size - 2; + uint32_t copy = (uint32_t) glen; + if (glen < LINUX_UNIX_PATH_MAX) + copy++; /* include the terminator, kernel-style */ + if (copy > avail) + copy = avail; + memcpy(linux_sa + 2, guest_path, copy); + return (int) (2 + copy); +} + +int net_sockaddr_from_mac(const struct sockaddr *mac_sa, + uint32_t mac_len, + uint8_t *linux_sa, + uint32_t linux_sa_size) +{ + if (mac_sa && mac_len > offsetof(struct sockaddr_un, sun_path) && + mac_sa->sa_family == AF_UNIX) { + int rc = + absock_sockaddr_un_from_mac((const struct sockaddr_un *) mac_sa, + mac_len, linux_sa, linux_sa_size); + if (rc >= 0) + return rc; + } + return mac_to_linux_sockaddr(mac_sa, (socklen_t) mac_len, linux_sa, + linux_sa_size); +} + +int net_sockaddr_to_mac(const uint8_t *linux_sa, + uint32_t addrlen, + bool create, + struct sockaddr_storage *mac_sa) +{ + uint16_t fam = 0; + if (addrlen >= 2) + memcpy(&fam, linux_sa, 2); + + if (fam == LINUX_AF_UNIX && addrlen > 2 && linux_sa[2] != '\0') { + /* Pathname socket: the name is a filesystem path and must go through + * sysroot translation like every other path-taking syscall; the raw + * bytes would name the unrelated host-literal file. Linux permits an + * unterminated sun_path, so bound the copy by addrlen. + */ + char guest_path[LINUX_UNIX_PATH_MAX + 1]; + uint32_t plen = addrlen - 2; + if (plen > LINUX_UNIX_PATH_MAX) + return -LINUX_EINVAL; + memcpy(guest_path, linux_sa + 2, plen); + guest_path[plen] = '\0'; + + /* Creates resolve the parent through the lookup walk and reattach + * the leaf: PATH_TR_CREATE skips the sidecar, so a bind inside a + * guest-created (tokenized) directory would miss the on-disk parent. + * The socket itself keeps its real name; socket names are not + * tokenized. "/name" and bare relative names have no tokenizable + * parent and translate whole. + */ + char *leaf = create ? strrchr(guest_path, '/') : NULL; + if (leaf == guest_path) + leaf = NULL; + if (leaf) + *leaf = '\0'; + /* The reserved-name guard lives under PATH_TR_CREATE, which only the + * leafless spelling below reaches; a reattached leaf bypasses it. + * Unguarded, the index spelling collides with the real bookkeeping + * file and reports EADDRINUSE for a name stat() and open(O_CREAT) + * both call ENOENT. Report ENOENT here so all three agree. + */ + if (leaf && sidecar_active() && sidecar_name_reserved(leaf + 1)) + return -LINUX_ENOENT; + path_translation_t tx; + if (path_translate_at(LINUX_AT_FDCWD, guest_path, + (create && !leaf) ? PATH_TR_CREATE : PATH_TR_NONE, + &tx) < 0) + return linux_errno(); + if (tx.fuse_path || tx.proc_resolved != 0) + return -LINUX_ENOSYS; + char joined[LINUX_PATH_MAX]; + const char *host_path = tx.host_path; + if (leaf) { + int jn = snprintf(joined, sizeof(joined), "%s/%s", tx.host_path, + leaf + 1); + if (jn < 0 || (size_t) jn >= sizeof(joined)) + return -LINUX_ENAMETOOLONG; + host_path = joined; + } + + char short_path[sizeof(((struct sockaddr_un *) 0)->sun_path)]; + if (strlen(host_path) >= sizeof(((struct sockaddr_un *) 0)->sun_path)) { + /* Surface the real failure (EACCES, EIO, ENOSPC, ...): the guest + * name is Linux-legal, so reporting ENAMETOOLONG would misattribute + * a symlink-layer error to the pathname length. + */ + if (absock_shorten_path(host_path, short_path, sizeof(short_path)) < + 0) + return linux_errno(); + host_path = short_path; + } + int mac_len = absock_build_sun(host_path, mac_sa); + if (mac_len < 0) + return -LINUX_ENAMETOOLONG; + return mac_len; + } + + int mac_len = linux_to_mac_sockaddr(linux_sa, addrlen, mac_sa); + return mac_len < 0 ? -LINUX_EINVAL : mac_len; +} + int absock_rewrite_connect(const uint8_t *linux_sa, uint32_t addrlen, struct sockaddr_storage *mac_sa) @@ -283,19 +514,53 @@ void absock_bind_rollback(int idx) static void absock_cleanup(void) { + /* Every process unlinks its own table-tracked sockets. A forked child + * starts from an empty table because fork hands over only the namespace + * id, so an entry here is never a sibling's. + */ for (int i = 0; i < ABSOCK_MAX_ENTRIES; i++) { if (absock_table[i].active) unlink(absock_table[i].fs_path); } - if (absock_dir_created) - rmdir(absock_dir); -} -void absock_init_cleanup(void) -{ - static int registered; - if (!registered) { - atexit(absock_cleanup); - registered = 1; + if (!absock_dir_created) + return; + + /* The shortening links are untracked, so no table owns them and the + * process that minted the namespace retires them on its way out. Sweep + * only symlinks: the abstract-socket backing files sharing this directory + * belong to whichever process bound them, including children that outlive + * the owner, and each unlinks its own above. Unlinking those too would + * destroy a socket a live child still has bound. A live child that loses + * a shortening link only degrades to the raw host link path, never to a + * missing socket. See docs/sysroot.md for the full lifecycle. + */ + if ((uint64_t) getpid() == absock_get_namespace_id()) { + DIR *d = opendir(absock_dir); + if (d) { + struct dirent *de; + while ((de = readdir(d)) != NULL) { + /* A filesystem may decline to classify an entry inline and + * report DT_UNKNOWN, so fall back to a nofollow stat there. + */ + bool is_link = de->d_type == DT_LNK; + if (de->d_type == DT_UNKNOWN) { + struct stat st; + is_link = fstatat(dirfd(d), de->d_name, &st, + AT_SYMLINK_NOFOLLOW) == 0 && + S_ISLNK(st.st_mode); + } + if (!is_link) + continue; + unlinkat(dirfd(d), de->d_name, 0); + } + closedir(d); + } } + + /* Any participant may retire the directory, not just the owner: rmdir + * succeeds only once it is empty, so the last one out removes it and a + * namespace whose directory was created by a forked child cannot leak. + */ + rmdir(absock_dir); } diff --git a/src/syscall/net-absock.h b/src/syscall/net-absock.h index c2b874e1..46379954 100644 --- a/src/syscall/net-absock.h +++ b/src/syscall/net-absock.h @@ -7,10 +7,34 @@ #pragma once +#include #include #include int absock_is_abstract_unix(const uint8_t *linux_sa, uint32_t addrlen); + +/* Convert a guest sockaddr to the host form. Pathname AF_UNIX addresses go + * through sysroot path translation (create semantics for bind, lookup for + * connect/sendto/sendmsg), with over-long translated paths diverted through + * a short symlink in the private absock dir; every other family delegates to + * linux_to_mac_sockaddr. Returns the mac sockaddr length, or a negative + * LINUX errno usable directly as the syscall result. + */ +int net_sockaddr_to_mac(const uint8_t *linux_sa, + uint32_t addrlen, + bool create, + struct sockaddr_storage *mac_sa); + +/* Convert a host sockaddr back to the guest form. Pathname AF_UNIX + * addresses are reverse-mapped through path_host_to_guest (undoing the + * over-length shortening symlink first) so the guest reads back the + * spelling it bound or connected with; every other family delegates to + * mac_to_linux_sockaddr. Same return convention as mac_to_linux_sockaddr. + */ +int net_sockaddr_from_mac(const struct sockaddr *mac_sa, + uint32_t mac_len, + uint8_t *linux_sa, + uint32_t linux_sa_size); int absock_rewrite_connect(const uint8_t *linux_sa, uint32_t addrlen, struct sockaddr_storage *mac_sa); @@ -24,4 +48,3 @@ void absock_bind_rollback(int idx); int absock_reverse_lookup(const char *fs_path, uint8_t *out_name, uint32_t *out_len); -void absock_init_cleanup(void); diff --git a/src/syscall/net-msg.c b/src/syscall/net-msg.c index 9478ae44..0ff606f6 100644 --- a/src/syscall/net-msg.c +++ b/src/syscall/net-msg.c @@ -24,6 +24,7 @@ #include "syscall/net.h" #include "syscall/net-sockopt.h" #include "syscall/net-abi.h" +#include "syscall/net-absock.h" #include "syscall/proc.h" #include "syscall/signal.h" @@ -222,10 +223,11 @@ int64_t sys_sendmsg(guest_t *g, int fd, uint64_t msg_gva, int linux_flags) host_fd_ref_close(&host_ref); return -LINUX_EFAULT; } - int ml = linux_to_mac_sockaddr(linux_sa, lmsg.msg_namelen, &mac_sa); + int ml = + net_sockaddr_to_mac(linux_sa, lmsg.msg_namelen, false, &mac_sa); if (ml < 0) { host_fd_ref_close(&host_ref); - return -LINUX_EINVAL; + return ml; } dest_sa = (struct sockaddr *) &mac_sa; dest_len = (socklen_t) ml; @@ -554,7 +556,7 @@ int64_t sys_recvmsg(guest_t *g, int fd, uint64_t msg_gva, int flags) if (lmsg.msg_name) { if (msg.msg_namelen > 0) { uint8_t linux_sa[128]; - int out_len = mac_to_linux_sockaddr((struct sockaddr *) &mac_sa, + int out_len = net_sockaddr_from_mac((struct sockaddr *) &mac_sa, msg.msg_namelen, linux_sa, (uint32_t) sizeof(linux_sa)); if (out_len > 0) { diff --git a/src/syscall/net.c b/src/syscall/net.c index f0488476..b591ac2b 100644 --- a/src/syscall/net.c +++ b/src/syscall/net.c @@ -372,7 +372,6 @@ int64_t sys_bind(guest_t *g, int fd, uint64_t addr_gva, uint32_t addrlen) /* Abstract Unix socket: rewrite to filesystem path */ int absock_idx = -1; if (absock_is_abstract_unix(linux_sa, addrlen)) { - absock_init_cleanup(); int bind_len; absock_idx = absock_bind_prepare(linux_sa, addrlen, &mac_sa, fd, &bind_len); @@ -386,10 +385,10 @@ int64_t sys_bind(guest_t *g, int fd, uint64_t addr_gva, uint32_t addrlen) } mac_len = bind_len; } else { - mac_len = linux_to_mac_sockaddr(linux_sa, addrlen, &mac_sa); + mac_len = net_sockaddr_to_mac(linux_sa, addrlen, true, &mac_sa); if (mac_len < 0) { host_fd_ref_close(&host_ref); - return -LINUX_EINVAL; + return mac_len; } } @@ -520,7 +519,7 @@ static int64_t do_accept(guest_t *g, } uint8_t linux_sa[128]; int out_len = - mac_to_linux_sockaddr((struct sockaddr *) &mac_sa, mac_len, + net_sockaddr_from_mac((struct sockaddr *) &mac_sa, mac_len, linux_sa, (uint32_t) sizeof(linux_sa)); if (out_len > 0) { uint32_t actual_len = (uint32_t) out_len; @@ -584,10 +583,10 @@ int64_t sys_connect(guest_t *g, int fd, uint64_t addr_gva, uint32_t addrlen) return -LINUX_ECONNREFUSED; } } else { - mac_len = linux_to_mac_sockaddr(linux_sa, addrlen, &mac_sa); + mac_len = net_sockaddr_to_mac(linux_sa, addrlen, false, &mac_sa); if (mac_len < 0) { host_fd_ref_close(&host_ref); - return -LINUX_EINVAL; + return mac_len; } } @@ -693,7 +692,7 @@ static int64_t sockaddr_writeback(guest_t *g, { uint8_t linux_sa[128]; int out_len = - mac_to_linux_sockaddr((const struct sockaddr *) mac_sa, mac_len, + net_sockaddr_from_mac((const struct sockaddr *) mac_sa, mac_len, linux_sa, (uint32_t) sizeof(linux_sa)); if (out_len < 0) { host_fd_ref_close(host_ref); @@ -850,10 +849,10 @@ int64_t sys_sendto(guest_t *g, host_fd_ref_close(&host_ref); return -LINUX_EFAULT; } - int mac_len = linux_to_mac_sockaddr(linux_sa, addrlen, &mac_sa); + int mac_len = net_sockaddr_to_mac(linux_sa, addrlen, false, &mac_sa); if (mac_len < 0) { host_fd_ref_close(&host_ref); - return -LINUX_EINVAL; + return mac_len; } dest = (struct sockaddr *) &mac_sa; dest_len = (socklen_t) mac_len; @@ -952,7 +951,7 @@ int64_t sys_recvfrom(guest_t *g, } uint8_t linux_sa[128]; int out_len = - mac_to_linux_sockaddr((struct sockaddr *) &mac_sa, mac_len, + net_sockaddr_from_mac((struct sockaddr *) &mac_sa, mac_len, linux_sa, (uint32_t) sizeof(linux_sa)); if (out_len > 0) { uint32_t actual_len = (uint32_t) out_len; diff --git a/src/syscall/path.c b/src/syscall/path.c index 2abbe415..8e3bd278 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -318,6 +318,54 @@ int path_translate_dirent_name(guest_fd_t dirfd, return 0; } +int64_t path_resolve_empty_at(guest_fd_t dirfd, path_empty_at_t *out) +{ + out->ref.fd = -1; + out->ref.owned = false; + out->fuse_fd = false; + out->proc_path[0] = '\0'; + out->fuse_path[0] = '\0'; + + if (dirfd == LINUX_AT_FDCWD) { + int fuse_rc = fuse_resolve_at_path(LINUX_AT_FDCWD, ".", out->fuse_path, + sizeof(out->fuse_path)); + if (fuse_rc < 0) + return linux_errno(); + if (fuse_rc > 0) + return 0; + out->fuse_path[0] = '\0'; + /* Open "." once so the caller's operation and any follow-up stat run + * against the same descriptor; resolving "." per call would let a + * concurrent chdir() on another thread retarget the second lookup. + * O_SEARCH, not O_RDONLY: Linux resolves AT_FDCWD + AT_EMPTY_PATH in + * a search-only (mode 0111) cwd, which an O_RDONLY open would reject + * with EACCES. O_CLOEXEC keeps a concurrent execve on another vCPU + * thread from leaking the descriptor into the new image. + */ + int fd = open(".", O_SEARCH | O_CLOEXEC); + if (fd < 0) + return linux_errno(); + out->ref.fd = fd; + out->ref.owned = true; + return 0; + } + + fd_entry_t snap; + if (!fd_snapshot(dirfd, &snap)) + return -LINUX_EBADF; + if (snap.type == FD_FUSE_DEV || snap.type == FD_FUSE_FILE || + snap.type == FD_FUSE_DIR) { + out->fuse_fd = true; + return 0; + } + if (snap.type == FD_PATH && snap.proc_path[0] != '\0') + str_copy_trunc(out->proc_path, snap.proc_path, sizeof(out->proc_path)); + + if (host_dirfd_ref_open(dirfd, &out->ref) < 0) + return -LINUX_EBADF; + return 0; +} + bool path_next_component(const char **pathp, const char **comp, size_t *len) { const char *p = *pathp; @@ -339,7 +387,7 @@ bool path_next_component(const char **pathp, const char **comp, size_t *len) static bool path_component_is_dot(const char *comp, size_t len) { - return len == 1 && comp[0] == '.'; + return path_component_eq(comp, len, "."); } const char *path_resolve_sysroot_path(const char *path, char *buf, size_t bufsz) @@ -862,9 +910,7 @@ static int classify_guest_path_mount(const char *guest_path) return PATH_MOUNT_ROOT; } -static int host_path_to_guest_path(const char *host_path, - char *out, - size_t outsz) +int path_host_to_guest(const char *host_path, char *out, size_t outsz) { char sysroot[LINUX_PATH_MAX]; const char *guest_path = host_path; @@ -876,6 +922,15 @@ static int host_path_to_guest_path(const char *host_path, guest_path = host_path + sysroot_len; if (*guest_path == '\0') guest_path = "/"; + /* A stripped remainder can still spell guest-created components + * as their on-disk sidecar tokens; reverse-map them so callers + * classify and report the guest names. Unmapped tokens pass + * through unchanged, so failure keeps the stripped path. + */ + else if (proc_sysroot_casefold_enabled() && + sidecar_reverse_map_host_path(sysroot, guest_path, out, + outsz) == 0) + return 0; } } @@ -932,7 +987,7 @@ static int dirfd_guest_base_path(guest_fd_t dirfd, char *out, size_t outsz) char host_path[LINUX_PATH_MAX]; if (path_openat2_dirfd_host_path(dirfd, host_path, sizeof(host_path)) == 0) - return host_path_to_guest_path(host_path, out, outsz); + return path_host_to_guest(host_path, out, outsz); /* fd_snapshot already proved dirfd is open, so a valid-but-wrong-type fd * (pipe, socket, epoll, ...) belongs here, not in the "bad fd" case: Linux @@ -1384,8 +1439,7 @@ int path_openat2_check_fd_xdev(int guest_fd, int start_class) char host_path[LINUX_PATH_MAX]; if (fcntl(snap.host_fd, F_GETPATH, host_path) < 0) return -1; - if (host_path_to_guest_path(host_path, guest_path, sizeof(guest_path)) < - 0) + if (path_host_to_guest(host_path, guest_path, sizeof(guest_path)) < 0) return -1; end_class = classify_guest_path_mount(guest_path); } else { diff --git a/src/syscall/path.h b/src/syscall/path.h index dd5db372..4a9d1d3e 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -57,6 +57,18 @@ static inline int path_translation_at_flags(const path_translation_t *tx, return tx->is_dev_shm ? (at_flags | AT_SYMLINK_NOFOLLOW) : at_flags; } +/* Flags an open(2) of a translated /dev/shm leaf needs: O_NOFOLLOW stops a + * symlink under the synthetic /dev/shm dir from resolving the leaf out of the + * namespace, and O_NONBLOCK stops a FIFO leaf from parking the vCPU thread on + * an open with no writer. Every open of a shm leaf goes through here, so the + * two requirements cannot be taken separately. + */ +static inline int path_translation_oflags(const path_translation_t *tx, + int oflags) +{ + return tx->is_dev_shm ? (oflags | O_NOFOLLOW | O_NONBLOCK) : oflags; +} + /* Advance *pathp to the next '/'-separated component, skipping empty segments * from repeated slashes. Returns true with the component (not NUL-terminated) * reported through comp and len, leaving *pathp at its end; returns false once @@ -84,6 +96,73 @@ static inline int path_component_copy(char *dst, return 0; } +/* True when the counted component [comp, comp+len) equals the NUL-terminated + * literal lit. Operates on the non-NUL-terminated component that + * path_next_component reports, so callers avoid copying into a scratch buffer + * just to strcmp. + */ +static inline bool path_component_eq(const char *comp, + size_t len, + const char *lit) +{ + size_t n = strlen(lit); + return len == n && !memcmp(comp, lit, n); +} + +/* True when a translated cwd-relative lookup may bypass dirfd resolution and + * intercept matching and go straight to the host against AT_FDCWD. Callers + * must pass tx->host_path to the host call, never the raw guest name: under a + * casefold sysroot a guest-created file exists on disk only under its sidecar + * token, which the translation already resolved. No sysroot gate is needed + * because host_path equals the raw name when no rewrite applied. + */ +static inline bool path_translation_relative_fast_path( + const path_translation_t *tx, + guest_fd_t dirfd) +{ + return tx->proc_resolved == 0 && !tx->fuse_path && + dirfd == LINUX_AT_FDCWD && tx->guest_path[0] != '\0' && + tx->guest_path[0] != '/'; +} + +/* Resolved object of an AT_EMPTY_PATH operation (the dirfd itself, or the + * current directory for AT_FDCWD). Exactly one of the identities holds: + * proc_path non-empty (dirfd is an O_PATH fd bound to a synthetic /proc + * file; ref is also open), fuse_path non-empty (the cwd lies inside a FUSE + * mount; ref stays closed), fuse_fd true (dirfd itself is FUSE-backed; ref + * stays closed), or plain host resolution with ref open. + */ +typedef struct { + host_fd_ref_t ref; + bool fuse_fd; + char proc_path[LINUX_PATH_MAX]; + char fuse_path[LINUX_PATH_MAX]; +} path_empty_at_t; + +/* Resolve an AT_EMPTY_PATH object, surfacing the FUSE and /proc identities + * that path_translate_at would have applied to a non-empty name as data: + * callers pick the policy (mutations reject FUSE with ENOSYS and synthetic + * /proc with EPERM; stat serves both through their interceptors). A dead fd + * yields -LINUX_EBADF, a failed cwd probe the mapped host errno. + * + * On success returns 0; when out->ref was opened the caller must close it + * on every return path (out->ref.fd is -1 otherwise). + */ +int64_t path_resolve_empty_at(guest_fd_t dirfd, path_empty_at_t *out); + +/* Convert a host path to its guest-visible spelling: strips the sysroot + * prefix and reverse-maps sidecar token components through their directory + * indices. The only sanctioned way to undo that leg; prefix arithmetic on + * host paths leaks token spellings to the guest. + * + * This inverts the sysroot leg only. path_translate_at() can also redirect + * /dev/shm into its per-UID backing dir and materialize a FUSE object, and + * neither of those is undone here: a host path produced by one of those legs + * comes back unchanged. Callers publishing such a path to the guest must + * carry the guest spelling forward themselves rather than reverse it. + */ +int path_host_to_guest(const char *host_path, char *out, size_t outsz); + bool path_might_use_open_intercept(const char *path); bool path_might_use_stat_intercept(const char *path); int path_check_intercept_access(const struct stat *st, int mode, int flags); @@ -146,8 +225,10 @@ int path_openat2_resolved_within_root(guest_fd_t dirfd, * - host_path_to_guest_path strips the configured sysroot prefix with * a case-sensitive strncmp; on case-insensitive macOS volumes a * differently-cased F_GETPATH could fail to strip and the dirfd is - * then classified as the root class. Sysroots that happen to live - * under /proc, /dev, or /sys on the host are not supported. + * then classified as the root class. Sidecar tokens in the stripped + * remainder are reverse-mapped, but an orphan token with no index row + * passes through under its on-disk spelling. Sysroots that happen to + * live under /proc, /dev, or /sys on the host are not supported. * - A sibling vCPU that chdir(2)s, dup3(2)s over dirfd, or mounts / * unmounts a FUSE filesystem between this check and the subsequent * sys_openat may shift the resolution into a different mount class diff --git a/src/syscall/proc-state.c b/src/syscall/proc-state.c index 9995cbd6..3177ca49 100644 --- a/src/syscall/proc-state.c +++ b/src/syscall/proc-state.c @@ -25,6 +25,7 @@ #include "syscall/proc.h" #include "syscall/proc-state.h" #include "syscall/path.h" +#include "syscall/sidecar.h" /* Shim blob reference (set by startup/bootstrap) */ static const unsigned char *shim_blob_ptr = NULL; @@ -84,20 +85,18 @@ void proc_state_init(void) int proc_cwd_refresh(void) { char cwd[LINUX_PATH_MAX]; + char mapped[LINUX_PATH_MAX]; const char *guest_cwd = cwd; if (!getcwd(cwd, sizeof(cwd))) return -1; - char sr[LINUX_PATH_MAX]; - if (proc_sysroot_snapshot(sr, sizeof(sr))) { - size_t sr_len = strlen(sr); - if (!strncmp(cwd, sr, sr_len) && - (cwd[sr_len] == '\0' || cwd[sr_len] == '/')) { - guest_cwd = cwd + sr_len; - if (*guest_cwd == '\0') - guest_cwd = "/"; - } - } + /* Host getcwd reports the sysroot prefix and sidecar ".ef_" token names + * for guest-created directories; the sanctioned converter strips the + * prefix and reverse-maps the tokens so neither spelling leaks into + * getcwd or /proc/self/cwd. + */ + if (path_host_to_guest(cwd, mapped, sizeof(mapped)) == 0) + guest_cwd = mapped; size_t len = strlen(guest_cwd); pthread_mutex_lock(&cwd_lock); @@ -515,10 +514,54 @@ static bool sysroot_path_exists(const char *resolved_path, bool follow_final) return lstat(resolved_path, &st) == 0; } +/* Guest-private prefixes. Creates under these are forced into the sysroot to + * dodge host case collisions, so lookups must resolve there too: with only + * the create side redirected, the same name was half shared and half private + * depending on the verb (a guest could stat a host /tmp file it could never + * have written, and its own reply landed where the host tool never looks). + * The bare directories match as well, or opendir("/tmp") would list the host + * /tmp while every child lookup resolves into the sysroot: a directory full + * of phantom entries. + * + * The caller passes an already-normalized spelling so that non-canonical + * spellings naming the same path ("//tmp/x", "/./tmp/x", "/a/../tmp/x" all name + * /tmp/x) are classified identically, and so this classifier and + * is_guest_system_path() always judge the same string. Matching is component by + * component, so "/var/tmpfoo" and "/.ccachefoo" cannot false-positive. Leading + * slashes are irrelevant here because path_next_component() skips them. + */ +static bool sysroot_private_prefix(const char *norm) +{ + const char *comp; + size_t len; + + /* A ".ccache" anywhere in the path is private: build tools scatter + * case-colliding objects through the tree, and the bare cache directory + * must resolve into the sysroot so its own listing is not phantom. + */ + const char *scan = norm; + while (path_next_component(&scan, &comp, &len)) + if (path_component_eq(comp, len, ".ccache")) + return true; + + /* A leading "tmp" or "var/tmp" component, bare or with descendants. */ + const char *walk = norm; + if (!path_next_component(&walk, &comp, &len)) + return false; + if (path_component_eq(comp, len, "tmp")) + return true; + if (path_component_eq(comp, len, "var") && + path_next_component(&walk, &comp, &len) && + path_component_eq(comp, len, "tmp")) + return true; + return false; +} + /* Resolve an absolute guest path against --sysroot. This keeps absolute guest * filesystem syscalls inside the sysroot when the target exists there, and * otherwise falls back to the literal host path so apps can still reach host - * resources such as /tmp or /etc/resolv.conf. Containment via realpath() is + * resources such as /etc/resolv.conf or files under the user's home. The + * guest-private prefixes never fall back. Containment via realpath() is * enforced only when the path actually resolves under sysroot, to prevent * symlink escape from a tree the caller intended to stay inside. */ @@ -641,14 +684,18 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, errno = ENAMETOOLONG; return NULL; } - - /* Prevent escaping guest system paths to macOS host paths, which leads - * to host contamination and permission failures (e.g. SIP/EPERM). + /* Normalize once and hand the same spelling to both classifiers: a path + * they disagree about would be private by one rule and host-bound by the + * other. The second guard prevents escaping guest system paths to macOS + * host paths, which leads to host contamination and permission failures + * (e.g. SIP/EPERM). */ char norm_path[LINUX_PATH_MAX]; - bool has_norm = - lexical_normalize_absolute_path(norm_path, path, sizeof(norm_path)); - if (is_guest_system_path(has_norm ? norm_path : path)) + const char *norm = + lexical_normalize_absolute_path(norm_path, path, sizeof(norm_path)) + ? norm_path + : path; + if (sysroot_private_prefix(norm) || is_guest_system_path(norm)) return buf; return path; @@ -729,20 +776,17 @@ const char *proc_resolve_sysroot_create_path(const char *path, if (errno != ENOENT && errno != ENOTDIR) return NULL; - /* Parent doesn't exist in sysroot. Only /tmp, /var/tmp, and ccache get - * forcefully redirected to the sysroot to avoid host case-collisions; + /* Parent doesn't exist in sysroot. Only the guest-private prefixes and the + * guest system directories get forcefully redirected to the sysroot; * everything else falls back to the host literal. - * Guest system directories must also be forced to resolve to the sysroot. */ char norm_path[LINUX_PATH_MAX]; - bool has_norm = - lexical_normalize_absolute_path(norm_path, path, sizeof(norm_path)); - const char *path_to_check = has_norm ? norm_path : path; - - if (strncmp(path_to_check, "/tmp/", 5) && - strncmp(path_to_check, "/var/tmp/", 9) && - !strstr(path_to_check, "/.ccache/") && - !is_guest_system_path(path_to_check)) + const char *norm = + lexical_normalize_absolute_path(norm_path, path, sizeof(norm_path)) + ? norm_path + : path; + + if (!sysroot_private_prefix(norm) && !is_guest_system_path(norm)) return path; if (!create_parents) { diff --git a/src/syscall/sidecar.c b/src/syscall/sidecar.c index 32cffff0..529e9158 100644 --- a/src/syscall/sidecar.c +++ b/src/syscall/sidecar.c @@ -813,6 +813,97 @@ int sidecar_translate_lookup_at(guest_fd_t dirfd, return 1; } +struct sidecar_dir_map { + sidecar_index_t index; +}; + +int sidecar_dir_map_open(host_fd_t dir_host_fd, sidecar_dir_map_t **map) +{ + *map = NULL; + if (!sidecar_active()) + return 0; + + sidecar_dir_map_t *m = calloc(1, sizeof(*m)); + if (!m) { + errno = ENOMEM; + return -1; + } + if (sidecar_load_index(dir_host_fd, &m->index) < 0) { + free(m); + return -1; + } + + /* A directory with no index loads as an empty mapping, which is reported as + * success with no handle: every name then keeps its on-disk spelling. + */ + if (m->index.count == 0) { + sidecar_index_free(&m->index); + free(m); + return 0; + } + + *map = m; + return 0; +} + +void sidecar_dir_map_close(sidecar_dir_map_t *map) +{ + if (!map) + return; + sidecar_index_free(&map->index); + free(map); +} + +int sidecar_dir_map_name(const sidecar_dir_map_t *map, + const char *host_name, + char *guest_name, + size_t guest_name_sz) +{ + if (guest_name_sz > 0) + guest_name[0] = '\0'; + if (!sidecar_active()) + return 0; + if (sidecar_name_reserved(host_name)) + return 1; + if (!map) + return 0; + + const char *guest = sidecar_lookup_token(&map->index, host_name); + if (!guest) + return 0; + + size_t len = strlen(guest); + if (len + 1 > guest_name_sz) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(guest_name, guest, len + 1); + return 0; +} + +int sidecar_translate_dirent_name_hostfd(host_fd_t dir_host_fd, + const char *host_name, + char *guest_name, + size_t guest_name_sz) +{ + if (!sidecar_active()) + return 0; + if (sidecar_name_reserved(host_name)) + return 1; + + /* One entry, so the index is opened and dropped again here. Callers walking + * a whole directory should hold a sidecar_dir_map_t instead. + */ + sidecar_index_t index; + if (sidecar_load_index(dir_host_fd, &index) < 0) + return -1; + + sidecar_dir_map_t map = {.index = index}; + int rc = sidecar_dir_map_name(&map, host_name, guest_name, guest_name_sz); + sidecar_index_free(&index); + return rc; +} + int sidecar_translate_dirent_name(guest_fd_t dirfd, const char *host_name, char *guest_name, @@ -820,37 +911,111 @@ int sidecar_translate_dirent_name(guest_fd_t dirfd, { if (!sidecar_active()) return 0; - if (sidecar_name_reserved(host_name)) - return 1; host_fd_ref_t ref; if (host_fd_ref_open(dirfd, &ref) < 0) { errno = EBADF; return -1; } - - sidecar_index_t index; - int rc = sidecar_load_index(ref.fd, &index); + int rc = sidecar_translate_dirent_name_hostfd(ref.fd, host_name, guest_name, + guest_name_sz); host_fd_ref_close(&ref); - if (rc < 0) - return -1; + return rc; +} - const char *guest = sidecar_lookup_token(&index, host_name); - if (!guest) { - sidecar_index_free(&index); - return 0; +/* True when name has the exact on-disk token shape: ".ef_" followed by 16 + * lowercase hex digits (the format sidecar_generate_token emits). + */ +bool sidecar_name_is_token(const char *name, size_t len) +{ + if (len != SIDECAR_TOKEN_NAME_LEN) + return false; + if (memcmp(name, SIDECAR_TOKEN_PREFIX, sizeof(SIDECAR_TOKEN_PREFIX) - 1)) + return false; + for (size_t i = sizeof(SIDECAR_TOKEN_PREFIX) - 1; i < len; i++) { + char c = name[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) + return false; } + return true; +} - size_t len = strlen(guest); - if (len + 1 > guest_name_sz) { - sidecar_index_free(&index); +/* Map a host path relative to the sysroot back to guest-visible names, + * reverse-translating any ".ef_" token component through its parent + * directory's sidecar index. Host getcwd() reports token names for + * guest-created directories, so reconstructing the guest cwd from it needs + * this walk. A token with no index entry passes through unchanged; degraded + * output beats failing the caller's refresh. host_rel must start with '/'; + * out receives an absolute guest path. Returns 0 on success, -1 on error. + */ +int sidecar_reverse_map_host_path(const char *sysroot, + const char *host_rel, + char *out, + size_t outsz) +{ + if (!sidecar_active() || !sysroot || !host_rel || host_rel[0] != '/') { + errno = EINVAL; + return -1; + } + + char host_dir[PATH_MAX]; + size_t dir_len = strlen(sysroot); + if (dir_len >= sizeof(host_dir)) { errno = ENAMETOOLONG; return -1; } - memcpy(guest_name, guest, len + 1); - sidecar_index_free(&index); + memcpy(host_dir, sysroot, dir_len + 1); + + size_t out_len = 0; + const char *scan = host_rel; + const char *comp; + size_t comp_len; + while (path_next_component(&scan, &comp, &comp_len)) { + char name[NAME_MAX + 1]; + if (path_component_copy(name, sizeof(name), comp, comp_len) < 0) + return -1; + + char guest[NAME_MAX + 1]; + const char *mapped = name; + if (sidecar_name_is_token(name, comp_len)) { + int dfd = open(host_dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dfd >= 0) { + sidecar_index_t index; + if (sidecar_load_index(dfd, &index) == 0) { + const char *g = sidecar_lookup_token(&index, name); + if (g && strlen(g) <= NAME_MAX) { + memcpy(guest, g, strlen(g) + 1); + mapped = guest; + } + sidecar_index_free(&index); + } + close(dfd); + } + } + + if (sidecar_append_component(out, outsz, &out_len, mapped, true) < 0) + return -1; + + /* Descend by the raw host component: index lookups for the next + * component need the parent's real on-disk path. + */ + if (sidecar_append_component(host_dir, sizeof(host_dir), &dir_len, name, + true) < 0) + return -1; + } + + /* An empty walk means the input named the sysroot itself. */ + if (out_len == 0) { + if (outsz < 2) { + errno = ENAMETOOLONG; + return -1; + } + out[out_len++] = '/'; + out[out_len] = '\0'; + } return 0; } + static int sidecar_encode_name(const char *name, char **out) { static const char hex[] = "0123456789abcdef"; @@ -879,6 +1044,12 @@ static int sidecar_exact_name_exists(int dirfd, const char *name) close(dup_fd); return -1; } + /* fdopendir inherits the fd's directory offset, and dirfd is often the + * long-lived cached sysroot base whose offset a previous scan already + * consumed; without a rewind the scan starts at EOF and reports every + * real-named entry as absent. + */ + rewinddir(dir); int found = 0; struct dirent *de; diff --git a/src/syscall/sidecar.h b/src/syscall/sidecar.h index 384c1272..1d9bb21e 100644 --- a/src/syscall/sidecar.h +++ b/src/syscall/sidecar.h @@ -22,6 +22,7 @@ bool sidecar_active(void); bool sidecar_name_reserved(const char *name); +bool sidecar_name_is_token(const char *name, size_t len); bool sidecar_path_targets_reserved_name(const char *path); int sidecar_translate_lookup_at(guest_fd_t dirfd, const char *path, @@ -31,6 +32,45 @@ int sidecar_translate_dirent_name(guest_fd_t dirfd, const char *host_name, char *guest_name, size_t guest_name_sz); +/* Same mapping for callers that already hold the directory's host fd (e.g. + * inotify snapshots), skipping the guest fd-table resolution. + */ +int sidecar_translate_dirent_name_hostfd(host_fd_t dir_host_fd, + const char *host_name, + char *guest_name, + size_t guest_name_sz); + +/* A directory's name mapping, held open across a whole readdir pass so the + * on-disk index is read and parsed once instead of once per entry. The one-shot + * calls above are this opened, queried, and closed again, which costs a file + * read per entry when a directory is full of tokens. The handle is opaque so + * the index representation stays private. + * + * sidecar_dir_map_open() returns 0 on success and -1 when the directory has an + * index that cannot be read, which callers must treat as a failure of the whole + * pass rather than as an empty mapping. On success *map may still be NULL, + * which means there is nothing to map (the sidecar is inert, or the directory + * has no index at all); that NULL is safe to pass to the calls below, where + * every name then maps to itself. + */ +typedef struct sidecar_dir_map sidecar_dir_map_t; + +int sidecar_dir_map_open(host_fd_t dir_host_fd, sidecar_dir_map_t **map); +void sidecar_dir_map_close(sidecar_dir_map_t *map); + +/* Map one entry name from this directory. Returns 1 when the entry is sidecar + * bookkeeping and the caller must skip it, 0 otherwise. On 0, guest_name holds + * the guest spelling, or is left empty when the name is a token with no index + * row (an orphan, which callers hide rather than expose). + */ +int sidecar_dir_map_name(const sidecar_dir_map_t *map, + const char *host_name, + char *guest_name, + size_t guest_name_sz); +int sidecar_reverse_map_host_path(const char *sysroot, + const char *host_rel, + char *out, + size_t outsz); int sidecar_openat(guest_fd_t dirfd, const char *path, int linux_flags, diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index 8f8c341f..2d6997be 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -2153,10 +2153,27 @@ static int64_t sc_execveat(guest_t *g, char pathname[LINUX_PATH_MAX]; if (guest_read_str(g, x1, pathname, sizeof(pathname)) < 0) return -LINUX_EFAULT; + /* Translate like every other *at handler: under a casefold sysroot a + * guest-created entry exists on disk only under its sidecar token, so + * handing the raw guest spelling to the host cannot resolve it (and + * could resolve a case-colliding host-literal file instead). The + * translated name stays dirfd-relative, so resolve it to an absolute + * host path through the same openat + F_GETPATH as before. + */ + path_translation_t tx; + if (path_translate_at(dirfd, pathname, PATH_TR_NONE, &tx) < 0) + return linux_errno(); + if (tx.fuse_path || tx.proc_resolved != 0) + return -LINUX_ENOSYS; host_fd_ref_t dir_ref; - if (host_fd_ref_open(dirfd, &dir_ref) < 0) + if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; - int tmp_fd = openat(dir_ref.fd, pathname, O_RDONLY); + /* O_CLOEXEC: the descriptor is closed a few lines below, but a + * concurrent execve on another vCPU thread inside that window would + * otherwise leak it into the new image. + */ + int tmp_fd = openat(path_translation_dirfd(&tx, &dir_ref), tx.host_path, + path_translation_oflags(&tx, O_RDONLY | O_CLOEXEC)); if (tmp_fd < 0) { host_fd_ref_close(&dir_ref); return linux_errno(); diff --git a/tests/check-sidecar-state.sh b/tests/check-sidecar-state.sh new file mode 100644 index 00000000..96fd2500 --- /dev/null +++ b/tests/check-sidecar-state.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Host-side check of a sysroot's casefold sidecar on-disk state, run by test +# recipes after the guest exits. +# +# check-sidecar-state.sh ci [outside-dir...] +# check-sidecar-state.sh cs [outside-dir...] +# +# ci mode asserts the index/token contract: at least one token was minted +# (the guest created mixed-case names, so a token-free sysroot means the +# sidecar never engaged), every index row names an existing token file, and +# every token file has an index row. cs mode asserts purity: no sidecar +# artifacts at all. Any extra directories are checked for artifact leakage +# outside the sysroot. +set -u -o pipefail + +sysroot=$1 +mode=$2 +shift 2 + +tab=$(printf '\t') + +fail() { + echo "check-sidecar-state: $1" + exit 1 +} + +if [ "$mode" = ci ]; then + find "$sysroot" -name '.ef_*' -print -quit | grep -q . || + fail "no tokens found in casefold sysroot" + + while IFS= read -r -d '' idx; do + dir=$(dirname "$idx") + while IFS="$tab" read -r hexname token; do + [ -n "$hexname" ] || continue + if [ ! -e "$dir/$token" ] && [ ! -L "$dir/$token" ]; then + fail "row for $token in $idx has no token file" + fi + done < "$idx" + while IFS= read -r -d '' tokenpath; do + token=$(basename "$tokenpath") + # Exact field compare rather than a regex: a token name contains + # "." (".ef_"), which as a BRE metacharacter matches any byte, + # so a corrupted index row could otherwise satisfy the check. + awk -F "$tab" -v tok="$token" '$2 == tok {f = 1} END {exit !f}' \ + "$idx" || + fail "token $tokenpath has no index row" + done < <(find "$dir" -mindepth 1 -maxdepth 1 -name '.ef_*' -print0) + done < <(find "$sysroot" -name '.elfuse_case_index' -print0) +else + hit=$(find "$sysroot" \( -name '.ef_*' -o -name '.elfuse_case_index*' \) \ + -print -quit) + [ -z "$hit" ] || fail "sidecar artifact on case-sensitive volume: $hit" +fi + +for outside in "$@"; do + hit=$(find "$outside" \( -name '.ef_*' -o -name '.elfuse_case_index*' \) \ + -print -quit 2>/dev/null) + [ -z "$hit" ] || fail "sidecar artifact escaped the sysroot: $hit" +done + +exit 0 diff --git a/tests/mkdir-arg.c b/tests/mkdir-arg.c new file mode 100644 index 00000000..46ae44c7 --- /dev/null +++ b/tests/mkdir-arg.c @@ -0,0 +1,25 @@ +/* + * mkdir helper + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Create each argv path (mode 0755), tolerating EEXIST. Sysroot recipes run + * this as a guest so a directory is created through the guest, which on a + * case-insensitive volume mints its sidecar token; a later run then resolves + * that name through the tokenized on-disk index. + */ + +#include +#include +#include + +int main(int argc, char **argv) +{ + for (int i = 1; i < argc; i++) + if (mkdir(argv[i], 0755) != 0 && errno != EEXIST) { + perror(argv[i]); + return 1; + } + return 0; +} diff --git a/tests/test-absock-cleanup.c b/tests/test-absock-cleanup.c new file mode 100644 index 00000000..b6eda350 --- /dev/null +++ b/tests/test-absock-cleanup.c @@ -0,0 +1,197 @@ +/* + * absock namespace lifecycle + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Over-long pathname AF_UNIX socket addresses divert their host path through a + * shortening symlink in a shared /tmp/elfuse-absock- directory. The + * namespace is shared across a forked guest tree (children inherit the root's + * namespace id), so neither exit order may destroy state the other side still + * needs. Both orders are covered: + * default mode, child exits first: the parent binds an over-long socket, a + * forked child binds its own and exits, and the parent's getsockname must + * still reverse-map to the guest spelling after the child is reaped; + * "owner-sweep" mode, root exits first: see owner_sweep_mode() below. + * The companion recipe check asserts the namespace dir does not leak after the + * guest exits. + * + * Needs a plain-dir sysroot on a case-insensitive volume: the four tokenized + * levels below push the host path past the 104-byte macOS sun_path so the + * shortening link is actually created, while the guest spelling stays under + * the 108-byte Linux limit. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DEEP_DIR "/Deep.A/Deep.B/Deep.C/Deep.D" + +static int bind_pathname(const char *path) +{ + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) + return -1; + struct sockaddr_un un = {0}; + un.sun_family = AF_UNIX; + strncpy(un.sun_path, path, sizeof(un.sun_path) - 1); + if (bind(fd, (struct sockaddr *) &un, sizeof(un)) < 0 || + listen(fd, 1) < 0) { + close(fd); + return -1; + } + return fd; +} + +static bool getsockname_is(int fd, const char *expect) +{ + struct sockaddr_un got = {0}; + socklen_t len = sizeof(got); + return getsockname(fd, (struct sockaddr *) &got, &len) == 0 && + strcmp(got.sun_path, expect) == 0; +} + +#define ROOT_ABS "\0absock-cleanup-root" +#define CHILD_ABS "\0absock-cleanup-child" +#define ABS_LEN(name) \ + ((socklen_t) (offsetof(struct sockaddr_un, sun_path) + sizeof(name) - 1)) + +static void abs_addr(struct sockaddr_un *un, const char *name, size_t len) +{ + memset(un, 0, sizeof(*un)); + un->sun_family = AF_UNIX; + memcpy(un->sun_path, name, len); +} + +static int bind_abstract(const char *name, size_t len) +{ + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) + return -1; + struct sockaddr_un un; + abs_addr(&un, name, len); + socklen_t alen = (socklen_t) (offsetof(struct sockaddr_un, sun_path) + len); + if (bind(fd, (struct sockaddr *) &un, alen) < 0 || listen(fd, 64) < 0) { + close(fd); + return -1; + } + return fd; +} + +/* Root-exits-first: the root binds before forking, so it creates and owns the + * namespace dir, and its exit sweep runs while the child is still alive. The + * sweep walks the shared dir, where the child's abstract-socket backing file + * also lives, so an indiscriminate unlink destroys a socket the child still has + * bound and listening. The root cannot report the outcome because it must exit + * first, and the child cannot report it through the exit status either, since + * the runtime reports the root's. It prints a marker line the recipe greps for; + * a verdict file would be sidecar-tokenized and unreadable by name from the + * host side. + */ +static int owner_sweep_mode(void) +{ + int ready[2], gone[2]; + if (pipe(ready) < 0 || pipe(gone) < 0) + return 1; + + if (bind_abstract(ROOT_ABS, sizeof(ROOT_ABS) - 1) < 0) + return 1; + + pid_t pid = fork(); + if (pid < 0) + return 1; + if (pid == 0) { + close(ready[0]); + close(gone[1]); + bool ok = bind_abstract(CHILD_ABS, sizeof(CHILD_ABS) - 1) >= 0; + if (write(ready[1], "r", 1) != 1) + _exit(1); + + /* The read returns 0 (EOF) once the root dies and its write end is + * closed, so the reconnect below races nothing. + */ + char b; + if (read(gone[0], &b, 1) != 0) + _exit(1); + + struct sockaddr_un un; + abs_addr(&un, CHILD_ABS, sizeof(CHILD_ABS) - 1); + int c = socket(AF_UNIX, SOCK_STREAM, 0); + ok = ok && c >= 0 && + connect(c, (struct sockaddr *) &un, ABS_LEN(CHILD_ABS)) == 0; + + const char *msg = ok ? "OWNER_SWEEP=ok\n" : "OWNER_SWEEP=swept\n"; + if (write_fd_all(1, msg, strlen(msg)) < 0) + _exit(1); + _exit(ok ? 0 : 3); + } + + close(ready[1]); + close(gone[0]); + char b; + if (read(ready[0], &b, 1) != 1) + return 1; + /* exit(), not _exit(): the sweep under test is an atexit handler. */ + exit(0); +} + +int main(int argc, char **argv) +{ + if (argc > 1 && !strcmp(argv[1], "owner-sweep")) + return owner_sweep_mode(); + + TEST("deep tokenized chain mkdir"); + bool deep = + (mkdir("/Deep.A", 0755) == 0 || errno == EEXIST) && + (mkdir("/Deep.A/Deep.B", 0755) == 0 || errno == EEXIST) && + (mkdir("/Deep.A/Deep.B/Deep.C", 0755) == 0 || errno == EEXIST) && + (mkdir(DEEP_DIR, 0755) == 0 || errno == EEXIST); + EXPECT_TRUE(deep, "mkdir deep chain"); + + TEST("parent over-long bind"); + int pfd = bind_pathname(DEEP_DIR "/Parent.Sock"); + EXPECT_TRUE(pfd >= 0, "parent bind+listen"); + + TEST("parent getsockname before fork"); + EXPECT_TRUE(pfd >= 0 && getsockname_is(pfd, DEEP_DIR "/Parent.Sock"), + "parent name round-trips"); + + /* The child binds its own over-long socket, so it too creates a shortening + * link in the shared namespace dir, then exits. Its exit sweep must leave + * the parent's link alone. + */ + TEST("child binds over-long and exits"); + pid_t pid = fork(); + if (pid == 0) { + int cfd = bind_pathname(DEEP_DIR "/Child.Sock"); + _exit(cfd >= 0 ? 0 : 1); + } + int status = 0; + EXPECT_TRUE(pid > 0 && waitpid(pid, &status, 0) == pid && + WIFEXITED(status) && WEXITSTATUS(status) == 0, + "child bound and exited cleanly"); + + TEST("parent getsockname after child exit"); + EXPECT_TRUE(pfd >= 0 && getsockname_is(pfd, DEEP_DIR "/Parent.Sock"), + "sibling exit must not remove the parent's shortening link"); + if (pfd >= 0) + close(pfd); + + SUMMARY("test-absock-cleanup"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-execveat-shm.c b/tests/test-execveat-shm.c new file mode 100644 index 00000000..18dece7e --- /dev/null +++ b/tests/test-execveat-shm.c @@ -0,0 +1,139 @@ +/* + * execveat + /dev/shm nofollow + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * sc_execveat must open a /dev/shm leaf with O_NOFOLLOW, matching direct + * sys_execve, so a symlink planted in the synthetic /dev/shm namespace cannot + * resolve its target out of the namespace. Two cases: + * (a) a real binary copied into /dev/shm still execs (nofollow does not + * break the ordinary, non-symlink path); + * (b) a /dev/shm symlink to a binary outside the namespace fails with ELOOP + * instead of being followed and executed. + * The binary re-execs itself with --phase2 as the "executed successfully" + * payload, which just exits 0. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define SHM_EXEC "/dev/shm/EfuseShmExec" +#define SHM_LINK "/dev/shm/EfuseShmLink" +#define SHM_FIFO "/dev/shm/EfuseShmFifo" + +int main(int argc, char **argv) +{ + if (argc > 1 && !strcmp(argv[1], "--phase2")) + return 0; + + const char *self = argv[0]; + char *payload[] = {(char *) "shm-exec", (char *) "--phase2", NULL}; + + /* The symlink fence needs an absolute target so that, if the leaf were + * followed, execve would actually run a valid binary (exit 0), cleanly + * distinct from the O_NOFOLLOW refusal (ELOOP). A relative argv[0] would + * resolve against the link's directory, breaking either way and masking + * the difference. + */ + char abs_self[4096]; + if (self[0] == '/') { + snprintf(abs_self, sizeof(abs_self), "%s", self); + } else { + char cwd[4096]; + if (!getcwd(cwd, sizeof(cwd))) + return 1; + snprintf(abs_self, sizeof(abs_self), "%s/%s", cwd, self); + } + + TEST("copy self into /dev/shm"); + EXPECT_TRUE(copy_file_exec(self, SHM_EXEC) == 0, "copy into shm"); + + /* (a) A regular shm binary must still exec through execveat. */ + TEST("execveat regular /dev/shm binary"); + pid_t pid = fork(); + if (pid == 0) { + syscall(SYS_execveat, AT_FDCWD, SHM_EXEC, payload, NULL, 0); + _exit(98); + } + int status = 0; + EXPECT_TRUE(pid > 0 && waitpid(pid, &status, 0) == pid && + WIFEXITED(status) && WEXITSTATUS(status) == 0, + "regular shm exec runs"); + + /* (b) A shm symlink pointing at a binary outside the namespace must not be + * followed: execveat must fail with ELOOP. The target is this very binary + * (a valid executable), so if nofollow were missing the child would exec it + * and exit 0, failing the assertion below. + */ + if (symlink(abs_self, SHM_LINK) != 0) { + TEST("execveat /dev/shm symlink nofollow"); + printf("SKIP (cannot create /dev/shm symlink: errno=%d)\n", errno); + } else { + TEST("execveat /dev/shm symlink refused"); + pid = fork(); + if (pid == 0) { + syscall(SYS_execveat, AT_FDCWD, SHM_LINK, payload, NULL, 0); + /* execveat returned, so it refused to follow: report ELOOP as 42. + */ + _exit(errno == ELOOP ? 42 : 43); + } + status = 0; + EXPECT_TRUE(pid > 0 && waitpid(pid, &status, 0) == pid && + WIFEXITED(status) && WEXITSTATUS(status) == 42, + "shm symlink leaf not followed (ELOOP)"); + unlink(SHM_LINK); + } + + /* truncate(2) and chdir(2) have no nofollow path variant, so they reach a + * shm leaf through an fd opened with the namespace's forced flags. These + * assert the two invariants those flags buy, which the execveat cases above + * do not reach: a symlink leaf must not be followed out of the namespace, + * and a FIFO leaf must not park the vCPU thread on an open with no peer. + */ + if (symlink("/etc/hosts", SHM_LINK) != 0) { + TEST("shm symlink truncate/chdir nofollow"); + printf("SKIP (cannot create /dev/shm symlink: errno=%d)\n", errno); + } else { + TEST("truncate shm symlink refused"); + EXPECT_TRUE(truncate(SHM_LINK, 0) != 0 && errno == ELOOP, + "symlink leaf must not be followed"); + + TEST("chdir shm symlink refused"); + EXPECT_TRUE(chdir(SHM_LINK) != 0, "symlink leaf must not be followed"); + unlink(SHM_LINK); + } + + if (mknod(SHM_FIFO, S_IFIFO | 0644, 0) != 0) { + TEST("shm FIFO leaf does not block"); + printf("SKIP (cannot create /dev/shm FIFO: errno=%d)\n", errno); + } else { + /* Reaching either assertion at all is the result under test: without + * O_NONBLOCK the open would never return and the lane would time out. + * Linux truncate(2) on a FIFO reports EINVAL. + */ + TEST("truncate shm FIFO returns"); + EXPECT_TRUE(truncate(SHM_FIFO, 0) != 0 && errno == EINVAL, + "FIFO truncate must report EINVAL, not block"); + + TEST("chdir shm FIFO returns"); + EXPECT_TRUE(chdir(SHM_FIFO) != 0, "FIFO chdir must return, not block"); + unlink(SHM_FIFO); + } + + unlink(SHM_EXEC); + SUMMARY("test-execveat-shm"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-fchmodat-empty-path.c b/tests/test-fchmodat-empty-path.c index d4788dc7..d6b927df 100644 --- a/tests/test-fchmodat-empty-path.c +++ b/tests/test-fchmodat-empty-path.c @@ -157,6 +157,28 @@ static void test_empty_path_at_fdcwd_targets_cwd(void) EXPECT_TRUE(ok, "mode mismatch"); } +/* A synthetic /proc identity has no host inode to chmod, so the AT_EMPTY_PATH + * path must refuse it rather than chmod whatever descriptor happens to back the + * emulation. Only an O_PATH descriptor carries the synthetic identity this far; + * a plain O_RDONLY /proc fd has no host directory to resolve and stops at EBADF + * before this rule applies. fchownat shares the rejection, so both tests pin + * it. + */ +static void test_empty_path_proc_fd_refused(void) +{ + TEST("fchmodat(/proc O_PATH fd, \"\", AT_EMPTY_PATH) refused"); + int fd = open("/proc/self/status", O_PATH); + if (fd < 0) { + printf("SKIP (cannot O_PATH-open /proc/self/status: errno=%d)\n", + errno); + return; + } + int rc = fchmodat2(fd, "", 0600, AT_EMPTY_PATH); + int err = errno; + close(fd); + EXPECT_TRUE(rc != 0 && err == EPERM, "synthetic proc identity must EPERM"); +} + int main(void) { printf("test-fchmodat-empty-path: AT_EMPTY_PATH chmod-by-fd semantics\n"); @@ -178,6 +200,7 @@ int main(void) test_plain_fchmod_still_rejects_opath(); test_empty_path_via_regular_fd(); test_empty_path_at_fdcwd_targets_cwd(); + test_empty_path_proc_fd_refused(); teardown_fixtures(); diff --git a/tests/test-fchownat-empty-path.c b/tests/test-fchownat-empty-path.c index 75a88bef..4aa59db5 100644 --- a/tests/test-fchownat-empty-path.c +++ b/tests/test-fchownat-empty-path.c @@ -128,6 +128,28 @@ static void test_empty_path_at_fdcwd_targets_cwd(void) EXPECT_TRUE(ok, "uid/gid mismatch"); } +/* A synthetic /proc identity has no host inode to chown, so the AT_EMPTY_PATH + * path must refuse it rather than chown whatever descriptor happens to back the + * emulation. Only an O_PATH descriptor carries the synthetic identity this far; + * a plain O_RDONLY /proc fd has no host directory to resolve and stops at EBADF + * before this rule applies. fchmodat shares the rejection, so both tests pin + * it. + */ +static void test_empty_path_proc_fd_refused(void) +{ + TEST("fchownat(/proc O_PATH fd, \"\", AT_EMPTY_PATH) refused"); + int fd = open("/proc/self/status", O_PATH); + if (fd < 0) { + printf("SKIP (cannot O_PATH-open /proc/self/status: errno=%d)\n", + errno); + return; + } + int rc = fchownat(fd, "", 7001, 7002, AT_EMPTY_PATH); + int err = errno; + close(fd); + EXPECT_TRUE(rc != 0 && err == EPERM, "synthetic proc identity must EPERM"); +} + int main(void) { printf("test-fchownat-empty-path: AT_EMPTY_PATH chown-by-fd semantics\n"); @@ -141,6 +163,7 @@ int main(void) test_plain_fchown_still_rejects_opath(); test_empty_path_via_regular_fd(); test_empty_path_at_fdcwd_targets_cwd(); + test_empty_path_proc_fd_refused(); teardown_fixtures(); diff --git a/tests/test-inotify-index-fail.c b/tests/test-inotify-index-fail.c new file mode 100644 index 00000000..0ff60e74 --- /dev/null +++ b/tests/test-inotify-index-fail.c @@ -0,0 +1,134 @@ +/* + * inotify snapshot when the sidecar index stops being readable + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A directory watch recovers the child name kqueue omits by diffing a snapshot + * of the directory against a baseline. Inside a casefold sysroot that snapshot + * reverse-maps token children through the directory's index, so if the index + * becomes unreadable between the baseline and the next snapshot, every mapped + * child would be missing from the new listing and the diff would report each + * one as deleted. The snapshot must fail instead, leaving the baseline in + * place, so no event is emitted for a file that still exists. + * + * The failure has to begin after the baseline is taken, which needs the host: + * guest creates the tree, arms the watch, prints WATCH_READY, waits for /go + * recipe sees WATCH_READY, chmods the index unreadable, touches a new file in + * the watched directory to provoke the next snapshot, creates /go + * guest reads events and reports whether any child was announced as deleted + * + * /go is created by the recipe, so it is host-staged and keeps its real + * spelling; a guest-created sentinel would be stored under a sidecar token and + * the recipe could not name it. The guest needs no index access after the + * chmod, so making the index unreadable cannot break the guest's own side of + * this. + * + * Run under --sysroot on a case-insensitive volume, so the sidecar is active. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +/* No TEST()/SUMMARY output here: the verdict has to reach the host, because the + * recipe is what perturbs the index and therefore what decides pass or fail. + */ +#include "test-util.h" + +#define WATCH_DIR "/Watch.Dir" +#define GO_PATH "/go" + +static const char *const kids[] = {"Ch.One", "Ch.Two", "Ch.Three"}; +#define N_KIDS ((int) (sizeof(kids) / sizeof(kids[0]))) + +/* Poll for the recipe's host-staged sentinel under a wall-clock bound rather + * than a fixed retry count, so a loaded machine cannot fail this spuriously. + */ +static bool wait_for_go(void) +{ + for (int waited_ms = 0; waited_ms < 20000; waited_ms += 50) { + if (access(GO_PATH, F_OK) == 0) + return true; + usleep(50000); + } + return false; +} + +int main(void) +{ + if (mkdir(WATCH_DIR, 0755) != 0 && errno != EEXIST) { + printf("test-inotify-index-fail: mkdir %s failed (errno=%d)\n", + WATCH_DIR, errno); + return 1; + } + for (int i = 0; i < N_KIDS; i++) { + char p[256]; + snprintf(p, sizeof(p), "%s/%s", WATCH_DIR, kids[i]); + if (write_file(p, "k\n") != 0) { + printf("test-inotify-index-fail: create %s failed (errno=%d)\n", p, + errno); + return 1; + } + } + + int ifd = inotify_init1(IN_NONBLOCK); + if (ifd < 0) { + printf("test-inotify-index-fail: inotify_init1 failed (errno=%d)\n", + errno); + return 1; + } + /* Arming the watch captures the baseline, with all three children mapped. + */ + int wd = inotify_add_watch(ifd, WATCH_DIR, IN_CREATE | IN_DELETE); + if (wd < 0) { + printf("test-inotify-index-fail: add_watch failed (errno=%d)\n", errno); + close(ifd); + return 1; + } + + printf("WATCH_READY\n"); + fflush(stdout); + + if (!wait_for_go()) { + printf("INDEX_FAIL=no-go\n"); + close(ifd); + return 1; + } + + /* Drain whatever the provoked snapshot produced. Any IN_DELETE naming a + * child is the failure: those files were never removed. + */ + bool phantom_delete = false; + char seen[256] = {0}; + for (int round = 0; round < 20; round++) { + char buf[4096]; + ssize_t n = read(ifd, buf, sizeof(buf)); + for (ssize_t off = 0; + off + (ssize_t) sizeof(struct inotify_event) <= n;) { + struct inotify_event *ev = (struct inotify_event *) (buf + off); + if (ev->len && (ev->mask & IN_DELETE)) { + for (int i = 0; i < N_KIDS; i++) + if (!strcmp(ev->name, kids[i])) { + phantom_delete = true; + snprintf(seen, sizeof(seen), "%s", ev->name); + } + } + off += (ssize_t) sizeof(*ev) + ev->len; + } + usleep(100000); + } + close(ifd); + + if (phantom_delete) { + printf("INDEX_FAIL=deleted:%s\n", seen); + return 1; + } + printf("INDEX_FAIL=ok\n"); + return 0; +} diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 76f86cb5..9033cff2 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -284,6 +284,25 @@ QEMU_SKIP=" test-scm-creds test-proc-fidelity " +# Tests that only run under qemu: the elfuse lane runs without a sysroot, so +# a test that creates fixtures at / cannot run there (the macOS root is not +# writable); its elfuse coverage lives in the make-check sysroot lanes. +ELFUSE_SKIP=" + test-path-matrix +" + +# Whitespace-separated membership test, shared by the per-runner skip lists so a +# third runner does not need a third copy. +list_has() +{ + local needle="$1" + local item + for item in $2; do + [ "$item" = "$needle" ] && return 0 + done + return 1 +} + # test-session: getpgid/getsid/setsid assume the test is its own session and # process-group leader, true when elfuse launches it directly but not when # sshd execs it non-interactively -- a launcher artifact, not an elfuse @@ -353,15 +372,6 @@ QEMU_SKIP=" # write -- a genuine behavioral difference worth reviewing on its own, # not just an environment artifact. -is_qemu_skipped() -{ - local label="$1" - local skipped - for skipped in $QEMU_SKIP; do - [ "$skipped" = "$label" ] && return 0 - done - return 1 -} # Honor QEMU_SKIP across all test_* wrappers. # @@ -370,11 +380,16 @@ is_qemu_skipped() maybe_qemu_skip() { local runner="$1" label="$2" - if [ "$runner" = "run_qemu" ] && is_qemu_skipped "$label"; then + if [ "$runner" = "run_qemu" ] && list_has "$label" "$QEMU_SKIP"; then test_report skip "$label" " (qemu)" skip=$((skip + 1)) return 0 fi + if [ "$runner" = "run_elfuse" ] && list_has "$label" "$ELFUSE_SKIP"; then + test_report skip "$label" " (elfuse: needs a sysroot lane)" + skip=$((skip + 1)) + return 0 + fi return 1 } @@ -597,6 +612,7 @@ run_unit_tests() test_check "$runner" "test-ls" "hello" "$bindir/test-ls" tests/ test_check "$runner" "test-roundtrip" "OK" "$bindir/test-roundtrip" test_check "$runner" "test-comprehensive" "0 failures" "$bindir/test-comprehensive" + test_check "$runner" "test-path-matrix" "PASS" "$bindir/test-path-matrix" native printf "\nProcess tests\n" test_check "$runner" "test-fork" "PASS" "$bindir/test-fork" @@ -1226,7 +1242,7 @@ run_suite() # detector does not recognize yet. EXPECTED_BASELINES=( "elfuse-aarch64|238|0" - "qemu-aarch64|218|0" + "qemu-aarch64|219|0" "elfuse-x86_64:apple-m1-m2|71|0" "elfuse-x86_64:apple-m3-plus|71|0" "elfuse-x86_64:apple-unknown|71|0" diff --git a/tests/test-path-matrix.c b/tests/test-path-matrix.c new file mode 100644 index 00000000..f3772ab2 --- /dev/null +++ b/tests/test-path-matrix.c @@ -0,0 +1,1139 @@ +/* + * Sysroot path-translation matrix + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Sectioned coverage of the path-taking syscalls against guest-created + * fixtures whose names exercise the casefold sidecar: every op must resolve + * the same file through absolute, cwd-relative, dirfd-relative, nested, and + * symlinked spellings, report ENOENT for wrong-case spellings, and never + * leak on-disk sidecar artifacts to the guest. Run under --sysroot with + * argv[1] = "ci" (case-insensitive volume, sidecar active) or "cs" + * (case-sensitive volume, sidecar inert); argv[2] optionally names a staged + * host file outside the sysroot for the host-fallback rows. The binary + * re-execs itself with --phase2 to assert resolution and cwd reconstruction + * survive fork+execve. + * + * Known gaps assert as expected failures: an XFAIL line counts as pass, an + * XPASS fails the run so a fixing commit must delete the marker. + * XF_OPENAT2_WALK: openat2 RESOLVE_* prechecks walk raw guest components + * and cannot see sidecar tokens. + * XF_SENDTO_ABS: sendto skips the abstract-socket rewrite for + * destination addresses. + * XF_REL_TOKENWALK: a cwd-relative path with tokenized intermediate + * components does not resolve; only the leaf is sidecar-walked. + * XF_SYMLINK_TOKENDIR, XF_MKNOD_TOKENDIR: the sidecar has no symlinkat + * or mknodat writers, so creating either inside a guest-created + * (tokenized) directory fails. + * XF_SYMLINK_TOKENTARGET: the host kernel follows symlink targets, so a + * target crossing tokenized components does not resolve. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; +static int xfails = 0; + +static bool mode_ci = true; +/* native: running on a real Linux kernel (qemu parity lane). The elfuse + * known-gap cells flip from expected-failure to must-pass, proving each gap + * is an elfuse-ism and the table never encodes one as Linux truth. + */ +static bool mode_native = false; + +/* Guest-created fixtures; mixed-case names get sidecar tokens on a casefold + * sysroot, so every access below crosses the translation layer. + */ +#define DIR_A "/DirA" +#define DIR_B DIR_A "/SubB" +#define FILE_C DIR_B "/File.C" +#define FILE_C_REL "DirA/SubB/File.C" +#define FILE_C_WRONG "/dira/subb/file.c" +/* Symlink and FIFO fixtures live in the sysroot root: the root is host + * staged (never tokenized), and the sidecar cannot create either inside a + * tokenized directory (XF_SYMLINK_TOKENDIR / XF_MKNOD_TOKENDIR below). + */ +#define SYM_L "/Sym.Link" +#define FIFO_F "/Fifo.Mixed" +/* Four nested guest-created levels: each becomes a 21-byte token component + * on a casefold sysroot, so host spellings under this chain exceed the + * 104-byte macOS sun_path while the guest spelling stays well under the + * 108-byte Linux limit. + */ +#define DEEP_DIR "/Deep.A/Deep.B/Deep.C/Deep.D" + +static void xfail_result(const char *id, bool op_succeeded) +{ + if (mode_native) { + TEST(id); + EXPECT_TRUE(op_succeeded, "must pass on a real kernel"); + return; + } + if (op_succeeded) { + printf("XPASS %s (delete the marker in the fixing commit)\n", id); + fails++; + } else { + printf("XFAIL %s\n", id); + xfails++; + } +} + +static void fixtures_create(void) +{ + TEST("fixture mkdir nested mixed"); + EXPECT_TRUE(mkdir(DIR_A, 0755) == 0 && mkdir(DIR_B, 0755) == 0, + "mkdir fixtures"); + + TEST("fixture create file"); + EXPECT_TRUE(write_file(FILE_C, "matrix\n") == 0, "create " FILE_C); + + TEST("fixture mknod fifo"); + EXPECT_TRUE(mknod(FIFO_F, S_IFIFO | 0644, 0) == 0, "mknod fifo"); + + /* Target the FIFO: mknod has no sidecar writer, so the FIFO keeps its + * real on-disk name and the host kernel can follow the link. A target + * crossing tokenized components is a walk gap (XF_SYMLINK_TOKENTARGET). + */ + TEST("fixture symlink rel target"); + EXPECT_TRUE(symlink("Fifo.Mixed", SYM_L) == 0, "symlink"); + + if (mode_ci) { + xfail_result("XF_SYMLINK_TOKENDIR", + symlink("SubB/File.C", DIR_A "/Tok.Sym") == 0); + xfail_result("XF_MKNOD_TOKENDIR", + mknod(DIR_A "/Tok.Fifo", S_IFIFO | 0644, 0) == 0); + } +} + +/* Forward resolution: one file, five spellings, every lookup verb. */ + +static void section_forward(const char *host_file) +{ + struct stat st; + + TEST("stat absolute nested"); + EXPECT_TRUE(stat(FILE_C, &st) == 0 && st.st_size == 7, "stat abs"); + + TEST("stat wrong case ENOENT"); + EXPECT_ERRNO(stat(FILE_C_WRONG, &st), ENOENT, "wrong case must miss"); + + /* Multi-component relative resolution with tokenized intermediates is a + * known walk gap; leaf-only relative names work from inside the dir. + */ + if (mode_ci) { + int rfd = open(FILE_C_REL, O_RDONLY); + xfail_result("XF_REL_TOKENWALK", rfd >= 0); + if (rfd >= 0) + close(rfd); + } + + TEST("leaf-relative open in cwd"); + int fd = -1; + bool leaf_ok = chdir(DIR_B) == 0 && (fd = open("File.C", O_RDONLY)) >= 0 && + access("File.C", R_OK) == 0 && chdir("/") == 0; + EXPECT_TRUE(leaf_ok, "leaf relative open+access"); + if (fd >= 0) + close(fd); + + TEST("openat dirfd-relative"); + int dirfd = open(DIR_A, O_RDONLY | O_DIRECTORY); + EXPECT_TRUE(dirfd >= 0, "open " DIR_A); + if (dirfd >= 0) { + TEST("fstatat dirfd-relative"); + EXPECT_TRUE( + fstatat(dirfd, "SubB/File.C", &st, 0) == 0 && st.st_size == 7, + "fstatat dirfd"); + + TEST("faccessat dirfd-relative"); + EXPECT_TRUE(faccessat(dirfd, "SubB/File.C", R_OK, 0) == 0, + "faccessat dirfd"); + + TEST("fstatat wrong case dirfd"); + EXPECT_ERRNO(fstatat(dirfd, "subb/file.c", &st, 0), ENOENT, + "dirfd wrong case"); + close(dirfd); + } + + TEST("stat via symlink"); + EXPECT_TRUE(stat(SYM_L, &st) == 0 && S_ISFIFO(st.st_mode), + "follow symlink to fifo"); + + bool tok_ok = symlink("DirA/SubB/File.C", "/Tok.Target") == 0 && + stat("/Tok.Target", &st) == 0 && st.st_size == 7; + if (mode_ci) { + xfail_result("XF_SYMLINK_TOKENTARGET", tok_ok); + } else { + TEST("stat via symlink nested"); + EXPECT_TRUE(tok_ok, "symlink to nested target"); + } + + /* Symlinks are the only guest-created entries stored under their real + * names, so absolute-path unlink of one exercises the exact-name scan, + * which must find the entry regardless of its directory offset. + */ + TEST("unlink symlink absolute"); + EXPECT_TRUE(unlink("/Tok.Target") == 0, "absolute symlink unlink"); + + TEST("readlink exact bytes"); + char buf[256]; + ssize_t n = readlink(SYM_L, buf, sizeof(buf) - 1); + EXPECT_TRUE(n == (ssize_t) strlen("Fifo.Mixed") && + memcmp(buf, "Fifo.Mixed", (size_t) n) == 0, + "readlink content untranslated"); + + TEST("readlinkat dirfd-relative"); + int rootfd = open("/", O_RDONLY | O_DIRECTORY); + char rlbuf[64]; + ssize_t rln = rootfd >= 0 + ? readlinkat(rootfd, "Sym.Link", rlbuf, sizeof(rlbuf) - 1) + : -1; + EXPECT_TRUE(rln == (ssize_t) strlen("Fifo.Mixed") && + memcmp(rlbuf, "Fifo.Mixed", (size_t) rln) == 0, + "readlinkat dirfd content"); + if (rootfd >= 0) + close(rootfd); + + TEST("lstat symlink NOFOLLOW"); + EXPECT_TRUE(lstat(SYM_L, &st) == 0 && S_ISLNK(st.st_mode), "lstat"); + + TEST("statfs on fixture dir"); + struct statfs sf; + EXPECT_TRUE(statfs(DIR_A, &sf) == 0 && sf.f_bsize > 0, "statfs"); + + TEST("statx absolute"); + struct statx stx; + EXPECT_TRUE(statx(AT_FDCWD, FILE_C, 0, STATX_BASIC_STATS, &stx) == 0 && + stx.stx_size == 7, + "statx"); + + if (host_file) { + TEST("host-fallback read"); + fd = open(host_file, O_RDONLY); + EXPECT_TRUE(fd >= 0, "host fallback open"); + if (fd >= 0) + close(fd); + } + + if (mode_ci) { + TEST("reserved index invisible"); + EXPECT_ERRNO(stat(DIR_A "/.elfuse_case_index", &st), ENOENT, + "index file must be hidden"); + + TEST("reserved name uncreatable"); + EXPECT_ERRNO( + open(DIR_A "/.elfuse_case_index", O_CREAT | O_WRONLY, 0644), ENOENT, + "index name must be uncreatable"); + } +} + +/* Metadata verbs on tokenized names: metadata syscalls must reach the host + * with the sidecar token spelling, not the raw guest name. + */ +static void section_metadata(void) +{ + struct stat st; + + TEST("chmod absolute"); + EXPECT_TRUE(chmod(FILE_C, 0640) == 0 && stat(FILE_C, &st) == 0 && + (st.st_mode & 07777) == 0640, + "chmod round-trip"); + + TEST("chown identity overlay"); + EXPECT_TRUE(chown(FILE_C, getuid(), getgid()) == 0, "chown"); + + TEST("utimensat absolute"); + struct timespec ts[2] = {{1234567, 0}, {1234567, 0}}; + EXPECT_TRUE(utimensat(AT_FDCWD, FILE_C, ts, 0) == 0 && + stat(FILE_C, &st) == 0 && st.st_mtime == 1234567, + "utimensat round-trip"); + + TEST("truncate absolute"); + EXPECT_TRUE( + truncate(FILE_C, 3) == 0 && stat(FILE_C, &st) == 0 && st.st_size == 3, + "truncate"); + + TEST("xattr set/get/remove"); + bool xok = setxattr(FILE_C, "user.matrix", "v1", 2, 0) == 0; + char xv[8] = {0}; + xok = xok && getxattr(FILE_C, "user.matrix", xv, sizeof(xv)) == 2 && + memcmp(xv, "v1", 2) == 0; + char xl[64]; + ssize_t xn = xok ? listxattr(FILE_C, xl, sizeof(xl)) : -1; + xok = xok && xn > 0 && memmem(xl, (size_t) xn, "user.matrix", 11) != NULL; + xok = xok && removexattr(FILE_C, "user.matrix") == 0; + EXPECT_TRUE(xok, "xattr quartet"); + + /* AT_EMPTY_PATH: the shared resolver path. */ + int ofd = open(FILE_C, O_PATH); + TEST("fstatat AT_EMPTY_PATH O_PATH"); + EXPECT_TRUE(ofd >= 0 && fstatat(ofd, "", &st, AT_EMPTY_PATH) == 0 && + st.st_size == 3, + "empty-path stat"); + + TEST("fchownat AT_EMPTY_PATH"); + EXPECT_TRUE( + ofd >= 0 && fchownat(ofd, "", getuid(), getgid(), AT_EMPTY_PATH) == 0, + "empty-path chown"); + + /* fchmodat2 goes through the same shared resolver; kernels before 6.6 + * lack the syscall, so ENOSYS passes on the native lane. + */ + TEST("fchmodat2 AT_EMPTY_PATH"); + long cr = ofd >= 0 ? syscall(452 /* __NR_fchmodat2 */, ofd, "", 0600, + AT_EMPTY_PATH) + : -1; + bool chmod_ok = cr == 0 && fstatat(ofd, "", &st, AT_EMPTY_PATH) == 0 && + (st.st_mode & 07777) == 0600; + if (mode_native && cr == -1 && errno == ENOSYS) + chmod_ok = true; + EXPECT_TRUE(chmod_ok, "empty-path chmod"); + if (ofd >= 0) + close(ofd); + + /* Linux resolves AT_FDCWD + AT_EMPTY_PATH in a search-only cwd; an + * O_RDONLY-based resolver would fail it with EACCES. Meaningless as + * root (native lane runs as root and bypasses the mode bits). + */ + if (!mode_native) { + TEST("statx empty-path search-only cwd"); + struct statx psx; + bool perm_ok = + mkdir("/Perm.Dir", 0755) == 0 && chdir("/Perm.Dir") == 0 && + chmod("/Perm.Dir", 0311) == 0 && + statx(AT_FDCWD, "", AT_EMPTY_PATH, STATX_BASIC_STATS, &psx) == 0 && + S_ISDIR(psx.stx_mode); + EXPECT_TRUE(perm_ok, "search-only cwd statx"); + chmod("/Perm.Dir", 0755); + chdir("/"); + } +} + +/* Mutations through the sidecar writer walk, via dirfd. */ + +static void section_mutations(void) +{ + struct stat st; + int dirfd = open(DIR_A, O_RDONLY | O_DIRECTORY); + TEST("mutation dirfd open"); + EXPECT_TRUE(dirfd >= 0, "open dir"); + if (dirfd < 0) + return; + + TEST("mkdirat mixed case"); + EXPECT_TRUE(mkdirat(dirfd, "New.Sub", 0755) == 0 && + fstatat(dirfd, "New.Sub", &st, 0) == 0 && + S_ISDIR(st.st_mode), + "mkdirat"); + + TEST("mknodat fifo dirfd"); + EXPECT_TRUE(mknodat(dirfd, "Fifo.Two", S_IFIFO | 0644, 0) == 0 && + fstatat(dirfd, "Fifo.Two", &st, 0) == 0 && + S_ISFIFO(st.st_mode), + "mknodat"); + + TEST("linkat hard link"); + EXPECT_TRUE(linkat(AT_FDCWD, FILE_C, dirfd, "Hard.Link", 0) == 0 && + fstatat(dirfd, "Hard.Link", &st, 0) == 0 && + st.st_nlink == 2, + "linkat nlink"); + + TEST("renameat within dir"); + EXPECT_TRUE(write_file(DIR_A "/Ren.Src", "r\n") == 0 && + renameat(dirfd, "Ren.Src", dirfd, "Ren.Dst") == 0 && + fstatat(dirfd, "Ren.Dst", &st, 0) == 0, + "renameat"); + + TEST("rename old name gone"); + EXPECT_ERRNO(fstatat(dirfd, "Ren.Src", &st, 0), ENOENT, "src stays gone"); + + TEST("renameat2 NOREPLACE EEXIST"); + EXPECT_ERRNO((long) syscall(SYS_renameat2, dirfd, "Ren.Dst", dirfd, + "Hard.Link", 1 /* RENAME_NOREPLACE */), + EEXIST, "NOREPLACE"); + + TEST("renameat across tokenized dirs"); + int subfd = openat(dirfd, "New.Sub", O_RDONLY | O_DIRECTORY); + bool cross_ok = subfd >= 0 && write_file(DIR_A "/Cross.Src", "x\n") == 0 && + renameat(dirfd, "Cross.Src", subfd, "Cross.Dst") == 0 && + fstatat(subfd, "Cross.Dst", &st, 0) == 0 && + fstatat(dirfd, "Cross.Src", &st, 0) == -1 && + errno == ENOENT; + EXPECT_TRUE(cross_ok, "cross-dir rename"); + if (subfd >= 0) + close(subfd); + + /* RENAME_EXCHANGE swaps two tokenized names; the host backend supports + * it for absolute AT_FDCWD paths only (renamex_np takes no dirfds). + */ + TEST("renameat2 EXCHANGE absolute"); + bool ex_ok = write_file(DIR_A "/Ex.One", "1\n") == 0 && + write_file(DIR_A "/Ex.Two", "22\n") == 0 && + syscall(SYS_renameat2, AT_FDCWD, DIR_A "/Ex.One", AT_FDCWD, + DIR_A "/Ex.Two", 2 /* RENAME_EXCHANGE */) == 0 && + stat(DIR_A "/Ex.One", &st) == 0 && st.st_size == 3 && + stat(DIR_A "/Ex.Two", &st) == 0 && st.st_size == 2; + EXPECT_TRUE(ex_ok, "exchange swaps contents"); + + TEST("unlinkat then ENOENT"); + EXPECT_TRUE(unlinkat(dirfd, "Ren.Dst", 0) == 0 && + fstatat(dirfd, "Ren.Dst", &st, 0) == -1 && errno == ENOENT, + "unlinkat"); + + TEST("wrong-case unlink ENOENT"); + EXPECT_ERRNO(unlinkat(dirfd, "hard.link", 0), ENOENT, + "fold-blind unlink must miss"); + + close(dirfd); +} + +/* Reverse direction: host state becoming guest-visible names. */ + +static bool dir_has_entry(const char *dir, const char *name) +{ + DIR *d = opendir(dir); + if (!d) + return false; + bool found = false; + struct dirent *de; + while ((de = readdir(d)) != NULL) + if (!strcmp(de->d_name, name)) { + found = true; + break; + } + closedir(d); + return found; +} + +static void section_reverse(void) +{ + TEST("chdir + getcwd guest names"); + char cwd[512] = {0}; + bool ok = chdir(DIR_B) == 0 && getcwd(cwd, sizeof(cwd)) != NULL && + strcmp(cwd, DIR_B) == 0; + EXPECT_TRUE(ok, "getcwd exact"); + + TEST("/proc/self/cwd guest names"); + char pbuf[512] = {0}; + ssize_t n = readlink("/proc/self/cwd", pbuf, sizeof(pbuf) - 1); + EXPECT_TRUE(n > 0 && strcmp(pbuf, DIR_B) == 0, "proc cwd exact"); + + TEST("cwd has no token"); + EXPECT_TRUE(strstr(cwd, ".ef_") == NULL && strstr(pbuf, ".ef_") == NULL, + "no .ef_ leak"); + chdir("/"); + + TEST("readdir shows guest names"); + DIR *d = opendir(DIR_A); + bool saw_sub = false, leak = false; + if (d) { + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (!strcmp(de->d_name, "SubB")) + saw_sub = true; + if (strstr(de->d_name, ".ef_") || + strstr(de->d_name, ".elfuse_case_index")) + leak = true; + } + closedir(d); + } + EXPECT_TRUE(d && saw_sub && !leak, "guest dirents only"); + + TEST("readdir root fixtures"); + EXPECT_TRUE(dir_has_entry("/", "Sym.Link") && dir_has_entry("/", "DirA"), + "root listing exact names"); + + /* fd-based directory change: the cwd reverse map must reconstruct guest + * names without ever having seen a path argument. + */ + TEST("fchdir + getcwd guest names"); + int dfd = open(DIR_B, O_RDONLY | O_DIRECTORY); + char fcwd[512] = {0}, fproc[512] = {0}; + bool fok = dfd >= 0 && fchdir(dfd) == 0 && + getcwd(fcwd, sizeof(fcwd)) != NULL && strcmp(fcwd, DIR_B) == 0; + ssize_t fn = readlink("/proc/self/cwd", fproc, sizeof(fproc) - 1); + EXPECT_TRUE(fok && fn > 0 && strcmp(fproc, DIR_B) == 0 && + strstr(fcwd, ".ef_") == NULL, + "fchdir cwd exact"); + if (dfd >= 0) + close(dfd); + chdir("/"); + + TEST("/proc/self/fd guest path"); + int fd = open(FILE_C, O_RDONLY); + char link[64], target[512] = {0}; + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + n = readlink(link, target, sizeof(target) - 1); + EXPECT_TRUE(fd >= 0 && n > 0 && strstr(target, ".ef_") == NULL && + strstr(target, "File.C") != NULL, + "fd readlink guest spelling"); + if (fd >= 0) + close(fd); + + /* The reverse map copies each component into a NAME_MAX+1 buffer and + * reassembles the guest path with bounded appends. A maximum-length + * component exercises both bounds at once: one byte more in either check + * and this round-trip fails or truncates. + */ + TEST("NAME_MAX component round-trip"); + char longname[NAME_MAX + 2]; + longname[0] = 'L'; + memset(longname + 1, 'n', NAME_MAX - 1); + longname[NAME_MAX] = '\0'; + char longdir[NAME_MAX + 8]; + snprintf(longdir, sizeof(longdir), "/%s", longname); + char lcwd[NAME_MAX + 64] = {0}; + bool lok = (mkdir(longdir, 0755) == 0 || errno == EEXIST) && + chdir(longdir) == 0 && getcwd(lcwd, sizeof(lcwd)) != NULL && + strcmp(lcwd, longdir) == 0 && strstr(lcwd, ".ef_") == NULL; + EXPECT_TRUE(lok, "max-length component survives the reverse map"); + chdir("/"); + + /* Root itself: the walk yields no components, so the assembled guest path + * must still be "/" rather than an empty string. + */ + TEST("sysroot root reverse-maps to /"); + char rcwd[64] = {0}; + EXPECT_TRUE(chdir("/") == 0 && getcwd(rcwd, sizeof(rcwd)) != NULL && + strcmp(rcwd, "/") == 0, + "root cwd is /"); +} + +/* Pathname AF_UNIX sockets through translation. */ + +static void section_sockets(void) +{ + TEST("unix bind in sysroot dir"); + int sfd = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un sun = {0}; + sun.sun_family = AF_UNIX; + strcpy(sun.sun_path, DIR_A "/Sock.Path"); + bool ok = sfd >= 0 && + bind(sfd, (struct sockaddr *) &sun, sizeof(sun)) == 0 && + listen(sfd, 1) == 0; + EXPECT_TRUE(ok, "bind+listen"); + + TEST("getsockname guest spelling"); + struct sockaddr_un got = {0}; + socklen_t got_len = sizeof(got); + EXPECT_TRUE(sfd >= 0 && + getsockname(sfd, (struct sockaddr *) &got, &got_len) == 0 && + strcmp(got.sun_path, sun.sun_path) == 0, + "bound name round-trips"); + + TEST("unix connect same path"); + int cfd = socket(AF_UNIX, SOCK_STREAM, 0); + EXPECT_TRUE( + cfd >= 0 && connect(cfd, (struct sockaddr *) &sun, sizeof(sun)) == 0, + "connect"); + + TEST("getpeername guest spelling"); + struct sockaddr_un peer = {0}; + socklen_t peer_len = sizeof(peer); + EXPECT_TRUE( + cfd >= 0 && + getpeername(cfd, (struct sockaddr *) &peer, &peer_len) == 0 && + strcmp(peer.sun_path, sun.sun_path) == 0, + "peer name round-trips"); + if (cfd >= 0) + close(cfd); + if (sfd >= 0) + close(sfd); + + TEST("unix dgram sendto path"); + int r = socket(AF_UNIX, SOCK_DGRAM, 0); + int w = socket(AF_UNIX, SOCK_DGRAM, 0); + struct sockaddr_un dun = {0}; + dun.sun_family = AF_UNIX; + strcpy(dun.sun_path, DIR_A "/Dgram.Path"); + /* Bind the sender too, so received datagrams carry a source address the + * reverse map must rewrite. + */ + struct sockaddr_un wun = {0}; + wun.sun_family = AF_UNIX; + strcpy(wun.sun_path, DIR_A "/Dgram.Src"); + char rbuf[4] = {0}; + struct sockaddr_un src = {0}; + socklen_t src_len = sizeof(src); + ok = r >= 0 && w >= 0 && + bind(r, (struct sockaddr *) &dun, sizeof(dun)) == 0 && + bind(w, (struct sockaddr *) &wun, sizeof(wun)) == 0 && + sendto(w, "dg", 2, 0, (struct sockaddr *) &dun, sizeof(dun)) == 2 && + recvfrom(r, rbuf, sizeof(rbuf), 0, (struct sockaddr *) &src, + &src_len) == 2 && + memcmp(rbuf, "dg", 2) == 0; + EXPECT_TRUE(ok, "dgram sendto via path"); + + TEST("recvfrom source guest spelling"); + EXPECT_TRUE(ok && strcmp(src.sun_path, wun.sun_path) == 0, + "source addr round-trips"); + + TEST("sendmsg/recvmsg pathname"); + struct iovec siov = {.iov_base = (void *) "mg", .iov_len = 2}; + struct msghdr smh = {0}; + smh.msg_name = &dun; + smh.msg_namelen = sizeof(dun); + smh.msg_iov = &siov; + smh.msg_iovlen = 1; + char mbuf[4] = {0}; + struct iovec riov = {.iov_base = mbuf, .iov_len = sizeof(mbuf)}; + struct sockaddr_un msrc = {0}; + struct msghdr rmh = {0}; + rmh.msg_name = &msrc; + rmh.msg_namelen = sizeof(msrc); + rmh.msg_iov = &riov; + rmh.msg_iovlen = 1; + EXPECT_TRUE(r >= 0 && w >= 0 && sendmsg(w, &smh, 0) == 2 && + recvmsg(r, &rmh, 0) == 2 && memcmp(mbuf, "mg", 2) == 0 && + strcmp(msrc.sun_path, wun.sun_path) == 0, + "msg round-trip guest names"); + if (r >= 0) + close(r); + if (w >= 0) + close(w); + + /* Overlong host spellings: four tokenized components put the host path + * past the 104-byte macOS sun_path, so bind must divert through the + * shortening symlink while getsockname still reports the guest path. + */ + TEST("unix bind overlong host path"); + bool deep_ok = + mkdir("/Deep.A", 0755) == 0 && mkdir("/Deep.A/Deep.B", 0755) == 0 && + mkdir("/Deep.A/Deep.B/Deep.C", 0755) == 0 && mkdir(DEEP_DIR, 0755) == 0; + int lfd = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un lun = {0}; + lun.sun_family = AF_UNIX; + strcpy(lun.sun_path, DEEP_DIR "/Long.Sock"); + EXPECT_TRUE(deep_ok && lfd >= 0 && + bind(lfd, (struct sockaddr *) &lun, sizeof(lun)) == 0 && + listen(lfd, 1) == 0, + "overlong bind+listen"); + + TEST("getsockname overlong round-trip"); + struct sockaddr_un lgot = {0}; + socklen_t lgot_len = sizeof(lgot); + EXPECT_TRUE( + lfd >= 0 && + getsockname(lfd, (struct sockaddr *) &lgot, &lgot_len) == 0 && + strcmp(lgot.sun_path, lun.sun_path) == 0, + "overlong bound name"); + + TEST("unix connect overlong path"); + int lcfd = socket(AF_UNIX, SOCK_STREAM, 0); + EXPECT_TRUE( + lcfd >= 0 && connect(lcfd, (struct sockaddr *) &lun, sizeof(lun)) == 0, + "overlong connect"); + if (lcfd >= 0) + close(lcfd); + if (lfd >= 0) + close(lfd); + + /* A 105-byte guest path is Linux-legal but longer than any macOS + * sun_path can hold, so the reverse map must rebuild the Linux sockaddr + * directly instead of bouncing through a mac sockaddr. + */ + TEST("bind 105-byte guest path"); + char lpath[108]; + int lplen = snprintf(lpath, sizeof(lpath), DEEP_DIR "/"); + memset(lpath + lplen, 'X', (size_t) (105 - lplen)); + lpath[105] = '\0'; + int xfd = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un xun = {0}; + xun.sun_family = AF_UNIX; + strcpy(xun.sun_path, lpath); + EXPECT_TRUE(deep_ok && xfd >= 0 && + bind(xfd, (struct sockaddr *) &xun, sizeof(xun)) == 0, + "105-byte bind"); + + TEST("getsockname 105-byte round-trip"); + struct sockaddr_un xgot = {0}; + socklen_t xgot_len = sizeof(xgot); + EXPECT_TRUE( + xfd >= 0 && + getsockname(xfd, (struct sockaddr *) &xgot, &xgot_len) == 0 && + strcmp(xgot.sun_path, lpath) == 0, + "105-byte bound name"); + if (xfd >= 0) + close(xfd); + + /* Known gap: sendto never applies the abstract rewrite, so a dgram to an + * abstract name that connect() would reach is not deliverable. + */ + int ar = socket(AF_UNIX, SOCK_DGRAM, 0); + int aw = socket(AF_UNIX, SOCK_DGRAM, 0); + struct sockaddr_un aun = {0}; + aun.sun_family = AF_UNIX; + memcpy(aun.sun_path, "\0matrix-abs", 11); + socklen_t alen = (socklen_t) (offsetof(struct sockaddr_un, sun_path) + 11); + bool abs_ok = ar >= 0 && aw >= 0 && + bind(ar, (struct sockaddr *) &aun, alen) == 0 && + sendto(aw, "ab", 2, 0, (struct sockaddr *) &aun, alen) == 2; + xfail_result("XF_SENDTO_ABS", abs_ok); + if (ar >= 0) + close(ar); + if (aw >= 0) + close(aw); + + /* The sidecar's bookkeeping names are invisible to stat and uncreatable + * through open(O_CREAT) (section_forward); bind must refuse them on the + * same terms. A bind whose path carries a '/' resolves the parent in + * lookup mode and reattaches the leaf verbatim, so the leaf never reaches + * the create-mode reserved-name guard: unguarded, the index spelling + * collides with the real bookkeeping file and reports EADDRINUSE, leaking + * the existence of a name stat() calls ENOENT. + */ + if (mode_ci) { + TEST("bind reserved index name"); + int rfd = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un run = {0}; + run.sun_family = AF_UNIX; + strcpy(run.sun_path, DIR_A "/.elfuse_case_index"); + EXPECT_ERRNO(bind(rfd, (struct sockaddr *) &run, sizeof(run)), ENOENT, + "index name must be unbindable"); + if (rfd >= 0) + close(rfd); + + /* Control: the leafless spelling already translates in create mode and + * so is guarded today. It pins both spellings to the same errno. + */ + TEST("bind reserved name at root"); + int qfd = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un qun = {0}; + qun.sun_family = AF_UNIX; + strcpy(qun.sun_path, "/.elfuse_case_index"); + EXPECT_ERRNO(bind(qfd, (struct sockaddr *) &qun, sizeof(qun)), ENOENT, + "root index name must be unbindable"); + if (qfd >= 0) + close(qfd); + } +} + +/* inotify watches and named events on tokenized directories. */ + +static bool inotify_wait_named(int ifd, uint32_t mask, const char *name) +{ + for (int i = 0; i < 50; i++) { + char evb[1024]; + ssize_t n = read(ifd, evb, sizeof(evb)); + for (ssize_t off = 0; off < n;) { + struct inotify_event *ev = (struct inotify_event *) (evb + off); + if ((ev->mask & mask) && ev->len && !strcmp(ev->name, name)) + return true; + off += (ssize_t) sizeof(*ev) + ev->len; + } + usleep(100000); + } + return false; +} + +static void section_inotify(void) +{ + TEST("inotify watch tokenized dir"); + int ifd = inotify_init1(IN_NONBLOCK); + int wd = + ifd >= 0 ? inotify_add_watch(ifd, DIR_B, IN_CREATE | IN_DELETE) : -1; + EXPECT_TRUE(ifd >= 0 && wd >= 0, "add watch"); + + TEST("inotify named IN_CREATE"); + bool named = wd >= 0 && write_file(DIR_B "/Note.New", "n\n") == 0 && + inotify_wait_named(ifd, IN_CREATE, "Note.New"); + EXPECT_TRUE(named, "event carries guest name"); + + /* The delete side diffs the directory snapshot: the vanished entry must + * also come back under its guest spelling. + */ + TEST("inotify named IN_DELETE"); + bool del_named = named && unlink(DIR_B "/Note.New") == 0 && + inotify_wait_named(ifd, IN_DELETE, "Note.New"); + EXPECT_TRUE(del_named, "delete carries guest name"); + + /* A token that the index does map must never surface under its on-disk + * spelling: the snapshot reverse-maps it, so the event carries the guest + * name. Create another mixed-case entry and confirm the event is the guest + * spelling with no token anywhere in the stream. (A token the index does + * not map is a different case, covered by test-sidecar-orphan: it keeps its + * spelling, because that spelling is what a lookup resolves.) + */ + TEST("inotify no token leak"); + bool leak = false, guest_named = false; + if (ifd >= 0 && wd >= 0 && write_file(DIR_B "/Ev.Mixed", "e\n") == 0) { + for (int i = 0; i < 50 && !guest_named; i++) { + char evb[1024]; + ssize_t n = read(ifd, evb, sizeof(evb)); + for (ssize_t off = 0; off < n;) { + struct inotify_event *ev = (struct inotify_event *) (evb + off); + if (ev->len && !strncmp(ev->name, ".ef_", 4)) + leak = true; + if ((ev->mask & IN_CREATE) && ev->len && + !strcmp(ev->name, "Ev.Mixed")) + guest_named = true; + off += (ssize_t) sizeof(*ev) + ev->len; + } + if (!guest_named) + usleep(100000); + } + unlink(DIR_B "/Ev.Mixed"); + } + EXPECT_TRUE(guest_named && !leak, "guest spelling, no .ef_ token"); + + /* Scale: the snapshot loads the directory index once and looks each entry + * up against it, so a directory full of tokens is where a stale or + * misindexed row would show up. Populate one, then create a single new + * entry and require exactly one IN_CREATE naming it. A per-entry lookup + * would report the same thing, so this is a guard on the batched form + * rather than a new behaviour. + */ + TEST("inotify scale: one event among many tokens"); + bool scale_ok = false; + if (ifd >= 0) { + if (mkdir("/Many.Dir", 0755) != 0 && errno != EEXIST) + goto scale_done; + for (int i = 0; i < 64; i++) { + char p[64]; + snprintf(p, sizeof(p), "/Many.Dir/Ent.%02d", i); + if (write_file(p, "x\n") != 0) + goto scale_done; + } + /* One entry at the component-length bound, so the batched lookup is + * exercised at the same edge as the reverse-map bounds test. + */ + char maxname[NAME_MAX + 16]; + int pre = snprintf(maxname, sizeof(maxname), "/Many.Dir/M"); + memset(maxname + pre, 'x', (size_t) (NAME_MAX - 1)); + maxname[pre + NAME_MAX - 1] = '\0'; + if (write_file(maxname, "m\n") != 0) + goto scale_done; + + int swd = inotify_add_watch(ifd, "/Many.Dir", IN_CREATE); + if (swd < 0) + goto scale_done; + if (write_file("/Many.Dir/New.Entry", "n\n") != 0) + goto scale_done; + + int seen = 0; + bool other = false; + for (int i = 0; i < 50 && !seen; i++) { + char evb[4096]; + ssize_t rn = read(ifd, evb, sizeof(evb)); + for (ssize_t off = 0; off < rn;) { + struct inotify_event *ev = (struct inotify_event *) (evb + off); + if (ev->len) { + if (!strcmp(ev->name, "New.Entry")) + seen++; + else if (!strncmp(ev->name, ".ef_", 4)) + other = true; + } + off += (ssize_t) sizeof(*ev) + ev->len; + } + if (!seen) + usleep(100000); + } + scale_ok = seen == 1 && !other; + } +scale_done: + EXPECT_TRUE(scale_ok, "exactly one guest-named event in a tokenized dir"); + + if (ifd >= 0) + close(ifd); +} + +/* Guest-private prefix symmetry: /tmp creates were always redirected into + * the sysroot; lookups follow the same mapping, so a created file must be + * visible to stat, open, and readdir of the same guest spelling. + */ +static void section_private_tmp(void) +{ + struct stat st; + + TEST("/tmp create then stat"); + EXPECT_TRUE(write_file("/tmp/Prefix.Probe", "p\n") == 0 && + stat("/tmp/Prefix.Probe", &st) == 0 && st.st_size == 2, + "tmp lookup sees create"); + + TEST("/tmp readdir sees create"); + EXPECT_TRUE(dir_has_entry("/tmp", "Prefix.Probe"), + "tmp listing includes create"); + + TEST("/tmp open round-trip"); + char pb[4] = {0}; + int pfd = open("/tmp/Prefix.Probe", O_RDONLY); + EXPECT_TRUE( + pfd >= 0 && read(pfd, pb, sizeof(pb)) == 2 && memcmp(pb, "p\n", 2) == 0, + "tmp open reads back"); + if (pfd >= 0) + close(pfd); + + TEST("/tmp unlink"); + EXPECT_TRUE(unlink("/tmp/Prefix.Probe") == 0, "tmp unlink"); +} + +/* Directory part of an absolute path into out (no trailing slash); "/" for a + * top-level entry. Used to derive the staged host dir from its file argument. + */ +static void path_dir(const char *path, char *out, size_t out_sz) +{ + size_t n = strlen(path); + while (n > 1 && path[n - 1] != '/') + n--; + if (n > 1) + n--; /* drop the separator unless it is the leading slash */ + if (n >= out_sz) + n = out_sz - 1; + memcpy(out, path, n); + out[n] = '\0'; +} + +/* Guest-private prefix classification: the private mapping is decided on the + * lexically normalized spelling and covers bare directories, not just + * descendants. Two properties are checked: normalized spellings of a + * guest-created private file all resolve to it, and a host file under /tmp, + * /var/tmp, or a bare .ccache never becomes visible. + */ +static void section_private_prefix(const char *host_file, + const char *tmp_shadow) +{ + if (!mode_ci) + return; /* sidecar inert on a case-sensitive volume */ + struct stat st; + + /* Escape fences run first, while host /var/folders paths are still + * reachable through host fallback: the /var/tmp check below creates a guest + * /var that legitimately shadows the staged host /.ccache. The + * recipe stages a real /tmp/ file and a bare /.ccache + * dir; neither may become visible through the private mapping, including + * via a non-canonical spelling. + */ + if (tmp_shadow) { + char canon[256], noncanon[256]; + snprintf(canon, sizeof(canon), "/tmp/%s", tmp_shadow); + snprintf(noncanon, sizeof(noncanon), "//tmp/%s", tmp_shadow); + TEST("host /tmp shadow hidden (canonical)"); + EXPECT_ERRNO(stat(canon, &st), ENOENT, "/tmp must not reach host"); + TEST("host /tmp shadow hidden (non-canonical)"); + EXPECT_ERRNO(stat(noncanon, &st), ENOENT, "//tmp must not reach host"); + } + if (host_file) { + char hostdir[512], ccpath[600]; + path_dir(host_file, hostdir, sizeof(hostdir)); + snprintf(ccpath, sizeof(ccpath), "%s/.ccache", hostdir); + TEST("bare host .ccache not listable"); + DIR *hc = opendir(ccpath); + if (hc) + closedir(hc); + EXPECT_TRUE(hc == NULL && errno == ENOENT, + "bare .ccache maps private, hiding host children"); + } + + TEST("/tmp non-canonical round-trip"); + bool nc = write_file("/tmp/NC.Probe", "n\n") == 0 && + stat("//tmp/NC.Probe", &st) == 0 && + stat("/./tmp/NC.Probe", &st) == 0 && + stat("/tmp/../tmp/NC.Probe", &st) == 0; + EXPECT_TRUE(nc, "normalized spellings reach the private file"); + unlink("/tmp/NC.Probe"); + + TEST("/var/tmp private create+list"); + bool vt = (mkdir("/var", 0755) == 0 || errno == EEXIST) && + (mkdir("/var/tmp", 0755) == 0 || errno == EEXIST) && + write_file("/var/tmp/V.Probe", "v\n") == 0; + EXPECT_TRUE(vt && dir_has_entry("/var/tmp", "V.Probe"), + "var/tmp lists its private child"); + + /* The same normalized-spelling guarantee as /tmp above, for the other two + * private prefixes: one classifier decides both, so each prefix must + * survive ".", "..", and doubled separators. + */ + TEST("/var/tmp non-canonical round-trip"); + bool vnc = vt && stat("//var/tmp/V.Probe", &st) == 0 && + stat("/var/./tmp/V.Probe", &st) == 0 && + stat("/var/../var/tmp/V.Probe", &st) == 0; + EXPECT_TRUE(vnc, "normalized spellings reach the private file"); + unlink("/var/tmp/V.Probe"); + + TEST(".ccache bare-dir lists child"); + bool cc = (mkdir("/Cache.Proj", 0755) == 0 || errno == EEXIST) && + (mkdir("/Cache.Proj/.ccache", 0755) == 0 || errno == EEXIST) && + write_file("/Cache.Proj/.ccache/Obj", "o\n") == 0; + EXPECT_TRUE(cc && dir_has_entry("/Cache.Proj/.ccache", "Obj"), + "bare .ccache sees its just-created child"); + + TEST(".ccache non-canonical round-trip"); + bool cnc = cc && stat("/Cache.Proj/.ccache/../.ccache/Obj", &st) == 0 && + stat("/Cache.Proj/./.ccache/Obj", &st) == 0; + EXPECT_TRUE(cnc, "normalized spellings reach the private .ccache child"); + + /* The escape direction: ".." that walks back out of a private prefix must + * stop being private, so a normalizer that collapsed ".." too late to + * matter would show up here rather than as a silent host fall-through. + */ + TEST("dotdot out of /var/tmp is not private"); + EXPECT_TRUE(stat("/var/tmp/../../etc/hosts", &st) != 0 || + stat("/etc/hosts", &st) == 0, + "escaping spelling must not be forced private"); +} + +/* openat2 RESOLVE_* prechecks: sidecar-blind forward walk (known gap). */ + +struct open_how_compat { + unsigned long long flags, mode, resolve; +}; + +static void section_openat2(void) +{ + int dirfd = open(DIR_A, O_RDONLY | O_DIRECTORY); + struct open_how_compat how = {O_RDONLY, 0, 0x08 /* RESOLVE_BENEATH */}; + long fd = -1; + if (dirfd >= 0) { + fd = syscall(437 /* __NR_openat2 */, dirfd, "SubB/File.C", &how, + sizeof(how)); + if (mode_ci) { + xfail_result("XF_OPENAT2_WALK", fd >= 0); + } else { + TEST("openat2 BENEATH cs mode"); + EXPECT_TRUE(fd >= 0, "openat2 beneath"); + } + if (fd >= 0) + close((int) fd); + close(dirfd); + } +} + +/* fork + execve of a guest-created copy: translation state and cwd survive + * both the exec of a tokenized path and the fork relaunch. + */ +/* dirfd == -1 execs via plain execve(path); anything else goes through + * execveat(dirfd, path, flags), covering both handlers with one harness. + */ +static void exec_child_case(const char *label, + int dirfd, + const char *path, + int flags) +{ + TEST(label); + pid_t pid = fork(); + if (pid == 0) { + if (chdir(DIR_B) != 0) + _exit(97); + char *argv2[] = {(char *) (DIR_A "/Exec.Copy"), (char *) "--phase2", + NULL}; + if (dirfd == -1) + execve(path, argv2, NULL); + else + syscall(SYS_execveat, dirfd, path, argv2, NULL, flags); + _exit(98); + } + int status = 0; + EXPECT_TRUE(pid > 0 && waitpid(pid, &status, 0) == pid && + WIFEXITED(status) && WEXITSTATUS(status) == 0, + "child clean exit"); +} + +static void section_exec(const char *self) +{ + TEST("exec copy into tokenized dir"); + bool copied = copy_file_exec(self, DIR_A "/Exec.Copy") == 0; + EXPECT_TRUE(copied, "copy self"); + if (!copied) + return; + + /* Every exec spelling re-runs the phase2 checks in the child, so a + * translation bypass or a token leak in /proc/self/exe fails the + * exact-path assertion there. + */ + exec_child_case("fork+execve tokenized path", -1, DIR_A "/Exec.Copy", 0); + + exec_child_case("execveat absolute AT_FDCWD", AT_FDCWD, DIR_A "/Exec.Copy", + 0); + + int xdirfd = open(DIR_A, O_RDONLY | O_DIRECTORY); + TEST("execveat dirfd open"); + EXPECT_TRUE(xdirfd >= 0, "open " DIR_A); + if (xdirfd >= 0) { + exec_child_case("execveat dirfd-relative", xdirfd, "Exec.Copy", 0); + close(xdirfd); + } + + int xfd = open(DIR_A "/Exec.Copy", O_RDONLY); + TEST("execveat empty-path open"); + EXPECT_TRUE(xfd >= 0, "open Exec.Copy"); + if (xfd >= 0) { + exec_child_case("execveat AT_EMPTY_PATH", xfd, "", AT_EMPTY_PATH); + close(xfd); + } +} + +/* Re-exec'd child: assert the exec'd process sees guest spellings. */ + +static int phase2_checks(void) +{ + TEST("phase2 getcwd inherited"); + char cwd[512] = {0}; + EXPECT_TRUE(getcwd(cwd, sizeof(cwd)) != NULL && strcmp(cwd, DIR_B) == 0, + "child cwd exact"); + + TEST("phase2 relative resolve"); + EXPECT_TRUE(access("File.C", R_OK) == 0, "child relative access"); + + TEST("phase2 /proc/self/exe"); + char exe[512] = {0}; + ssize_t n = readlink("/proc/self/exe", exe, sizeof(exe) - 1); + EXPECT_TRUE(n > 0 && strcmp(exe, DIR_A "/Exec.Copy") == 0, + "exe exact guest path"); + + SUMMARY("test-path-matrix phase2"); + return fails > 0 ? 1 : 0; +} + +int main(int argc, char **argv) +{ + if (argc > 1 && !strcmp(argv[1], "--phase2")) + return phase2_checks(); + + if (argc > 1 && !strcmp(argv[1], "native")) + mode_native = true; + mode_ci = !mode_native && !(argc > 1 && !strcmp(argv[1], "cs")); + const char *host_file = argc > 2 ? argv[2] : NULL; + const char *tmp_shadow = argc > 3 ? argv[3] : NULL; + + printf("test-path-matrix (%s mode)\n", + mode_native ? "native" : (mode_ci ? "ci" : "cs")); + + fixtures_create(); + section_forward(host_file); + section_metadata(); + section_mutations(); + section_reverse(); + section_sockets(); + section_inotify(); + section_private_tmp(); + section_openat2(); + section_exec(argv[0]); + /* Runs last: it creates a guest /var, which legitimately shadows the host + * /var/folders sysroot path that section_exec opens through argv[0], so it + * must not precede any section that still needs host-side fallback. + */ + section_private_prefix(host_file, tmp_shadow); + + printf("\nexpected failures: %d\n", xfails); + SUMMARY("test-path-matrix"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sidecar-orphan.c b/tests/test-sidecar-orphan.c new file mode 100644 index 00000000..54ada883 --- /dev/null +++ b/tests/test-sidecar-orphan.c @@ -0,0 +1,86 @@ +/* + * Unmapped sidecar token visibility + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A `.ef_<16 hex>` file with no row in its directory's index is an ordinary + * file to the guest, not hidden state. Lookup falls back to the literal on-disk + * spelling whenever the index has no row, which is what lets host-staged names + * work at all, so that same fallback reaches a token-shaped name. It must + * therefore appear in a listing under that spelling: hiding it would make a + * file the guest can stat and open impossible to enumerate. + * + * This is the rule both dirent consumers follow. getdents64 passes an unmapped + * name through, and the inotify snapshot does the same, so a listing and an + * event name agree with each other and with lookup. + * + * The orphan is staged by the recipe because the guest cannot create one: every + * guest-created name is registered in the index as it is written. + * + * Run under --sysroot on a case-insensitive volume, so the sidecar is active. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +#define STAGE_DIR "/data" +#define ORPHAN_TOKEN ".ef_0123456789abcdef" +#define PLAIN_NAME "Plain.Host" + +int main(void) +{ + DIR *d = opendir(STAGE_DIR); + if (!d) { + printf("test-sidecar-orphan: cannot open %s (errno=%d)\n", STAGE_DIR, + errno); + return 1; + } + + bool saw_orphan = false, saw_plain = false, saw_index = false; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (!strncmp(de->d_name, ".ef_", 4)) + saw_orphan = true; + if (!strcmp(de->d_name, PLAIN_NAME)) + saw_plain = true; + if (!strncmp(de->d_name, ".elfuse_case_index", 18)) + saw_index = true; + } + closedir(d); + + TEST("unmapped token listed as-is"); + EXPECT_TRUE(saw_orphan, "a token with no index row keeps its spelling"); + + TEST("host-staged sibling listed"); + EXPECT_TRUE(saw_plain, "a real name with no index row passes through"); + + TEST("index file not listed"); + EXPECT_TRUE(!saw_index, "sidecar bookkeeping stays hidden"); + + /* Listing it is only correct because it is reachable: lookup falls back to + * the literal spelling, so the name a listing reports can be opened. These + * two assertions have to agree, and this is the one that says why. + */ + TEST("unmapped token reachable by that name"); + struct stat st; + char path[256]; + snprintf(path, sizeof(path), "%s/%s", STAGE_DIR, ORPHAN_TOKEN); + EXPECT_TRUE(stat(path, &st) == 0, "listed name must resolve"); + + TEST("absent token spelling still ENOENT"); + EXPECT_ERRNO(stat(STAGE_DIR "/.ef_ffffffffffffffff", &st), ENOENT, + "the fallback must not invent files"); + + SUMMARY("test-sidecar-orphan"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-create-paths.c b/tests/test-sysroot-create-paths.c index 4a386a12..5d614b7f 100644 --- a/tests/test-sysroot-create-paths.c +++ b/tests/test-sysroot-create-paths.c @@ -21,16 +21,6 @@ int passes = 0, fails = 0; -static int write_file(const char *path, const char *contents) -{ - int fd = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0644); - if (fd < 0) - return -1; - int rc = write_fd_all(fd, contents, strlen(contents)); - close(fd); - return rc; -} - static int xattr_probe(const char *path) { errno = 0; diff --git a/tests/test-sysroot-exec-cwd.c b/tests/test-sysroot-exec-cwd.c new file mode 100644 index 00000000..e8fab0b9 --- /dev/null +++ b/tests/test-sysroot-exec-cwd.c @@ -0,0 +1,147 @@ +/* + * Sysroot guest cwd across fork/exec regression test + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A plain-directory sysroot on a case-insensitive volume stores guest-created + * names as sidecar ".ef_" tokens. The guest cwd must never leak those + * token names: after chdir into a guest-created directory, getcwd() and + * /proc/self/cwd must report the guest path both in the calling process and + * in a forked child after execve (the fork relaunches elfuse, whose lazily + * recomputed cwd must reverse-map tokens back to the guest name). + */ + +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +#define WORK_DIR "/exec-cwd-work" +#define NESTED_DIR WORK_DIR "/Nested" + +static void check_cwd(const char *ctx) +{ + char cwd[512]; + char proc_cwd[512]; + ssize_t len; + + TEST("getcwd reports guest path"); + if (!getcwd(cwd, sizeof(cwd))) { + FAIL("getcwd failed"); + } else if (strcmp(cwd, NESTED_DIR) != 0) { + printf("[%s: getcwd said '%s'] ", ctx, cwd); + FAIL("getcwd leaked a non-guest path"); + } else { + PASS(); + } + + TEST("cwd has no sidecar token"); + if (!getcwd(cwd, sizeof(cwd))) { + FAIL("getcwd failed"); + } else if (strstr(cwd, ".ef_")) { + FAIL("getcwd leaked a sidecar token name"); + } else { + PASS(); + } + + TEST("/proc/self/cwd reports guest path"); + len = readlink("/proc/self/cwd", proc_cwd, sizeof(proc_cwd) - 1); + if (len < 0) { + FAIL("readlink /proc/self/cwd failed"); + } else { + proc_cwd[len] = '\0'; + if (strcmp(proc_cwd, NESTED_DIR) != 0) { + printf("[%s: /proc/self/cwd said '%s'] ", ctx, proc_cwd); + FAIL("/proc/self/cwd leaked a non-guest path"); + } else { + PASS(); + } + } +} + +int main(int argc, char **argv) +{ + if (argc > 1 && !strcmp(argv[1], "--check")) { + printf("test-sysroot-exec-cwd: forked child checks\n"); + check_cwd("child"); + SUMMARY("test-sysroot-exec-cwd child"); + return fails > 0 ? 1 : 0; + } + + printf("test-sysroot-exec-cwd: guest cwd across fork/exec\n"); + + TEST("mkdir guest work dirs"); + /* The directories must be created by the guest: only guest-created + * names get sidecar tokens on a case-insensitive plain-dir sysroot, + * and the token spelling must never surface in the reported cwd. + */ + if (mkdir(WORK_DIR, 0755) < 0 || mkdir(NESTED_DIR, 0755) < 0) + FAIL("mkdir failed"); + else + PASS(); + + TEST("chdir into nested guest dir"); + if (chdir(NESTED_DIR) < 0) + FAIL("chdir failed"); + else + PASS(); + + check_cwd("parent"); + + TEST("relative access sees guest-created file"); + /* faccessat with a relative path must resolve through the sidecar + * index like openat does: the file exists on disk only under its + * token name, so a raw host lookup of the guest name reports ENOENT. + */ + { + int fd = open("t-file", O_CREAT | O_WRONLY, 0644); + if (fd < 0) { + FAIL("create t-file failed"); + } else { + close(fd); + if (access("t-file", F_OK) != 0) + FAIL("access t-file failed"); + else + PASS(); + } + } + + char self[512]; + ssize_t n = readlink("/proc/self/exe", self, sizeof(self) - 1); + if (n < 0) { + perror("readlink /proc/self/exe"); + return 1; + } + self[n] = '\0'; + + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + return 1; + } + if (pid == 0) { + char *child_argv[] = {self, "--check", NULL}; + execve(self, child_argv, NULL); + perror("execve"); + _exit(127); + } + + int status = 0; + TEST("forked child passes its checks"); + if (waitpid(pid, &status, 0) < 0) + FAIL("waitpid failed"); + else if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) + FAIL("child cwd checks failed"); + else + PASS(); + + SUMMARY("test-sysroot-exec-cwd"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-util.h b/tests/test-util.h index 5a1a1a71..ca501089 100644 --- a/tests/test-util.h +++ b/tests/test-util.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include "raw-syscall.h" @@ -86,6 +87,47 @@ static inline int write_fd_all(int fd, const void *buf, size_t len) return 0; } +/* Create (or truncate) path and write contents, retrying short writes. */ +static inline int write_file(const char *path, const char *contents) +{ + int fd = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0644); + if (fd < 0) + return -1; + int rc = write_fd_all(fd, contents, strlen(contents)); + close(fd); + return rc; +} + +/* Copy src to dst as an executable (mode 0755), retrying short writes. Used by + * the exec tests, which stage a copy of the running binary somewhere the + * translation layer has to resolve. + */ +static inline int copy_file_exec(const char *src, const char *dst) +{ + int in = open(src, O_RDONLY); + if (in < 0) + return -1; + int out = open(dst, O_CREAT | O_WRONLY | O_TRUNC, 0755); + if (out < 0) { + close(in); + return -1; + } + + int rc = 0; + char buf[65536]; + ssize_t n; + while ((n = read(in, buf, sizeof(buf))) > 0) + if (write_fd_all(out, buf, (size_t) n) < 0) { + rc = -1; + break; + } + if (n < 0) + rc = -1; + close(in); + close(out); + return rc; +} + static inline void test_unreachable(void) { abort();