From 524ef3e7933fa5253d7aa8b2b389f477654ac3bf Mon Sep 17 00:00:00 2001 From: marvin Date: Thu, 10 Sep 2026 12:18:13 +0200 Subject: [PATCH] Respect USER directive from OCI image config Add support for the Dockerfile USER directive so that the container's CMD/ENTRYPOINT runs as the configured user instead of root. Changes: - inject.rs: Add user field to Config struct, write /slim/user at build time - build.rs: Pass config.user through to inject() - slim-init.sh: Read /slim/user and drop privileges via su before executing the command. Numeric uids are resolved to usernames via /etc/passwd (BusyBox su does not accept numeric args). When dropping privileges, run as a child (not exec) so PID 1 stays root and can poweroff after the command exits. - test.sh: Add test_user verifying build-time CMD and --cmd override both run as the configured user Closes #7 --- example/test.sh | 35 ++++++++++++++++++++++++++++++ src/build.rs | 2 +- src/inject.rs | 47 ++++++++++++++++++++-------------------- src/scripts/slim-init.sh | 37 +++++++++++++++++++++++++++++-- 4 files changed, 94 insertions(+), 27 deletions(-) diff --git a/example/test.sh b/example/test.sh index e4aaade..6d6c439 100755 --- a/example/test.sh +++ b/example/test.sh @@ -103,6 +103,39 @@ EOF rm -rf "$work" } +test_user() { + echo "=== Testing USER directive ===" + + work="$(mktemp -d)" + img="slim-test-user" + + cat > "$work/Containerfile" <<'EOF' +FROM alpine:latest +RUN adduser -D -u 1500 testuser +USER testuser +CMD ["/bin/sh", "-c", "echo USER_BUILD_OK:$(id -u):$(whoami); poweroff -f"] +EOF + + echo "-- Building container image with USER directive..." + if ! podman build --network=none -t "$img" -f "$work/Containerfile" >/dev/null 2>&1; then + report fail "USER directive (podman build failed)" + rm -rf "$work" + return + fi + + echo "-- Test 1: build-time CMD runs as USER" + "$SLIM_BIN" build qcow2 "$img" >/dev/null 2>&1 + output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" 2>&1 || true) + check_output "$output" "USER_BUILD_OK:1500:testuser" "USER build-time CMD runs as testuser" + + echo "-- Test 2: run --cmd override runs as USER" + output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" --cmd 'echo USER_RUN_OK:$(id -u):$(whoami); poweroff -f' 2>&1 || true) + check_output "$output" "USER_RUN_OK:1500:testuser" "USER run --cmd override runs as testuser" + + cleanup "$img" "$img" + rm -rf "$work" +} + test_service() { echo "=== Testing nextcloud service ===" @@ -171,6 +204,8 @@ cargo build 2>&1 test_distro "alpine" "alpine:latest" "" test_distro "archlinux" "archlinux:latest" "RUN pacman -Sy --noconfirm iproute2 wget; pacman -Sc --noconfirm" +test_user + test_service echo "" diff --git a/src/build.rs b/src/build.rs index 189b9dc..32d16c9 100644 --- a/src/build.rs +++ b/src/build.rs @@ -78,7 +78,7 @@ fn build_inner( let command = cmd.unwrap_or_else(|| inject::infer_command(&config)); let exec_script = inject::build_exec_script(&command, &config.env, config.working_dir.as_deref()); - inject::inject(mount_path, &exec_script)?; + inject::inject(mount_path, &exec_script, config.user.as_deref())?; println!("Injected /slim/ (init + exec)"); match kind { diff --git a/src/inject.rs b/src/inject.rs index ea3ea0b..428264d 100644 --- a/src/inject.rs +++ b/src/inject.rs @@ -22,6 +22,8 @@ pub struct Config { pub env: Vec, #[serde(default)] pub working_dir: Option, + #[serde(default)] + pub user: Option, } pub fn inspect_config(image: &str) -> Result { @@ -85,33 +87,30 @@ pub fn build_exec_script(command: &str, env: &[String], working_dir: Option<&str lines } -/// Inject /slim/init and /slim/exec into a mounted container image rootfs. -pub fn inject(mount_path: &Path, exec_script: &str) -> Result<()> { +/// Install `content` into the mounted rootfs at `/slim/` with +/// the given mode. +fn install_into_rootfs(mount: &str, name: &str, mode: &str, content: &str) -> Result<()> { + let temp = tempfile::NamedTempFile::new()?; + fs::write(temp.path(), content)?; + let src = temp.path().to_str().context("temp path is not UTF-8")?; + let dest = format!("{mount}/slim/{name}"); + cmd(&[ + "podman", "unshare", "--", "install", "-D", "-m", mode, src, &dest, + ])?; + Ok(()) +} + +/// Inject /slim/init, /slim/exec, and optionally /slim/user into a mounted +/// container image rootfs. +pub fn inject(mount_path: &Path, exec_script: &str, user: Option<&str>) -> Result<()> { let mount_str = mount_path.to_str().context("mount path is not UTF-8")?; - let init_temp = tempfile::NamedTempFile::new()?; - let exec_temp = tempfile::NamedTempFile::new()?; - fs::write(init_temp.path(), SLIM_INIT)?; - fs::write(exec_temp.path(), exec_script)?; + install_into_rootfs(mount_str, "init", "755", SLIM_INIT)?; + install_into_rootfs(mount_str, "exec", "755", exec_script)?; - let init_src = init_temp - .path() - .to_str() - .context("temp path is not UTF-8")?; - let exec_src = exec_temp - .path() - .to_str() - .context("temp path is not UTF-8")?; - - let init_dest = format!("{mount_str}/slim/init"); - let exec_dest = format!("{mount_str}/slim/exec"); - - cmd(&[ - "podman", "unshare", "--", "install", "-D", "-m", "755", init_src, &init_dest, - ])?; - cmd(&[ - "podman", "unshare", "--", "install", "-D", "-m", "755", exec_src, &exec_dest, - ])?; + if let Some(user) = user.filter(|u| !u.is_empty()) { + install_into_rootfs(mount_str, "user", "644", user)?; + } Ok(()) } diff --git a/src/scripts/slim-init.sh b/src/scripts/slim-init.sh index e976080..b2d0ba5 100644 --- a/src/scripts/slim-init.sh +++ b/src/scripts/slim-init.sh @@ -34,6 +34,39 @@ ip addr add 10.0.2.15/24 dev eth0 2>/dev/null || echo "ip addr add eth0 failed" ip route add default via 10.0.2.2 2>/dev/null || echo "ip route add default failed" echo nameserver 10.0.2.3 > /etc/resolv.conf +# === Drop privileges (respect USER directive from image config) === +# /slim/user may contain a username, uid, or uid:gid (OCI image spec). +# Numeric uids are resolved to a username via /etc/passwd because BusyBox +# su does not accept numeric arguments. +SLIM_USER="" +[ -f /slim/user ] && SLIM_USER=$(cat /slim/user 2>/dev/null) + +# Resolve a numeric uid to a username from /etc/passwd. +slim_resolve_user() { + _u="$1" + case "$_u" in + *[!0-9]*) printf '%s' "$_u" ;; + *) + _resolved=$(awk -F: -v uid="$_u" '$3==uid{print $1; exit}' /etc/passwd 2>/dev/null) + [ -n "$_resolved" ] && printf '%s' "$_resolved" || printf '%s' "$_u" + ;; + esac +} + +# Exec a command, dropping privileges if SLIM_USER is set. +# When dropping privileges, run as a child (not exec) so PID 1 stays root +# and can poweroff after the command exits — non-root cannot call poweroff. +slim_exec() { + _cmd="$1" + if [ -z "$SLIM_USER" ]; then + exec /bin/sh -c "$_cmd" + fi + _user="${SLIM_USER%%:*}" + _user=$(slim_resolve_user "$_user") + su "$_user" -c "$_cmd" + poweroff -f +} + # === Execute the configured command === # If slim.cmd= is on the kernel cmdline, decode and exec it. # Otherwise, exec /slim/exec (generated from the image's CMD/ENTRYPOINT). @@ -46,9 +79,9 @@ for tok in $(cat /proc/cmdline 2>/dev/null); do || decoded=$(printf '%s' "$v" | openssl base64 -d 2>/dev/null) \ || echo "base64 decode failed" if [ -n "$decoded" ]; then - exec /bin/sh -c "$decoded" + slim_exec "$decoded" fi ;; esac done -exec /slim/exec +slim_exec "/slim/exec"