diff --git a/Makefile b/Makefile index b653ad72..3584bb91 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 e7a1a59a..561e9f0d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -25,6 +25,8 @@ Supported user-facing options: | `--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 @@ -48,13 +50,14 @@ 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 +## Guest Identity, Working Directory, And Environment -`--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`, `--workdir`, `--env`, and `--clear-env` select what the guest starts as, +where it starts, and what it sees in its environment. A contradictory `--user` +request or a malformed `--env` entry 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 @@ -82,6 +85,14 @@ 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 36594519..83bb1d66 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -35,7 +35,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 \ @@ -168,7 +168,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 @@ -186,6 +187,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) @@ -236,7 +238,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-launch-flags,launch flags) $(call run-lane,test-rosetta-cli,rosetta CLI gating) $(call run-lane,test-bench-guardrail,hot-syscall guardrail) @@ -1027,9 +1029,10 @@ 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 +## 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) @@ -1487,6 +1490,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..d4467131 --- /dev/null +++ b/src/core/guest-env.c @@ -0,0 +1,147 @@ +/* + * 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; +} + +char **guest_env_build(char *const *host_env, + char *const *overrides, + int n_overrides, + bool clear_env, + int *out_n) +{ + 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 NULL; + } + 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_n = n; + return envp; + +fail: + strv_free((const char **) envp, n); + return NULL; +} diff --git a/src/core/guest-env.h b/src/core/guest-env.h new file mode 100644 index 00000000..875b0296 --- /dev/null +++ b/src/core/guest-env.h @@ -0,0 +1,44 @@ +/* + * 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 '='. + * + * Returns 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. Because the + * drop sanitizes, a caller that wants @host_env forwarded unchanged skips + * the call (launch_args_t.envp of NULL already means that) rather than + * passing no overrides. On allocation failure or an empty override name (as + * setenv(3)) returns NULL, logged, with *out_n untouched. + */ +char **guest_env_build(char *const *host_env, + char *const *overrides, + int n_overrides, + bool clear_env, + int *out_n); diff --git a/src/core/launch.c b/src/core/launch.c index cc3bb3e2..5fd2b906 100644 --- a/src/core/launch.c +++ b/src/core/launch.c @@ -54,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; diff --git a/src/core/launch.h b/src/core/launch.h index fa2c5d92..f9832ea4 100644 --- a/src/core/launch.h +++ b/src/core/launch.h @@ -5,15 +5,16 @@ * * elfuse_launch is the single entry point for "run a guest binary in a * 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. + * behind one struct is what lets a front end select the guest identity, cwd, + * and environment 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 / - * 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 @@ -52,6 +53,12 @@ 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, 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. diff --git a/src/main.c b/src/main.c index f1516f00..839f23d7 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 @@ -115,15 +118,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 @@ -216,11 +210,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]" sep \ - "[--user UID[:GID]] [--workdir DIR] [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 ") @@ -251,6 +245,9 @@ int main(int argc, char **argv) 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`: @@ -266,6 +263,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)); @@ -323,6 +322,11 @@ int main(int argc, char **argv) "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" @@ -436,6 +440,23 @@ int main(int argc, char **argv) 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; @@ -513,6 +534,21 @@ 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. With + * neither flag given envp stays NULL, which launch_args_t defines as the + * host environ, unchanged. + */ + if (n_env_overrides > 0 || clear_env) { + envp = guest_env_build(environ, env_overrides, n_env_overrides, + clear_env, &n_envp); + if (!envp) + goto cleanup; + } + free(env_overrides); + env_overrides = NULL; + if (arg_start >= argc) { log_error(ELFUSE_USAGE); goto cleanup; @@ -684,7 +720,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 / workdir copies. + * sysroot_path / guest_argv / envp / workdir copies. */ launch_args_t largs = { .elf_path = elf_host_path, @@ -692,6 +728,7 @@ 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, @@ -721,10 +758,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/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/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..4a7d876d --- /dev/null +++ b/tests/test-guest-env-host.c @@ -0,0 +1,351 @@ +/* + * 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. Registers only failures, so a cell contributes + * exactly one verdict: the pass comes from the oracle comparison, which + * run_cell skips when this returns false. + */ +static bool 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 false; + } + 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 false; + } + 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 false; + } + } + } + return true; +} + +/* ---- 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); + + int n = -1; /* must be left untouched on refusal */ + char **envp = + guest_env_build(host_env, (char *const *) ovs, n_ovs, clear_env, &n); + + char detail[1024]; + + if (want.refused) { + if (envp != NULL || n != -1) { + snprintf(detail, sizeof(detail), + "%s: want refusal leaving *out_n untouched, got envp=%p " + "n=%d", + cell, (void *) envp, n); + host_check(false, "refuse", detail); + } else { + host_ok(); + } + return; + } + + if (envp == NULL) { + snprintf(detail, sizeof(detail), "%s: envp is NULL, want a vector", + cell); + host_check(false, "build", detail); + return; + } + + if (!check_structure(envp, n, cell)) { + strv_free((const char **) envp, n); + return; + } + + 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++) { + 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, &n) == NULL && + 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-launch-flags.sh b/tests/test-launch-flags.sh index 84e2bb85..374beaac 100755 --- a/tests/test-launch-flags.sh +++ b/tests/test-launch-flags.sh @@ -1,20 +1,28 @@ #!/usr/bin/env bash -# test-launch-flags.sh -- Pin the rejection rules of the guest launch flags +# 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 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. +# --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" @@ -65,6 +73,11 @@ 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 +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 @@ -98,6 +111,159 @@ check accept "--workdir under a root sysroot" '' --sysroot / --workdir /var/tmp 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. +# stderr lands in a file rather than /dev/null so a lane that dies on launch +# can report why, not just the exit code. +guest_env() +{ + "$ELFUSE" "$@" "$ENV_DUMP" 2> "$scratch/guest-env-stderr" +} + +# 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)" + cat "$scratch/guest-env-stderr" >&2 + 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)" + cat "$scratch/guest-env-stderr" >&2 + 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)" + cat "$scratch/guest-env-stderr" >&2 + 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)" + cat "$scratch/guest-env-stderr" >&2 + 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> "$scratch/guest-env-stderr")" || status=$? + if [ "$status" -ne 0 ]; then + report_fail "--env after -- is guest argv, not a flag (exit $status)" + cat "$scratch/guest-env-stderr" >&2 + 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> "$scratch/guest-env-stderr" \ + | tr '\0' '\n')" || status=$? + if [ "$status" -ne 0 ]; then + report_fail "/proc/self/environ cross-check (exit $status)" + cat "$scratch/guest-env-stderr" >&2 + 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