diff --git a/Makefile b/Makefile index 58ad9ce0..d6d920a2 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,7 @@ SRCS := \ core/vdso.c \ core/shim-globals.c \ core/bootstrap.c \ + core/guest-env.c \ core/launch.c \ core/rosetta.c \ core/sysroot.c \ @@ -261,6 +262,13 @@ $(BUILD_DIR)/test-dynamic-array-host: \ @echo " LD $@" $(Q)$(CC) $(CFLAGS) -o $@ $^ +## Build the guest environment merge host test (native macOS binary) +# guest-env.o's only dependency is the log macro, which the test stubs. +$(BUILD_DIR)/test-guest-env-host: $(BUILD_DIR)/test-guest-env-host.o \ + $(BUILD_DIR)/core/guest-env.o | $(BUILD_DIR) + @echo " LD $@" + $(Q)$(CC) $(CFLAGS) -o $@ $^ + # Guest test binaries (cross-compiled, aarch64-linux) # Only used when GUEST_TEST_BINARIES is not set. diff --git a/docs/usage.md b/docs/usage.md index 50d09133..d690c0d6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -23,6 +23,10 @@ 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` | +| `--env KEY=VALUE` | Set a guest environment variable. Repeatable; a bare `KEY` imports the host value | +| `--clear-env` | Start from an empty environment; only `--env` entries apply | | `--` | End `elfuse` option parsing; remaining tokens are guest argv | `ELFUSE_FAKEROOT_EXEC` has no flag form. It names one executable, by absolute @@ -46,6 +50,49 @@ 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, Working Directory, And Environment + +`--user`, `--workdir`, `--env`, and `--clear-env` select what the guest starts as, +where it starts, and what it sees in its environment. A contradictory `--user` +or `--env` 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, +`/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). + +`--env` follows `docker run -e`. It is repeatable: `KEY=VALUE` replaces that +variable when it is already present and appends it otherwise, while a bare `KEY` +imports the host's value for `KEY`. Unset and set-to-empty are distinct: a name +the host does not set is skipped rather than imported as an empty value, while a +host `KEY=` imports as `KEY=`. An empty variable name is rejected. Given neither +`--env` nor `--clear-env`, the guest inherits the host environment unchanged. +`--clear-env` starts from nothing, leaving only what `--env` puts back. + ## Common Launch Patterns Run a statically linked guest binary: diff --git a/mk/config.mk b/mk/config.mk index 3bedb28c..72c648ce 100644 --- a/mk/config.mk +++ b/mk/config.mk @@ -28,7 +28,8 @@ NATIVE_TESTS := tests/test-multi-vcpu.c tests/test-rwx.c \ tests/probe-volume-naming.c \ tests/test-dynamic-array-host.c \ tests/test-string-builder-host.c \ - tests/test-wakeup-pipe-host.c + tests/test-wakeup-pipe-host.c \ + tests/test-guest-env-host.c SPECIAL_TEST_SRCS := tests/test-lowbase-mem.c SPECIAL_TEST_BINS := $(BUILD_DIR)/test-lowbase-mem-200000 $(BUILD_DIR)/test-lowbase-mem-300000 @@ -42,12 +43,16 @@ ifdef GUEST_TEST_BINARIES TEST_DIR := $(GUEST_TEST_BINARIES)/bin TEST_DEPS := TEST_HELLO_DEP := + # A prebuilt tree predates test-env-dump, so the environment lanes of + # test-launch-flags.sh skip themselves rather than fail there. + TEST_ENV_DEPS := else TEST_DIR := $(BUILD_DIR) TEST_C_SRCS := $(filter-out $(NATIVE_TESTS) $(SPECIAL_TEST_SRCS) $(ROSETTA_X86_64_SRCS),$(wildcard tests/*.c)) TEST_C_BINS := $(patsubst tests/%.c,$(BUILD_DIR)/%,$(TEST_C_SRCS)) TEST_DEPS := $(BUILD_DIR)/test-hello $(TEST_C_BINS) $(SPECIAL_TEST_BINS) TEST_HELLO_DEP := $(BUILD_DIR)/test-hello + TEST_ENV_DEPS := $(BUILD_DIR)/test-env-dump $(BUILD_DIR)/test-cat endif # Colors (used by test output) diff --git a/mk/tests.mk b/mk/tests.mk index 75b6cb17..7ccf8a8f 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -25,7 +25,8 @@ ELFUSE_HOST_NOFILE_MIN ?= $(shell bash "$(CURDIR)/tests/test-config.sh" --host-n test-config \ test-mremap-tail-emfile \ test-proctitle-host test-proctitle-low-stack \ - test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ + test-sysroot-procfs-exec 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 \ @@ -33,7 +34,7 @@ ELFUSE_HOST_NOFILE_MIN ?= $(shell bash "$(CURDIR)/tests/test-config.sh" --host-n test-sysroot-absock-names test-absock-cleanup \ test-linkat-symlink-fallback test-casefold-host \ test-casefold-walk-host test-absock-names-host \ - test-wakeup-pipe-host \ + test-wakeup-pipe-host test-guest-env-host \ test-sysroot-name-unique \ test-sysroot-name-relative \ test-nosysroot-literal-names test-sysroot-outside-names \ @@ -166,7 +167,8 @@ CHECK_HOST_UNIT_BINS := $(addprefix $(BUILD_DIR)/, \ test-vcpu-run-hooks-host test-identity-override-host \ test-teardown-live-vcpu-host test-casefold-host \ test-casefold-walk-host test-absock-names-host \ - test-dynamic-array-host test-string-builder-host test-wakeup-pipe-host) + test-dynamic-array-host test-string-builder-host \ + test-wakeup-pipe-host test-guest-env-host) # Lanes shared by check and check-sanitizer, in execution order: the host # unit binaries, then the name-contract lanes cheap enough for a sanitizer @@ -184,6 +186,7 @@ $(call run-host-unit,test-absock-names-host,absock derived-name unit test) $(call run-host-unit,test-dynamic-array-host,dynamic array unit test) $(call run-host-unit,test-string-builder-host,string builder unit test) $(call run-host-unit,test-wakeup-pipe-host,wakeup pipe concurrency unit test) +$(call run-host-unit,test-guest-env-host,guest environment merge cross product) $(call run-lane,test-sysroot-name-unique,one on-disk name per guest name) $(call run-lane,test-sysroot-name-relative,relative and dirfd-relative names) $(call run-lane,test-sysroot-name-i18n,non-ASCII guest filenames) @@ -233,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 flags) $(call run-lane,test-rosetta-cli,rosetta CLI gating) $(call run-lane,test-bench-guardrail,hot-syscall guardrail) @@ -1013,6 +1017,12 @@ test-sysroot-procfs-exec: $(ELFUSE_BIN) $(BUILD_DIR)/test-procfs-exec 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, and that --env / --clear-env reach the guest's environ. +test-launch-flags: $(ELFUSE_BIN) $(TEST_HELLO_DEP) $(TEST_ENV_DEPS) + @bash tests/test-launch-flags.sh $(ELFUSE_BIN) $(TEST_DIR)/test-hello \ + $(TEST_DIR)/test-env-dump $(TEST_DIR)/test-cat + ## Check the --help and argument-error usage synopses against each other test-usage-synopsis: $(ELFUSE_BIN) @bash tests/test-usage-synopsis.sh $(ELFUSE_BIN) @@ -1469,6 +1479,10 @@ test-absock-names-host: $(BUILD_DIR)/test-absock-names-host test-wakeup-pipe-host: $(BUILD_DIR)/test-wakeup-pipe-host $(BUILD_DIR)/test-wakeup-pipe-host +## Run the guest environment merge cross product +test-guest-env-host: $(BUILD_DIR)/test-guest-env-host + $(BUILD_DIR)/test-guest-env-host + # Volume naming probe ## Report how the filesystem treats filenames (regenerates docs/filenames.md tables) probe-volume-naming: $(BUILD_DIR)/probe-volume-naming diff --git a/src/core/guest-env.c b/src/core/guest-env.c new file mode 100644 index 00000000..d83bbf1c --- /dev/null +++ b/src/core/guest-env.c @@ -0,0 +1,155 @@ +/* + * Guest environment vector construction for the launch flags + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Implementation of guest_env_build (contract and rationale in guest-env.h). + */ + +#include +#include +#include +#include + +#include "debug/log.h" +#include "utils.h" + +#include "core/guest-env.h" + +/* Name length of an override token: everything before the first '=', or the + * whole token for a bare "KEY". Zero means an empty variable name. + */ +static size_t override_key_len(const char *ov) +{ + const char *eq = strchr(ov, '='); + return eq ? (size_t) (eq - ov) : strlen(ov); +} + +/* Name length of a "KEY=VALUE" entry, or 0 when @entry is not one: it carries + * no '=', or its name is empty. Both make the entry unmatchable by an + * override, which is why guest_env_build drops rather than forwards them. + */ +static size_t entry_key_len(const char *entry) +{ + const char *eq = strchr(entry, '='); + return eq ? (size_t) (eq - entry) : 0; +} + +/* Index in @envp[0 .. @n) of the entry naming @key (of length @klen), or -1. + * The @klen'th byte decides the match: "PATH" must not find "PATH_EXTRA=...". + * That read is in bounds because it happens only after strncmp matched all + * @klen bytes, none of which is a NUL. + */ +static int env_find(char *const *envp, int n, const char *key, size_t klen) +{ + for (int i = 0; i < n; i++) + if (!strncmp(envp[i], key, klen) && envp[i][klen] == '=') + return i; + return -1; +} + +/* Value a bare "KEY" override imports, or NULL when @host_env[0 .. @n_host) + * does not set it. getenv(3) over an explicit vector, so no global environ is + * consulted. @n_host of 0 covers a NULL @host_env without dereferencing it. + */ +static const char *host_lookup(char *const *host_env, + int n_host, + const char *key, + size_t klen) +{ + int i = env_find(host_env, n_host, key, klen); + return i < 0 ? NULL : host_env[i] + klen + 1; +} + +int guest_env_build(char *const *host_env, + char *const *overrides, + int n_overrides, + bool clear_env, + char ***out_envp, + int *out_n) +{ + if (n_overrides == 0 && !clear_env) { + *out_envp = NULL; + *out_n = 0; + return 0; + } + + int n_host = 0; + if (host_env) + while (host_env[n_host]) + n_host++; + + /* Exact upper bound: the base contributes at most every host entry, each + * override appends at most once (a replace and a skipped import append + * none), plus the NULL terminator. + */ + int cap = 1 + n_overrides + (clear_env ? 0 : n_host); + char **envp = calloc((size_t) cap, sizeof(char *)); + if (!envp) { + log_error("out of memory"); + return -1; + } + int n = 0; + + if (!clear_env) { + for (int i = 0; i < n_host; i++) { + size_t klen = entry_key_len(host_env[i]); + /* env_find() rescans envp[0 .. n) per entry, so this is + * quadratic over an environment of tens of entries. A hash would + * cost more to build than the scan costs to run at that size. + */ + if (klen == 0 || env_find(envp, n, host_env[i], klen) >= 0) + continue; + envp[n] = strdup(host_env[i]); + if (!envp[n]) { + log_error("out of memory"); + goto fail; + } + n++; + } + } + + for (int i = 0; i < n_overrides; i++) { + const char *ov = overrides[i]; + size_t klen = override_key_len(ov); + if (klen == 0) { + log_error("invalid --env entry \"%s\": empty variable name", ov); + goto fail; + } + + char *entry; + if (ov[klen] == '=') { + entry = strdup(ov); + } else { + const char *val = host_lookup(host_env, n_host, ov, klen); + if (!val) + continue; + size_t need = klen + 1 + strlen(val) + 1; + entry = malloc(need); + if (entry) + snprintf(entry, need, "%s=%s", ov, val); + } + if (!entry) { + log_error("out of memory"); + goto fail; + } + + int slot = env_find(envp, n, ov, klen); + if (slot >= 0) { + free(envp[slot]); + envp[slot] = entry; + } else { + envp[n++] = entry; + } + } + + envp[n] = NULL; + *out_envp = envp; + *out_n = n; + return 0; + +fail: + strv_free((const char **) envp, n); + return -1; +} diff --git a/src/core/guest-env.h b/src/core/guest-env.h new file mode 100644 index 00000000..d3a00125 --- /dev/null +++ b/src/core/guest-env.h @@ -0,0 +1,45 @@ +/* + * Guest environment vector construction for the launch flags + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * elfuse builds the guest's "KEY=VALUE" array, which build_linux_stack copies + * onto the initial guest stack, from the host environment plus the --env / + * --clear-env flags, following `docker run -e` so an OCI front end can hand a + * guest exactly the environment an image config asks for. The host + * environment arrives as a parameter rather than from environ, so tests can + * hand guest_env_build arbitrary base vectors, malformed entries included. + */ + +#pragma once + +#include + +/* Build the guest environment vector from @host_env and the --env overrides. + * + * @host_env (NULL-terminated) fills two roles: the base the overrides merge + * into (unless @clear_env), and the source a bare "KEY" override imports + * from. They stay separate because `docker run -e KEY` imports from the + * launcher's environment even when the base was cleared. NULL empties both. + * + * @overrides holds @n_overrides entries. "KEY=VALUE" replaces that key in + * place when present and appends otherwise; a bare "KEY" imports the host + * value, and a name the host does not set is skipped rather than imported as + * empty. The value is everything after the first '='. + * + * On success returns 0. *out_envp is NULL when @n_overrides is 0 and + * @clear_env is false, and the caller uses @host_env unchanged; otherwise it + * is a malloc'd NULL-terminated array, entry count in *out_n, freed with + * strv_free (src/utils.h). Every entry carries a '=' and a unique non-empty + * name; @host_env entries violating that are dropped, or a later override + * would append beside the entry it meant to replace. On allocation failure + * or an empty override name (as setenv(3)) returns -1, logged, with the + * outputs untouched. + */ +int guest_env_build(char *const *host_env, + char *const *overrides, + int n_overrides, + bool clear_env, + char ***out_envp, + int *out_n); diff --git a/src/core/launch.c b/src/core/launch.c index 0aa7454e..70f06f26 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" @@ -51,7 +54,7 @@ _Static_assert(sizeof(shim_bin) <= INFRA_SHIM_SLOT, int elfuse_launch(const launch_args_t *args) { extern char **environ; - char **envp_use = environ; + char **envp_use = args->envp ? args->envp : environ; guest_t g; bool guest_initialized = false; @@ -69,6 +72,28 @@ int elfuse_launch(const launch_args_t *args) ? args->guest_argv[0] : args->elf_path; + /* Fakeroot means the guest starts as uid/gid 0: proc_identity_init's + * defaults and the ELFUSE_FAKEROOT_EXEC transition both set root together + * with the flag, and uid_is_permitted() grants every setuid under fakeroot + * on that basis. A non-root identity request would keep that grant while + * reporting an unprivileged uid, so the guest could call setuid(0) at + * will. Enforced here, as with the Rosetta GDB check below. + */ + 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 +133,60 @@ int elfuse_launch(const launch_args_t *args) proc_set_sysroot_casefold(false); } + /* The guest cwd is the host process cwd, as in sys_chdir's real-directory + * branch; 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; + } + /* 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() hands back the host spelling for a path + * the sysroot does not hold, the overlay contract for guest syscalls, + * but that would start the guest in a same-named host directory + * outside the tree --workdir named. Demand containment against the + * canonical prefix from proc_sysroot_snapshot() instead. + */ + 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 +282,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..27c6867d 100644 --- a/src/core/launch.h +++ b/src/core/launch.h @@ -4,16 +4,19 @@ * 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: every launcher + * reaches it through the CLI, including `elfuse-oci run`, which execs elfuse + * with the flags that fill launch_args_t rather than linking against it. + * Keeping bring-up behind one struct is what lets a front end select the + * guest identity, cwd, and environment without a second bring-up path. * * 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 / - * sysroot / guest_argv heap copies or the sysroot_mount the host CLI may - * have provisioned. Those stay with the caller so behaviors that need the - * original CLI argv (proctitle rewriting, --create-sysroot detach on exit, - * host cwd save+restore) stay coherent however the launch was kicked off. + * sysroot / guest_argv / envp / cwd_guest heap copies or the sysroot_mount + * the host CLI may have provisioned. Those stay with the caller so behaviors + * that need the original CLI argv (proctitle rewriting, --create-sysroot + * detach on exit, host cwd save+restore) stay coherent however the launch was + * kicked off. * * The caller owns every pointer in launch_args_t for the duration of the * call; elfuse_launch reads but never frees them. Per-field lifetime and @@ -33,9 +36,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 +55,27 @@ typedef struct { int guest_argc; const char **guest_argv; + /* NULL-terminated guest environ. NULL means "use host environ". envp is + * char** (not const) to match the environ/guest_bootstrap_prepare + * convention: guest programs may mutate their environment. + */ + char **envp; + + /* When true, stage uid/gid as the guest identity before bring-up so the + * auxv AT_UID/AT_GID snapshot and getuid()/getgid() agree. When false, + * uid/gid are ignored and the guest runs under the compile-time default + * GUEST_UID/GUEST_GID (0 under fakeroot), never the host identity; a + * launcher that wants the host identity must set has_creds and pass + * getuid()/getgid(). + */ + bool has_creds; + uint32_t uid, gid; + + /* Guest-absolute initial working directory. NULL inherits the host + * cwd (the caller may chdir first to control it). + */ + 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..6e77c004 100644 --- a/src/main.c +++ b/src/main.c @@ -31,6 +31,7 @@ #include "elfuse-limits.h" #include "core/bootstrap.h" +#include "core/guest-env.h" #include "core/guest.h" #include "core/launch.h" #include "core/rosetta.h" @@ -47,6 +48,8 @@ #include "debug/log.h" #include "debug/syscall-hist.h" +extern char **environ; + static int parse_int_arg(const char *s, int min, int max, int *out) { /* Seed end with s (strtol's no-conversion result) so the end == s guard @@ -97,15 +100,6 @@ static int resolve_guest_elf_host_path(const char *elf_guest_path, return 0; } -static void free_guest_argv(const char **guest_argv, int guest_argc) -{ - if (!guest_argv) - return; - for (int i = 0; i < guest_argc; i++) - free((void *) guest_argv[i]); - free((void *) guest_argv); -} - /* The infra-reserve layout invariants documented in guest.h are derived from * raw offset constants, so a future edit that grows the pool by shifting one * offset without the others would silently overlap two regions. Enforce them at @@ -198,10 +192,11 @@ static int host_dc_zva_assert(void) * which fits 80 columns. Sharing one body keeps the flag list from drifting * between them (one copy had already lost the --gdb flags). */ -#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...]" +#define ELFUSE_USAGE_BODY(sep) \ + "usage: elfuse [--verbose] [--timeout N] [--sysroot PATH]" sep \ + "[--create-sysroot PATH] [--no-rosetta] [--fakeroot] [--gdb PORT]" sep \ + "[--gdb-stop-on-entry] [--user UID[:GID]] [--workdir DIR]" sep \ + "[--env KEY=VALUE] [--clear-env] [args...]" #define ELFUSE_USAGE ELFUSE_USAGE_BODY(" ") #define ELFUSE_USAGE_WRAPPED ELFUSE_USAGE_BODY("\n ") @@ -229,6 +224,12 @@ 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; + char **env_overrides = NULL; + int n_env_overrides = 0; + bool clear_env = false; 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`: @@ -244,6 +245,8 @@ int main(int argc, char **argv) char elf_host_path[LINUX_PATH_MAX]; bool elf_host_temp = false; bool have_host_cwd = (getcwd(host_cwd, sizeof(host_cwd)) != NULL); + char **envp = NULL; + int n_envp = 0; int exit_code = 1; memset(&sysroot_mount, 0, sizeof(sysroot_mount)); @@ -296,6 +299,16 @@ 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" + " --env KEY=VALUE Set a guest environment variable; " + "repeatable. 'KEY' (no '=') inherits from the host environ\n" + " --clear-env Start the guest environment empty " + "(only --env entries apply); default inherits the host " + "environ\n" "\n" "Environment:\n" " ELFUSE_NO_ROSETTA=1 Same as --no-rosetta\n" @@ -368,6 +381,69 @@ 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]; + char *end; + errno = 0; + unsigned long u = strtoul(spec, &end, 10); + if (errno || end == spec || u > UINT32_MAX) { + log_error("invalid --user UID: %s", spec); + goto cleanup; + } + unsigned long gg = u; + if (*end == ':') { + errno = 0; + char *end2; + gg = strtoul(end + 1, &end2, 10); + if (errno || end2 == end + 1 || *end2 != '\0' || + gg > UINT32_MAX) { + log_error("invalid --user UID:GID: %s", spec); + goto cleanup; + } + } else if (*end != '\0') { + log_error("invalid --user spec: %s", spec); + goto cleanup; + } + uid = (uint32_t) u; + gid = (uint32_t) gg; + has_creds = true; + arg_start += 2; + } else if (!strcmp(argv[arg_start], "--workdir") && + arg_start + 1 < argc) { + /* Reject relative paths up front: translation would resolve + * them against the host cwd, silently starting the guest outside + * the intended tree. strdup now because + * runtime_set_process_title clobbers the original 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], "--env") && arg_start + 1 < argc) { + /* Tokens are borrowed from argv; guest_env_build strdups what it + * keeps (the ordering rationale sits at its call site). argc + * bounds the flag count, so one allocation needs no growth path. + */ + if (!env_overrides) { + env_overrides = (char **) calloc((size_t) argc, sizeof(char *)); + if (!env_overrides) { + log_error("out of memory"); + goto cleanup; + } + } + env_overrides[n_env_overrides++] = argv[arg_start + 1]; + arg_start += 2; + } else if (!strcmp(argv[arg_start], "--clear-env")) { + clear_env = true; + arg_start++; } else if (!strcmp(argv[arg_start], "--")) { arg_start++; break; @@ -445,6 +521,17 @@ int main(int argc, char **argv) return fork_child_main(fork_child_fd, vfork_notify_fd, verbose, timeout_sec); + /* Before runtime_set_process_title clobbers the argv block env_overrides + * borrows from, and before --create-sysroot would provision a + * sparsebundle for a launch a malformed --env is about to refuse. + */ + if (guest_env_build(environ, env_overrides, n_env_overrides, clear_env, + &envp, &n_envp) < 0) { + goto cleanup; + } + free(env_overrides); + env_overrides = NULL; + if (arg_start >= argc) { log_error(ELFUSE_USAGE); goto cleanup; @@ -616,7 +703,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 / envp / workdir copies. */ launch_args_t largs = { .elf_path = elf_host_path, @@ -624,6 +711,11 @@ int main(int argc, char **argv) .sysroot = sysroot, .guest_argc = guest_argc, .guest_argv = guest_argv, + .envp = envp, + .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, @@ -649,9 +741,12 @@ int main(int argc, char **argv) if (have_host_cwd && host_cwd[0] != '\0' && chdir(host_cwd) < 0) (void) chdir("/"); sysroot_cleanup_mount(&sysroot_mount); - free_guest_argv(guest_argv, guest_argc); + strv_free(guest_argv, guest_argc); free(elf_path); free(sysroot_path); + free(workdir); + free(env_overrides); + strv_free((const char **) envp, n_envp); 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..5755dc37 100644 --- a/src/syscall/proc.h +++ b/src/syscall/proc.h @@ -140,6 +140,21 @@ 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. + * proc_identity_init applies them in place of the GUEST_UID/GUEST_GID + * defaults, so the auxv AT_UID/AT_GID snapshot taken by build_linux_stack + * matches what getuid()/getgid() later report. The staged value is consumed + * by the next proc_identity_init, so it applies to a single bring-up only. + */ +void proc_set_initial_ids(uint32_t uid, uint32_t gid); + +/* Drop a staged --user value that no proc_identity_init consumed. A launch + * that fails between proc_set_initial_ids and proc_init would otherwise + * leave the value staged, and the next bring-up in the same host process + * would apply the failed launch's identity instead of the defaults. + */ +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/src/utils.h b/src/utils.h index 79a72699..68e4d257 100644 --- a/src/utils.h +++ b/src/utils.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -101,6 +102,20 @@ static inline size_t str_copy_trunc(char *dst, const char *src, size_t dst_size) return src_len; } +/* Free @n owned strings and the array holding them. Every slot must be a heap + * copy, never a borrowed environ or argv pointer; a NULL @v is a no-op. The + * count is a parameter rather than a NULL terminator because the guest argv + * is counted rather than terminated. + */ +static inline void strv_free(const char **v, int n) +{ + if (!v) + return; + for (int i = 0; i < n; i++) + free((void *) v[i]); + free((void *) v); +} + /* close(2) on a cleanup path: preserves errno across the close so the caller's * failure errno survives untouched. Skips the close when fd < 0. */ 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-env-dump.c b/tests/test-env-dump.c new file mode 100644 index 00000000..841f46cc --- /dev/null +++ b/tests/test-env-dump.c @@ -0,0 +1,21 @@ +/* + * Dump the guest environment, one entry per line + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Prints environ verbatim and in order so tests/test-launch-flags.sh can + * hold the vector build_linux_stack copied to what --env asked for, entry + * for entry. + */ + +#include + +extern char **environ; + +int main(void) +{ + for (char **e = environ; *e; e++) + puts(*e); + return 0; +} diff --git a/tests/test-guest-env-host.c b/tests/test-guest-env-host.c new file mode 100644 index 00000000..28a5871d --- /dev/null +++ b/tests/test-guest-env-host.c @@ -0,0 +1,368 @@ +/* + * Native-host cross product for the guest environment merge + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Runs the guest_env_build cross product (base x override spelling x name + * already present) against a reference merge plus per-cell structural + * invariants, pinning `docker run -e`. The vector reaching a live guest is + * tests/test-launch-flags.sh. Native macOS binary; no HVF entitlement needed. + */ + +#include +#include +#include +#include + +#include "debug/log.h" +#include "host-test-util.h" +#include "utils.h" + +#include "core/guest-env.h" + +/* Dummy log implementation to avoid linking debug/log.o. Where the stub in + * test-shebang-host.c prints, this one discards: refusal is the expected + * outcome in hundreds of cells, and echoing each would bury the failures. + */ +void log_impl(int level, const char *file, int line, const char *fmt, ...) +{ + (void) level; + (void) file; + (void) line; + (void) fmt; +} + +/* ---- the oracle -------------------------------------------------------- */ + +enum { MODEL_MAX = 32, MODEL_ENTRY_MAX = 256 }; + +typedef struct { + char name[MODEL_MAX][MODEL_ENTRY_MAX]; /* insertion-ordered names */ + char value[MODEL_MAX][MODEL_ENTRY_MAX]; + int n; + bool refused; /* an override named nothing */ +} model_t; + +/* Split "KEY=VALUE" at the first '='. Returns false when @s is not one: + * no '=' at all, or an empty name. + */ +static bool model_split(const char *s, char *name, char *value) +{ + const char *eq = strchr(s, '='); + if (!eq || eq == s) + return false; + size_t klen = (size_t) (eq - s); + if (klen >= MODEL_ENTRY_MAX || strlen(eq + 1) >= MODEL_ENTRY_MAX) + return false; + memcpy(name, s, klen); + name[klen] = '\0'; + strcpy(value, eq + 1); + return true; +} + +static int model_index(const model_t *m, const char *name) +{ + for (int i = 0; i < m->n; i++) { + if (!strcmp(m->name[i], name)) + return i; + } + return -1; +} + +static void model_set(model_t *m, const char *name, const char *value) +{ + int i = model_index(m, name); + if (i < 0) { + if (m->n == MODEL_MAX) { + /* Raise MODEL_MAX rather than let the oracle drop an entry: a + * silent truncation here reads as the implementation inventing an + * extra entry. + */ + fprintf(stderr, "oracle overflow: raise MODEL_MAX past %d\n", + MODEL_MAX); + exit(2); + } + i = m->n++; + snprintf(m->name[i], MODEL_ENTRY_MAX, "%s", name); + } + snprintf(m->value[i], MODEL_ENTRY_MAX, "%s", value); +} + +/* The launcher's value for @name, or NULL. First match wins, as getenv(3). */ +static const char *model_host_value(char *const *host_env, const char *name) +{ + if (!host_env) + return NULL; + for (int i = 0; host_env[i]; i++) { + char n[MODEL_ENTRY_MAX], v[MODEL_ENTRY_MAX]; + if (model_split(host_env[i], n, v) && !strcmp(n, name)) + return host_env[i] + strlen(name) + 1; + } + return NULL; +} + +/* Reference merge. Builds the environment as an insertion-ordered name list, + * so position comes from when a name was first seen rather than from which + * slot the implementation happened to reuse. + */ +static void model_build(model_t *m, + char *const *host_env, + const char *const *overrides, + int n_overrides, + bool clear_env) +{ + memset(m, 0, sizeof(*m)); + + if (!clear_env && host_env) { + for (int i = 0; host_env[i]; i++) { + char n[MODEL_ENTRY_MAX], v[MODEL_ENTRY_MAX]; + if (!model_split(host_env[i], n, v)) + continue; /* names nothing an override could replace */ + if (model_index(m, n) >= 0) + continue; /* a repeat of a name already taken */ + model_set(m, n, v); + } + } + + for (int i = 0; i < n_overrides; i++) { + const char *ov = overrides[i]; + const char *eq = strchr(ov, '='); + if (eq == ov || (!eq && !*ov)) { + m->refused = true; + return; + } + if (eq) { + char n[MODEL_ENTRY_MAX], v[MODEL_ENTRY_MAX]; + model_split(ov, n, v); + model_set(m, n, v); + } else { + const char *hv = model_host_value(host_env, ov); + if (hv) + model_set(m, ov, hv); + } + } +} + +/* ---- structural invariants --------------------------------------------- */ + +/* Hold @envp to what every returned vector owes regardless of the cell: + * NULL-terminated at @n, every entry a well-formed "KEY=VALUE", no name twice. + * The oracle cannot catch a vector that agrees with it entry-for-entry and is + * still malformed past @n. + */ +static void check_structure(char **envp, int n, const char *cell) +{ + char detail[512]; + + if (envp[n] != NULL) { + snprintf(detail, sizeof(detail), "%s: envp[%d] is not NULL", cell, n); + host_check(false, "structure", detail); + return; + } + for (int i = 0; i < n; i++) { + const char *eq = strchr(envp[i], '='); + if (!eq || eq == envp[i]) { + snprintf(detail, sizeof(detail), "%s: entry %d \"%s\" has no name", + cell, i, envp[i]); + host_check(false, "structure", detail); + return; + } + size_t klen = (size_t) (eq - envp[i]); + for (int j = 0; j < i; j++) { + if (!strncmp(envp[j], envp[i], klen) && envp[j][klen] == '=') { + snprintf(detail, sizeof(detail), + "%s: entries %d and %d share a name (\"%s\", \"%s\")", + cell, j, i, envp[j], envp[i]); + host_check(false, "structure", detail); + return; + } + } + } + host_ok(); +} + +/* ---- the product ------------------------------------------------------- */ + +/* One base per way a host vector can be awkward. */ +static char *base_plain[] = {(char *) "A=1", (char *) "B=2", (char *) "C=3", + NULL}; +/* The one base separating a name the launcher set to "" from one it never + * set: the alphabet's bare "A" imports "A=" here instead of being skipped. + */ +static char *base_emptyval[] = {(char *) "A=", (char *) "B=2", NULL}; +static char *base_dup[] = {(char *) "A=first", (char *) "B=2", + (char *) "A=second", NULL}; +static char *base_noeq[] = {(char *) "WEIRD", (char *) "A=1", NULL}; +static char *base_emptyname[] = {(char *) "=orphan", (char *) "A=1", NULL}; +static char *base_prefix[] = {(char *) "A=1", (char *) "AB=2", (char *) "ABC=3", + NULL}; +static char *base_empty[] = {NULL}; + +static const struct { + const char *name; + char *const *v; +} bases[] = { + {"null", NULL}, + {"empty", base_empty}, + {"plain", base_plain}, + {"empty-value", base_emptyval}, + {"dup-name", base_dup}, + {"no-eq", base_noeq}, + {"empty-name", base_emptyname}, + {"prefix", base_prefix}, +}; + +/* One token per branch of the merge. The refused "=VAL" is in the product so + * a rejection lands at every base and at either override position, not only + * at the single base named_cells reaches. + */ +static const char *const alphabet[] = { + "NEW=x", "A=over", "A=", "A=a=b", "A", + "MISSING", "AB=over", "WEIRD=fixed", "=VAL", +}; +enum { ALPHA_N = (int) (sizeof(alphabet) / sizeof(alphabet[0])) }; + +/* Run one cell and hold it to the oracle and the invariants. */ +static void run_cell(char *const *host_env, + const char *base_name, + const char *const *ovs, + int n_ovs, + bool clear_env) +{ + char cell[256]; + int off = snprintf(cell, sizeof(cell), "base=%s clear=%d ovs=[", base_name, + clear_env); + for (int i = 0; i < n_ovs && off < (int) sizeof(cell); i++) + off += snprintf(cell + off, sizeof(cell) - (size_t) off, "%s%s", + i ? "," : "", ovs[i]); + snprintf(cell + off, sizeof(cell) - (size_t) off, "]"); + + model_t want; + model_build(&want, host_env, ovs, n_ovs, clear_env); + + char **envp = (char **) 0xdeadbeef; /* must be left untouched on refusal */ + int n = -1; + int rc = guest_env_build(host_env, (char *const *) ovs, n_ovs, clear_env, + &envp, &n); + + char detail[1024]; + + if (want.refused) { + if (rc != -1 || envp != (char **) 0xdeadbeef || n != -1) { + snprintf(detail, sizeof(detail), + "%s: want refusal leaving outputs untouched, got rc=%d", + cell, rc); + host_check(false, "refuse", detail); + } else { + host_ok(); + } + return; + } + + if (rc != 0) { + snprintf(detail, sizeof(detail), "%s: rc=%d, want 0", cell, rc); + host_check(false, "build", detail); + return; + } + + /* NULL envp means "use the host environ", so every other cell owes a + * real vector, --clear-env with no override included: build_linux_stack + * counts envc itself. + */ + if (n_ovs == 0 && !clear_env) { + if (envp != NULL || n != 0) { + snprintf(detail, sizeof(detail), + "%s: want NULL envp and n=0, got envp=%p n=%d", cell, + (void *) envp, n); + host_check(false, "passthrough", detail); + } else { + host_ok(); + } + return; + } + + if (envp == NULL) { + snprintf(detail, sizeof(detail), "%s: envp is NULL", cell); + host_check(false, "build", detail); + return; + } + + check_structure(envp, n, cell); + + if (n != want.n) { + snprintf(detail, sizeof(detail), "%s: %d entries, want %d", cell, n, + want.n); + host_check(false, "count", detail); + } else { + bool same = true; + for (int i = 0; i < n && same; i++) { + char expect[MODEL_ENTRY_MAX * 2]; + snprintf(expect, sizeof(expect), "%s=%s", want.name[i], + want.value[i]); + if (strcmp(envp[i], expect)) { + snprintf(detail, sizeof(detail), + "%s: entry %d is \"%s\", want \"%s\"", cell, i, + envp[i], expect); + host_check(false, "entry", detail); + same = false; + } + } + if (same) + host_ok(); + } + + strv_free((const char **) envp, n); +} + +/* ---- cells the product cannot spell ------------------------------------ */ + +static void named_cells(void) +{ + /* The empty variable names the alphabet cannot carry. "=VAL" is in it, so + * the product already refuses that spelling at every base and at either + * override position; "=" and "" reach the same refusal through the + * bare-token arm of override_key_len, which no product cell reaches. + */ + static const char *const bad[] = {"=", ""}; + for (int i = 0; i < (int) (sizeof(bad) / sizeof(bad[0])); i++) { + char **envp = (char **) 0xdeadbeef; + int n = -1; + char detail[128]; + snprintf(detail, sizeof(detail), "override \"%s\" was accepted", + bad[i]); + host_check(guest_env_build(base_plain, (char *const *) &bad[i], 1, + false, &envp, &n) == -1 && + envp == (char **) 0xdeadbeef && n == -1, + "empty variable name", detail); + } + + /* strv_free's documented no-op. A crash here fails the run outright. */ + strv_free(NULL, 0); + strv_free(NULL, 7); + host_check(true, "strv_free(NULL)", ""); +} + +int main(void) +{ + int n_bases = (int) (sizeof(bases) / sizeof(bases[0])); + + for (int b = 0; b < n_bases; b++) { + for (int c = 0; c < 2; c++) { + bool clear = c != 0; + run_cell(bases[b].v, bases[b].name, NULL, 0, clear); + for (int i = 0; i < ALPHA_N; i++) { + const char *one[] = {alphabet[i]}; + run_cell(bases[b].v, bases[b].name, one, 1, clear); + for (int j = 0; j < ALPHA_N; j++) { + const char *two[] = {alphabet[i], alphabet[j]}; + run_cell(bases[b].v, bases[b].name, two, 2, clear); + } + } + } + } + + named_cells(); + + return host_summary("test-guest-env-host"); +} 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..b5b4948e --- /dev/null +++ b/tests/test-launch-flags.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# test-launch-flags.sh -- Pin the behavior of the guest launch flags +# +# Copyright 2026 elfuse contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Usage: tests/test-launch-flags.sh +# [ ] +# +# --user, --workdir, --env, and --clear-env 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. Given the environment lanes +# add only what tests/test-guest-env-host.c cannot reach: main() collecting +# the --env tokens, build_linux_stack, and the /proc/self/environ sink. + +set -euo pipefail + +ELFUSE="${1:?Usage: $0 }" +GUEST="${2:?Usage: $0 }" +# The environment observers are optional: a prebuilt guest-binary tree +# (GUEST_TEST_BINARIES) predates test-env-dump, so those lanes skip rather +# than fail when it is absent. +ENV_DUMP="${3:-}" +ENV_CAT="${4:-}" + +# 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 "--env with an empty variable name" "empty variable name" \ + --env =VAL + +check reject "--env with an empty argument" "empty variable name" --env "" + +# --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 + +# 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) +# elfuse backs guest /dev/shm with a private host directory that outlives the +# run, so create the leaf: the check must measure the refusal, not a missing +# directory. chmod because create_private_dir() rejects a group or other bit. +shm_root="/tmp/elfuse-shm-$(id -u)" +shm_wd="$shm_root/launch-flags-wd" +trap 'rm -rf "$scratch" "$shm_wd"' EXIT +mkdir -p "$scratch/sysroot/inroot" "$scratch/hostonly" "$shm_wd" +chmod 700 "$shm_root" + +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 + +# Refusal rationale in elfuse_launch; see dev_shm_resolve_path(). +check reject "--workdir under /dev/shm" "not supported" \ + --workdir /dev/shm/launch-flags-wd + +# The environment the lanes below measure against. MARKER is set so an import +# and a replacement have something to find; ABSENT is cleared in this process +# so the "unset on the host" lane cannot be answered by an inherited value. +export ELFUSE_TEST_MARKER=marker-value +unset ELFUSE_TEST_ABSENT + +# guest_env -- the guest's environ, one entry per line, in order +guest_env() +{ + "$ELFUSE" "$@" "$ENV_DUMP" 2> /dev/null +} + +# env_exact +# asserts the guest's whole environ, in order. Every caller passes +# --clear-env: only a cleared base makes the full vector predictable. +env_exact() +{ + local desc="$1" want="$2" + shift 2 + local got status=0 + got="$(guest_env "$@")" || status=$? + if [ "$status" -ne 0 ]; then + report_fail "$desc (exit $status)" + return + fi + if [ "$got" != "$want" ]; then + report_fail "$desc" + printf 'want:\n%s\ngot:\n%s\n' "$want" "$got" >&2 + return + fi + report_pass "$desc" +} + +if [ -z "$ENV_DUMP" ] || [ ! -f "$ENV_DUMP" ]; then + report_skip "environment lanes (no env-dump guest binary)" +else + # The `elfuse-oci run` spelling, and the only one that makes the guest's + # environment a function of the flags alone. + env_exact "--clear-env alone yields an empty environment" "" --clear-env + # One vector through every branch of the merge at once. + # tests/test-guest-env-host.c settles which answer each branch owes; this + # lane adds that the answer survives build_linux_stack into the guest. + env_exact "the whole merge reaches the guest, in order" \ + "$(printf 'A=2\nB=\nC=b=c\nELFUSE_TEST_MARKER=marker-value')" \ + --clear-env --env A=1 --env B= --env C=b=c \ + --env ELFUSE_TEST_MARKER --env ELFUSE_TEST_ABSENT --env A=2 + # --clear-env selects the base wherever it appears, so a launcher that + # appends it after the --env list gets the same environment. + env_exact "--clear-env after --env selects the same base" "A=1" \ + --env A=1 --clear-env + + # The only lane with a long override list, so it is the one that would + # catch main() sizing the override array short of the flags given. + many_flags=() + many_want="" + for i in 1 2 3 4 5 6 7 8 9 10 11 12; do + many_flags+=(--env "K$i=v$i") + many_want="$many_want${many_want:+$'\n'}K$i=v$i" + done + env_exact "twelve --env entries all reach the guest" "$many_want" \ + --clear-env "${many_flags[@]}" + + # Here-strings below, never pipes: under pipefail a `grep -q` exiting at + # its first match SIGPIPEs the writer. Every capture carries + # `|| status=$?` because set -e would abort on the very miss these lanes + # exist to detect. + status=0 + inherited="$(guest_env)" || status=$? + if [ "$status" -ne 0 ]; then + report_fail "no env flags inherits the host environment (exit $status)" + elif grep -qx 'ELFUSE_TEST_MARKER=marker-value' <<< "$inherited"; then + report_pass "no env flags inherits the host environment" + else + report_fail "no env flags inherits the host environment" + printf '%s\n' "$inherited" >&2 + fi + + status=0 + appended="$(guest_env --env ELFUSE_TEST_NEW=x)" || status=$? + if [ "$status" -ne 0 ]; then + report_fail "--env appends a new name (exit $status)" + elif [ "$(tail -1 <<< "$appended")" = "ELFUSE_TEST_NEW=x" ] \ + && grep -qx 'ELFUSE_TEST_MARKER=marker-value' <<< "$appended"; then + report_pass "--env appends a new name and keeps the host entries" + else + report_fail "--env appends a new name and keeps the host entries" + printf '%s\n' "$appended" >&2 + fi + + # One line for the name, at the index the host entry occupied. + status=0 + unrelated="$(guest_env --env ELFUSE_TEST_UNRELATED=1)" || status=$? + base_index="$(grep -n '^ELFUSE_TEST_MARKER=' <<< "$unrelated" \ + | cut -d: -f1)" || true + replacement="$(guest_env --env ELFUSE_TEST_MARKER=replaced)" || status=$? + replaced="$(grep -n '^ELFUSE_TEST_MARKER=' <<< "$replacement")" || true + if [ "$status" -ne 0 ]; then + report_fail "--env replaces a host name in place (exit $status)" + elif [ -n "$base_index" ] \ + && [ "$replaced" = "$base_index:ELFUSE_TEST_MARKER=replaced" ]; then + report_pass "--env replaces a host name in place" + else + report_fail "--env replaces a host name in place (got '$replaced', \ +want index $base_index)" + fi + + # "--" ends elfuse's own parsing, so an image entrypoint beginning with a + # flag reaches the guest as argv instead of steering the launcher. + status=0 + after_dashdash="$("$ELFUSE" --clear-env -- "$ENV_DUMP" --env A=1 \ + 2> /dev/null)" || status=$? + if [ "$status" -ne 0 ]; then + report_fail "--env after -- is guest argv, not a flag (exit $status)" + elif [ -z "$after_dashdash" ]; then + report_pass "--env after -- is guest argv, not a flag" + else + report_fail "--env after -- is guest argv, not a flag" + printf '%s\n' "$after_dashdash" >&2 + fi + + # The procfs sink is held to the same flags and expected entries as the + # stack lanes above; it is not a direct comparison of one run's two + # sinks. + if [ -n "$ENV_CAT" ] && [ -f "$ENV_CAT" ]; then + # /proc/self/environ is NUL-separated and command substitution drops + # NUL bytes, so this one must translate inside the pipeline rather + # than through a variable. Safe against the SIGPIPE race above + # because tr reads to EOF and never exits early. + status=0 + procfs="$("$ELFUSE" --clear-env --env A=1 --env B=2 "$ENV_CAT" \ + /proc/self/environ 2> /dev/null | tr '\0' '\n')" || status=$? + if [ "$status" -ne 0 ]; then + report_fail "/proc/self/environ cross-check (exit $status)" + elif [ "$procfs" = "$(printf 'A=1\nB=2')" ]; then + report_pass "/proc/self/environ agrees with the stack environ" + else + report_fail "/proc/self/environ agrees with the stack environ" + printf '%s\n' "$procfs" >&2 + fi + else + report_skip "/proc/self/environ cross-check (no cat guest binary)" + fi +fi + +report_summary +# shellcheck disable=SC2154 # fail is incremented in tests/lib/report.sh +[ "$fail" -eq 0 ] || exit 1