From a2b7476d1171f00f85c4486c645052dd832b2ef1 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Thu, 13 Aug 2026 16:07:56 +0200 Subject: [PATCH 1/2] Add --user and --workdir launch flags An OCI image front end needs to set the guest identity and working directory without patching the runtime; both flags map onto launch_args_t fields and `elfuse-oci run` drives exactly this interface. The --user identity is staged before bring-up (proc_set_initial_ids) so the auxv AT_UID/AT_GID snapshot taken by build_linux_stack matches what getuid()/getgid() later report. proc_identity_init consumes the staged value, and the elfuse_launch fail path calls proc_clear_initial_ids: a bring-up that fails before proc_init would otherwise leave the value staged for the next launch in the same host process. parse_id_component accepts digits only. strtoul negates the unsigned result, so "-0" would parse as root and "-18446744073709551615" as uid 1, identities the spec on the command line never names. --workdir rejects non-absolute paths up front instead of silently resolving them against the host cwd, and is applied by elfuse_launch after the casefold probe so the translation sees the sysroot's real case behavior. The resolved host path must sit inside the sysroot prefix: proc_resolve_sysroot_path falls back to the host spelling when the sysroot has no entry at the path, which is the overlay contract for guest syscalls but would start the guest in a same-named host directory here, so the launch refuses it. --fakeroot and a non-root --user are refused together. Fakeroot starts the guest as uid/gid 0 and uid_is_permitted() grants every id switch on that basis; a non-root --user would keep that grant while the guest reported an unprivileged uid, letting the guest call setuid(0) at will. The refusal lives in elfuse_launch beside the Rosetta GDB check, so every launcher inherits the privilege rule, and it exits through the shared fail unwind so a FUSE-materialized temp ELF is unlinked even when bring-up never starts. tests/test-launch-flags.sh covers the refusal, the parse rules, and the --workdir sysroot containment; its sign, leading-space, and negated-zero cases were observed failing against the strtoul spelling, while the UINT32_MAX boundary and empty-GID cases are regression guards that spelling also rejected. tests/test-identity-override-host.c pins the staging consume-once and clear semantics as regression guards (the cross-launch leak needs two bring-ups in one host process, which no launcher performs). docs/usage.md documents both flags, the containment rule, and the two-sided root requirement behind the fakeroot refusal. --- docs/usage.md | 33 ++++++++++ mk/tests.mk | 9 ++- src/core/launch.c | 66 ++++++++++++++++++++ src/core/launch.h | 24 ++++++-- src/main.c | 77 +++++++++++++++++++++++- src/syscall/proc-identity.c | 24 ++++++++ src/syscall/proc.h | 12 ++++ tests/lib/report.sh | 3 +- tests/test-identity-override-host.c | 36 ++++++++++- tests/test-launch-flags.sh | 93 +++++++++++++++++++++++++++++ 10 files changed, 365 insertions(+), 12 deletions(-) create mode 100755 tests/test-launch-flags.sh diff --git a/docs/usage.md b/docs/usage.md index 50d09133..e929b9ca 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -23,6 +23,8 @@ Supported user-facing options: | `--fakeroot` | Start the guest as uid/gid 0 with full emulated capabilities (also `ELFUSE_FAKEROOT=1`) | | `--gdb PORT` | Listen for a GDB RSP client on `PORT` (aarch64 guests only) | | `--gdb-stop-on-entry` | Stop before the first guest instruction | +| `--user UID[:GID]` | Run the guest as `UID`, and `GID` when given (defaults to `UID`). Numeric only | +| `--workdir DIR` | Guest-absolute initial working directory, resolved under `--sysroot` | | `--` | End `elfuse` option parsing; remaining tokens are guest argv | `ELFUSE_FAKEROOT_EXEC` has no flag form. It names one executable, by absolute @@ -46,6 +48,37 @@ only bounds a single `hv_vcpu_run()` iteration before the host regains control, which is what allows host-side timers and signals to be observed promptly. Setting `--timeout 0` disables this watchdog for long-running CPU-bound guests. +## Guest Identity And Working Directory + +`--user` and `--workdir` select what the guest starts as and where it starts. +A contradictory `--user` request is rejected before the VM is created, and +`--workdir` is resolved during bring-up, before the first guest instruction, +so a bad request fails with a diagnostic instead of launching a guest that +runs as something other than what was asked for. + +`--user UID[:GID]` sets the identity the guest reports through `getuid` and +`getgid`. It does not change the host process credentials: elfuse translates the +guest's syscalls, so the number the guest sees is elfuse's to choose. The spec is +numeric, and a bare `UID` sets the group to the same value. Symbolic names are +resolved against the image `/etc/passwd` and `/etc/group` one layer up, by +`elfuse-oci`. + +`--fakeroot` cannot be combined with a non-root `--user`. Fakeroot starts the guest +as uid/gid 0, and the setuid permission check grants every id switch on that basis, +so a guest that reported an unprivileged uid could still call `setuid(0)` at will. +Both halves must be root, which makes `--fakeroot --user 0:0` valid and +`--fakeroot --user 0:1000` a refusal. + +`--workdir DIR` takes a guest-absolute path and is rejected otherwise. A relative +path would be resolved against the host working directory, silently starting the +guest outside the intended tree. The path is translated through `--sysroot` and +then entered, the same way a guest `chdir` into a real directory is handled, +with one launch-time restriction: the resolved directory must sit inside the +sysroot. For a path the sysroot does not hold, a guest syscall falls back to +the host, but a workdir that exists only on the host would start the guest +outside the requested tree, so the launch refuses it. FUSE-mounted and +`/proc`-virtual directories are not supported through this flag. + ## Common Launch Patterns Run a statically linked guest binary: diff --git a/mk/tests.mk b/mk/tests.mk index 8b4b72f0..36594519 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -26,7 +26,8 @@ ELFUSE_HOST_NOFILE_MIN ?= $(shell bash "$(CURDIR)/tests/test-config.sh" --host-n test-mremap-tail-emfile \ test-proctitle-host test-proctitle-low-stack \ test-sysroot-procfs-exec test-sysroot-fd-magiclink \ - test-timeout-disable test-fuse-alpine \ + test-timeout-disable test-launch-flags \ + test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ test-sysroot-dotdot test-sysroot-openat2-walk \ test-sysroot-inotify-names test-sysroot-exec-names \ @@ -235,6 +236,7 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage test-config \ $(call run-lane,test-case-collision-fallback,case collisions on a folding sysroot) $(call run-lane,test-fuse-alpine,Alpine sysroot FUSE validation) $(call run-lane,test-timeout-disable,timeout=0 validation) + $(call run-lane,test-launch-flags,launch flag rejection) $(call run-lane,test-rosetta-cli,rosetta CLI gating) $(call run-lane,test-bench-guardrail,hot-syscall guardrail) @@ -1024,6 +1026,11 @@ test-sysroot-fd-magiclink: $(ELFUSE_BIN) $(BUILD_DIR)/test-fd-magiclink test-timeout-disable: $(ELFUSE_BIN) $(TEST_HELLO_DEP) @$(ELFUSE_BIN) --timeout 0 $(TEST_DIR)/test-hello > /dev/null +## Verify --user / --workdir / --fakeroot reject contradictory requests before +## guest bring-up. +test-launch-flags: $(ELFUSE_BIN) $(TEST_HELLO_DEP) + @bash tests/test-launch-flags.sh $(ELFUSE_BIN) $(TEST_DIR)/test-hello + ## Check the --help and argument-error usage synopses against each other test-usage-synopsis: $(ELFUSE_BIN) @bash tests/test-usage-synopsis.sh $(ELFUSE_BIN) diff --git a/src/core/launch.c b/src/core/launch.c index 0aa7454e..f4687903 100644 --- a/src/core/launch.c +++ b/src/core/launch.c @@ -16,9 +16,11 @@ #include #include +#include #include #include #include +#include #include #include "core/bootstrap.h" @@ -29,6 +31,7 @@ #include "runtime/futex.h" /* futex_interrupt_request */ #include "runtime/procemu.h" /* proc_pty_release_process_slaves */ #include "runtime/thread.h" +#include "syscall/path.h" #include "syscall/proc.h" #include "syscall/wakeup-pipe.h" @@ -69,6 +72,25 @@ int elfuse_launch(const launch_args_t *args) ? args->guest_argv[0] : args->elf_path; + /* uid_is_permitted() grants every setuid under fakeroot because fakeroot + * starts the guest as uid/gid 0. A non-root --user keeps that grant while + * reporting an unprivileged uid, so the guest could call setuid(0). + */ + if (proc_fakeroot_enabled() && args->has_creds && + (args->uid != 0 || args->gid != 0)) { + log_error( + "--fakeroot runs the guest as uid/gid 0 and cannot be combined " + "with --user %u:%u", + args->uid, args->gid); + goto fail; + } + + /* Stage --user before bring-up; proc.h states why it cannot be applied + * afterwards. + */ + if (args->has_creds) + proc_set_initial_ids(args->uid, args->gid); + if (guest_bootstrap_prepare( &g, args->elf_path, elf_host_temp, elf_guest_path, args->sysroot, args->guest_argc, args->guest_argv, envp_use, shim_bin, @@ -108,6 +130,48 @@ int elfuse_launch(const launch_args_t *args) proc_set_sysroot_casefold(false); } + /* Placed after the casefold probe so path_translate_at() sees the + * sysroot's real case behavior. + */ + if (args->cwd_guest && args->cwd_guest[0] != '\0') { + path_translation_t tx; + if (path_translate_at(LINUX_AT_FDCWD, args->cwd_guest, PATH_TR_NONE, + &tx) < 0) { + log_error("failed to resolve working directory %s: %s", + args->cwd_guest, strerror(errno)); + goto fail; + } + /* proc_resolve_sysroot_path() falls back to the host spelling for a + * path the sysroot does not hold, which would start the guest in a + * same-named host directory outside the tree --workdir named. + */ + if (args->sysroot) { + char sr[LINUX_PATH_MAX]; + if (!proc_sysroot_snapshot(sr, sizeof(sr))) { + log_error("failed to read the sysroot prefix for --workdir %s", + args->cwd_guest); + goto fail; + } + /* Same carve-out as path_dirent_dir_holds_escapes(): "--sysroot /" + * owns every host path, but path_prefix_match on a bare separator + * accepts "/" alone. + */ + size_t srlen = strlen(sr); + if (srlen > 1 && !path_prefix_match(tx.host_path, sr, srlen)) { + log_error("--workdir %s does not resolve inside the sysroot", + args->cwd_guest); + goto fail; + } + } + if (chdir(tx.host_path) < 0) { + log_error("failed to set working directory %s: %s", args->cwd_guest, + strerror(errno)); + goto fail; + } + if (proc_cwd_refresh() < 0) + proc_cwd_invalidate(); + } + hv_vcpu_t vcpu; hv_vcpu_exit_t *vexit; if (guest_bootstrap_create_vcpu(&g, &boot, args->verbose, &vcpu, &vexit) < @@ -203,7 +267,9 @@ int elfuse_launch(const launch_args_t *args) fail: /* Bring-up failed: unwind whatever exists so far, including the temp * unlink this side owns past the prepare call (contract in launch.h). + * Staged --user credentials are dropped too (proc.h). */ + proc_clear_initial_ids(); if (guest_initialized) guest_destroy(&g); if (elf_host_temp) diff --git a/src/core/launch.h b/src/core/launch.h index fe2ed9f9..fa2c5d92 100644 --- a/src/core/launch.h +++ b/src/core/launch.h @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 * * elfuse_launch is the single entry point for "run a guest binary in a - * fresh HVF VM until it exits". main() is its only in-tree caller; keeping - * bring-up behind one struct is what lets another front end (the planned - * OCI run helper) reuse this path instead of growing a second bring-up. + * fresh HVF VM until it exits". main() is its only caller; keeping bring-up + * behind one struct is what lets a front end select the guest identity and + * cwd through the CLI instead of growing a second bring-up. * * The function owns the guest_t, the vCPU, the GDB stub, the run loop, the * diagnostic dumps, and guest teardown; it does NOT own the elf_path / @@ -33,9 +33,9 @@ typedef struct { /* elf_path is a FUSE-materialized temp to unlink once * guest_bootstrap_prepare has loaded it (kept for Rosetta guests, which - * reopen the path). The caller owns the unlink on any pre-prepare - * failure; elfuse_launch owns it from the prepare call onward, - * including a prepare that fails. + * reopen the path). Ownership of the unlink transfers to elfuse_launch + * at the call: every failure path inside it, refusals before the + * prepare call included, unlinks a temp elf_path. */ bool elf_host_temp; @@ -52,6 +52,18 @@ typedef struct { int guest_argc; const char **guest_argv; + /* When true, uid/gid are staged before bring-up so the auxv AT_UID/AT_GID + * snapshot and getuid()/getgid() agree. When false the guest runs under + * GUEST_UID/GUEST_GID (0 under fakeroot), never the host identity. + */ + bool has_creds; + uint32_t uid, gid; + + /* Guest-absolute initial working directory, resolved under sysroot. + * NULL inherits the host cwd. + */ + const char *cwd_guest; + /* GDB Remote Serial Protocol port (0 disables the stub) and whether * to halt before the first guest instruction. */ diff --git a/src/main.c b/src/main.c index 77b5b12c..f1516f00 100644 --- a/src/main.c +++ b/src/main.c @@ -61,6 +61,24 @@ static int parse_int_arg(const char *s, int min, int max, int *out) return 0; } +/* Parse one --user id component, stopping at @end. strtoul would take a sign + * and negate the unsigned result, so "-0" parses as root; an id is digits. + */ +static int parse_id_component(const char *s, const char **end, uint32_t *out) +{ + if (*s < '0' || *s > '9') + return -1; + unsigned long long value = 0; + for (; *s >= '0' && *s <= '9'; s++) { + value = value * 10 + (unsigned long long) (*s - '0'); + if (value > UINT32_MAX) + return -1; + } + *end = s; + *out = (uint32_t) value; + return 0; +} + static int resolve_guest_elf_host_path(const char *elf_guest_path, char *elf_host_path, size_t elf_host_path_sz, @@ -201,7 +219,8 @@ static int host_dc_zva_assert(void) #define ELFUSE_USAGE_BODY(sep) \ "usage: elfuse [--verbose] [--timeout N] [--sysroot PATH]" sep \ "[--create-sysroot PATH] [--no-rosetta] [--fakeroot]" sep \ - "[--gdb PORT] [--gdb-stop-on-entry] [args...]" + "[--gdb PORT] [--gdb-stop-on-entry]" sep \ + "[--user UID[:GID]] [--workdir DIR] [args...]" #define ELFUSE_USAGE ELFUSE_USAGE_BODY(" ") #define ELFUSE_USAGE_WRAPPED ELFUSE_USAGE_BODY("\n ") @@ -229,6 +248,9 @@ int main(int argc, char **argv) int gdb_port = 0; bool gdb_stop_on_entry = false; bool fakeroot = false; + bool has_creds = false; + uint32_t uid = 0, gid = 0; + char *workdir = NULL; int arg_start = 1; /* Everything the shared cleanup label reads is declared and initialized * here, above the option loop, so any later error path can `goto cleanup`: @@ -296,6 +318,11 @@ int main(int argc, char **argv) "Protocol on PORT\n" " --gdb-stop-on-entry Halt before the first guest " "instruction\n" + " --user UID[:GID] Run the guest as UID (and GID; " + "defaults to UID). Numeric; elfuse-oci resolves symbolic " + "names\n" + " --workdir DIR Guest-absolute initial working " + "directory (resolved under --sysroot)\n" "\n" "Environment:\n" " ELFUSE_NO_ROSETTA=1 Same as --no-rosetta\n" @@ -368,6 +395,47 @@ int main(int argc, char **argv) } else if (!strcmp(argv[arg_start], "--gdb-stop-on-entry")) { gdb_stop_on_entry = true; arg_start++; + } else if (!strcmp(argv[arg_start], "--user") && arg_start + 1 < argc) { + const char *spec = argv[arg_start + 1], *end; + uint32_t u, g; + if (parse_id_component(spec, &end, &u) < 0) { + log_error("invalid --user UID: %s", spec); + goto cleanup; + } + g = u; + if (*end == ':') { + const char *gend; + if (parse_id_component(end + 1, &gend, &g) < 0 || + *gend != '\0') { + log_error("invalid --user UID:GID: %s", spec); + goto cleanup; + } + } else if (*end != '\0') { + log_error("invalid --user spec: %s", spec); + goto cleanup; + } + uid = u; + gid = g; + has_creds = true; + arg_start += 2; + } else if (!strcmp(argv[arg_start], "--workdir") && + arg_start + 1 < argc) { + /* A relative path resolves against the host cwd, silently + * starting the guest outside the intended tree. strdup because + * runtime_set_process_title() clobbers the argv block. + */ + if (argv[arg_start + 1][0] != '/') { + log_error("--workdir requires a guest-absolute path, got %s", + argv[arg_start + 1]); + goto cleanup; + } + free(workdir); + workdir = strdup(argv[arg_start + 1]); + if (!workdir) { + log_error("out of memory"); + goto cleanup; + } + arg_start += 2; } else if (!strcmp(argv[arg_start], "--")) { arg_start++; break; @@ -616,7 +684,7 @@ int main(int argc, char **argv) * retains ownership of the original argv (proctitle above), the sysroot * mount (detached at the cleanup label after the guest exits so the * mount stays live for the whole run), host cwd, and the heap elf_path / - * sysroot_path / guest_argv copies. + * sysroot_path / guest_argv / workdir copies. */ launch_args_t largs = { .elf_path = elf_host_path, @@ -624,6 +692,10 @@ int main(int argc, char **argv) .sysroot = sysroot, .guest_argc = guest_argc, .guest_argv = guest_argv, + .has_creds = has_creds, + .uid = uid, + .gid = gid, + .cwd_guest = workdir, .gdb_port = gdb_port, .gdb_stop_on_entry = gdb_stop_on_entry, .timeout_sec = timeout_sec, @@ -652,6 +724,7 @@ int main(int argc, char **argv) free_guest_argv(guest_argv, guest_argc); free(elf_path); free(sysroot_path); + free(workdir); if (elf_host_temp) unlink(elf_host_path); diff --git a/src/syscall/proc-identity.c b/src/syscall/proc-identity.c index 08b6b04d..7956b0ca 100644 --- a/src/syscall/proc-identity.c +++ b/src/syscall/proc-identity.c @@ -33,6 +33,10 @@ static _Atomic int32_t guest_has_ctty = 1; static _Atomic bool fakeroot_enabled = false; +static _Atomic bool initial_ids_staged = false; +static _Atomic uint32_t initial_uid = GUEST_UID; +static _Atomic uint32_t initial_gid = GUEST_GID; + void proc_set_fakeroot_enabled(bool enabled) { atomic_store(&fakeroot_enabled, enabled); @@ -64,6 +68,18 @@ const char *proc_fakeroot_exec_path(void) return fakeroot_exec_path[0] ? fakeroot_exec_path : NULL; } +void proc_set_initial_ids(uint32_t uid, uint32_t gid) +{ + atomic_store(&initial_uid, uid); + atomic_store(&initial_gid, gid); + atomic_store(&initial_ids_staged, true); +} + +void proc_clear_initial_ids(void) +{ + atomic_store(&initial_ids_staged, false); +} + void proc_identity_init(void) { guest_pid = 1; @@ -78,6 +94,14 @@ void proc_identity_init(void) gid = 0; } + /* A staged --user wins over the defaults and over fakeroot; consumed + * here so it applies to one bring-up only (contract in proc.h). + */ + if (atomic_exchange(&initial_ids_staged, false)) { + uid = atomic_load(&initial_uid); + gid = atomic_load(&initial_gid); + } + emu_uid = uid; emu_euid = uid; emu_suid = uid; diff --git a/src/syscall/proc.h b/src/syscall/proc.h index 6096f98b..cc1ccc74 100644 --- a/src/syscall/proc.h +++ b/src/syscall/proc.h @@ -140,6 +140,18 @@ bool proc_set_fakeroot_exec_path(const char *path); */ const char *proc_fakeroot_exec_path(void); +/* Stage the initial guest credentials (--user) before proc_init, so the auxv + * AT_UID/AT_GID snapshot taken by build_linux_stack matches what + * getuid()/getgid() later report. Consumed by the next proc_identity_init. + */ +void proc_set_initial_ids(uint32_t uid, uint32_t gid); + +/* Drop a staged --user that no proc_identity_init consumed: a launch failing + * between proc_set_initial_ids and proc_init would otherwise apply the failed + * launch's identity to the next bring-up in the same host process. + */ +void proc_clear_initial_ids(void); + /* Store the guest command line for /proc/self/cmdline emulation. argv is a * NULL-terminated array of strings. */ diff --git a/tests/lib/report.sh b/tests/lib/report.sh index 5314d662..d5421c21 100644 --- a/tests/lib/report.sh +++ b/tests/lib/report.sh @@ -1,5 +1,4 @@ -# Shared reporting helpers for standalone test scripts (the -# tests/test-rosetta-*.sh suite). +# Shared reporting helpers for standalone test scripts. # # Copyright 2026 elfuse contributors # SPDX-License-Identifier: Apache-2.0 diff --git a/tests/test-identity-override-host.c b/tests/test-identity-override-host.c index 133d055a..be7291cf 100644 --- a/tests/test-identity-override-host.c +++ b/tests/test-identity-override-host.c @@ -1,8 +1,14 @@ /* - * Host-side unit test for ELFUSE_FAKEROOT environment overrides. + * Host-side unit test for ELFUSE_FAKEROOT environment overrides and the + * --user staging protocol. * * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 + * + * The staging cases are regression guards for the proc_set_initial_ids + * contract in proc.h. No launcher performs two bring-ups in one host + * process, so a regression shows up as the wrong uid/gid below rather than + * as a launch failure. */ #include @@ -86,6 +92,34 @@ int main(void) assert(proc_get_gid() == GUEST_GID); assert(proc_fakeroot_enabled() == false); + /* Test 5: consume-once. A leftover would leak one launch's --user into + * the next launch of the same host process. + */ + proc_set_fakeroot_enabled(false); + proc_set_initial_ids(1234, 5678); + proc_identity_init(); + assert(proc_get_uid() == 1234); + assert(proc_get_euid() == 1234); + assert(proc_get_suid() == 1234); + assert(proc_get_gid() == 5678); + assert(proc_get_egid() == 5678); + assert(proc_get_sgid() == 5678); + + proc_identity_init(); + assert(proc_get_uid() == GUEST_UID); + assert(proc_get_gid() == GUEST_GID); + + /* Test 6: the elfuse_launch fail path drops the staging + * (proc_clear_initial_ids contract in proc.h). + */ + proc_set_initial_ids(1234, 5678); + proc_clear_initial_ids(); + proc_identity_init(); + assert(proc_get_uid() == GUEST_UID); + assert(proc_get_euid() == GUEST_UID); + assert(proc_get_gid() == GUEST_GID); + assert(proc_get_egid() == GUEST_GID); + printf("test-identity-override-host: PASS\n"); return 0; } diff --git a/tests/test-launch-flags.sh b/tests/test-launch-flags.sh new file mode 100755 index 00000000..de338055 --- /dev/null +++ b/tests/test-launch-flags.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# test-launch-flags.sh -- Pin the rejection rules of the guest launch flags +# +# Copyright 2026 elfuse contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Usage: tests/test-launch-flags.sh +# +# --user and --workdir are rejected before the first guest instruction +# (--user before the VM exists, --workdir during bring-up), so a launcher +# gets a diagnostic rather than a guest running as something other than what +# was asked for. + +set -euo pipefail + +ELFUSE="${1:?Usage: $0 }" +GUEST="${2:?Usage: $0 }" + +# shellcheck source=tests/lib/report.sh +. "$(dirname "$0")/lib/report.sh" + +# Counters are per-script; see tests/lib/report.sh. +pass=0 +fail=0 +skip=0 + +# check +check() +{ + local want="$1" desc="$2" pattern="$3" + shift 3 + local out status=0 + out="$("$ELFUSE" "$@" "$GUEST" 2>&1)" || status=$? + if [ "$want" = reject ]; then + if [ "$status" -eq 0 ]; then + report_fail "$desc (accepted, want rejection)" + return + fi + if ! printf '%s' "$out" | grep -qF "$pattern"; then + report_fail "$desc (exit $status but message lacks '$pattern')" + printf '%s\n' "$out" >&2 + return + fi + else + if [ "$status" -ne 0 ]; then + report_fail "$desc (exit $status, want success)" + printf '%s\n' "$out" >&2 + return + fi + fi + report_pass "$desc" +} + +check reject "--fakeroot with a non-root --user" "cannot be combined" \ + --fakeroot --user 1000:1000 +check reject "--fakeroot with a root uid but non-root gid" "cannot be combined" \ + --fakeroot --user 0:1000 +check reject "--workdir relative path" "absolute" --workdir rel/path +check reject "--user non-numeric" "invalid --user" --user alice +check reject "--user with a sign" "invalid --user" --user +1000 +check reject "--user negated zero" "invalid --user" --user -0 +check reject "--user with leading space" "invalid --user" --user ' 1000' +check reject "--user GID with a sign" "invalid --user" --user 1000:+7 +check reject "--user empty GID" "invalid --user" --user 1000: +# Both sides of parse_id_component's UINT32_MAX bail. +check reject "--user one past UINT32_MAX" "invalid --user" --user 4294967296 + +# --fakeroot and --user agree here, so the pair must still launch: the check +# refuses a contradiction, not the combination itself. +check accept "--fakeroot with an explicit root --user" '' --fakeroot --user 0:0 +check accept "--user UINT32_MAX" '' --user 4294967295 + +# The containment refusal and its rationale live in elfuse_launch. mktemp +# lands in /var/folders, outside is_sysroot_backed_temp_path()'s /tmp prefix, +# so proc_resolve_sysroot_path's host fallback is reachable here. +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +mkdir -p "$scratch/sysroot/inroot" "$scratch/hostonly" + +check reject "--workdir absent in the sysroot, present on the host" \ + "does not resolve inside the sysroot" \ + --sysroot "$scratch/sysroot" --workdir "$scratch/hostonly" + +check accept "--workdir present in the sysroot" '' \ + --sysroot "$scratch/sysroot" --workdir /inroot + +# Pins elfuse_launch's "--sysroot /" carve-out: a bare separator must not +# reject every workdir under it. +check accept "--workdir under a root sysroot" '' --sysroot / --workdir /var/tmp + +report_summary +# shellcheck disable=SC2154 # fail is incremented in tests/lib/report.sh +[ "$fail" -eq 0 ] || exit 1 From 4cc9f153bd47e7d9088d65ce0e401741efab13a2 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Thu, 13 Aug 2026 22:08:11 +0200 Subject: [PATCH 2/2] Refuse /dev/shm working directories path_translate_at redirects /dev/shm/ into the per-UID host backing dir and returns before sysroot resolution, so elfuse_launch ran only sys_chdir's real-directory branch for such a workdir. The plain chdir followed a symlink leaf that shm_open_leaf's O_NOFOLLOW fd refuses, and proc_cwd_refresh published the backing location, so getcwd reported /private/tmp/elfuse-shm-/ where a guest chdir into the same directory reports /dev/shm/. Entering the leaf correctly needs that O_NOFOLLOW fd and the virtual-cwd publish, which would make launch.c a second holder of the never-follow duty dev_shm_resolve_path enumerates, for a workdir no image asks for. The flag refuses the path instead, on the same grounds guest_bootstrap_prepare refuses a /dev/shm ELF interpreter. The test chmods the backing root because create_private_dir rejects a group or other permission bit, and that failure surfaces as a resolve error rather than the refusal the case measures. The leaf itself never has to exist: path_translate_at sets is_dev_shm from the guest prefix alone. --- docs/usage.md | 7 +++++-- src/core/launch.c | 9 +++++++++ tests/test-launch-flags.sh | 12 +++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index e929b9ca..e7a1a59a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -76,8 +76,11 @@ then entered, the same way a guest `chdir` into a real directory is handled, with one launch-time restriction: the resolved directory must sit inside the sysroot. For a path the sysroot does not hold, a guest syscall falls back to the host, but a workdir that exists only on the host would start the guest -outside the requested tree, so the launch refuses it. FUSE-mounted and -`/proc`-virtual directories are not supported through this flag. +outside the requested tree, so the launch refuses it. FUSE-mounted, +`/proc`-virtual, and `/dev/shm` directories are not supported through this +flag: a guest `chdir` into `/dev/shm` does two things this flag does not (it +refuses a symlink leaf, and it keeps `getcwd` reporting the `/dev/shm` +spelling rather than the backing location). ## Common Launch Patterns diff --git a/src/core/launch.c b/src/core/launch.c index f4687903..cc3bb3e2 100644 --- a/src/core/launch.c +++ b/src/core/launch.c @@ -141,6 +141,15 @@ int elfuse_launch(const launch_args_t *args) args->cwd_guest, strerror(errno)); goto fail; } + /* A shm leaf needs sys_chdir's O_NOFOLLOW fd and virtual-cwd publish; + * entering it here would add a holder of the never-follow invariant + * dev_shm_resolve_path() enumerates. Refuse instead. + */ + if (tx.is_dev_shm) { + log_error("--workdir %s: /dev/shm is not supported", + args->cwd_guest); + goto fail; + } /* proc_resolve_sysroot_path() falls back to the host spelling for a * path the sysroot does not hold, which would start the guest in a * same-named host directory outside the tree --workdir named. diff --git a/tests/test-launch-flags.sh b/tests/test-launch-flags.sh index de338055..84e2bb85 100755 --- a/tests/test-launch-flags.sh +++ b/tests/test-launch-flags.sh @@ -74,8 +74,14 @@ check accept "--user UINT32_MAX" '' --user 4294967295 # lands in /var/folders, outside is_sysroot_backed_temp_path()'s /tmp prefix, # so proc_resolve_sysroot_path's host fallback is reachable here. scratch=$(mktemp -d) +# create_private_dir() rejects a group or other bit on the /dev/shm backing +# root, and that failure surfaces as a resolve error, not the refusal the last +# check measures. The leaf itself never has to exist: path_translate_at() sets +# is_dev_shm from the guest prefix alone. +shm_root="/tmp/elfuse-shm-$(id -u)" trap 'rm -rf "$scratch"' EXIT -mkdir -p "$scratch/sysroot/inroot" "$scratch/hostonly" +mkdir -p "$scratch/sysroot/inroot" "$scratch/hostonly" "$shm_root" +chmod 700 "$shm_root" check reject "--workdir absent in the sysroot, present on the host" \ "does not resolve inside the sysroot" \ @@ -88,6 +94,10 @@ check accept "--workdir present in the sysroot" '' \ # reject every workdir under it. check accept "--workdir under a root sysroot" '' --sysroot / --workdir /var/tmp +# Refusal rationale in elfuse_launch; see dev_shm_resolve_path(). +check reject "--workdir under /dev/shm" "not supported" \ + --workdir /dev/shm/launch-flags-wd + report_summary # shellcheck disable=SC2154 # fail is incremented in tests/lib/report.sh [ "$fail" -eq 0 ] || exit 1