Compare commits

..
5 Commits
Author SHA1 Message Date
marvin 93cbf7ca47 Support host:guest mount syntax with WORKINGDIR resolution
CI / build (pull_request) Successful in 13s
CI / build (push) Successful in 13s
--mount now accepts <host-path>:<guest-path> (Docker-style). When the
guest path is omitted, the host path is used. Non-absolute guest paths
are resolved against the image WORKINGDIR, which is written to
/slim/workdir at build time and read by slim-init.sh at boot.

Changes:
- qemu.rs: Parse host:guest spec, pass guest path (not host path) on
  the kernel cmdline as slim.mount=<tag>:<base64(guest_path)>
- inject.rs: Accept working_dir param, write /slim/workdir into rootfs
- build.rs: Pass config.working_dir to inject()
- slim-init.sh: Read /slim/workdir, resolve relative guest paths
  against it before mounting
- test.sh: Test host:guest absolute paths (multi-mount) and relative
  guest path resolved against WORKINGDIR

Addresses PR #9 review comment from @hulthe.
2026-09-10 12:21:44 +02:00
marvin c3b6a72d39 Fix U3: escape commas in --mount path for QEMU -virtfs
QEMU QemuOpts splits on commas; escape literal commas in the host
path as ,, per QEMU convention to prevent option injection and
boot failures on paths containing commas.

Fix U5: extract b64dec() helper in slim-init.sh

Deduplicate the 3-line base64 fallback decode block used by both
the slim.mount and slim.cmd handlers into a single b64dec() function.

Fix U6: add multi-mount test case

test_mount now passes two --mount flags and asserts both slim0
and slim1 9p tags appear, covering the multi-mount tag-generation
path.
2026-09-10 12:21:30 +02:00
marvin 90de50dfed Add --mount flag for 9p host directory sharing
Add support for sharing host directories into the VM via QEMU 9p
(virtio-9p).  The --mount flag can be repeated; each host directory
appears at the same absolute path inside the VM.

Design:
- QEMU: each --mount gets a short 9p tag (slim0, slim1, …) via
  -virtfs local,path=…,mount_tag=slimN,security_model=mapped-xattr.
  Tags are kept short because 9p mount_tag has a ~31-byte limit.
- Kernel cmdline: the full destination path is passed as
  slim.mount=<tag>:<base64(path)> so the init script knows where to
  mount each tag.  Base64 avoids issues with spaces/special chars.
- slim-init.sh: after networking, parse slim.mount= entries, mkdir -p
  the destination, and mount -t 9p <tag> <dest> -o trans=virtio,version=9p2000.L

Tests verify: 9p share detection via sysfs mount_tag, 9p entry in
mount output, file content accessible at the expected path, and clean
VM exit.
2026-09-10 12:19:59 +02:00
marvin 6111bbeb8d Merge pull request 'Respect USER directive from OCI image config' (#8) from feature/user-directive into master
CI / build (push) Successful in 13s
2026-09-10 12:18:57 +02:00
marvin 524ef3e793 Respect USER directive from OCI image config
CI / build (pull_request) Successful in 12s
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
2026-09-10 12:18:13 +02:00
4 changed files with 105 additions and 46 deletions
+35
View File
@@ -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_mount() {
echo "=== Testing --mount (9p shares) ==="
@@ -268,6 +301,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_mount
test_service
+6 -1
View File
@@ -78,7 +78,12 @@ 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, config.working_dir.as_deref())?;
inject::inject(
mount_path,
&exec_script,
config.user.as_deref(),
config.working_dir.as_deref(),
)?;
println!("Injected /slim/ (init + exec)");
match kind {
+29 -43
View File
@@ -22,6 +22,8 @@ pub struct Config {
pub env: Vec<String>,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default)]
pub user: Option<String>,
}
pub fn inspect_config(image: &str) -> Result<Config> {
@@ -85,54 +87,38 @@ pub fn build_exec_script(command: &str, env: &[String], working_dir: Option<&str
lines
}
/// Inject /slim/init and /slim/exec (and optionally /slim/workdir) into a
/// mounted container image rootfs.
pub fn inject(mount_path: &Path, exec_script: &str, working_dir: Option<&str>) -> Result<()> {
/// Install `content` into the mounted rootfs at `<mount>/slim/<name>` 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 and /slim/workdir
/// into a mounted container image rootfs.
pub fn inject(
mount_path: &Path,
exec_script: &str,
user: Option<&str>,
working_dir: 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)?;
}
if let Some(dir) = working_dir.filter(|d| !d.is_empty()) {
let workdir_temp = tempfile::NamedTempFile::new()?;
fs::write(workdir_temp.path(), dir)?;
let workdir_src = workdir_temp
.path()
.to_str()
.context("temp path is not UTF-8")?;
let workdir_dest = format!("{mount_str}/slim/workdir");
cmd(&[
"podman",
"unshare",
"--",
"install",
"-D",
"-m",
"644",
workdir_src,
&workdir_dest,
])?;
install_into_rootfs(mount_str, "workdir", "644", dir)?;
}
Ok(())
+35 -2
View File
@@ -70,6 +70,39 @@ for tok in $(cat /proc/cmdline 2>/dev/null); do
esac
done
# === 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=<base64> is on the kernel cmdline, decode and exec it.
# Otherwise, exec /slim/exec (generated from the image's CMD/ENTRYPOINT).
@@ -80,9 +113,9 @@ for tok in $(cat /proc/cmdline 2>/dev/null); do
v=${tok#slim.cmd=}
decoded=$(b64dec "$v") || 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"