From 10767a09e2922c3159ca283fe3f003fcfe41a2da Mon Sep 17 00:00:00 2001 From: Trung Date: Mon, 3 Aug 2026 23:44:47 +0700 Subject: [PATCH] Let a marked exec enter fakeroot mode Fakeroot could only be armed before the first guest image ran, via --fakeroot or ELFUSE_FAKEROOT, so nothing a guest did afterwards could raise privilege. A guest shell had no equivalent of sudo: the one command that needs root forced the whole session to run as root. Add ELFUSE_FAKEROOT_EXEC, naming a single executable whose exec crosses into fakeroot. The decision is made on file identity, not on the pathname: execve resolves the configured path the same way it resolves the target, and compares st_dev/st_ino against the fstat of the descriptor it already opened. A name compare would decide on one path and execute another, since the guest and host spellings of a sysroot file differ, translation collapses symlinks and "..", and a writable parent lets the guest swap the leaf after the compare. Identity also makes execve and execveat reach one decision, because both arrive with the image already open. The target is resolved per call rather than cached, so replacing the marked executable takes effect, and because --sysroot is not established when the environment is parsed. Anything unresolvable fails closed. A script is not excluded the way a setuid script is: that rule distrusts the interpreter line of a file the kernel never vetted, while here the embedder named one file out of band. The transition is committed alongside the setuid credential commit, past every failure path, so an exec that never happens leaves the caller unprivileged. It sets root ids and the process-wide gate, mirroring proc_identity_init under --fakeroot, and reaches fork children through the --fakeroot argv forkipc derives from that gate. Nothing clears the gate, so this elevates the process tree from there on, which the docs now say outright. The pair runs inside the ATTN_BIT_CRED bracket the setuid family already uses, so sibling getuid fast paths cannot read pre-exec ids out of the shim cache. A malformed value is rejected at startup rather than ignored, since a privilege boundary that fails quietly is worse than one that refuses to start. With the variable unset, the default, no exec ever elevates. tests/test-fakeroot-exec.c re-execs itself as the marked path and covers the unprivileged start, the elevated exec, root surviving a fork into a fresh host process, another spelling of the same file elevating too, execveat reaching the same verdict, and a different file not elevating. The escape hatch is elfuse-only, so the qemu lane skips it. Fix #265 --- docs/usage.md | 17 ++ src/main.c | 33 +++- src/syscall/exec.c | 93 ++++++++++- src/syscall/proc-identity.c | 23 +++ src/syscall/proc.h | 32 ++++ tests/test-fakeroot-exec.c | 298 ++++++++++++++++++++++++++++++++++++ tests/test-matrix.sh | 12 +- 7 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 tests/test-fakeroot-exec.c diff --git a/docs/usage.md b/docs/usage.md index 35aa6276..abad19c0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -20,10 +20,27 @@ Supported user-facing options: | `--sysroot PATH` | Resolve guest absolute paths under `PATH`, falling back to the host for paths it does not hold | | `--create-sysroot PATH` | Provision a case-sensitive APFS sparsebundle mounted at `PATH`, then use it as the sysroot | | `--no-rosetta` | Disable the x86_64-via-Rosetta translator (also `ELFUSE_NO_ROSETTA=1`) | +| `--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 | | `--` | End `elfuse` option parsing; remaining tokens are guest argv | +`ELFUSE_FAKEROOT_EXEC` has no flag form. It names one executable, by absolute +path, whose `execve` enters fakeroot mode, so a guest can run unprivileged and +raise privilege the way `sudo` does rather than paying for root over the whole +session. Two properties are worth knowing before using it: + +- The match is on file identity, not on the pathname. Any spelling that reaches + the marked executable elevates -- guest path or host path under `--sysroot`, + through symlinks, relative or not -- and a spelling that reaches some other + file does not. Replacing the file at that path replaces what elevates. +- Elevation is never dropped. The marked image, everything it `exec`s + afterwards, and everything it forks all stay root. It is a `sudo`-shaped + transition for a process tree, not a per-command one. + +Unset, which is the default, no exec ever elevates. A value that is not an +absolute path is rejected at startup rather than ignored. + `--timeout` is a run-loop watchdog. It does not cap total process runtime. It 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. diff --git a/src/main.c b/src/main.c index 842e1846..6c410c1f 100644 --- a/src/main.c +++ b/src/main.c @@ -314,7 +314,17 @@ int main(int argc, char **argv) " --gdb PORT Listen for GDB Remote Serial " "Protocol on PORT\n" " --gdb-stop-on-entry Halt before the first guest " - "instruction\n"); + "instruction\n" + "\n" + "Environment:\n" + " ELFUSE_NO_ROSETTA=1 Same as --no-rosetta\n" + " ELFUSE_FAKEROOT=1 Same as --fakeroot\n" + " ELFUSE_FAKEROOT_EXEC Absolute path of the one executable " + "whose exec enters fakeroot mode, letting a guest start " + "unprivileged and raise privilege later; matched by file " + "identity, and never dropped afterwards, so that image and " + "everything it execs or forks stay root (unset: no exec ever " + "elevates)\n"); return 0; } } @@ -417,6 +427,27 @@ int main(int argc, char **argv) } proc_set_fakeroot_enabled(fakeroot); + /* Opt-in sudo-style transition: name one executable whose exec enters + * fakeroot, so a guest can start unprivileged and raise privilege later. + * Nothing happens unless the embedder sets this. + * + * A malformed value is fatal, like every other rejected option in the loop + * above. Starting anyway would leave a guest whose marked command silently + * never elevates, and a privilege boundary that fails quietly is worse than + * one that refuses to start. Arming only records the path; whether it names + * an existing file is decided per exec, so a missing file is not an error + * here. + */ + const char *fakeroot_exec_env = getenv("ELFUSE_FAKEROOT_EXEC"); + if (fakeroot_exec_env && *fakeroot_exec_env && + !proc_set_fakeroot_exec_path(fakeroot_exec_env)) { + log_error( + "ELFUSE_FAKEROOT_EXEC must be an absolute path shorter than %d " + "bytes", + LINUX_PATH_MAX); + return 1; + } + /* Top-level processes establish the capacity; fork helpers normally inherit * it, but recheck before receiving the parent's FD table. Guest * RLIMIT_NOFILE state is virtualized separately and cannot lower this diff --git a/src/syscall/exec.c b/src/syscall/exec.c index 27b71dbb..8f834599 100644 --- a/src/syscall/exec.c +++ b/src/syscall/exec.c @@ -334,6 +334,41 @@ static int read_string_array(guest_t *g, return count; } +/* True when st -- the fstat of the image execve actually opened -- is the file + * ELFUSE_FAKEROOT_EXEC names. + * + * Matching on (st_dev, st_ino) rather than on the pathname is what makes the + * hatch safe to hand a guest-supplied string. A name compare would decide on + * one path and execute another: the guest spelling and the host spelling of a + * sysroot file differ, translation collapses symlinks and ".." on the way to + * the file, and a writable parent directory lets the guest swap the leaf + * between the compare and the open. Identity answers "is this that file" about + * the descriptor already open, so every spelling that reaches the marked + * executable elevates and nothing else does. + * + * Resolved per call rather than cached at startup so that replacing the marked + * executable takes effect, and because --sysroot is not established yet when + * the environment is parsed. Fails closed on anything unresolvable, including a + * fuse-materialized image, whose private temp copy has an identity of its own. + */ +static bool exec_matches_fakeroot_target(const struct stat *st) +{ + const char *marked = proc_fakeroot_exec_path(); + if (!marked || !st) + return false; + + path_translation_t tx; + if (path_translate_at(LINUX_AT_FDCWD, marked, PATH_TR_NONE, &tx) < 0) + return false; + if (tx.fuse_path) + return false; + + struct stat marked_st; + if (stat(tx.host_path, &marked_st) != 0) + return false; + return marked_st.st_dev == st->st_dev && marked_st.st_ino == st->st_ino; +} + static int check_exec_permission(const struct stat *st) { uint32_t uid = proc_get_euid(); @@ -455,12 +490,14 @@ int64_t sys_execve(hv_vcpu_t vcpu, * /proc filesystem. */ if (!strcmp(path, "/proc/self/exe")) { - const char *exe = proc_get_elf_path(); - if (!exe) { + /* Snapshot rather than proc_get_elf_path(): that pointer is shared + * mutable state, and a sibling execve republishing it would tear the + * string this copy -- and the fakeroot decision below -- reads. + */ + if (!proc_elf_path_snapshot(path, sizeof(path))) { err = -LINUX_ENOENT; goto fail; } - str_copy_trunc(path, exe, sizeof(path)); log_debug("execve resolved to \"%s\"", path); } @@ -518,6 +555,19 @@ int64_t sys_execve(hv_vcpu_t vcpu, goto fail; } + /* Decide the fakeroot transition from the directly-executed file, before + * the shebang loop repoints path_host at an interpreter: a marked wrapper + * script has to elevate on its own identity, not on /bin/sh's. + * + * Unlike the setuid rule below, a script is not excluded. That rule exists + * because any file on the system can carry a setuid bit, so the kernel + * cannot trust the interpreter line of one it never vetted. Here the + * embedder named exactly one file out of band; its shebang line is as much + * the embedder's choice as its ELF contents would be, and a marked dynamic + * binary would trust guest-reachable shared libraries just the same. + */ + bool enter_fakeroot = exec_matches_fakeroot_target(&exec_st); + while (true) { char interp_start[256]; char interp_arg[256]; @@ -820,9 +870,42 @@ int64_t sys_execve(hv_vcpu_t vcpu, */ /* Commit credentials right before the Point of No Return. * Saved UID/GID are refreshed from the final effective IDs. + * + * The fakeroot transition lands here, past every failure path, so an exec + * that never happens leaves the current image unprivileged. It mirrors what + * --fakeroot gives a process at startup (proc_identity_init): root IDs plus + * the process-wide gate, and it reaches fork children through the + * --fakeroot argv forkipc already derives from that gate. Nothing clears + * the gate afterwards, so this elevates the whole process tree from here + * on, not just the image being loaded. + * + * elfuse never tears sibling guest threads down at exec, so a sibling that + * outlives this call keeps the credentials committed here for the rest of + * the process lifetime -- a multithreaded guest that execs the marked + * binary from one thread hands root to every thread that was already + * running. The gate is published after the IDs because no permission check + * grants on the gate alone: proc-identity.c pairs it with "emu_euid == 0 || + * fakeroot", and sys_getgroups and capget require both. Publishing it last + * can therefore only narrow the window, never open one. + * + * The ATTN_BIT_CRED bracket is the same protocol the setuid family uses in + * syscall.c: without it the shim's EL1 identity cache keeps answering + * sibling getuid fast paths with pre-exec IDs until + * exec_republish_shim_globals_or_die runs, well past guest_reset. */ - proc_set_ids(proc_get_uid(), new_euid, new_euid, proc_get_gid(), new_egid, - new_egid); + uint32_t new_uid = proc_get_uid(); + uint32_t new_gid = proc_get_gid(); + if (enter_fakeroot) { + new_uid = new_euid = 0; + new_gid = new_egid = 0; + } + shim_globals_attn_or(g, ATTN_BIT_CRED); + proc_set_ids(new_uid, new_euid, new_euid, new_gid, new_egid, new_egid); + if (enter_fakeroot) + proc_set_fakeroot_enabled(true); + shim_globals_publish_creds(g, proc_get_uid(), proc_get_euid(), + proc_get_gid(), proc_get_egid()); + shim_globals_attn_and(g, ~ATTN_BIT_CRED); if (0) { fail: diff --git a/src/syscall/proc-identity.c b/src/syscall/proc-identity.c index 68991d39..c2f94339 100644 --- a/src/syscall/proc-identity.c +++ b/src/syscall/proc-identity.c @@ -10,8 +10,10 @@ #include #include #include +#include #include "syscall/abi.h" +#include "syscall/internal.h" #include "core/shim-globals.h" #include "runtime/thread.h" #include "syscall/proc-identity.h" @@ -41,6 +43,27 @@ bool proc_fakeroot_enabled(void) return atomic_load(&fakeroot_enabled); } +/* Written once during startup, before any guest code runs, and only read after + * that. No lock: exec reads it from guest threads that all start later. + */ +static char fakeroot_exec_path[LINUX_PATH_MAX]; + +bool proc_set_fakeroot_exec_path(const char *path) +{ + size_t len = path ? strlen(path) : 0; + if (len == 0 || path[0] != '/' || len >= sizeof(fakeroot_exec_path)) { + fakeroot_exec_path[0] = '\0'; + return false; + } + memcpy(fakeroot_exec_path, path, len + 1); + return true; +} + +const char *proc_fakeroot_exec_path(void) +{ + return fakeroot_exec_path[0] ? fakeroot_exec_path : NULL; +} + void proc_identity_init(void) { guest_pid = 1; diff --git a/src/syscall/proc.h b/src/syscall/proc.h index 83104fef..6096f98b 100644 --- a/src/syscall/proc.h +++ b/src/syscall/proc.h @@ -108,6 +108,38 @@ bool proc_rosetta_active(void); void proc_set_fakeroot_enabled(bool enabled); bool proc_fakeroot_enabled(void); +/* Opt-in escape hatch letting a guest-initiated exec enter fakeroot mode. + * Without it fakeroot can only be turned on before the first image runs + * (--fakeroot / ELFUSE_FAKEROOT), so a guest shell has no way to raise + * privilege for a single command the way sudo does on Linux. + * + * path names the one executable allowed to make that transition, as an absolute + * path resolved the way the guest resolves paths: under --sysroot the sysroot + * spelling and the host spelling of one file both name that file, because the + * match is on file identity rather than on the string (see + * proc_fakeroot_exec_path). Returns false without arming anything for NULL, an + * empty string, a relative path, or one that does not fit. + * + * Startup only. The stored path is read without a lock by every vCPU thread + * that reaches execve, so this must be called before any of them exists; + * calling it later is a data race. + */ +bool proc_set_fakeroot_exec_path(const char *path); + +/* The configured fakeroot exec path, or NULL when none was armed. + * + * The caller resolves and stats it to compare against the file it actually + * opened, so any spelling that reaches that file elevates and a spelling that + * reaches a different file does not. What elevates is therefore an inode, not a + * name: replacing the file at the configured path replaces what elevates, and + * moving the marked executable away disarms the hatch. + * + * The elevation this feeds is not per-command. Nothing ever clears the fakeroot + * gate, so the marked image, everything it execs afterwards, and everything it + * forks all stay root -- the same shape as a sudo child, not a one-shot. + */ +const char *proc_fakeroot_exec_path(void); + /* Store the guest command line for /proc/self/cmdline emulation. argv is a * NULL-terminated array of strings. */ diff --git a/tests/test-fakeroot-exec.c b/tests/test-fakeroot-exec.c new file mode 100644 index 00000000..c38b122f --- /dev/null +++ b/tests/test-fakeroot-exec.c @@ -0,0 +1,298 @@ +/* + * ELFUSE_FAKEROOT_EXEC opt-in privilege transition + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Fakeroot can otherwise only be armed before the first guest image runs, so a + * guest shell has no way to raise privilege for one command the way sudo does + * on Linux. ELFUSE_FAKEROOT_EXEC names a single executable whose exec enters + * fakeroot: exec it and the new image runs as root; exec anything else and the + * caller stays unprivileged. + * + * The test re-execs itself, so the matrix points ELFUSE_FAKEROOT_EXEC at this + * binary. It checks the initial image is unprivileged, that the marked exec + * lands at uid/gid 0, that root survives the fork into a fresh host process, + * that another spelling of the same file elevates too (the match is on file + * identity, not on the pathname), that execveat reaches the same decision, and + * that a copy of the same program at a different path does not elevate. + * + * Not covered here: a sysroot lane, where the guest and host spellings of the + * marked file genuinely differ; a marked path that is a shebang script; and + * what a sibling *thread* observes when another thread execs the marked binary, + * which needs a same-process test rather than the fork-based ones below. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +#ifndef AT_EMPTY_PATH +#define AT_EMPTY_PATH 0x1000 +#endif + +int passes = 0, fails = 0; + +extern char **environ; + +/* Child modes report through the exit status: 0 when the observed credentials + * match what the mode expects, 1 otherwise. The parent turns that into a check. + */ +#define CHILD_OK 0 +#define CHILD_BAD 1 + +static int all_ids_are(uid_t want) +{ + return getuid() == want && geteuid() == want && getgid() == want && + getegid() == want; +} + +/* True when none of the four ids is root. The group ids matter as much as the + * user ids here: the contract for an unmarked exec is that credentials are + * untouched, not merely that uid stayed non-zero. + */ +static int no_id_is_root(void) +{ + return getuid() != 0 && geteuid() != 0 && getgid() != 0 && getegid() != 0; +} + +static void report_ids(const char *what) +{ + fprintf(stderr, "%s: uid=%d euid=%d gid=%d egid=%d\n", what, (int) getuid(), + (int) geteuid(), (int) getgid(), (int) getegid()); +} + +/* Exec'd as the ELFUSE_FAKEROOT_EXEC path: must be root, and a fork from here + * must land in a host process that is still root. + */ +static int mode_expect_root(void) +{ + if (!all_ids_are(0)) { + report_ids("child: expected root"); + return CHILD_BAD; + } + + pid_t pid = fork(); + if (pid < 0) { + perror("child: fork"); + return CHILD_BAD; + } + if (pid == 0) + _exit(all_ids_are(0) ? CHILD_OK : CHILD_BAD); + + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + perror("child: waitpid"); + return CHILD_BAD; + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != CHILD_OK) { + fprintf(stderr, "child: forked grandchild lost root (status=%d)\n", + status); + return CHILD_BAD; + } + return CHILD_OK; +} + +/* Exec'd under any other path: credentials must be untouched. */ +static int mode_expect_unprivileged(void) +{ + if (!no_id_is_root()) { + report_ids("child: unexpected root"); + return CHILD_BAD; + } + return CHILD_OK; +} + +/* Exit status a child uses when the exec itself never happened, so a staging + * mistake cannot be misread as a verdict about credentials. + */ +#define CHILD_NO_EXEC 127 + +/* Run prog with a single mode argument and return its exit status, or -1. + * use_execveat picks the execveat(AT_EMPTY_PATH) entry point over execve, which + * hands sys_execve a host path it resolved itself rather than the guest string. + */ +static int run_child_via(const char *prog, const char *mode, int use_execveat) +{ + char *argv[] = {(char *) prog, (char *) mode, NULL}; + + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + return -1; + } + if (pid == 0) { + if (use_execveat) { + int fd = open(prog, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + perror("child: open for execveat"); + _exit(CHILD_NO_EXEC); + } + syscall(SYS_execveat, fd, "", argv, environ, AT_EMPTY_PATH); + perror("child: execveat"); + } else { + execve(prog, argv, environ); + perror("child: execve"); + } + _exit(CHILD_NO_EXEC); + } + + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + perror("waitpid"); + return -1; + } + if (!WIFEXITED(status)) { + fprintf(stderr, "child did not exit normally (status=%d)\n", status); + return -1; + } + if (WEXITSTATUS(status) == CHILD_NO_EXEC) + fprintf(stderr, "child never exec'd %s\n", prog); + return WEXITSTATUS(status); +} + +static int run_child(const char *prog, const char *mode) +{ + return run_child_via(prog, mode, 0); +} + +/* Copy the program to a private path so the negative case is a different file + * rather than a spelling trick on the configured one. + */ +static int copy_program(const char *src, char *dst, size_t dst_sz) +{ + /* mkstemp, not a pid-derived name: it creates the file exclusively, so a + * pre-created path (or a symlink planted at one) cannot redirect the copy. + */ + snprintf(dst, dst_sz, "/tmp/elfuse-fakeroot-exec-copy-XXXXXX"); + + int in = open(src, O_RDONLY); + if (in < 0) { + perror("copy: open src"); + return -1; + } + int out = mkstemp(dst); + if (out < 0) { + perror("copy: mkstemp"); + close(in); + return -1; + } + /* mkstemp creates 0600. The copy needs the other-execute bit: its host + * owner is the real macOS uid, which never matches the emulated guest uid, + * so check_exec_permission only ever consults the other bits. + */ + int rc = 0; + if (fchmod(out, 0755) < 0) { + perror("copy: fchmod"); + rc = -1; + } + + char buf[65536]; + ssize_t n = 0; + while (rc == 0 && (n = read(in, buf, sizeof(buf))) > 0) { + ssize_t off = 0; + while (off < n) { + ssize_t w = write(out, buf + off, (size_t) (n - off)); + if (w < 0) { + perror("copy: write"); + rc = -1; + break; + } + off += w; + } + } + if (rc == 0 && n < 0) { + perror("copy: read"); + rc = -1; + } + close(in); + /* Close before exec: a still-open writable fd makes execve fail ETXTBSY. */ + if (close(out) < 0 && rc == 0) { + perror("copy: close dst"); + rc = -1; + } + /* One drop site for the staged file: mkstemp already created it, so every + * failure from here on has to leave nothing behind in the shared /tmp. + */ + if (rc < 0) + unlink(dst); + return rc; +} + +int main(int argc, char **argv) +{ + if (argc > 1 && !strcmp(argv[1], "expect-root")) + return mode_expect_root(); + if (argc > 1 && !strcmp(argv[1], "expect-unprivileged")) + return mode_expect_unprivileged(); + + const char *marked = getenv("ELFUSE_FAKEROOT_EXEC"); + if (!marked || !*marked) { + /* A hard failure, not a skip: the matrix always exports this, so an + * unset variable means the harness stopped arming the feature and the + * whole file would otherwise report a silent pass. + */ + printf("test-fakeroot-exec: ELFUSE_FAKEROOT_EXEC is not set\n"); + return 1; + } + + printf("ELFUSE_FAKEROOT_EXEC tests (marked=%s)\n", marked); + + TEST("initial image unprivileged"); + EXPECT_TRUE(no_id_is_root(), "started as root"); + + TEST("exec of marked path is root"); + EXPECT_EQ(run_child(marked, "expect-root"), CHILD_OK, + "marked exec did not reach root"); + + /* Cross-process only: run_child forks, and a guest fork is a separate host + * process with its own identity globals, so this pins that elevation does + * not leak back across that boundary. What a surviving sibling *thread* + * sees is a different question and needs a same-process test. + */ + TEST("elevation stays in the exec'd process"); + EXPECT_TRUE(no_id_is_root(), "parent gained root"); + + /* Identity, not spelling: "/./" opens the very same file, so it + * must elevate too. The inverse -- a different file -- is the copy below. + */ + char spelled[PATH_MAX]; + const char *base = strrchr(marked, '/'); + /* Always non-NULL: elfuse rejects a non-absolute ELFUSE_FAKEROOT_EXEC. */ + int spelled_len = snprintf(spelled, sizeof(spelled), "%.*s/.%s", + (int) (base - marked), marked, base); + TEST("other spelling of marked file is root"); + if (spelled_len < 0 || (size_t) spelled_len >= sizeof(spelled)) + FAIL("respelled path did not fit"); + else + EXPECT_EQ(run_child(spelled, "expect-root"), CHILD_OK, + "same file under another name did not reach root"); + + TEST("execveat of marked file is root"); + EXPECT_EQ(run_child_via(marked, "expect-root", 1), CHILD_OK, + "execveat did not reach root"); + + char copy[PATH_MAX]; + if (copy_program(marked, copy, sizeof(copy)) == 0) { + TEST("exec of another file stays unprivileged"); + EXPECT_EQ(run_child(copy, "expect-unprivileged"), CHILD_OK, + "unmarked exec gained root or would not run"); + unlink(copy); + } else { + TEST("exec of another file stays unprivileged"); + FAIL("could not stage program copy"); + } + + SUMMARY("test-fakeroot-exec"); + return fails == 0 ? 0 : 1; +} diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 8565766f..7fc9c10d 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -276,6 +276,7 @@ QEMU_SKIP=" test-msync test-credentials test-credentials-fakeroot + test-fakeroot-exec test-sched-policy test-rseq test-tier-a @@ -327,6 +328,9 @@ QEMU_SKIP=" # test-credentials: pins elfuse's restricted "fakeroot" setuid/capset/ # getgroups model (deliberately narrower than real root); the qemu # reference lane runs as genuine root, which has unrestricted privilege. +# test-fakeroot-exec: ELFUSE_FAKEROOT_EXEC is an elfuse-only escape hatch -- +# a real kernel has no notion of an executable that turns on fakeroot, so +# the marked exec simply stays unprivileged there. # test-sched-policy: exercises elfuse's explicitly-a-stub scheduler policy # layer (see the file's own header) -- RT class changes are always # -EPERM'd regardless of privilege, whereas real root can set them. @@ -765,6 +769,12 @@ run_unit_tests() printf "\nCredential/identity emulation\n" test_rc "$runner" "test-credentials" 0 "$bindir/test-credentials" test_rc "$runner" "test-credentials-fakeroot" 0 --fakeroot "$bindir/test-credentials" + # Arm the opt-in transition on the test binary itself: it re-execs its own + # path to cross into fakeroot, and a copy of itself to prove the negative. + # The assignment prefix scopes the variable to this one call, so an + # interrupted run cannot leave it set for anything that follows. + ELFUSE_FAKEROOT_EXEC="$bindir/test-fakeroot-exec" \ + test_rc "$runner" "test-fakeroot-exec" 0 "$bindir/test-fakeroot-exec" printf "\nScheduler policy stub\n" test_rc "$runner" "test-sched-policy" 0 "$bindir/test-sched-policy" @@ -1232,7 +1242,7 @@ run_suite() # observed counts diverge. apple-unknown is the fallback row for SoC strings the # detector does not recognize yet. EXPECTED_BASELINES=( - "elfuse-aarch64|238|0" + "elfuse-aarch64|239|0" "qemu-aarch64|218|0" "elfuse-x86_64:apple-m1-m2|71|0" "elfuse-x86_64:apple-m3-plus|71|0"