Skip to content
Draft
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
33 changes: 33 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Supported user-facing options:
| `--fakeroot` | Start the guest as uid/gid 0 with full emulated capabilities (also `ELFUSE_FAKEROOT=1`) |
| `--gdb PORT` | Listen for a GDB RSP client on `PORT` (aarch64 guests only) |
| `--gdb-stop-on-entry` | Stop before the first guest instruction |
| `--user UID[:GID]` | Run the guest as `UID`, and `GID` when given (defaults to `UID`). Numeric only |
| `--workdir DIR` | Guest-absolute initial working directory, resolved under `--sysroot` |
| `--` | End `elfuse` option parsing; remaining tokens are guest argv |

`ELFUSE_FAKEROOT_EXEC` has no flag form. It names one executable, by absolute
Expand All @@ -46,6 +48,37 @@ only bounds a single `hv_vcpu_run()` iteration before the host regains control,
which is what allows host-side timers and signals to be observed promptly.
Setting `--timeout 0` disables this watchdog for long-running CPU-bound guests.

## Guest Identity And Working Directory

`--user` and `--workdir` select what the guest starts as and where it starts.
A contradictory `--user` request is rejected before the VM is created, and
`--workdir` is resolved during bring-up, before the first guest instruction,
so a bad request fails with a diagnostic instead of launching a guest that
runs as something other than what was asked for.

`--user UID[:GID]` sets the identity the guest reports through `getuid` and
`getgid`. It does not change the host process credentials: elfuse translates the
guest's syscalls, so the number the guest sees is elfuse's to choose. The spec is
numeric, and a bare `UID` sets the group to the same value. Symbolic names are
resolved against the image `/etc/passwd` and `/etc/group` one layer up, by
`elfuse-oci`.

`--fakeroot` cannot be combined with a non-root `--user`. Fakeroot starts the guest
as uid/gid 0, and the setuid permission check grants every id switch on that basis,
so a guest that reported an unprivileged uid could still call `setuid(0)` at will.
Both halves must be root, which makes `--fakeroot --user 0:0` valid and
`--fakeroot --user 0:1000` a refusal.

`--workdir DIR` takes a guest-absolute path and is rejected otherwise. A relative
path would be resolved against the host working directory, silently starting the
guest outside the intended tree. The path is translated through `--sysroot` and
then entered, the same way a guest `chdir` into a real directory is handled,
with one launch-time restriction: the resolved directory must sit inside the
sysroot. For a path the sysroot does not hold, a guest syscall falls back to
the host, but a workdir that exists only on the host would start the guest
outside the requested tree, so the launch refuses it. FUSE-mounted and
`/proc`-virtual directories are not supported through this flag.

## Common Launch Patterns

Run a statically linked guest binary:
Expand Down
9 changes: 8 additions & 1 deletion mk/tests.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -231,6 +232,7 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage test-config \
$(call run-lane,test-case-collision-fallback,case collisions on a folding sysroot)
$(call run-lane,test-fuse-alpine,Alpine sysroot FUSE validation)
$(call run-lane,test-timeout-disable,timeout=0 validation)
$(call run-lane,test-launch-flags,launch flag rejection)
$(call run-lane,test-rosetta-cli,rosetta CLI gating)
$(call run-lane,test-bench-guardrail,hot-syscall guardrail)

Expand Down Expand Up @@ -1011,6 +1013,11 @@ 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.
test-launch-flags: $(ELFUSE_BIN) $(TEST_HELLO_DEP)
@bash tests/test-launch-flags.sh $(ELFUSE_BIN) $(TEST_DIR)/test-hello

## Check the --help and argument-error usage synopses against each other
test-usage-synopsis: $(ELFUSE_BIN)
@bash tests/test-usage-synopsis.sh $(ELFUSE_BIN)
Expand Down
90 changes: 90 additions & 0 deletions src/core/launch.c
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@

#include <Hypervisor/Hypervisor.h>
#include <Hypervisor/hv_vcpu.h>
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#include "core/bootstrap.h"
Expand All @@ -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/poll.h" /* wakeup_pipe_signal */
#include "syscall/proc.h"

Expand Down Expand Up @@ -69,6 +72,32 @@ 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 rather than in the CLI parser for the same reason
* as the Rosetta GDB check below: every caller inherits the privilege
* rule, not just main().
*/
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: prepare's proc_init re-seeds the identity
* state, and build_linux_stack snapshots it into auxv AT_UID/AT_GID.
* Setting the ids after prepare would leave getauxval() reporting the
* default identity while getuid() reports the requested one.
*/
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,
Expand Down Expand Up @@ -108,6 +137,63 @@ int elfuse_launch(const launch_args_t *args)
proc_set_sysroot_casefold(false);
}

/* Apply the guest's initial working directory. The guest cwd IS the host
* process cwd (sys_chdir translates a guest path and calls host chdir),
* so --workdir DIR does the same: translate DIR against the sysroot (now
* that casefold is configured above) and chdir to the resulting host path,
* then refresh the cached guest-visible cwd so the first getcwd sees DIR.
* This mirrors the plain real-directory branch of sys_chdir; FUSE-mounted
* or /proc-virtual workdirs are not supported through this flag (neither
* is a realistic image WorkingDir). Resolution sits after
* guest_bootstrap_prepare because its inputs are bring-up outputs:
* proc_init re-seeds the path state, prepare registers the sysroot via
* proc_set_sysroot, and the casefold probe above configures the walk.
*/
if (args->cwd_guest && args->cwd_guest[0] != '\0') {
Comment thread
henrybear327 marked this conversation as resolved.
path_translation_t tx;
if (path_translate_at(LINUX_AT_FDCWD, args->cwd_guest, PATH_TR_NONE,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
&tx) < 0) {
log_error("failed to resolve working directory %s: %s",
args->cwd_guest, strerror(errno));
goto fail;
}
/* proc_resolve_sysroot_path() hands back the host spelling for a path
* the sysroot does not hold, which is the overlay contract for guest
* syscalls but would start the guest in a same-named host directory
* here, outside the tree --workdir named. Demand containment instead:
* proc_sysroot_snapshot() returns the realpath()-canonical prefix the
* resolver prefixes contained results with, so a component-boundary
* match against it is exact.
*/
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;
}
/* "--sysroot /" is the one prefix that is a bare separator: it
* owns every host path, yet path_prefix_match accepts only "/"
* itself, since the byte after the prefix in "/x" is neither
* '\0' nor '/'. Same carve-out as
* path_dirent_dir_holds_escapes().
*/
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();
Comment thread
henrybear327 marked this conversation as resolved.
}

