Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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.

Expand Down
23 changes: 17 additions & 6 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion mk/config.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
19 changes: 13 additions & 6 deletions mk/tests.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions src/core/guest-env.c
Original file line number Diff line number Diff line change
@@ -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 <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#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;
}
44 changes: 44 additions & 0 deletions src/core/guest-env.h
Original file line number Diff line number Diff line change
@@ -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 <stdbool.h>

/* 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);
2 changes: 1 addition & 1 deletion src/core/launch.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 13 additions & 6 deletions src/core/launch.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading