From d7613a4987bb8dabde152ffb3b4f96a36791c930 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Fri, 11 Sep 2026 14:40:10 +0200 Subject: [PATCH] Rewrite init script in rust and fix non-shell CMDs --- Cargo.lock | 53 ++++- Cargo.toml | 10 +- example/test.sh | 57 ++++++ init/Cargo.toml | 8 + init/src/main.rs | 429 +++++++++++++++++++++++++++++++++++++++ src/build.rs | 10 +- src/inject.rs | 94 ++++----- src/qemu.rs | 4 +- src/registry.rs | 6 + src/scripts/slim-init.sh | 121 ----------- 10 files changed, 603 insertions(+), 189 deletions(-) create mode 100644 init/Cargo.toml create mode 100644 init/src/main.rs delete mode 100644 src/scripts/slim-init.sh diff --git a/Cargo.lock b/Cargo.lock index 287a218..7035487 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,15 +66,15 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "cfg-if" @@ -82,6 +82,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "clap" version = "4.6.6" @@ -155,12 +161,13 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -212,14 +219,26 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -332,6 +351,14 @@ dependencies = [ "xdg", ] +[[package]] +name = "slim-init" +version = "0.1.0" +dependencies = [ + "base64", + "nix", +] + [[package]] name = "strsim" version = "0.11.1" @@ -340,9 +367,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -395,6 +422,12 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 6118542..0fe7469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,11 @@ +[workspace] +members = [".", "init"] + +[profile.release] +opt-level = "z" +lto = true +strip = true + [package] name = "slim" version = "0.1.0" @@ -5,7 +13,7 @@ edition = "2024" [dependencies] anyhow = "1.0.104" -base64 = "0.22" +base64 = "0.23" clap = { version = "4.6.6", features = ["derive"] } flate2 = "1.1.9" serde = { version = "1.0.229", features = ["derive"] } diff --git a/example/test.sh b/example/test.sh index 5ddf8aa..8cffec4 100755 --- a/example/test.sh +++ b/example/test.sh @@ -295,8 +295,63 @@ EOF rm -rf "$work" } +test_systemd() { + echo "=== Testing systemd as init ===" + + work="$(mktemp -d)" + img="slim-test-systemd" + + cat > "$work/slim-test.service" <<'SVC' +[Unit] +Description=slim systemd test +After=multi-user.target + +[Service] +Type=oneshot +ExecStart=/bin/sh -c "echo SYSTEMD_TEST_OK > /dev/console; sleep 1; systemctl poweroff" + +[Install] +WantedBy=multi-user.target +SVC + + cat > "$work/Containerfile" </dev/null 2>&1; then + report fail "systemd as init (podman build failed)" + rm -rf "$work" + return + fi + + echo "-- Building slim VM..." + if ! "$SLIM_BIN" build qcow2 "$img" >/dev/null 2>&1; then + report fail "systemd as init (slim build failed)" + cleanup "$img" "$img" + rm -rf "$work" + return + fi + + echo "-- Booting VM (systemd as PID 1)..." + output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" 2>&1 || true) + check_output "$output" "SYSTEMD_TEST_OK" "systemd boots and runs service" + check_output "$output" "reboot: Power down" "systemd shuts down cleanly" + + cleanup "$img" "$img" + rm -rf "$work" +} + echo "Building slim..." cargo build 2>&1 +cargo build --release -p slim-init --target x86_64-unknown-linux-musl 2>&1 +cp target/x86_64-unknown-linux-musl/release/slim-init \ + "${XDG_DATA_HOME:-$HOME/.local/share}/slim-rs/slim-init" test_distro "alpine" "alpine:latest" "" test_distro "archlinux" "archlinux:latest" "RUN pacman -Sy --noconfirm iproute2 wget; pacman -Sc --noconfirm" @@ -307,6 +362,8 @@ test_mount test_service +test_systemd + echo "" echo "=== Results: $pass passed, $fail failed ===" [ "$fail" -eq 0 ] diff --git a/init/Cargo.toml b/init/Cargo.toml new file mode 100644 index 0000000..8841b26 --- /dev/null +++ b/init/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "slim-init" +version = "0.1.0" +edition = "2024" + +[dependencies] +base64 = "0.23" +nix = { version = "0.31", features = ["mount", "fs", "process", "user", "reboot"] } diff --git a/init/src/main.rs b/init/src/main.rs new file mode 100644 index 0000000..0bba306 --- /dev/null +++ b/init/src/main.rs @@ -0,0 +1,429 @@ +//! slim universal init. +//! +//! Injected by `slim build` at /slim/init and invoked via init=/slim/init. +//! Sets up devices, filesystems, networking, and 9p shares, then execs the +//! container's CMD/ENTRYPOINT (or a runtime override) with execvp so the +//! command becomes PID 1 directly. + +use base64::Engine; +use nix::mount::{MsFlags, mount}; +use nix::sys::reboot::{RebootMode, reboot}; +use nix::sys::stat::{Mode, SFlag, makedev, mknod}; +use nix::sys::wait::waitpid; +use nix::unistd::{ForkResult, Gid, Uid, User, execvp, fork, setgid, setuid}; +use std::env; +use std::ffi::CString; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; + +fn warn(msg: &str) { + eprintln!("slim-init: {msg}"); +} + +/// Run a fallible operation, logging on error. +fn try_io(label: &str, f: F) +where + F: FnOnce() -> std::io::Result, +{ + if let Err(e) = f() { + warn(&format!("{label}: {e}")); + } +} + +/// Run a command, logging on error or non-zero exit. +fn try_cmd(label: &str, cmd: &mut Command) { + match cmd.status() { + Ok(status) => { + if !status.success() { + warn(&format!("{label}: exited with {status}")); + } + } + Err(e) => warn(&format!("{label}: {e}")), + } +} + +/// The kernel passes a minimal environment to init. Set a default PATH +/// so that `ip` and other tools can be found by name. +fn ensure_path() { + if env::var("PATH").is_err() { + // SAFETY: we are single-threaded before fork/exec. + unsafe { + env::set_var( + "PATH", + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ); + } + } +} + +fn find_in_path(prog: &str) -> Option { + let path = env::var("PATH").ok()?; + for dir in path.split(':') { + let candidate = format!("{dir}/{prog}"); + if Path::new(&candidate).exists() { + return Some(candidate); + } + } + None +} + +fn try_mount( + source: Option<&str>, + target: &str, + fstype: Option<&str>, + flags: MsFlags, + data: Option<&str>, +) { + if let Err(e) = mount(source, target, fstype, flags, data) { + warn(&format!("mount {target}: {e}")); + } +} + +fn setup_filesystems() { + if !Path::new("/dev/console").exists() { + try_io("mknod /dev/console", || { + mknod( + "/dev/console", + SFlag::S_IFCHR, + Mode::S_IRUSR | Mode::S_IWUSR, + makedev(5, 1), + ) + .map_err(std::io::Error::from) + }); + } + + let dirs = [ + "/proc", + "/sys", + "/dev/pts", + "/dev/shm", + "/run", + "/tmp", + "/sys/fs/cgroup", + ]; + for d in &dirs { + try_io(&format!("mkdir {d}"), || fs::create_dir_all(d)); + } + + try_mount(Some("proc"), "/proc", Some("proc"), MsFlags::empty(), None); + try_mount(Some("sysfs"), "/sys", Some("sysfs"), MsFlags::empty(), None); + try_mount( + Some("devpts"), + "/dev/pts", + Some("devpts"), + MsFlags::empty(), + None, + ); + try_mount( + Some("tmpfs"), + "/dev/shm", + Some("tmpfs"), + MsFlags::empty(), + None, + ); + try_mount( + Some("tmpfs"), + "/run", + Some("tmpfs"), + MsFlags::empty(), + Some("mode=755"), + ); + try_mount( + Some("tmpfs"), + "/tmp", + Some("tmpfs"), + MsFlags::empty(), + Some("mode=1777"), + ); + + // cgroup2 + try_mount( + Some("cgroup2"), + "/sys/fs/cgroup", + Some("cgroup2"), + MsFlags::empty(), + None, + ); + if let Ok(controllers) = fs::read_to_string("/sys/fs/cgroup/cgroup.controllers") { + for c in controllers.split_whitespace() { + try_io("write cgroup.subtree_control", || { + fs::write("/sys/fs/cgroup/cgroup.subtree_control", format!("+{c}")) + }); + } + } +} + +fn setup_rootless_prereqs() { + // make-rshared / + if let Err(e) = + mount::(None, "/", None, MsFlags::MS_REC | MsFlags::MS_SHARED, None) + { + warn(&format!("make-rshared /: {e}")); + } + + // chmod u+s newuidmap newgidmap + for bin in &["/usr/bin/newuidmap", "/usr/bin/newgidmap"] { + match fs::metadata(bin) { + Ok(meta) => { + let mut perms = meta.permissions(); + perms.set_mode(perms.mode() | 0o4000); + try_io(&format!("chmod u+s {bin}"), || { + fs::set_permissions(bin, perms) + }); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => warn(&format!("stat {bin}: {e}")), + } + } +} + +fn setup_timezone() { + try_io("symlink timezone", || { + std::os::unix::fs::symlink("/usr/share/zoneinfo/Europe/Stockholm", "/etc/localtime") + }); +} + +fn setup_networking() { + let ip = find_in_path("ip").unwrap_or_else(|| "/sbin/ip".to_string()); + try_cmd( + "ip link set lo up", + Command::new(&ip).args(["link", "set", "lo", "up"]), + ); + try_cmd( + "ip link set eth0 up", + Command::new(&ip).args(["link", "set", "eth0", "up"]), + ); + try_cmd( + "ip addr add 10.0.2.15/24 dev eth0", + Command::new(&ip).args(["addr", "add", "10.0.2.15/24", "dev", "eth0"]), + ); + try_cmd( + "ip route add default via 10.0.2.2", + Command::new(&ip).args(["route", "add", "default", "via", "10.0.2.2"]), + ); + try_io("write /etc/resolv.conf", || { + fs::write("/etc/resolv.conf", "nameserver 10.0.2.3\n") + }); +} + +fn setup_9p_shares(workdir: &str) { + let cmdline = match fs::read_to_string("/proc/cmdline") { + Ok(c) => c, + Err(e) => { + warn(&format!("read /proc/cmdline: {e}")); + return; + } + }; + let b64 = base64::engine::general_purpose::STANDARD; + + for tok in cmdline.split_whitespace() { + if let Some(rest) = tok.strip_prefix("slim.mount=") { + let Some((tag, dest_b64)) = rest.split_once(':') else { + continue; + }; + let dest = match b64.decode(dest_b64) { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(s) => s, + Err(e) => { + warn(&format!("9p: invalid UTF-8 in guest path: {e}")); + continue; + } + }, + Err(e) => { + warn(&format!("9p: base64 decode failed: {e}")); + continue; + } + }; + + // Resolve relative guest paths against WORKINGDIR + let dest = if dest.starts_with('/') { + dest + } else { + format!("{}/{}", workdir, dest) + }; + + try_io(&format!("mkdir {dest}"), || fs::create_dir_all(&dest)); + try_mount( + Some(tag), + &dest, + Some("9p"), + MsFlags::empty(), + Some("trans=virtio,version=9p2000.L"), + ); + } + } +} + +fn read_file_opt(path: &str) -> Option { + fs::read_to_string(path) + .ok() + .filter(|s| !s.trim().is_empty()) +} + +fn split_null_delimited(data: &[u8]) -> Vec { + data.split(|&b| b == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect() +} + +fn read_argv() -> Vec { + // Check for runtime override: slim.cmd= + let b64 = base64::engine::general_purpose::STANDARD; + if let Ok(cmdline) = fs::read_to_string("/proc/cmdline") { + for tok in cmdline.split_whitespace() { + if let Some(rest) = tok.strip_prefix("slim.cmd=") + && let Ok(decoded) = b64.decode(rest) + { + let argv = split_null_delimited(&decoded); + if !argv.is_empty() { + return argv; + } + } + } + } + + // Fall back to build-time argv from /slim/exec + match fs::read("/slim/exec") { + Ok(data) => { + let argv = split_null_delimited(&data); + if !argv.is_empty() { + return argv; + } + } + Err(e) => warn(&format!("read /slim/exec: {e}")), + } + + warn("no command found, falling back to /bin/sh"); + vec!["/bin/sh".to_string()] +} + +fn apply_env() { + if let Ok(data) = fs::read("/slim/env") { + for entry in split_null_delimited(&data) { + if let Some((key, val)) = entry.split_once('=') { + // SAFETY: we are single-threaded before fork/exec. + unsafe { env::set_var(key, val) }; + } + } + } +} + +fn apply_workdir() { + if let Some(dir) = read_file_opt("/slim/workdir") { + try_io("set working directory", || env::set_current_dir(&dir)); + } +} + +struct UserSpec { + uid: Uid, + gid: Gid, +} + +fn resolve_user(spec: &str) -> Option { + let (user_part, gid_part) = match spec.split_once(':') { + Some((u, g)) => (u, Some(g)), + None => (spec, None), + }; + + let (uid, default_gid) = if let Ok(numeric) = user_part.parse::() { + let user = User::from_uid(Uid::from_raw(numeric)).ok().flatten(); + let gid = user + .as_ref() + .map(|u| u.gid) + .unwrap_or(Gid::from_raw(numeric)); + (Uid::from_raw(numeric), gid) + } else { + let user = User::from_name(user_part).ok().flatten()?; + (user.uid, user.gid) + }; + + let gid = match gid_part { + Some(g) => match g.parse::() { + Ok(n) => Gid::from_raw(n), + Err(_) => default_gid, + }, + None => default_gid, + }; + + Some(UserSpec { uid, gid }) +} + +fn exec_command(argv: &[String], user: Option) -> ! { + let cstrings: Vec = argv + .iter() + .map(|s| CString::new(s.as_str()).unwrap_or_default()) + .collect(); + let c_prog = cstrings[0].clone(); + let c_refs: Vec<&CString> = cstrings.iter().collect(); + + match user { + None => { + // No privilege drop: exec directly, PID 1 becomes the command. + let Err(e) = execvp(&c_prog, &c_refs); + warn(&format!("execvp {}: {e}", argv[0])); + let _ = reboot(RebootMode::RB_POWER_OFF); + std::process::exit(1); + } + Some(user) => { + // Drop privileges in a child so PID 1 (root) can poweroff afterwards. + match unsafe { fork() } { + Ok(ForkResult::Child) => { + // setgid before setuid to avoid losing privileges + if let Err(e) = setgid(user.gid) { + warn(&format!("setgid: {e}")); + } + if let Err(e) = setuid(user.uid) { + warn(&format!("setuid: {e}")); + } + let Err(e) = execvp(&c_prog, &c_refs); + warn(&format!("execvp {}: {e}", argv[0])); + std::process::exit(127); + } + Ok(ForkResult::Parent { child }) => { + let _ = waitpid(child, None); + let _ = reboot(RebootMode::RB_POWER_OFF); + std::process::exit(0); + } + Err(e) => { + warn(&format!("fork: {e}")); + let _ = reboot(RebootMode::RB_POWER_OFF); + std::process::exit(1); + } + } + } + } +} + +fn main() { + ensure_path(); + + // === Devices & special filesystems === + setup_filesystems(); + + // === Rootless container prerequisites (best-effort) === + setup_rootless_prereqs(); + + // === Timezone === + setup_timezone(); + + // === Networking === + setup_networking(); + + // === 9p shares === + let workdir = read_file_opt("/slim/workdir").unwrap_or_default(); + setup_9p_shares(&workdir); + + // === Environment & working directory === + apply_env(); + apply_workdir(); + + // === Resolve user for privilege drop === + let user = read_file_opt("/slim/user").and_then(|spec| resolve_user(&spec)); + + // === Execute the configured command === + let argv = read_argv(); + exec_command(&argv, user); +} diff --git a/src/build.rs b/src/build.rs index 7d24624..47f5400 100644 --- a/src/build.rs +++ b/src/build.rs @@ -75,12 +75,14 @@ fn build_inner( cmd: Option, ) -> Result<()> { let config = inject::inspect_config(image)?; - 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()); + let argv = match cmd { + Some(s) => vec!["/bin/sh".into(), "-c".into(), s], + None => inject::infer_argv(&config), + }; inject::inject( mount_path, - &exec_script, + &argv, + &config.env, config.user.as_deref(), config.working_dir.as_deref(), )?; diff --git a/src/inject.rs b/src/inject.rs index 14f06a5..845a65d 100644 --- a/src/inject.rs +++ b/src/inject.rs @@ -1,15 +1,14 @@ -//! Inject: inspect a podman image's config, infer the default command, and -//! inject distro-agnostic /slim/ scripts (init + exec) into the mounted rootfs. +//! Inject: inspect a podman image's config, infer the default command argv, +//! and inject the slim init binary + null-delimited argv/env files into the +//! mounted rootfs. use anyhow::{Context, Result, anyhow}; use serde::Deserialize; -use std::fmt::Write as _; use std::fs; use std::path::Path; use crate::command::cmd; - -const SLIM_INIT: &str = include_str!("scripts/slim-init.sh"); +use crate::registry::init_binary_path; #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "PascalCase")] @@ -40,56 +39,38 @@ pub fn inspect_config(image: &str) -> Result { Ok(config) } -/// Infer the command string from the image config. +/// Infer the command argv from the image config. /// /// Concatenates ENTRYPOINT + CMD (Docker semantics). If neither is present, -/// falls back to `/bin/sh`. -pub fn infer_command(config: &Config) -> String { - let parts = config.entrypoint.iter().chain(config.cmd.iter()); - let parts: Vec<_> = parts.map(|s| s.as_str()).collect(); - - match &parts[..] { - [] => "/bin/sh".into(), - ["/bin/sh" | "sh", "-c", cmd] => cmd.to_string(), - _ => shell_join(&parts), - } -} - -fn shell_escape(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -fn shell_join(parts: &[&str]) -> String { - parts +/// falls back to `["/bin/sh"]`. +pub fn infer_argv(config: &Config) -> Vec { + let parts: Vec = config + .entrypoint .iter() - .map(|p| shell_escape(p)) - .collect::>() - .join(" ") + .chain(config.cmd.iter()) + .cloned() + .collect(); + + if parts.is_empty() { + vec!["/bin/sh".into()] + } else { + parts + } } -/// Generate the /slim/exec script from a command string, env vars, and working dir. -pub fn build_exec_script(command: &str, env: &[String], working_dir: Option<&str>) -> String { - let mut lines = String::from("#!/bin/sh\n"); - if let Some(dir) = working_dir.filter(|d| !d.is_empty()) { - _ = writeln!(&mut lines, "cd {} 2>/dev/null", shell_escape(dir)); +/// Join items as null-delimited bytes. +pub fn null_delimited(items: &[String]) -> Vec { + let mut data = Vec::new(); + for item in items { + data.extend_from_slice(item.as_bytes()); + data.push(0); } - for var in env { - if let Some((key, val)) = var.split_once('=') { - _ = writeln!( - &mut lines, - "export {}={}", - shell_escape(key), - shell_escape(val) - ); - } - } - _ = writeln!(&mut lines, "exec /bin/sh -c {}", shell_escape(command)); - lines + data } /// 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<()> { +fn install_into_rootfs(mount: &str, name: &str, mode: &str, content: &[u8]) -> Result<()> { let temp = tempfile::NamedTempFile::new()?; fs::write(temp.path(), content)?; let src = temp.path().to_str().context("temp path is not UTF-8")?; @@ -100,25 +81,34 @@ fn install_into_rootfs(mount: &str, name: &str, mode: &str, content: &str) -> Re Ok(()) } -/// Inject /slim/init, /slim/exec, and optionally /slim/user and /slim/workdir -/// into a mounted container image rootfs. +/// Inject /slim/init (binary), /slim/exec (null-delimited argv), /slim/env +/// (null-delimited env), and optionally /slim/user and /slim/workdir into a +/// mounted container image rootfs. pub fn inject( mount_path: &Path, - exec_script: &str, + argv: &[String], + env: &[String], user: Option<&str>, working_dir: Option<&str>, ) -> Result<()> { let mount_str = mount_path.to_str().context("mount path is not UTF-8")?; - install_into_rootfs(mount_str, "init", "755", SLIM_INIT)?; - install_into_rootfs(mount_str, "exec", "755", exec_script)?; + let init_binary = fs::read(init_binary_path()?).context("Failed to read slim-init binary")?; + let exec_data = null_delimited(argv); + let env_data = null_delimited(env); + + install_into_rootfs(mount_str, "init", "755", &init_binary)?; + install_into_rootfs(mount_str, "exec", "644", &exec_data)?; + if !env_data.is_empty() { + install_into_rootfs(mount_str, "env", "644", &env_data)?; + } if let Some(user) = user.filter(|u| !u.is_empty()) { - install_into_rootfs(mount_str, "user", "644", user)?; + install_into_rootfs(mount_str, "user", "644", user.as_bytes())?; } if let Some(dir) = working_dir.filter(|d| !d.is_empty()) { - install_into_rootfs(mount_str, "workdir", "644", dir)?; + install_into_rootfs(mount_str, "workdir", "644", dir.as_bytes())?; } Ok(()) diff --git a/src/qemu.rs b/src/qemu.rs index e5cc29d..c054f27 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -85,7 +85,9 @@ pub(crate) fn run( } if let Some(cmd) = &cmd { - let encoded = base64::engine::general_purpose::STANDARD.encode(cmd.as_bytes()); + let argv = ["/bin/sh".to_string(), "-c".to_string(), cmd.clone()]; + let data: Vec = argv.join("\0").into_bytes(); + let encoded = base64::engine::general_purpose::STANDARD.encode(&data); cmdline.push(format!("slim.cmd={encoded}")); } diff --git a/src/registry.rs b/src/registry.rs index 5a8e6bf..33e6602 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -32,6 +32,12 @@ pub(crate) fn registry_base_dir() -> Result { .context("Failed to create XDG_DATA_HOME subdirectory") } +pub(crate) fn init_binary_path() -> Result { + let base = xdg::BaseDirectories::with_prefix("slim-rs"); + base.get_data_file("slim-init") + .context("slim-init not found in XDG data dir") +} + pub(crate) fn registry_dir(image: &str) -> Result { validate_image_name(image)?; let base = registry_base_dir()?; diff --git a/src/scripts/slim-init.sh b/src/scripts/slim-init.sh deleted file mode 100644 index 7951f13..0000000 --- a/src/scripts/slim-init.sh +++ /dev/null @@ -1,121 +0,0 @@ -#!/bin/sh -# slim universal init - distro-agnostic VM bootstrap. -# Injected by `slim build` at /slim/init and invoked via init=/slim/init. - -# === Helpers === -b64dec() { - printf '%s' "$1" | base64 -d 2>/dev/null || printf '%s' "$1" | openssl base64 -d 2>/dev/null -} - -# === Devices & special filesystems === -[ -c /dev/console ] || mknod -m 600 /dev/console c 5 1 -mkdir -p /proc /sys /dev/pts /dev/shm -mount -t proc proc /proc -mount -t sysfs sysfs /sys -mount -t devpts devpts /dev/pts -mount -t tmpfs tmpfs /dev/shm -mount -t tmpfs tmpfs /run -o mode=755 -mount -t tmpfs tmpfs /tmp -o mode=1777 - -# === cgroup2 === -mkdir -p /sys/fs/cgroup -mount -t cgroup2 none /sys/fs/cgroup 2>/dev/null || echo "mount cgroup2 failed" -# shellcheck disable=SC2013 # word-splitting is intentional: controllers are space-separated -for c in $(cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null); do - echo "+$c" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || echo "enable cgroup controller $c failed" -done - -# === Rootless container prerequisites (best-effort) === -mount --make-rshared / 2>/dev/null || echo "make-rshared / failed" -chmod u+s /usr/bin/newuidmap /usr/bin/newgidmap 2>/dev/null || echo "chmod newuidmap/newgidmap failed" - -# === Timezone === -ln -sf /usr/share/zoneinfo/Europe/Stockholm /etc/localtime 2>/dev/null || echo "set timezone failed" - -# === Networking (QEMU slirp: guest 10.0.2.15/24, gw 10.0.2.2, dns 10.0.2.3) === -ip link set lo up 2>/dev/null || echo "ip link set lo up failed" -ip link set eth0 up 2>/dev/null || echo "ip link set eth0 up failed" -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 - -# === 9p shares (host directories shared via --mount) === -# Each share is passed as slim.mount=: on the -# kernel cmdline. The 9p tag is short (e.g. slim0) because mount_tag -# has a ~31-byte limit; the guest path is base64-encoded. -# Non-absolute guest paths are resolved against /slim/workdir (the -# image's WORKINGDIR). -SLIM_WORKDIR="" -[ -f /slim/workdir ] && SLIM_WORKDIR=$(cat /slim/workdir 2>/dev/null) -# shellcheck disable=SC2013 # word-splitting is intentional: cmdline tokens are space-separated -for tok in $(cat /proc/cmdline 2>/dev/null); do - case "$tok" in - slim.mount=*) - v=${tok#slim.mount=} - tag=${v%%:*} - dest_b64=${v#*:} - dest=$(b64dec "$dest_b64") || echo "base64 decode failed" - if [ -n "$tag" ] && [ -n "$dest" ]; then - # Resolve relative paths against the image's WORKINGDIR - case "$dest" in - /*) ;; - *) dest="${SLIM_WORKDIR:-/}/${dest}" ;; - esac - mkdir -p "$dest" - mount -t 9p "$tag" "$dest" -o trans=virtio,version=9p2000.L 2>/dev/null \ - && echo "mounted 9p '$tag' -> '$dest'" \ - || echo "failed to mount 9p '$tag' -> '$dest'" - fi - ;; - 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= is on the kernel cmdline, decode and exec it. -# Otherwise, exec /slim/exec (generated from the image's CMD/ENTRYPOINT). -# shellcheck disable=SC2013 # word-splitting is intentional: cmdline tokens are space-separated -for tok in $(cat /proc/cmdline 2>/dev/null); do - case "$tok" in - slim.cmd=*) - v=${tok#slim.cmd=} - decoded=$(b64dec "$v") || echo "base64 decode failed" - if [ -n "$decoded" ]; then - slim_exec "$decoded" - fi - ;; - esac -done -slim_exec "/slim/exec"