From 11e1bf9e5e8131e9fc34a44d2505cba4dba87dd6 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 22 Jul 2026 15:48:33 +0200 Subject: [PATCH 1/3] Translate guest paths through the sysroot sidecar Consolidate the sysroot path-translation handlers so guest-visible paths are reconstructed and reverse-mapped consistently across syscalls. Two guest-visible results change with that consolidation. A bind(2) whose address carries a '/' resolves the parent and reattaches the leaf verbatim, so the leaf never reached the reserved-name guard: binding a sidecar bookkeeping name collided with the real index file and reported EADDRINUSE for a name that stat(2) and open(O_CREAT) both report as ENOENT. It now agrees with them. The abstract-socket exit sweep unlinked every entry in the shared namespace directory, destroying backing files a forked child still had bound and listening, so it now retires only the untracked shortening symlinks; any participant may remove the directory once it empties, which also keeps a namespace created by a forked child from leaking. Path classification normalizes once per lookup and hands the same spelling to both the private-prefix and the guest-system-path rules, which previously ran two different normalizers over the same input and had to be kept in agreement by hand. Directory snapshots hold the sidecar index open across a readdir pass instead of reloading and reparsing it for every entry, which cost one file read per tokenized name. Opening the mapping reports an unreadable index separately from a directory that simply has none, because the two must not be treated alike: an index that cannot be read fails the snapshot so the watch keeps its previous baseline, while a directory without one maps every name to itself. Collapsing them would drop every mapped child from the listing, which the diff would then report as deletions of files that still exist. Both short-lived descriptors these paths open now carry O_CLOEXEC, so a concurrent execve on another vCPU thread cannot leak one into the new image while it is held. --- src/runtime/procemu.c | 51 +++--- src/syscall/exec.c | 91 ++++++---- src/syscall/fs-stat.c | 54 +++--- src/syscall/fs.c | 141 ++++++--------- src/syscall/inotify.c | 64 ++++++- src/syscall/net-absock.c | 283 ++++++++++++++++++++++++++++++- src/syscall/net-absock.h | 25 ++- src/syscall/net-msg.c | 8 +- src/syscall/net.c | 19 +-- src/syscall/path.c | 68 +++++++- src/syscall/path.h | 85 +++++++++- src/syscall/proc-state.c | 100 ++++++++--- src/syscall/sidecar.c | 203 ++++++++++++++++++++-- src/syscall/sidecar.h | 40 +++++ src/syscall/syscall.c | 21 ++- tests/test-fchmodat-empty-path.c | 23 +++ tests/test-fchownat-empty-path.c | 23 +++ 17 files changed, 1051 insertions(+), 248 deletions(-) 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/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(); From f865fad3841f531c218e51b176668a820f54fdcb Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 22 Jul 2026 15:48:33 +0200 Subject: [PATCH 2/3] Add sysroot path-translation test suite Cover the sysroot translation model end to end: - Add the sysroot path-translation matrix test and the exec-cwd regression test. - Run the path matrix on a case-sensitive volume and validate it against a real kernel. - Cover guest-private prefix classification, execveat /dev/shm nofollow, the absock namespace lifecycle, and PT_INTERP tokenized-parent fallback. - Add the sidecar-state check helper. - Retire the listxattr indirect-coverage exemption, since the matrix now calls the syscall directly and the coverage audit rejects exemptions that no longer apply. The absock lifecycle is covered in both exit orders, since the two destroy different state: the existing case has a forked child exit first, and an owner-sweep mode has the namespace owner exit while a child still holds a bound abstract socket. The owner cannot report that result itself, so the surviving child prints a marker the recipe greps for. Reverse mapping is pinned at its bounds with a maximum-length component and at the sysroot root, the /dev/shm nofollow and nonblock fences are asserted through truncate and chdir, which reach a leaf by descriptor, and a tokenized directory holding many entries checks that one create still yields exactly one named event. Shared file helpers move to tests/test-util.h so the three copies that had drifted cannot diverge again; the promoted copy retries short writes, which two of them did not. Two lanes cover the index rules that a guest cannot set up for itself, so the host stages the state. test-sidecar-orphan places a token-shaped file with no index row beside an ordinary host-staged file and requires both to be listed and both to resolve, which 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 requires that no child is announced as deleted; the guest prints a readiness marker and waits on a host-staged sentinel, because the failure has to begin after the baseline is taken and a guest-created sentinel would itself be stored under a token the recipe could not name. --- mk/tests.mk | 235 +++++- scripts/check-syscall-coverage.py | 1 - tests/check-sidecar-state.sh | 61 ++ tests/mkdir-arg.c | 25 + tests/test-absock-cleanup.c | 197 +++++ tests/test-execveat-shm.c | 139 ++++ tests/test-inotify-index-fail.c | 134 ++++ tests/test-matrix.sh | 38 +- tests/test-path-matrix.c | 1139 +++++++++++++++++++++++++++++ tests/test-sidecar-orphan.c | 86 +++ tests/test-sysroot-create-paths.c | 10 - tests/test-sysroot-exec-cwd.c | 147 ++++ tests/test-util.h | 42 ++ 13 files changed, 2231 insertions(+), 23 deletions(-) create mode 100644 tests/check-sidecar-state.sh create mode 100644 tests/mkdir-arg.c create mode 100644 tests/test-absock-cleanup.c create mode 100644 tests/test-execveat-shm.c create mode 100644 tests/test-inotify-index-fail.c create mode 100644 tests/test-path-matrix.c create mode 100644 tests/test-sidecar-orphan.c create mode 100644 tests/test-sysroot-exec-cwd.c 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/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-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(); From 68886eb650d18930878b58363e72a8cf3257894d Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 22 Jul 2026 15:48:33 +0200 Subject: [PATCH 3/3] Document the sysroot translation model Describe how guest paths are translated through the sysroot and sidecar, including AF_UNIX socket addresses, and link it from the internals and usage docs. Record the parts of the model a reader would otherwise have to trace the code to recover: which leg of the pipeline path_host_to_guest inverts and which legs it leaves untouched, why a bind reattaching its leaf must apply the reserved-name guard itself, and how the abstract-socket namespace directory is retired across a forked guest tree. The /dev/shm section gains the open-flag half of the never-follow invariant, covering the helper that forces those flags and the reason every open of a shm leaf is also nonblocking. State the two rules that follow from the index being a mapping rather than a gate: a name the index does not describe keeps its on-disk spelling, because lookup falls back to that spelling and a listing must agree with what can be opened; and an index that exists but cannot be read fails the operation instead of reading as empty, so a watch keeps its baseline rather than reporting every mapped child as deleted. Record the two lanes that hold those rules. --- docs/internals.md | 32 ++++-- docs/sysroot.md | 243 ++++++++++++++++++++++++++++++++++++++++++++++ docs/usage.md | 4 + 3 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 docs/sysroot.md 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