hv_vcpu_t vcpu;
hv_vcpu_exit_t *vexit;
if (guest_bootstrap_create_vcpu(&g, &boot, args->verbose, &vcpu, &vexit) <
Expand Down Expand Up @@ -203,7 +289,11 @@ 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: a failure before prepare's
* proc_init would leave them armed for the next launch in this process
* (a no-op when proc_identity_init already consumed them).
*/
proc_clear_initial_ids();
if (guest_initialized)
guest_destroy(&g);
if (elf_host_temp)
Expand Down
29 changes: 23 additions & 6 deletions src/core/launch.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
* 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 and cwd 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 /
Expand All @@ -33,9 +35,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;

Expand All @@ -52,6 +54,21 @@ typedef struct {
int guest_argc;
const char **guest_argv;

/* 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), NOT 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.
*/
Expand Down
78 changes: 76 additions & 2 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@ static int host_dc_zva_assert(void)
#define ELFUSE_USAGE_BODY(sep) \
"usage: elfuse [--verbose] [--timeout N] [--sysroot PATH]" sep \
"[--create-sysroot PATH] [--no-rosetta] [--fakeroot]" sep \
"[--gdb PORT] [--gdb-stop-on-entry] <elf-path> [args...]"
"[--gdb PORT] [--gdb-stop-on-entry]" sep \
"[--user UID[:GID]] [--workdir DIR] <elf-path> [args...]"

#define ELFUSE_USAGE ELFUSE_USAGE_BODY(" ")
#define ELFUSE_USAGE_WRAPPED ELFUSE_USAGE_BODY("\n ")
Expand Down Expand Up @@ -228,6 +229,14 @@ int main(int argc, char **argv)
int gdb_port = 0;
bool gdb_stop_on_entry = false;
bool fakeroot = false;
/* Launch flags driven by `elfuse-oci run` (and usable directly). They
* map onto launch_args_t fields; --user overrides the guest identity and
* --workdir sets the guest's initial cwd. Both are additive: existing
* flags are unchanged.
*/
bool has_creds = false;
uint32_t uid = 0, gid = 0;
char *workdir = NULL;
int arg_start = 1;
/* Everything the shared cleanup label reads is declared and initialized
* here, above the option loop, so any later error path can `goto cleanup`:
Expand Down Expand Up @@ -295,6 +304,11 @@ int main(int argc, char **argv)
"Protocol on PORT\n"
" --gdb-stop-on-entry Halt before the first guest "
"instruction\n"
" --user UID[:GID] Run the guest as UID (and GID; "
"defaults to UID). Numeric; elfuse-oci resolves symbolic "
"names\n"
" --workdir DIR Guest-absolute initial working "
"directory (resolved under --sysroot)\n"
"\n"
"Environment:\n"
" ELFUSE_NO_ROSETTA=1 Same as --no-rosetta\n"
Expand Down Expand Up @@ -367,6 +381,58 @@ 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) {
/* Numeric UID[:GID]; elfuse-oci resolves symbolic User
* against the image /etc/passwd+group and passes numbers. A bare
* UID sets gid=uid (typical single-user image).
*/
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) {
/* Guest-absolute working directory; elfuse_launch translates it
* against the sysroot and chdirs there. 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], "--")) {
arg_start++;
break;
Expand Down Expand Up @@ -401,6 +467,9 @@ int main(int argc, char **argv)
if (fakeroot_env && strcmp(fakeroot_env, "1") == 0)
fakeroot = true;
}
/* elfuse_launch refuses the fakeroot + non-root --user contradiction
* once this state is set, so every launcher inherits the privilege rule.
*/
proc_set_fakeroot_enabled(fakeroot);

/* Opt-in sudo-style transition: name one executable whose exec enters
Expand Down Expand Up @@ -615,14 +684,18 @@ int main(int argc, char **argv)
* retains ownership of the original argv (proctitle above), the sysroot
* mount (detached at the cleanup label after the guest exits so the
* mount stays live for the whole run), host cwd, and the heap elf_path /
* sysroot_path / guest_argv copies.
* sysroot_path / guest_argv / workdir copies.
*/
launch_args_t largs = {
.elf_path = elf_host_path,
.elf_host_temp = elf_host_temp,
.sysroot = sysroot,
.guest_argc = guest_argc,
.guest_argv = guest_argv,
.has_creds = has_creds,
.uid = uid,
.gid = gid,
.cwd_guest = workdir,
.gdb_port = gdb_port,
.gdb_stop_on_entry = gdb_stop_on_entry,
.timeout_sec = timeout_sec,
Expand Down Expand Up @@ -651,6 +724,7 @@ int main(int argc, char **argv)
free_guest_argv(guest_argv, guest_argc);
free(elf_path);
free(sysroot_path);
free(workdir);
if (elf_host_temp)
unlink(elf_host_path);

Expand Down
Loading
Loading