Skip to content
Closed
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
47 changes: 47 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
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
20 changes: 17 additions & 3 deletions mk/tests.mk
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,16 @@ 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 \
test-sysroot-interp-fallback test-sysroot-interp-cased \
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 @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
155 changes: 155 additions & 0 deletions src/core/guest-env.c
Original file line number Diff line number Diff line change
@@ -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 <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;
}

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;
}
45 changes: 45 additions & 0 deletions src/core/guest-env.h
Original file line number Diff line number Diff line change
@@ -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 <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 '='.
*
* 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);
Loading
Loading