From 51f56116a3fdc5e3b760db3ab3755aeb198340f4 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 30 Oct 2026 19:10:48 +0100 Subject: [PATCH] feat(rootfs): cross the rootfs as an ext4 image on a block device The rootfs was a virtiofs share over an extracted tree, and that showed: the tree's owners were the host user's, because an unprivileged extract cannot chown to a tar's uid, and every chown inside the machine failed with EPERM against the host side. The rootfs crosses as a raw ext4 image now. mke2fs builds the cache image straight from the entry tarball (-d takes gz, xz and zst, and bakes each entry's recorded uid, gid and mode), the run copies it into a scratch directory with cp --reflink=auto --sparse=auto, and the engine attaches it with krun_add_disk + krun_set_root_disk_remount; the init blob mounts /dev/vda at / and chroots. The OCI layer bundle merges into one plain tar first, with whiteouts applied across layers and headers passed verbatim, so registry images keep their ownership too. Per-run provisioning rides debugfs: the generated init lands at /ecrou-init, the host resolver overwrites /etc/resolv.conf (unlink first - resolver-bearing images ship it as a symlink), a stale control file goes. The exit-code control file now outranks the machine child's exit code: with a block root the engine's exit-code channel (an ioctl through the virtiofs root device) never fires, so decode_status reads the file first and treats the child's code as machinery talk. Shell detection and the rare control-file read are debugfs stats, no extracted tree to walk. extract.rs keeps what the image build consumes (the tar decoder and the OCI merge) and drops the extraction-to-disk path it once carried; the module is private now. The engine needs the blk feature from here on: the snap and the CI engine build follow in their own commits. --- AGENTS.md | 6 +- crates/ecrou/src/cache.rs | 82 +-- crates/ecrou/src/extract.rs | 1062 +++++++++------------------------ crates/ecrou/src/image.rs | 405 +++++++++++++ crates/ecrou/src/krun.rs | 61 +- crates/ecrou/src/lib.rs | 13 +- crates/ecrou/src/progress.rs | 8 +- crates/ecrou/src/registry.rs | 3 +- crates/ecrou/src/rootfs.rs | 161 ++++- crates/ecrou/src/supervise.rs | 76 +-- crates/ecrou/src/vm.rs | 112 ++-- 11 files changed, 1032 insertions(+), 957 deletions(-) create mode 100644 crates/ecrou/src/image.rs diff --git a/AGENTS.md b/AGENTS.md index cd8e188..fc38b9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,10 +38,12 @@ Rules: (`crates/ecrou/src/rootfs.rs`) - `cache` - the rootfs entry store: naming, locking, freshness, layer pointers (`crates/ecrou/src/cache.rs`) - - `pack` - rootfs tarball packing (`crates/ecrou/src/pack.rs`) + - `image` - ext4 image building and inspection + (`crates/ecrou/src/image.rs`) - `distro` - distro definitions and mirrors (`crates/ecrou/src/distro.rs`) - `download` - image download (`crates/ecrou/src/download.rs`) - - `extract` - rootfs extraction (`crates/ecrou/src/extract.rs`) + - `extract` - tar decoding and the OCI layer merge feeding the image + build (`crates/ecrou/src/extract.rs`) - `registry` - OCI registry client (`crates/ecrou/src/registry.rs`) - `utils`, `error`, `progress` - shared helpers - `deps` - dependency additions/bumps (manifests, lockfile) diff --git a/crates/ecrou/src/cache.rs b/crates/ecrou/src/cache.rs index b91e7d4..59e4104 100644 --- a/crates/ecrou/src/cache.rs +++ b/crates/ecrou/src/cache.rs @@ -13,10 +13,8 @@ use anyhow::{anyhow, Context}; use crate::distro::{map_arch, Distro, ImageSource}; use crate::download::digest_sidecar; -use crate::extract::extract_tarball; use crate::progress::Progress; use crate::registry::fetch_oci_digest; -use crate::rootfs::PreparedRootfs; use crate::Result; /// Whether an OCI image with a floating tag (":latest") has moved since @@ -135,66 +133,37 @@ impl RootfsCache { } } -pub(crate) fn extract_prepared( - source: &Path, - progress: &dyn Progress, -) -> anyhow::Result { - let temp = tempfile::tempdir().context("Failed to create temporary directory")?; - let rootfs = temp.path().to_path_buf(); - - log::debug!("Extracting to: {}", rootfs.display()); - extract_tarball(source, &rootfs, progress)?; - provision_resolver(&rootfs); - - Ok(PreparedRootfs { rootfs: temp }) -} - -/// Give the guest the host's resolver: the engine proxies guest -/// sockets to the host's stack, so a nameserver line that works on the -/// host works in the machine, loopback stub included. Without it the -/// guest has whatever the image shipped, which is usually nothing. -/// Best effort: a rootfs with its own resolver config keeps it. -fn provision_resolver(rootfs: &Path) { - // The engine proxies guest sockets to the host's stack, so any - // host-reachable nameserver works in the machine - but loopback - // addresses do not cross: 127.0.0.53 stays inside the guest, where - // nothing listens. So the source of truth is systemd-resolved's - // uplink config when present, and loopback nameservers are dropped - // from whatever is copied. Best effort: failures leave the - // image's file. - let host = match std::fs::read_to_string("/run/systemd/resolve/resolv.conf") +/// The host resolver config, ready to provision into the machine: the +/// engine proxies guest sockets to the host's stack, so a nameserver +/// line that works on the host works in the machine, loopback stub +/// included. Without it the guest has whatever the image shipped, +/// which is usually nothing. Best effort: None leaves the image's own +/// file standing. +pub(crate) fn host_resolver_content() -> Option { + // Loopback addresses do not cross: 127.0.0.53 stays inside the + // guest, where nothing listens. The source of truth is + // systemd-resolved's uplink config when present, and loopback + // nameservers are dropped from whatever is copied. + match std::fs::read_to_string("/run/systemd/resolve/resolv.conf") .or_else(|_| std::fs::read_to_string("/etc/resolv.conf")) { - Ok(s) if !s.trim().is_empty() => drop_loopback_nameservers(&s), + Ok(s) if !s.trim().is_empty() => { + let content = drop_loopback_nameservers(&s); + if content.contains("nameserver ") { + Some(content) + } else { + log::debug!("host resolver config has no usable nameserver; leaving the image's"); + None + } + } Ok(_) => { log::debug!("host resolver config is empty; leaving the image's in place"); - return; + None } Err(e) => { - log::debug!( - "no host resolver config ({}); leaving the image's in place", - e - ); - return; + log::debug!("no host resolver config ({e}); leaving the image's in place"); + None } - }; - if !host.contains("nameserver ") { - log::debug!("host resolver config has no usable nameserver; leaving the image's"); - return; - } - let target = rootfs.join("etc/resolv.conf"); - if let Err(e) = std::fs::remove_file(&target) { - if e.kind() != std::io::ErrorKind::NotFound { - log::debug!("could not clear {}: {}", target.display(), e); - return; - } - } - if let Some(parent) = target.parent() { - let _ = std::fs::create_dir_all(parent); - } - match std::fs::write(&target, host) { - Ok(()) => log::debug!("provisioned a resolver into {}", target.display()), - Err(e) => log::debug!("could not write {}: {}", target.display(), e), } } @@ -335,9 +304,6 @@ mod tests { ); } - /// Build a minimal rootfs tree with a nested executable and a symlink, - /// pack it, and extract it back through the public extract_tarball path. - #[test] fn a_digest_pin_names_its_own_cache_entry() { let cache = RootfsCache::new(std::path::PathBuf::from("/cache")); diff --git a/crates/ecrou/src/extract.rs b/crates/ecrou/src/extract.rs index a642c98..2907a7e 100644 --- a/crates/ecrou/src/extract.rs +++ b/crates/ecrou/src/extract.rs @@ -1,828 +1,356 @@ -use crate::progress::{Progress, Unit}; -use anyhow::Context; +//! Tar machinery for the image build: a decoder that infers +//! compression from the file name and then the magic bytes, and the +//! OCI layer-bundle merger that turns ecrou's `layers.manifest` +//! bundle into one plain tar. mke2fs consumes both. +//! +//! The extraction-to-disk path this module once carried is gone: runs +//! boot an image built straight from the tarball (see +//! [`crate::image`]), and a plain copy never preserved the archive's +//! owners the way mke2fs does. -use crate::Result; use std::fs::File; use std::io::{BufReader, Read}; -use std::os::unix::fs::PermissionsExt; -use std::path::{Component, Path, PathBuf}; +use std::path::Path; -/// Extract a tarball to the specified directory -pub fn extract_tarball(tarball: &Path, dest: &Path, progress: &dyn Progress) -> Result<()> { - let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or(""); +use anyhow::Context; - // OCI bundle files are always named "oci-…" by generate_cache_filename. - // Use the filename prefix as a zero-I/O dispatch signal instead of - // scanning the archive for a layers.manifest entry (which required - // reading the entire compressed archive twice for large OCI images). - if filename.starts_with("oci-") { - log::debug!("Detected multi-layer OCI image, extracting layers..."); - return Ok(extract_multi_layer_oci(tarball, dest, progress)?); - } - - Ok(extract_single_archive(tarball, dest, progress)?) -} - -/// Extract a plain rootfs tarball, detecting the compression format from -/// the filename with a magic-byte fallback. -fn extract_single_archive( - tarball: &Path, - dest: &Path, - progress: &dyn Progress, -) -> anyhow::Result<()> { - let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or(""); - - let file = File::open(tarball) - .with_context(|| format!("Failed to open tarball: {}", tarball.display()))?; +use crate::progress::{Progress, Unit}; +/// A plain-tar reader for `path`: compression from the file name, then +/// from the magic bytes. Serves the image build, which consumes the +/// same formats the extraction path always did; bzip2 stays refused. +pub(crate) fn tar_decoder(path: &Path) -> anyhow::Result>> { + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let file = File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; let reader = BufReader::new(file); - let mut fixups = DeferredFixups::default(); - - // Detect compression format from filename - if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { - extract_gz(reader, dest, &mut fixups, progress)?; + let inner: Box = if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { + Box::new(flate2::read::GzDecoder::new(reader)) } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { - extract_xz(reader, dest, &mut fixups, progress)?; + Box::new(xz2::read::XzDecoder::new(reader)) } else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") { - extract_zst(reader, dest, &mut fixups, progress)?; - } else if filename.ends_with(".tar.bz2") || filename.ends_with(".tbz2") { - // Named before the magic-byte fallback misreads a bzip2 - // stream as an uncompressed tar. - return Err(anyhow::anyhow!( - "bzip2-compressed tarballs are not supported: {} (use a \ - .tar.gz, .tar.xz or .tar.zst rootfs)", - tarball.display() - )); - } else if filename.ends_with(".tar") { - extract_tar(reader, dest, &mut fixups, progress)?; + Box::new(zstd::stream::read::Decoder::new(reader)?) } else { - // Try to detect from magic bytes let mut magic = [0u8; 6]; - let mut peek_reader = BufReader::new(File::open(tarball)?); - peek_reader.read_exact(&mut magic)?; - + let mut peek = BufReader::new(File::open(path)?); + let _ = peek.read_exact(&mut magic); // short reads are fine for detection + drop(peek); + let file = File::open(path)?; match magic { - [0x1f, 0x8b, ..] => { - // gzip magic - drop(peek_reader); - let file = File::open(tarball)?; - extract_gz(BufReader::new(file), dest, &mut fixups, progress)?; - } + [0x1f, 0x8b, ..] => Box::new(flate2::read::GzDecoder::new(BufReader::new(file))), [0xfd, b'7', b'z', b'X', b'Z', 0x00] => { - // xz magic - drop(peek_reader); - let file = File::open(tarball)?; - extract_xz(BufReader::new(file), dest, &mut fixups, progress)?; + Box::new(xz2::read::XzDecoder::new(BufReader::new(file))) } - [0x28, 0xb5, 0x2f, 0xfd, ..] => { - // zstd magic - drop(peek_reader); - let file = File::open(tarball)?; - extract_zst(BufReader::new(file), dest, &mut fixups, progress)?; + [0x28, b'5', 0x2f, 0xfd, ..] => { + Box::new(zstd::stream::read::Decoder::new(BufReader::new(file))?) } - _ => { - // Assume uncompressed tar - drop(peek_reader); - let file = File::open(tarball)?; - extract_tar(BufReader::new(file), dest, &mut fixups, progress)?; + [b'B', b'Z', b'h', ..] => { + return Err(anyhow::anyhow!( + "bzip2-compressed tarballs are not supported: {} (use a \ + .tar.gz, .tar.xz or .tar.zst rootfs)", + path.display() + )) } + _ => Box::new(BufReader::new(file)), } - } - - apply_fixups(dest, &mut fixups) + }; + Ok(tar::Archive::new(inner)) } -/// Extract a multi-layer OCI image -fn extract_multi_layer_oci( - tarball: &Path, - dest: &Path, +/// Merge an OCI layer bundle into one plain tar for the image build. +/// Entry headers pass through verbatim, so the ownership and modes the +/// layers record reach the image instead of dying at a host chown the +/// way they did when layers unpacked into a tree. +/// +/// Two passes. The first walks every layer collecting whiteouts; the +/// second walks them again and writes every entry no later layer +/// removes, a `.wh.` file by exact path and an `.wh..wh..opq` marker +/// by clearing its parent directory. Returns `false` when the bundle +/// carries no layers.manifest: the prefix matched but the archive is a +/// plain rootfs tarball, and the caller builds from it directly. +pub(crate) fn merge_oci_tarball( + bundle: &Path, + out: &Path, progress: &dyn Progress, -) -> anyhow::Result<()> { - let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or(""); +) -> anyhow::Result { + let temp = tempfile::tempdir_in(out.parent().unwrap_or(Path::new("."))) + .context("Failed to create temp directory for OCI layers")?; + tar_decoder(bundle)?.unpack(temp.path())?; - // Extract the outer OCI bundle (layers.manifest + layer_N.tar.gz files) - // into a temp directory. This is our own format, not an OCI layer, so - // plain unpack is fine here. - let temp_dir = tempfile::tempdir().context("Failed to create temp directory for OCI layers")?; + let manifest_path = temp.path().join("layers.manifest"); + let manifest = match std::fs::read_to_string(&manifest_path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e).context("Failed to read layers.manifest"), + }; + let layers: Vec = manifest + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| temp.path().join(l)) + .collect(); - let file = File::open(tarball) - .with_context(|| format!("Failed to open OCI tarball: {}", tarball.display()))?; - let reader = BufReader::new(file); - - if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { - tar::Archive::new(flate2::read::GzDecoder::new(reader)) - .unpack(temp_dir.path()) - .context("Failed to unpack OCI bundle")?; - } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { - tar::Archive::new(xz2::read::XzDecoder::new(reader)) - .unpack(temp_dir.path()) - .context("Failed to unpack OCI bundle")?; - } else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") { - tar::Archive::new(zstd::stream::read::Decoder::new(reader)?) - .unpack(temp_dir.path()) - .context("Failed to unpack OCI bundle")?; - } else { - tar::Archive::new(reader) - .unpack(temp_dir.path()) - .context("Failed to unpack OCI bundle")?; - } - - let manifest_path = temp_dir.path().join("layers.manifest"); - if !manifest_path.exists() { - // Not our layer-bundle layout: an "oci-" entry whose outer - // archive is a plain rootfs tarball (the dispatch keys on the - // filename prefix, so a non-bundle entry still lands here). - log::debug!("No layers.manifest in bundle; extracting as plain rootfs tarball"); - return extract_single_archive(tarball, dest, progress); - } - - // Read the layers manifest - let manifest = - std::fs::read_to_string(&manifest_path).context("Failed to read layers.manifest")?; - - // One fixup set across all layers: later layers routinely write into - // directories the base layer ships read-only, and hard-link targets - // routinely live in an earlier layer. - let mut fixups = DeferredFixups::default(); - - // Apply each layer in order with full whiteout handling. - for layer_name in manifest.lines() { - let layer_path = temp_dir.path().join(layer_name); - if !layer_path.exists() { + // Pass one: every whiteout across every layer, keyed by layer + // index. A later layer's whiteout removes an earlier layer's path; + // knowing the full set up front is what lets pass two stay a + // single forward walk. + let mut deletes: Vec> = + vec![std::collections::HashSet::new(); layers.len()]; + let mut opaques: Vec> = + vec![std::collections::HashSet::new(); layers.len()]; + for (i, layer) in layers.iter().enumerate() { + if !layer.exists() { continue; } - log::debug!("Extracting layer: {}", layer_name); - extract_oci_layer(&layer_path, dest, &mut fixups, progress)?; - } - - apply_fixups(dest, &mut fixups) -} - -/// Decompress and extract one OCI layer tarball into `dest`, honouring -/// whiteout markers. Compression is inferred from the filename then from -/// magic bytes. -fn extract_oci_layer( - layer_path: &Path, - dest: &Path, - fixups: &mut DeferredFixups, - progress: &dyn Progress, -) -> anyhow::Result<()> { - use std::io::Read; - let layer_name = layer_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); - let file = File::open(layer_path) - .with_context(|| format!("Failed to open layer: {}", layer_path.display()))?; - let reader = BufReader::new(file); - - // The decompressor type differs per format; erase it behind a trait - // object so a single archive-extraction call site serves every branch. - let archive: tar::Archive> = - if layer_name.ends_with(".tar.gz") || layer_name.ends_with(".tgz") { - tar::Archive::new(Box::new(flate2::read::GzDecoder::new(reader))) - } else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") { - tar::Archive::new(Box::new(xz2::read::XzDecoder::new(reader))) - } else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") { - tar::Archive::new(Box::new(zstd::stream::read::Decoder::new(reader)?)) - } else { - // Fall back to magic-byte detection - let mut magic = [0u8; 6]; - let mut peek = BufReader::new(File::open(layer_path)?); - let _ = peek.read_exact(&mut magic); // short reads are fine for detection - drop(peek); - let file = File::open(layer_path)?; - match magic { - [0x1f, 0x8b, ..] => { - tar::Archive::new(Box::new(flate2::read::GzDecoder::new(BufReader::new(file)))) - } - [0xfd, b'7', b'z', b'X', b'Z', 0x00] => { - tar::Archive::new(Box::new(xz2::read::XzDecoder::new(BufReader::new(file)))) - } - [0x28, 0xb5, 0x2f, 0xfd, ..] => tar::Archive::new(Box::new( - zstd::stream::read::Decoder::new(BufReader::new(file))?, - )), - _ => tar::Archive::new(Box::new(BufReader::new(file))), - } - }; - - extract_archive(archive, dest, "Extracting OCI layer", fixups, progress) -} - -fn extract_gz( - reader: R, - dest: &Path, - fixups: &mut DeferredFixups, - progress: &dyn Progress, -) -> anyhow::Result<()> { - let gz_decoder = flate2::read::GzDecoder::new(reader); - let archive = tar::Archive::new(gz_decoder); - - extract_archive(archive, dest, "Extracting gzip archive", fixups, progress)?; - - Ok(()) -} - -fn extract_xz( - reader: R, - dest: &Path, - fixups: &mut DeferredFixups, - progress: &dyn Progress, -) -> anyhow::Result<()> { - let xz_decoder = xz2::read::XzDecoder::new(reader); - let archive = tar::Archive::new(xz_decoder); - - extract_archive(archive, dest, "Extracting xz archive", fixups, progress)?; - - Ok(()) -} - -fn extract_zst( - reader: R, - dest: &Path, - fixups: &mut DeferredFixups, - progress: &dyn Progress, -) -> anyhow::Result<()> { - let zst_decoder = zstd::Decoder::new(reader)?; - let archive = tar::Archive::new(zst_decoder); - - extract_archive(archive, dest, "Extracting zstd archive", fixups, progress)?; - - Ok(()) -} - -fn extract_tar( - reader: R, - dest: &Path, - fixups: &mut DeferredFixups, - progress: &dyn Progress, -) -> anyhow::Result<()> { - let archive = tar::Archive::new(reader); - - extract_archive(archive, dest, "Extracting tar archive", fixups, progress)?; - - Ok(()) -} - -/// Archive entries that must not be materialized inline because the tar -/// order does not guarantee their prerequisites exist yet. -#[derive(Default)] -struct DeferredFixups { - /// Regular-file entries with the mode they ship; applied after the - /// walk. The crate's own chmod runs with the special bits masked - /// off (see `extract_archive`), so this is where a setgid binary - /// gets its bit back — degraded, not fatal, when the filesystem - /// refuses it. - file_modes: Vec<(PathBuf, u32)>, - /// Directory entries with the mode they ship; applied last. Images - /// routinely ship read-only directories (Fedora's ca-trust tree is - /// dr-xr-xr-x) whose contents would be rejected by an eagerly-applied - /// mode. - dir_modes: Vec<(PathBuf, u32)>, - /// Hard link entries as (destination, raw link target); applied after - /// the file pass so targets appearing later in the tar (or in an - /// earlier layer) resolve. - hard_links: Vec<(PathBuf, PathBuf)>, -} - -/// Join an archive-relative entry path onto `dest`, refusing paths that -/// traverse above the root (mirrors tar's own unpack component walk). -fn safe_join(dest: &Path, rel: &Path) -> Option { - let mut out = dest.to_path_buf(); - for part in rel.components() { - match part { - Component::Prefix(_) | Component::RootDir | Component::CurDir => {} - Component::ParentDir => return None, - Component::Normal(p) => out.push(p), - } - } - Some(out) -} - -/// Resolve a raw link target against `dest`, normalizing `..` lexically. -/// None when the target climbs out of the rootfs. -fn normalize_within(dest: &Path, target: &Path) -> Option { - let mut out = dest.to_path_buf(); - for part in target.components() { - match part { - Component::Prefix(_) | Component::RootDir | Component::CurDir => {} - Component::ParentDir => { - if out == dest { - return None; - } - out.pop(); - } - Component::Normal(p) => out.push(p), - } - } - Some(out) -} - -/// Apply hard links and the collected modes after extraction. -fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> anyhow::Result<()> { - for (dst, target) in fixups.hard_links.drain(..) { - apply_hard_link(dest, &dst, &target)?; - } - for (path, mode) in fixups.file_modes.drain(..) { - set_shipped_mode(&path, mode); - } - for (path, mode) in fixups.dir_modes.drain(..) { - set_shipped_mode(&path, mode); - } - Ok(()) -} - -/// Apply one shipped mode, degrading the special bits when the -/// filesystem refuses them. Images ship setgid binaries (alpine's -/// `chage`, group `shadow`) and setgid directories, and an extracting -/// context that cannot set the bit — an overlayfs tmp, a confined -/// sandbox — fails the chmod with EPERM outright. Unprivileged tar -/// degrades the same way: keep the rwx bits, drop setuid/setgid/ -/// sticky, and let the guest re-apply what it needs. The extract -/// never dies for a mode again; a refused plain chmod is only a -/// warning (a later layer's whiteout may have removed the path, and -/// the mode is cosmetic for a machine that lives for one command). -fn set_shipped_mode(path: &Path, mode: u32) { - let applied = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)); - if applied.is_err() && mode & 0o7000 != 0 { - let plain = mode & !0o7000; - log::debug!( - "chmod {:o} refused for {}; extracting {:o} instead", - mode, - path.display(), - plain - ); - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(plain)); - } -} - -/// Materialize one hard link. A real hard link is used when the target -/// exists on disk; otherwise the entry is preserved verbatim as a symlink — -/// some images (Fedora's ca-trust directory-hash) ship links whose targets -/// are generated after linking and never exist anywhere in the archive. -fn apply_hard_link(dest: &Path, dst: &Path, target: &Path) -> anyhow::Result<()> { - if dst.symlink_metadata().is_ok() { - std::fs::remove_file(dst) - .with_context(|| format!("Failed to replace {}", dst.display()))?; - } - if let Some(src) = normalize_within(dest, target) { - if std::fs::hard_link(&src, dst).is_ok() { - return Ok(()); - } - } - std::os::unix::fs::symlink(target, dst) - .with_context(|| format!("Failed to link {} -> {}", dst.display(), target.display()))?; - log::debug!( - "Warning: hard link target {} absent; extracted {} as a symlink", - target.display(), - dst.display() - ); - Ok(()) -} - -/// Extract tar archive entries, reporting a per-file count through -/// `progress` and handling whiteout files for OCI layers -fn extract_archive( - mut archive: tar::Archive, - dest: &Path, - label: &str, - fixups: &mut DeferredFixups, - progress: &dyn Progress, -) -> anyhow::Result<()> { - progress.begin(label, Unit::Files, None); - - // Permissions are applied by hand through the fixups, not here: - // with preservation on, the crate chmods each file to the mode it - // ships, and a setgid entry meeting a filesystem that refuses the - // bit kills the whole extract with EPERM. With preservation off - // the crate's own chmod masks to plain rwx, which no filesystem - // refuses, and the shipped modes come back through the fixups — - // degraded where they must be, exact everywhere else. - archive.set_preserve_permissions(false); - archive.set_preserve_ownerships(false); - archive.set_unpack_xattrs(false); - - let entries = archive - .entries() - .context("Failed to read archive entries")?; - - let mut count: u64 = 0; - for entry in entries { - let mut entry = entry.context("Failed to read archive entry")?; - - // Clone the path before any mutable borrow of entry - let path = entry.path().context("Invalid tar entry path")?.into_owned(); - - let filename = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - - // Handle whiteout files (OCI layer markers for deletions). - // Whiteout paths come from the archive, so both branches resolve - // them through `safe_join` like every other entry: a crafted - // marker whose path or target climbs above the rootfs is - // skipped, never followed host-side. - if filename == ".wh..wh..opq" { - // Opaque whiteout: clear all previously-extracted content in the parent directory - let parent = path.parent().unwrap_or(Path::new("")); - match safe_join(dest, parent) { - Some(dest_dir) => { - if dest_dir.symlink_metadata().is_ok() { - for child in std::fs::read_dir(&dest_dir) - .with_context(|| format!("Failed to read {}", dest_dir.display()))? - { - let child = child?; - let child_path = child.path(); - remove_path(&child_path).with_context(|| { - format!( - "Opaque whiteout: failed to remove {}", - child_path.display() - ) - })?; - } - } - } - None => log::debug!("skipping whiteout above the rootfs: {}", path.display()), - } - // Do not extract the .wh..wh..opq marker itself - } else if let Some(real_name) = filename.strip_prefix(".wh.") { - // Regular whiteout: delete the named path from lower layers. - // The name joins the parent before the check, so a `.wh..` - // marker, whose target would be the parent directory - // itself, is refused like any path that climbs. - let parent = path.parent().unwrap_or(Path::new("")); - match safe_join(dest, &parent.join(real_name)) { - Some(target) => { - if target.symlink_metadata().is_ok() { - remove_path(&target).with_context(|| { - format!("Whiteout: failed to remove {}", target.display()) - })?; - } - } - None => log::debug!("skipping whiteout above the rootfs: {}", path.display()), - } - // Do not extract the .wh.* marker itself - } else if entry.header().entry_type().is_dir() { - // Create the directory but defer its mode: the mode in the tar - // may be read-only, and this directory's own contents (and other - // layers') still need to be written into it. - if let Some(dest_dir) = safe_join(dest, &path) { - std::fs::create_dir_all(&dest_dir) - .with_context(|| format!("Failed to create {}", dest_dir.display()))?; - if let Ok(mode) = entry.header().mode() { - fixups.dir_modes.push((dest_dir, mode)); - } - } - } else if entry.header().entry_type().is_hard_link() { - // Defer: the target may be a later entry in this tar, live in an - // earlier layer, or not exist at all (see apply_hard_link). - if let Some(dest_path) = safe_join(dest, &path) { - if let Some(link) = entry.link_name().context("Invalid hard link target")? { - if !link.as_os_str().is_empty() { - fixups.hard_links.push((dest_path, link.into_owned())); - } - } - } - } else { - entry - .unpack_in(dest) - .with_context(|| format!("Failed to extract {}", path.display()))?; - // The crate left the file at plain rwx; the shipped mode — - // special bits included — goes through the fixups. A - // symlink is skipped: set_permissions follows links, and a - // link's own mode is nothing anyone can apply. - if !entry.header().entry_type().is_symlink() { - if let Some(dest_path) = safe_join(dest, &path) { - if let Ok(mode) = entry.header().mode() { - fixups.file_modes.push((dest_path, mode)); - } - } + for entry in tar_decoder(layer)?.entries().context("reading a layer")? { + let entry = entry.context("reading a layer entry")?; + let path = entry.path().context("Invalid tar entry path")?.into_owned(); + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if name == ".wh..wh..opq" { + let parent = path.parent().unwrap_or(Path::new("")); + opaques[i].insert(normalize_entry_path(parent)); + } else if let Some(real) = name.strip_prefix(".wh.") { + let parent = path.parent().unwrap_or(Path::new("")); + deletes[i].insert(normalize_entry_path(&parent.join(real))); } } - - count += 1; - progress.advance(1); } + // Pass two: the merged archive, headers verbatim. + let out_file = File::create(out).with_context(|| format!("creating {}", out.display()))?; + let mut builder = tar::Builder::new(out_file); + progress.begin("Merging OCI layers", Unit::Files, None); + for (i, layer) in layers.iter().enumerate() { + if !layer.exists() { + continue; + } + for entry in tar_decoder(layer)?.entries().context("reading a layer")? { + let mut entry = entry.context("reading a layer entry")?; + let path = entry.path().context("Invalid tar entry path")?.into_owned(); + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if name == ".wh..wh..opq" || name.starts_with(".wh.") { + continue; + } + let key = normalize_entry_path(&path); + if deleted_by_later(&key, i, &deletes, &opaques) { + continue; + } + let mut header = entry.header().clone(); + if header.entry_type().is_file() { + builder + .append_data(&mut header, &key, &mut entry) + .with_context(|| format!("writing {} into the merged archive", key))?; + } else { + // Directories, links, symlinks, devices: the header + // carries everything, the data is always empty. + builder + .append_data(&mut header, &key, std::io::empty()) + .with_context(|| format!("writing {} into the merged archive", key))?; + } + progress.advance(1); + } + } + builder.finish().context("finishing the merged archive")?; progress.finish(); - log::debug!("Extracted {} files", count); - Ok(()) + Ok(true) } -/// Remove a path: uses remove_dir_all for real directories, remove_file for -/// everything else (regular files, symlinks — including symlinks-to-dirs). -fn remove_path(path: &Path) -> std::io::Result<()> { - // symlink_metadata does not follow symlinks, so a symlink-to-dir correctly - // reports file_type().is_symlink() rather than is_dir(). - let meta = std::fs::symlink_metadata(path)?; - if meta.is_dir() { - std::fs::remove_dir_all(path) - } else { - std::fs::remove_file(path) +/// Normalize an entry path for comparison: plain components joined by +/// slashes, `./` and leading-`/` noise gone, climbs impossible by +/// construction (ParentDir never survives the filter). +fn normalize_entry_path(rel: &Path) -> String { + rel.components() + .filter_map(|c| match c { + std::path::Component::Normal(p) => Some(p.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/") +} + +/// Whether any layer after `layer` removes `key`: a whiteout at the +/// exact path, or an opaque marker clearing a directory that holds it. +fn deleted_by_later( + key: &str, + layer: usize, + deletes: &[std::collections::HashSet], + opaques: &[std::collections::HashSet], +) -> bool { + for j in layer + 1..deletes.len() { + if deletes[j].contains(key) { + return true; + } + for dir in &opaques[j] { + if key == dir || key.starts_with(&format!("{dir}/")) { + return true; + } + } } + false } #[cfg(test)] mod tests { use super::*; use crate::progress::NoProgress; - use std::os::unix::fs::{MetadataExt, PermissionsExt}; - use tar::{Builder, EntryType, Header}; - /// Build a plain tar archive from (path, entry-type, mode, payload) - /// tuples in the given order; link payloads are the raw link targets. - fn build_tar(path: &Path, entries: &[(&str, EntryType, u32, &[u8])]) { - let file = File::create(path).unwrap(); - let mut ar = Builder::new(file); - build_tar_into(&mut ar, entries); - ar.finish().unwrap(); - } - - /// Append (path, entry-type, mode, payload) tuples to an open - /// archive; entry paths go through the builder's own validation. - fn build_tar_into(ar: &mut Builder, entries: &[(&str, EntryType, u32, &[u8])]) { - for (name, kind, mode, payload) in entries { - let mut header = Header::new_gnu(); - header.set_size(if *kind == EntryType::Regular { - payload.len() as u64 - } else { - 0 - }); - header.set_entry_type(*kind); + /// One layer: entries appended as (type, path, mode, uid, data). + fn layer(path: &Path, entries: &[(&str, &str, u32, u64, &[u8])]) { + let mut builder = tar::Builder::new(std::fs::File::create(path).unwrap()); + for (kind, name, mode, uid, data) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); header.set_mode(*mode); - if *kind == EntryType::Symlink || *kind == EntryType::Link { - header - .set_link_name(std::path::PathBuf::from( - std::str::from_utf8(payload).unwrap(), - )) - .unwrap(); - } - header.set_cksum(); - if *kind == EntryType::Regular { - ar.append_data(&mut header, name, *payload).unwrap(); - } else { - ar.append_data(&mut header, name, std::io::empty()).unwrap(); - } + header.set_uid(*uid); + header.set_gid(*uid); + header.set_entry_type(match *kind { + "dir" => tar::EntryType::Directory, + _ => tar::EntryType::Regular, + }); + builder.append_data(&mut header, name, *data).unwrap(); } - } - - /// Fedora's ca-certificates ships etc/pki/ca-trust/extracted/pem/ - /// directory-hash/ as a read-only (0555) directory full of per-cert hash - /// symlinks (002c0b4f.0 -> GlobalSign_Root_R46.pem) whose targets come - /// later in the tar or not at all, plus hard links elsewhere in the - /// image. Extracting must never open or follow a link payload, and must - /// not choke on the read-only directory. - #[test] - fn fedora_directory_hash_links_extract_without_following() { - let src = tempfile::tempdir().unwrap(); - let out = tempfile::tempdir().unwrap(); - let tarball = src.path().join("rootfs.tar"); - build_tar( - &tarball, - &[ - ( - "etc/pki/ca-trust/extracted/pem/directory-hash/", - EntryType::Directory, - 0o555, - b"", - ), - // symlink whose target appears later in the archive - ( - "etc/pki/ca-trust/extracted/pem/directory-hash/002c0b4f.0", - EntryType::Symlink, - 0o777, - b"GlobalSign_Root_R46.pem", - ), - // symlink whose target never appears (dangling) - ( - "etc/pki/ca-trust/extracted/pem/directory-hash/01419da9.0", - EntryType::Symlink, - 0o777, - b"Missing_Root.pem", - ), - // the real cert file, written after the read-only dir exists - ( - "etc/pki/ca-trust/extracted/pem/directory-hash/GlobalSign_Root_R46.pem", - EntryType::Regular, - 0o644, - b"-----BEGIN CERTIFICATE-----", - ), - ], - ); - - extract_tarball(&tarball, out.path(), &NoProgress).unwrap(); - - let base = out - .path() - .join("etc/pki/ca-trust/extracted/pem/directory-hash"); - assert_eq!( - std::fs::read_link(base.join("002c0b4f.0")).unwrap(), - Path::new("GlobalSign_Root_R46.pem") - ); - assert_eq!( - std::fs::read_link(base.join("01419da9.0")).unwrap(), - Path::new("Missing_Root.pem"), - "dangling symlinks must be created verbatim, not followed" - ); - assert_eq!( - std::fs::read(base.join("GlobalSign_Root_R46.pem")).unwrap(), - b"-----BEGIN CERTIFICATE-----" - ); - let dir_mode = base.symlink_metadata().unwrap().permissions().mode() & 0o7777; - assert_eq!(dir_mode, 0o555, "shipped directory mode must be restored"); - } - - /// Alpine ships setgid-shadow binaries (usr/bin/chage at 02755, - /// group `shadow`) and setgid directories. Their modes no longer - /// ride the crate's own chmod — one refused setgid bit there killed - /// the whole extract with EPERM — so they must come back through the - /// fixups exactly where the filesystem allows the bits. - #[test] - fn setgid_entries_keep_their_shipped_modes() { - let src = tempfile::tempdir().unwrap(); - let out = tempfile::tempdir().unwrap(); - let tarball = src.path().join("rootfs.tar"); - build_tar( - &tarball, - &[ - ("usr/", EntryType::Directory, 0o755, b""), - ("usr/bin/", EntryType::Directory, 0o755, b""), - ("usr/bin/chage", EntryType::Regular, 0o2755, b"#!/bin/sh\n"), - // setuid and setgid together, the way su ships - ("usr/bin/su", EntryType::Regular, 0o6755, b"#!/bin/sh\n"), - ("usr/bin/plain", EntryType::Regular, 0o644, b"plain"), - ("var/", EntryType::Directory, 0o755, b""), - ("var/spool/", EntryType::Directory, 0o2755, b""), - ], - ); - - extract_tarball(&tarball, out.path(), &NoProgress).unwrap(); - - let mode = |p: &str| { - std::fs::symlink_metadata(out.path().join(p)) - .unwrap() - .permissions() - .mode() - & 0o7777 - }; - assert_eq!(mode("usr/bin/chage"), 0o2755, "setgid restored"); - assert_eq!(mode("usr/bin/su"), 0o6755, "setuid and setgid restored"); - assert_eq!(mode("usr/bin/plain"), 0o644, "plain modes untouched"); - assert_eq!(mode("var/spool"), 0o2755, "setgid directories restored"); + builder.finish().unwrap(); } #[test] - fn hard_link_to_later_target_extracts() { - let src = tempfile::tempdir().unwrap(); - let out = tempfile::tempdir().unwrap(); - let tarball = src.path().join("rootfs.tar"); - build_tar( - &tarball, + fn the_merge_applies_whiteouts_and_keeps_headers() { + let work = tempfile::tempdir().unwrap(); + let dir = work.path(); + layer( + &dir.join("layer1.tar"), &[ - ("etc/dir/", EntryType::Directory, 0o755, b""), - // hard link recorded before its target - ( - "etc/dir/link.0", - EntryType::Link, - 0o644, - b"etc/dir/real.pem", - ), - ("etc/dir/real.pem", EntryType::Regular, 0o644, b"payload"), + ("dir", "etc", 0o755, 0, b""), + ("file", "etc/a", 0o644, 42, b"a"), + ("file", "etc/keep", 0o644, 42, b"k"), ], ); - - extract_tarball(&tarball, out.path(), &NoProgress).unwrap(); - assert_eq!( - std::fs::read(out.path().join("etc/dir/link.0")).unwrap(), - b"payload" + layer( + &dir.join("layer2.tar"), + &[ + ("file", "etc/.wh.a", 0o0, 0, b""), + ("file", "etc/b", 0o600, 104, b"b"), + ], ); - let link = out - .path() - .join("etc/dir/link.0") - .symlink_metadata() + // The bundle is our own layout: layers.manifest plus layer files. + std::fs::write(dir.join("layers.manifest"), "layer1.tar\nlayer2.tar\n").unwrap(); + let mut bundle = tar::Builder::new(std::fs::File::create(dir.join("bundle.tar")).unwrap()); + bundle + .append_path_with_name(dir.join("layers.manifest"), "layers.manifest") .unwrap(); - assert!( - !link.file_type().is_symlink(), - "target exists later in the tar, so a real hard link is expected" - ); - let link_ino = std::fs::metadata(out.path().join("etc/dir/link.0")) + bundle + .append_path_with_name(dir.join("layer1.tar"), "layer1.tar") + .unwrap(); + bundle + .append_path_with_name(dir.join("layer2.tar"), "layer2.tar") + .unwrap(); + bundle.finish().unwrap(); + + let out = dir.join("merged.tar"); + let had = merge_oci_tarball(&dir.join("bundle.tar"), &out, &NoProgress).unwrap(); + assert!(had); + + let mut archive = tar::Archive::new(std::fs::File::open(&out).unwrap()); + let names: Vec = archive + .entries() .unwrap() - .ino(); - let real_ino = std::fs::metadata(out.path().join("etc/dir/real.pem")) - .unwrap() - .ino(); - assert_eq!(link_ino, real_ino); - } - - /// Whiteout markers delete inside the rootfs and never above it: - /// both a `..`-carrying marker path and a `.wh..` marker, whose - /// target is the parent directory itself, must be refused while the - /// ordinary markers still do their job. - #[test] - fn whiteouts_delete_inside_and_never_above_the_rootfs() { - let src = tempfile::tempdir().unwrap(); - let outside = tempfile::tempdir().unwrap(); - let out = tempfile::tempdir().unwrap(); - let victim = outside.path().join("victim.txt"); - std::fs::write(&victim, b"keep").unwrap(); - - let tarball = src.path().join("rootfs.tar"); - let file = File::create(&tarball).unwrap(); - let mut ar = Builder::new(file); - build_tar_into( - &mut ar, - &[ - ("etc/", EntryType::Directory, 0o755, b""), - ("etc/passwd", EntryType::Regular, 0o644, b"old"), - // the ordinary marker: etc/passwd goes away - ("etc/.wh.passwd", EntryType::Regular, 0o0, b""), - // a marker whose target would be the parent directory - ("etc/.wh..", EntryType::Regular, 0o0, b""), - ("etc/motd", EntryType::Regular, 0o644, b"kept"), - ], - ); - // A marker path climbing above the rootfs, written as a raw - // header: the builder refuses `..` names, an attacker's tar - // does not have to. - let mut header = Header::new_gnu(); - header.set_entry_type(EntryType::Regular); - header.set_size(0); - header.set_mode(0o0); - let mut name = [0u8; 100]; - name[..b"../.wh.victim.txt".len()].copy_from_slice(b"../.wh.victim.txt"); - header.as_gnu_mut().unwrap().name = name; - header.set_cksum(); - ar.append(&header, std::io::empty()).unwrap(); - ar.finish().unwrap(); - - extract_tarball(&tarball, out.path(), &NoProgress).unwrap(); - - assert!( - !out.path().join("etc/passwd").exists(), - "the ordinary whiteout must remove its entry" - ); - assert!( - out.path().join("etc/motd").exists(), - "other entries survive" - ); + .map(|e| { + let e = e.unwrap(); + format!( + "{} {} {:o}", + if e.header().entry_type().is_dir() { + "d" + } else { + "f" + }, + e.path().unwrap().display(), + e.header().mode().unwrap() + ) + }) + .collect(); + // a is whiteout-deleted by layer2; b and keep stay; modes and + // ownership ride the headers verbatim. assert_eq!( - std::fs::read(&victim).unwrap(), - b"keep", - "a whiteout above the rootfs must not touch the host" - ); - assert!( - out.path().exists(), - "the `.wh..` marker must not delete the rootfs itself" + names, + vec![ + "d etc 755".to_string(), + "f etc/keep 644".to_string(), + "f etc/b 600".to_string(), + ], + "{names:?}" ); } #[test] - fn dangling_hard_link_does_not_abort_extraction() { - let src = tempfile::tempdir().unwrap(); - let out = tempfile::tempdir().unwrap(); - let tarball = src.path().join("rootfs.tar"); - build_tar( - &tarball, - &[ - ("etc/dir/", EntryType::Directory, 0o755, b""), - // target is not in the archive at all - ( - "etc/dir/dangling.0", - EntryType::Link, - 0o644, - b"etc/dir/absent.pem", - ), - ("etc/dir/keep.txt", EntryType::Regular, 0o644, b"kept"), - ], - ); - - extract_tarball(&tarball, out.path(), &NoProgress).unwrap(); - assert_eq!( - std::fs::read(out.path().join("etc/dir/keep.txt")).unwrap(), - b"kept", - "extraction must not abort on an unresolvable hard link" - ); - let meta = out.path().join("etc/dir/dangling.0").symlink_metadata(); - assert!(meta.map(|m| m.file_type().is_symlink()).unwrap_or(false)); - assert_eq!( - std::fs::read_link(out.path().join("etc/dir/dangling.0")).unwrap(), - Path::new("etc/dir/absent.pem") - ); + fn a_bundle_without_a_manifest_reports_itself() { + // An "oci-" prefixed archive that is really a plain rootfs + // tarball: the merge finds no layers.manifest and says so, so + // the caller builds from the archive directly. + let work = tempfile::tempdir().unwrap(); + let dir = work.path(); + layer(&dir.join("plain.tar"), &[("dir", "etc", 0o755, 0, b"")]); + let out = dir.join("merged.tar"); + let had = merge_oci_tarball(&dir.join("plain.tar"), &out, &NoProgress).unwrap(); + assert!(!had); + assert!(!out.exists()); } + #[test] fn a_bzip2_tarball_is_a_named_error() { - let dir = tempfile::tempdir().unwrap(); - let tarball = dir.path().join("rootfs.tar.bz2"); - std::fs::write(&tarball, b"BZh9-not-really").unwrap(); - let out = tempfile::tempdir().unwrap(); - let err = format!( - "{:#}", - extract_tarball(&tarball, out.path(), &NoProgress).unwrap_err() - ); - assert!( - err.contains("bzip2-compressed tarballs are not supported"), - "{err}" + // Magic-byte detection: the name says nothing, the stream says + // bzip2, and the refusal names the formats that work. + let work = tempfile::tempdir().unwrap(); + let tarball = work.path().join("mystery"); + std::fs::write(&tarball, b"BZh9").unwrap(); + let err = match tar_decoder(&tarball) { + Err(e) => format!("{e:#}"), + Ok(_) => panic!("a bzip2 stream must not decode"), + }; + assert!(err.contains("bzip2"), "{err}"); + } + + #[test] + fn the_decoder_reads_gz_xz_zst_and_plain() { + use std::io::Write; + + let work = tempfile::tempdir().unwrap(); + let dir = work.path(); + layer(&dir.join("t.tar"), &[("dir", "etc", 0o755, 0, b"")]); + let raw = std::fs::read(dir.join("t.tar")).unwrap(); + + let mut enc = flate2::write::GzEncoder::new( + std::fs::File::create(dir.join("t.tar.gz")).unwrap(), + flate2::Compression::default(), ); + enc.write_all(&raw).unwrap(); + enc.finish().unwrap(); + + let mut enc = + xz2::write::XzEncoder::new(std::fs::File::create(dir.join("t.tar.xz")).unwrap(), 6); + enc.write_all(&raw).unwrap(); + enc.finish().unwrap(); + + let mut enc = zstd::stream::write::Encoder::new( + std::fs::File::create(dir.join("t.tar.zst")).unwrap(), + 3, + ) + .unwrap(); + enc.write_all(&raw).unwrap(); + enc.finish().unwrap(); + + // No extension at all: detection falls to the magic bytes. + std::fs::copy(dir.join("t.tar.gz"), dir.join("blob")).unwrap(); + + for name in ["t.tar", "t.tar.gz", "t.tar.xz", "t.tar.zst", "blob"] { + let count = tar_decoder(&dir.join(name)) + .unwrap() + .entries() + .unwrap() + .count(); + assert_eq!(count, 1, "{name}"); + } } } diff --git a/crates/ecrou/src/image.rs b/crates/ecrou/src/image.rs new file mode 100644 index 0000000..54b8020 --- /dev/null +++ b/crates/ecrou/src/image.rs @@ -0,0 +1,405 @@ +//! The ext4 rootfs image: the rootfs crosses the machine boundary as a +//! raw ext4 image mke2fs builds straight from the image tarball, and +//! per-run provisioning rides debugfs. Both tools ship in e2fsprogs; a +//! missing tool is an environment problem, named as one. +//! +//! Building from the tarball instead of an extracted tree is what keeps +//! ownership honest: mke2fs bakes each entry's recorded uid, gid and +//! mode into the new filesystem, with no host-side chown in between to +//! lose them. An unprivileged extract cannot preserve a tar's owners +//! (chown to an arbitrary uid is root's alone); a tarball to image +//! pipeline never needs one. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{anyhow, Context}; + +use crate::progress::Progress; + +/// Build the rootfs image at `image` from `tarball`. +/// +/// An OCI layer bundle merges into one plain tar first, so the whiteout +/// bookkeeping and the verbatim headers happen in one place; a bundle +/// that turns out to carry no manifest builds directly, like any other +/// archive. mkfs consumes gz, xz and zst tarballs natively. +pub(crate) fn build_rootfs_image( + tarball: &Path, + image: &Path, + progress: &dyn Progress, +) -> anyhow::Result<()> { + progress.message("Building the rootfs image"); + let name = tarball + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + let mut merged: Option = None; + if name.starts_with("oci-") { + let side = image.with_extension("oci.tar"); + if crate::extract::merge_oci_tarball(tarball, &side, progress)? { + merged = Some(side); + } + } + let source: &Path = merged.as_deref().unwrap_or(tarball); + let result = mkfs_from_tar(source, image); + if let Some(side) = merged { + // The merged archive has value only until mke2fs consumes it; + // a failure to clean up must not mask mkfs's own error. + let _ = std::fs::remove_file(side); + } + result +} + +/// Run mke2fs over the archive. The filesystem size and inode count +/// come from a scan of the archive: mke2fs derives neither from a tar. +fn mkfs_from_tar(tarball: &Path, image: &Path) -> anyhow::Result<()> { + let (bytes, entries) = tar_scan(tarball)?; + // Headroom for the journal, the inode tables and block rounding. + // The file is created sparse, so generous headroom costs nothing + // on disk and gives the guest room to install packages. + let fs_bytes = bytes + bytes / 8 + 512 * 1024 * 1024; + // The disk presents in 512-byte sectors: a size in between hides + // its tail from the guest. + let fs_bytes = fs_bytes.div_ceil(4096) * 4096; + // -N pins the inode count: the ratio default does not know the + // archive's file count and comes up short on trees of small files. + let inodes = entries + entries / 4 + 2048; + + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(image) + .with_context(|| format!("creating {}", image.display()))?; + file.set_len(fs_bytes) + .with_context(|| format!("sizing {}", image.display()))?; + drop(file); + + let out = Command::new("mkfs.ext4") + .args(["-q", "-F", "-N", &inodes.to_string(), "-d"]) + .arg(tarball) + .arg(image) + .output() + .map_err(|e| anyhow!("mkfs.ext4 not found ({e}): install e2fsprogs; the snap stages it"))?; + if !out.status.success() { + return Err(anyhow!( + "mkfs.ext4 failed building the rootfs image: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) +} + +/// The uncompressed byte total and entry count of an image tarball. +fn tar_scan(tarball: &Path) -> anyhow::Result<(u64, u64)> { + let mut archive = crate::extract::tar_decoder(tarball)?; + let mut bytes = 0u64; + let mut entries = 0u64; + for entry in archive.entries().context("reading the archive")? { + let entry = entry.context("reading an archive entry")?; + entries += 1; + bytes += entry.header().entry_size().unwrap_or(0); + } + Ok((bytes, entries)) +} + +/// Copy the cache entry image into the scratch directory. cp reflinks +/// where the filesystem has them and keeps the copy sparse either way: +/// an image occupying 400 MiB must not materialize a GiB of holes. +pub(crate) fn copy_image(src: &Path, dst: &Path) -> anyhow::Result<()> { + let out = Command::new("cp") + .args(["--reflink=auto", "--sparse=auto"]) + .arg(src) + .arg(dst) + .output() + .map_err(|e| anyhow!("cp not found ({e}): coreutils is required"))?; + if !out.status.success() { + return Err(anyhow!( + "copying the rootfs image failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) +} + +/// Write files into an image: guest paths unlinked first (a stale +/// control file, a symlink standing where a regular file must), then +/// host files copied in and their modes pinned. One debugfs process +/// does all of it; no mount anywhere. An unlink of an absent path is +/// noise, not an error: the link set is defensive. +pub(crate) fn provision( + image: &Path, + unlinks: &[&str], + files: &[(&Path, &str, u32)], +) -> anyhow::Result<()> { + if files.is_empty() { + return Ok(()); + } + let cmds = image.with_extension("provision"); + let mut script = String::new(); + for guest in unlinks { + script.push_str(&format!("rm {guest}\n")); + } + for (host, guest, mode) in files { + script.push_str(&format!("write {} {guest}\n", host.display())); + // sif mode replaces the whole i_mode, type bits included: a + // bare 0755 leaves an inode with no file type, and the + // kernel's ext4_lookup refuses it as bogus. Every provisioned + // file is regular, so the regular-file type goes in with the + // permission bits. + script.push_str(&format!("sif {guest} mode 0{:o}\n", 0o100000 | mode)); + } + std::fs::write(&cmds, script).with_context(|| format!("writing {}", cmds.display()))?; + let out = Command::new("debugfs") + .args(["-w", "-f"]) + .arg(&cmds) + .arg(image) + .output() + .map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs; the snap stages it"))?; + let _ = std::fs::remove_file(&cmds); + if !out.status.success() { + return Err(anyhow!( + "debugfs failed provisioning {}: {}", + image.display(), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) +} + +/// A file's content out of the image. debugfs echoes commands and +/// prints a banner on stdout; both start with "debugfs" and the only +/// reader (the exit-code control file) holds an integer, so those +/// lines are dropped. An absent file reads back empty. +pub(crate) fn read_file(image: &Path, guest: &str) -> anyhow::Result { + let out = Command::new("debugfs") + .arg("-R") + .arg(format!("cat {guest}")) + .arg(image) + .output() + .map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs"))?; + let text = String::from_utf8_lossy(&out.stdout); + Ok(text + .lines() + .filter(|l| !l.starts_with("debugfs")) + .collect::>() + .join("\n")) +} + +/// Whether a guest path exists in the image: a debugfs stat that finds +/// an inode. Symlinks resolve; stat follows them like the guest will. +pub(crate) fn stat_exists(image: &Path, guest: &str) -> anyhow::Result { + let out = Command::new("debugfs") + .arg("-R") + .arg(format!("stat {guest}")) + .arg(image) + .output() + .map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs"))?; + Ok(String::from_utf8_lossy(&out.stdout).contains("Inode:")) +} + +/// Which shell the image ships, probed in the image. The order mirrors +/// `utils::detect_shell` over an extracted tree, which the image build +/// replaced; the fallback is the same, and fails in the guest with a +/// clear exec error if the image ships no shell at all. +pub(crate) fn detect_shell(image: &Path) -> anyhow::Result<&'static str> { + for shell in ["/bin/bash", "/bin/sh", "/usr/bin/bash", "/usr/bin/sh"] { + if stat_exists(image, shell)? { + return Ok(shell); + } + } + Ok("/bin/sh") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::MetadataExt; + + fn have_e2fsprogs() -> bool { + Command::new("mkfs.ext4").arg("-V").output().is_ok() + && Command::new("debugfs").arg("-V").output().is_ok() + } + + /// A tarball with one root-owned directory and a file owned by uid + /// 104, the way distro archives record ownership. Gzip when the + /// name says so, so both dispatch paths get exercised. + fn fixture(tarball: &Path) { + let file = std::fs::File::create(tarball).unwrap(); + let name = tarball.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // The erased writer keeps one build path; the compression + // comes from the name, exactly like the reader dispatches. + let mut builder = tar::Builder::new(Box::new(if name.ends_with(".tar.gz") { + Box::new(flate2::write::GzEncoder::new( + file, + flate2::Compression::default(), + )) as Box + } else { + Box::new(file) as Box + })); + + let mut dir = tar::Header::new_gnu(); + dir.set_size(0); + dir.set_entry_type(tar::EntryType::Directory); + dir.set_mode(0o755); + dir.set_uid(0); + dir.set_gid(0); + builder + .append_data(&mut dir, "etc", std::io::empty()) + .unwrap(); + + let mut file = tar::Header::new_gnu(); + file.set_size(2); + file.set_entry_type(tar::EntryType::Regular); + file.set_mode(0o644); + file.set_uid(104); + file.set_gid(104); + builder + .append_data(&mut file, "etc/marker", b"hi" as &[u8]) + .unwrap(); + + builder.finish().unwrap(); + } + + fn build(tarball: &Path, image: &Path) { + build_rootfs_image(tarball, image, &crate::progress::NoProgress).unwrap(); + } + + #[test] + fn the_image_carries_the_tarball_ownership() { + if !have_e2fsprogs() { + eprintln!("SKIP: no e2fsprogs"); + return; + } + let work = tempfile::tempdir().unwrap(); + let tarball = work.path().join("rootfs.tar.gz"); + fixture(&tarball); + let image = work.path().join("rootfs.img"); + build(&tarball, &image); + + let out = Command::new("debugfs") + .arg("-R") + .arg("stat /etc/marker") + .arg(&image) + .output() + .unwrap(); + let stat = String::from_utf8_lossy(&out.stdout); + assert!( + stat.lines() + .any(|l| l.contains("User:") && l.contains("104")), + "{stat}" + ); + assert!( + stat.lines() + .any(|l| l.contains("Group:") && l.contains("104")), + "{stat}" + ); + } + + #[test] + fn provisioning_writes_files_and_modes() { + if !have_e2fsprogs() { + eprintln!("SKIP: no e2fsprogs"); + return; + } + let work = tempfile::tempdir().unwrap(); + let tarball = work.path().join("rootfs.tar"); + fixture(&tarball); + let image = work.path().join("rootfs.img"); + build(&tarball, &image); + + let script = work.path().join("ecrou-init"); + std::fs::write(&script, "#!/bin/sh\necho one\n").unwrap(); + + // The unlink of an absent path is noise: resolv.conf does not + // exist in this fixture, /.ecrou-exit does not either. + provision( + &image, + &["/.ecrou-exit", "/etc/resolv.conf"], + &[(&script, "/ecrou-init", 0o755)], + ) + .unwrap(); + + assert_eq!( + read_file(&image, "/ecrou-init").unwrap(), + "#!/bin/sh\necho one" + ); + assert!(stat_exists(&image, "/ecrou-init").unwrap()); + assert!(!stat_exists(&image, "/etc/marker-does-not-exist").unwrap()); + + let out = Command::new("debugfs") + .arg("-R") + .arg("stat /ecrou-init") + .arg(&image) + .output() + .unwrap(); + let stat = String::from_utf8_lossy(&out.stdout); + assert!(stat.contains("Mode: 0755"), "{stat}"); + } + + #[test] + fn a_plain_tarball_builds_the_same_way() { + if !have_e2fsprogs() { + eprintln!("SKIP: no e2fsprogs"); + return; + } + let work = tempfile::tempdir().unwrap(); + let tarball = work.path().join("rootfs.tar"); + fixture(&tarball); + let image = work.path().join("rootfs.img"); + build(&tarball, &image); + assert_eq!(read_file(&image, "/etc/marker").unwrap(), "hi"); + } + + #[test] + fn a_missing_file_reads_back_empty() { + if !have_e2fsprogs() { + eprintln!("SKIP: no e2fsprogs"); + return; + } + let work = tempfile::tempdir().unwrap(); + let tarball = work.path().join("rootfs.tar"); + fixture(&tarball); + let image = work.path().join("rootfs.img"); + build(&tarball, &image); + assert_eq!(read_file(&image, "/.ecrou-exit").unwrap(), ""); + } + + #[test] + fn the_shell_probe_finds_what_the_image_ships() { + if !have_e2fsprogs() { + eprintln!("SKIP: no e2fsprogs"); + return; + } + let work = tempfile::tempdir().unwrap(); + let tarball = work.path().join("rootfs.tar"); + fixture(&tarball); + let image = work.path().join("rootfs.img"); + build(&tarball, &image); + // The fixture ships no shell: the fallback, not a probe hit. + assert_eq!(detect_shell(&image).unwrap(), "/bin/sh"); + } + + #[test] + fn the_scratch_copy_stays_sparse() { + if !have_e2fsprogs() { + eprintln!("SKIP: no e2fsprogs"); + return; + } + let work = tempfile::tempdir().unwrap(); + let tarball = work.path().join("rootfs.tar"); + fixture(&tarball); + let entry = work.path().join("entry.img"); + build(&tarball, &entry); + // The headroom truncation is sparse: real blocks stay small. + let real = entry.metadata().unwrap().blocks() * 512; + let apparent = entry.metadata().unwrap().len(); + assert!(real < apparent / 2, "real {real}, apparent {apparent}"); + + let scratch = work.path().join("scratch.img"); + copy_image(&entry, &scratch).unwrap(); + let copied = scratch.metadata().unwrap(); + assert_eq!(copied.len(), apparent); + assert!(copied.blocks() * 512 < apparent / 2); + } +} diff --git a/crates/ecrou/src/krun.rs b/crates/ecrou/src/krun.rs index ef0853b..f7a28ca 100644 --- a/crates/ecrou/src/krun.rs +++ b/crates/ecrou/src/krun.rs @@ -30,13 +30,17 @@ const LOAD_REMEDIATION: &str = "install libkrun, or point LD_LIBRARY_PATH \ at the directory holding it (a snap stages it next to the binary)"; macro_rules! symbol { - ($lib:expr, $name:expr, $ty:ty) => {{ + ($lib:expr, $name:expr, $ty:ty) => { + symbol!($lib, $name, $ty, "libkrun too old?") + }; + ($lib:expr, $name:expr, $ty:ty, $hint:expr) => {{ // Safety: the symbol is used through the library handle with the // signature from libkrun.h written at the call site. unsafe { $lib.get::<$ty>($name) }.with_context(|| { format!( - "symbol {} missing: libkrun too old?", - String::from_utf8_lossy($name) + "symbol {} missing: {}", + String::from_utf8_lossy($name), + $hint ) })? }}; @@ -125,6 +129,57 @@ impl Krun { krun_ret(unsafe { f(ctx_id, root_c.as_ptr()) }, "krun_set_root") } + /// `krun_add_disk`: a raw image as a virtio-blk device. An engine + /// built without the blk feature ships no such symbol, and the + /// lookup failure names that, not age. + pub fn add_disk( + &self, + ctx_id: u32, + block_id: &str, + path: &Path, + read_only: bool, + ) -> Result<()> { + let block_c = CString::new(block_id).context("block id contains a NUL")?; + let path_c = path_cstring(path)?; + let f = symbol!( + self.lib, + b"krun_add_disk\0", + unsafe extern "C" fn(u32, *const c_char, *const c_char, bool) -> i32, + "the engine was built without the blk feature, so it ships no block devices" + ); + // Safety: both CStrings are NUL-terminated and outlive the call. + krun_ret( + unsafe { f(ctx_id, block_c.as_ptr(), path_c.as_ptr(), read_only) }, + "krun_add_disk", + ) + } + + /// `krun_set_root_disk_remount`: which block device the init blob + /// mounts as `/` and chroots into. A disk must have been added + /// first; the mount options stay at their defaults. + pub fn set_root_disk_remount(&self, ctx_id: u32, device: &str, fstype: &str) -> Result<()> { + let device_c = CString::new(device).context("device contains a NUL")?; + let fstype_c = CString::new(fstype).context("fstype contains a NUL")?; + let f = symbol!( + self.lib, + b"krun_set_root_disk_remount\0", + unsafe extern "C" fn(u32, *const c_char, *const c_char, *const c_char) -> i32, + "the engine was built without the blk feature, so it ships no block devices" + ); + // Safety: the CStrings are NUL-terminated and outlive the call. + krun_ret( + unsafe { + f( + ctx_id, + device_c.as_ptr(), + fstype_c.as_ptr(), + std::ptr::null(), + ) + }, + "krun_set_root_disk_remount", + ) + } + /// `krun_add_virtiofs`: an extra tagged share, mounted guest-side. pub fn add_virtiofs(&self, ctx_id: u32, tag: &str, path: &Path) -> Result<()> { let tag_c = CString::new(tag).context("share tag contains a NUL")?; diff --git a/crates/ecrou/src/lib.rs b/crates/ecrou/src/lib.rs index 2a8f44d..962384b 100644 --- a/crates/ecrou/src/lib.rs +++ b/crates/ecrou/src/lib.rs @@ -2,11 +2,11 @@ //! //! This crate is the library behind the `ecrou` CLI. It resolves distro //! and OCI image references, downloads them through a content cache, -//! extracts the rootfs into a scratch directory, and boots it as a -//! one-shot microVM: one command runs as the machine's only workload, -//! its stdio streams through, its exit code comes back, and the machine -//! is thrown away. The rootfs crosses as a virtiofs share, the workspace -//! as explicit shares the consumer names. +//! builds the ext4 image the machine boots, and runs it as a one-shot +//! microVM: one command runs as the machine's only workload, its stdio +//! streams through, its exit code comes back, and the machine is thrown +//! away. The rootfs crosses as a raw image on a virtio-blk disk, the +//! workspace as explicit virtiofs shares the consumer names. //! //! The founding rule: the host process never mounts a filesystem and //! never enters a namespace. Every boundary crossing is a file or a @@ -35,8 +35,9 @@ pub mod cache; pub mod distro; pub mod download; pub mod error; -pub mod extract; +mod extract; pub mod guest; +mod image; pub mod krun; pub mod progress; mod registry; diff --git a/crates/ecrou/src/progress.rs b/crates/ecrou/src/progress.rs index 4bcbd66..4c4d5a3 100644 --- a/crates/ecrou/src/progress.rs +++ b/crates/ecrou/src/progress.rs @@ -1,8 +1,8 @@ //! Progress reporting for long-running operations. //! -//! The library never touches the terminal: downloads, extraction and -//! initramfs creation report their progress through the [`Progress`] -//! sink supplied by the caller. Consumers choose what to render — +//! The library never touches the terminal: downloads and image builds +//! report their progress through the [`Progress`] sink supplied by the +//! caller. Consumers choose what to render — //! progress bars, spinners, structured events — or pass [`NoProgress`] //! for silence. @@ -11,7 +11,7 @@ pub enum Unit { /// Bytes transferred (downloads). Bytes, - /// Files processed (extraction, archive creation). + /// Files processed (archive merges, the image build). Files, } diff --git a/crates/ecrou/src/registry.rs b/crates/ecrou/src/registry.rs index 36ebe49..52cf4e8 100644 --- a/crates/ecrou/src/registry.rs +++ b/crates/ecrou/src/registry.rs @@ -523,7 +523,8 @@ fn download_blob_with_auth( } /// Map an OCI layer mediaType to the appropriate file extension. -/// The extension is stored in layers.manifest and used by extract_oci_layer +/// The extension is stored in layers.manifest and read back by the +/// OCI merge in [`crate::extract`] /// for compression dispatch, so it must accurately reflect the blob encoding. fn media_type_to_extension(media_type: &str) -> &'static str { match media_type { diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs index b0ae984..e0c168a 100644 --- a/crates/ecrou/src/rootfs.rs +++ b/crates/ecrou/src/rootfs.rs @@ -1,6 +1,12 @@ //! Cache-aware rootfs lifecycle: resolve an image reference, download -//! through the tarball cache, and extract into a scratch directory for -//! [`crate::vm`] to boot. +//! through the tarball cache, and build the ext4 image the machine +//! boots. A run copies the cached image into a scratch directory, +//! provisions its init into the copy, and throws it all away on exit. + +//! The image is the deliverable: mke2fs builds it straight from the +//! tarball, so the ownership the archive records is the ownership the +//! guest sees - guest root on a real filesystem can then change it at +//! will, which a virtiofs share owned by the host user never allowed. //! //! The run path is [`RootfsCache::prepare`]: a present cache entry is //! trusted as-is — the registry is never contacted — and the freshness @@ -21,7 +27,7 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context}; -use crate::cache::{extract_prepared, oci_freshness_changed, EntryLock, RootfsCache}; +use crate::cache::{oci_freshness_changed, EntryLock, RootfsCache}; use crate::distro::{resolve_source, ImageSource}; use crate::download::download_image; use crate::error::Error; @@ -40,10 +46,10 @@ pub struct Pulled { } impl RootfsCache { - /// Resolve `req`, download through the cache when needed, and extract - /// into a fresh scratch directory (removed when the returned - /// [`PreparedRootfs`] is dropped). Download and extraction progress is - /// reported through `progress`. + /// Resolve `req`, download through the cache when needed, and copy + /// the entry image into a fresh scratch directory (removed when the + /// returned [`PreparedRootfs`] is dropped). Download and image-build + /// progress is reported through `progress`. /// /// This is the boot path, not the freshness path: a present entry /// is trusted as-is and the registry is never contacted — the @@ -51,29 +57,72 @@ impl RootfsCache { /// pulls by default, because there is nothing to boot otherwise. pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result { let (source, cache_path, _) = self.ensure_entry(req, progress, false)?; - // A local archive bypasses the cache: the file is the source - // on every run (--no-cache has nothing to say about it), and - // the derived entry name only matters for diagnostics. - if let ImageSource::LocalTarball { path } = &source { - return extract_prepared(path, progress).map_err(Error::Other); + let scratch = tempfile::tempdir().context("Failed to create temporary directory")?; + let image = scratch.path().join("rootfs.img"); + match &source { + // A local archive bypasses the cache: the file is the + // source on every run (--no-cache has nothing to say about + // it), and the derived entry name only matters for + // diagnostics. + ImageSource::LocalTarball { path } => { + crate::image::build_rootfs_image(path, &image, progress)?; + } + _ => { + let entry_image = cache_path.with_extension("img"); + self.ensure_image(&cache_path, &entry_image, progress)?; + crate::image::copy_image(&entry_image, &image)?; + } } - Ok(extract_prepared(&cache_path, progress)?) + Ok(PreparedRootfs { scratch }) } - /// Fetch or refresh the image's cache entry without extracting it: - /// resolve the reference, re-check unpinned OCI tags against the + /// Fetch or refresh the cache entry without booting it: resolve + /// the reference, re-check unpinned OCI tags against the /// registry digest, re-resolve floating distro aliases, and /// download only when the entry is absent or has moved. The /// explicit freshness operation — `prepare` (and so `run`) boots /// the local entry untouched. pub fn pull(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result { let (_, cache_path, downloaded) = self.ensure_entry(req, progress, true)?; + // The image is the deliverable: pull leaves the entry ready to + // boot, so a run pays for a copy, never for a build. + let entry_image = cache_path.with_extension("img"); + self.ensure_image(&cache_path, &entry_image, progress)?; Ok(Pulled { cache_path, downloaded, }) } + /// The entry's image, built from the entry tarball when absent or + /// older than the tarball. Runs copy the image; the tarball is the + /// build input only. One writer per entry, like the download path. + fn ensure_image(&self, tarball: &Path, image: &Path, progress: &dyn Progress) -> Result<()> { + let mtime = |p: &Path| { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }; + let fresh = || image.exists() && mtime(image) >= mtime(tarball); + if fresh() { + return Ok(()); + } + std::fs::create_dir_all(&self.dir) + .with_context(|| format!("Failed to create cache directory {}", self.dir.display()))?; + let _entry_lock = EntryLock::acquire(image, progress).map_err(Error::Other)?; + // Re-derive freshness under the lock: a competing run may have + // built the image while we waited. + if fresh() { + log::debug!( + "another run built {} while we waited; using it", + image.display() + ); + return Ok(()); + } + crate::image::build_rootfs_image(tarball, image, progress)?; + Ok(()) + } + /// The shared spine of `prepare` and `pull`: resolve the request /// and see to it the pristine cache entry exists and is current. /// @@ -190,18 +239,32 @@ impl PrepareRequest { } } -/// A rootfs extracted into a scratch directory, ready for -/// [`crate::vm::run`] to boot. The directory is removed when this -/// value is dropped. +/// A rootfs image copied into a scratch directory, ready for +/// [`crate::vm::run`] to boot. The scratch directory is removed when +/// this value is dropped. #[derive(Debug)] pub struct PreparedRootfs { - pub(crate) rootfs: TempDir, + pub(crate) scratch: TempDir, } impl PreparedRootfs { - /// The extracted rootfs directory. + /// The run's scratch directory: the image copy lives here, next to + /// the console capture and the files waiting to be provisioned in. pub fn dir(&self) -> &Path { - self.rootfs.path() + self.scratch.path() + } + + /// The image copy the machine boots. + pub(crate) fn image(&self) -> PathBuf { + self.scratch.path().join("rootfs.img") + } + + /// Per-run surgery on the image copy: the generated init at its + /// fixed guest path, the host resolver over whatever the image + /// shipped, a stale control file removed. debugfs does it; nothing + /// mounts. + pub(crate) fn provision(&self, unlinks: &[&str], files: &[(&Path, &str, u32)]) -> Result<()> { + crate::image::provision(&self.image(), unlinks, files).map_err(Error::Other) } } @@ -210,6 +273,13 @@ mod tests { use super::*; use crate::progress::NoProgress; + fn have_e2fsprogs() -> bool { + std::process::Command::new("mkfs.ext4") + .arg("-V") + .output() + .is_ok() + } + #[test] fn pull_rejects_local_archives() { let cache = RootfsCache::new("/cache"); @@ -220,6 +290,10 @@ mod tests { #[test] fn pull_on_a_cached_entry_reports_no_download() { + if !have_e2fsprogs() { + eprintln!("SKIP: no e2fsprogs"); + return; + } let dir = tempfile::tempdir().unwrap(); let cache = RootfsCache::new(dir.path().to_path_buf()); let pinned = crate::distro::parse_image_ref( @@ -229,7 +303,18 @@ mod tests { .unwrap(); let entry = cache.tarball_path(&pinned, "amd64"); std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); - std::fs::write(&entry, b"x").unwrap(); + // A real (tiny) rootfs tarball: the entry is trusted, but pull + // still owes the cache its bootable image, built from it. + let file = std::fs::File::create(&entry).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut tar = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(0); + header.set_entry_type(tar::EntryType::Directory); + header.set_mode(0o755); + tar.append_data(&mut header, "etc", std::io::empty()) + .unwrap(); + tar.into_inner().unwrap().finish().unwrap(); let req = PrepareRequest::new( "ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -237,6 +322,17 @@ mod tests { let pulled = cache.pull(&req, &NoProgress).unwrap(); assert!(!pulled.downloaded); assert_eq!(pulled.cache_path, entry); + // The image is the deliverable: pull leaves it next to the + // tarball, and a second pull rebuilds nothing. + let image = entry.with_extension("img"); + assert!(image.exists()); + let before = std::fs::metadata(&image).unwrap().modified().unwrap(); + let pulled = cache.pull(&req, &NoProgress).unwrap(); + assert!(!pulled.downloaded); + assert_eq!( + std::fs::metadata(&image).unwrap().modified().unwrap(), + before + ); } #[test] @@ -277,12 +373,16 @@ mod tests { #[test] fn a_local_archive_prepares_without_touching_the_cache() { + if !have_e2fsprogs() { + eprintln!("SKIP: no e2fsprogs"); + return; + } // A rootfs tree, packed next to itself as ./rootfs.tar.gz, the // way a caller names a local archive. let work = tempfile::tempdir().unwrap(); let tree = work.path().join("tree"); std::fs::create_dir_all(&tree).unwrap(); - std::fs::write(tree.join("etc-marker"), b"local").unwrap(); + std::fs::write(tree.join("etc-marker"), b"local\n").unwrap(); let tarball = work.path().join("rootfs.tar.gz"); let file = std::fs::File::create(&tarball).unwrap(); let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); @@ -296,10 +396,17 @@ mod tests { // process's working directory, not the tempdir. let req = PrepareRequest::new(tarball.to_str().unwrap()); let rootfs = cache.prepare(&req, &NoProgress).expect("prepares"); - assert_eq!( - std::fs::read(rootfs.dir().join("etc-marker")).unwrap(), - b"local" - ); + // The scratch holds a bootable image carrying the tree, not a + // tree itself: the marker is read back out of the image. + let image = rootfs.dir().join("rootfs.img"); + assert!(image.exists()); + let out = std::process::Command::new("debugfs") + .arg("-R") + .arg("cat /etc-marker") + .arg(&image) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout), "local\n"); // Nothing was downloaded or stashed: the file is the source. assert!(cache_dir.path().read_dir().unwrap().next().is_none()); } diff --git a/crates/ecrou/src/supervise.rs b/crates/ecrou/src/supervise.rs index d4253dc..0c7f4a5 100644 --- a/crates/ecrou/src/supervise.rs +++ b/crates/ecrou/src/supervise.rs @@ -209,11 +209,11 @@ pub(crate) fn kill_machine(child: libc::pid_t) { pub(crate) fn wait_machine( child: libc::pid_t, reaped: &std::sync::atomic::AtomicI32, - exit_file_host: &Path, + read_exit: &dyn Fn() -> Option, ) -> Result { let reaped_status = reaped.load(std::sync::atomic::Ordering::SeqCst); if reaped_status >= 0 { - return decode_status(reaped_status, exit_file_host); + return decode_status(reaped_status, read_exit); } let mut status: libc::c_int = 0; // Safety: waitpid on our own child; the pointer points at valid @@ -233,17 +233,18 @@ pub(crate) fn wait_machine( .context("waiting for the machine process"), )); } - decode_status(status, exit_file_host) + decode_status(status, read_exit) } /// Translate the machine child's raw wait status into the result of a -/// run. Only the guest init can mint a guest exit code: it reports the -/// payload's code through the control file and the VMM exits the child -/// with it. Everything else is ecrou's own machinery talking, and is -/// an error, never a code — the shell's 128+n convention would -/// otherwise masquerade a SIGKILLed or OOM-killed VMM as the payload -/// having died by that signal. -fn decode_status(status: libc::c_int, exit_file_host: &Path) -> Result { +/// run. Only the guest init can mint a guest exit code, and it does so +/// through the control file, which outranks the child's own exit code: +/// with a block root the engine's exit-code channel (an ioctl through +/// the virtiofs root device) never fires, so the child's code is +/// machinery talk. Everything else is an error, never a code — the +/// shell's 128+n convention would otherwise masquerade a SIGKILLed or +/// OOM-killed VMM as the payload having died by that signal. +fn decode_status(status: libc::c_int, read_exit: &dyn Fn() -> Option) -> Result { use std::os::unix::process::ExitStatusExt; let status = std::process::ExitStatus::from_raw(status); if let Some(sig) = status.signal() { @@ -256,18 +257,20 @@ fn decode_status(status: libc::c_int, exit_file_host: &Path) -> Result { ))); } let code = status.code().unwrap_or(125); + // The init writes the payload's code before it exits; a file that + // parses is the run's answer, whatever the child said. + if let Some(s) = read_exit() { + if let Ok(c) = s.trim().parse::() { + return Ok(c); + } + } match code { // Reserved by the machinery itself: 125 is the child's bail // code (boot failure), 126 is the init refusing to run the - // payload (a share that would not mount, a missing workdir). A - // payload that genuinely exits either code reported through - // the control file first — the file is the arbiter. + // payload (a share that would not mount, a missing workdir). + // Nothing reported through the control file, so these are + // exactly what they say. 125 | 126 => { - if let Ok(s) = std::fs::read_to_string(exit_file_host) { - if let Ok(c) = s.trim().parse::() { - return Ok(c); - } - } let cause = if code == 125 { "the machine failed to boot" } else { @@ -304,7 +307,10 @@ mod tests { signaled(libc::SIGKILL), signaled(libc::SIGTERM), ] { - let err = decode_status(raw, &dir.path().join("absent")).unwrap_err(); + let err = decode_status(raw, &|| { + std::fs::read_to_string(dir.path().join("absent")).ok() + }) + .unwrap_err(); assert!( err.to_string().contains("engine or hypervisor failure"), "{err}" @@ -316,36 +322,36 @@ mod tests { fn a_boot_failure_is_an_error_and_a_payload_code_is_not() { let dir = tempfile::tempdir().unwrap(); let exit_file = dir.path().join(".ecrou-exit"); + let read = || std::fs::read_to_string(&exit_file).ok(); // 125 with no report: the child's bail code. - let err = decode_status(exited(125), &exit_file).unwrap_err(); + let err = decode_status(exited(125), &read).unwrap_err(); assert!(err.to_string().contains("failed to boot"), "{err}"); // 126 with no report: the init refused (share, workdir). - let err = decode_status(exited(126), &exit_file).unwrap_err(); + let err = decode_status(exited(126), &read).unwrap_err(); assert!(err.to_string().contains("share failed to mount"), "{err}"); // A payload that genuinely exited a reserved code reported // through the control file first. std::fs::write(&exit_file, "7").unwrap(); - assert_eq!(decode_status(exited(125), &exit_file).unwrap(), 7); + assert_eq!(decode_status(exited(125), &read).unwrap(), 7); std::fs::write(&exit_file, "3").unwrap(); - assert_eq!(decode_status(exited(126), &exit_file).unwrap(), 3); + assert_eq!(decode_status(exited(126), &read).unwrap(), 3); } #[test] - fn an_ordinary_code_needs_no_control_file() { + fn the_control_file_outranks_the_childs_code() { let dir = tempfile::tempdir().unwrap(); - assert_eq!( - decode_status(exited(42), &dir.path().join("absent")).unwrap(), - 42 - ); - assert_eq!( - decode_status(exited(0), &dir.path().join("absent")).unwrap(), - 0 - ); - // A garbage control file does not turn a normal code into an - // error either — the code came through the VMM's own exit. let exit_file = dir.path().join(".ecrou-exit"); + // The usual block-root run: the payload exited 42, the engine + // had no channel, the child says 0. The file wins. + std::fs::write(&exit_file, "42").unwrap(); + let read = || std::fs::read_to_string(&exit_file).ok(); + assert_eq!(decode_status(exited(0), &read).unwrap(), 42); + // A file that does not parse falls through to the child's code. std::fs::write(&exit_file, "not a number").unwrap(); - assert_eq!(decode_status(exited(42), &exit_file).unwrap(), 42); + assert_eq!(decode_status(exited(42), &read).unwrap(), 42); + // An absent file too. + assert_eq!(decode_status(exited(42), &|| None::).unwrap(), 42); + assert_eq!(decode_status(exited(0), &|| None::).unwrap(), 0); } #[test] diff --git a/crates/ecrou/src/vm.rs b/crates/ecrou/src/vm.rs index 017f71f..b140490 100644 --- a/crates/ecrou/src/vm.rs +++ b/crates/ecrou/src/vm.rs @@ -8,7 +8,7 @@ //! the control file read back for the exit code. The VMM runs inside //! this process, so a kill of the process is a kill of the machine (T4). -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::{anyhow, Context}; @@ -158,18 +158,24 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result>(); - let payload = payload_command(options, rootfs.dir()); - let env = compose_env(rootfs.dir(), options, &hostname); + // The shell the image ships, probed in the image: with no + // extracted tree, detection is a debugfs stat, not a file check. + let shell = crate::image::detect_shell(&image).map_err(Error::Other)?; + let payload = payload_command(options, shell); + let env = compose_env(shell, options, &hostname); // The guest init is generated for every run: it is the exit-code // contract (it writes the control file), the share mounter, and the - // hostname applier. - let init_path = rootfs.dir().join("ecrou-init"); + // hostname applier. It lands inside the image, at the fixed path + // the init blob execs, through debugfs. + let init_path = state.join("ecrou-init"); let capture = options.capture.as_ref().map(|_| guest::GuestCapture { stdout: guest::CAPTURE_STDOUT.to_string(), stderr: guest::CAPTURE_STDERR.to_string(), @@ -192,14 +198,24 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result = + vec![(&init_path, "/ecrou-init", 0o755)]; + let host_resolv = state.join("resolv.conf"); + if let Some(resolv) = crate::cache::host_resolver_content() { + std::fs::write(&host_resolv, resolv) + .with_context(|| format!("writing {}", host_resolv.display()))?; + unlinks.push("/etc/resolv.conf"); + provisioned.push((&host_resolv, "/etc/resolv.conf", 0o644)); + } + rootfs.provision(&unlinks, &provisioned)?; krun.set_log_level(match options.log_level { LogLevel::Quiet => 0, // off @@ -208,7 +224,12 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result Result Result anyhow::Result<()> { } /// The guest payload: the caller's argv, or the rootfs's default shell. -fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec { - let shell = utils::detect_shell(rootfs_dir).to_string(); +fn payload_command(options: &RunOptions, shell: &str) -> Vec { + let shell = shell.to_string(); match options.command.as_slice() { // No command: the image's shell. [] => vec![shell], @@ -380,15 +403,12 @@ fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec { /// The guest environment, composed from scratch: a usable base, the /// workspace mount point, and the caller's pairs. Never the host /// environment. -fn compose_env(rootfs_dir: &Path, options: &RunOptions, hostname: &str) -> Vec<(String, String)> { +fn compose_env(shell: &str, options: &RunOptions, hostname: &str) -> Vec<(String, String)> { let host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()); let mut env = vec![ ("HOME".to_string(), "/root".to_string()), ("USER".to_string(), "root".to_string()), - ( - "SHELL".to_string(), - utils::detect_shell(rootfs_dir).to_string(), - ), + ("SHELL".to_string(), shell.to_string()), ("TERM".to_string(), host_term), ("HOSTNAME".to_string(), hostname.to_string()), ( @@ -405,15 +425,6 @@ fn compose_env(rootfs_dir: &Path, options: &RunOptions, hostname: &str) -> Vec<( env } -fn set_exec_bit(path: &Path) -> anyhow::Result<()> { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(path) - .with_context(|| format!("stat {}", path.display()))? - .permissions(); - perms.set_mode(perms.mode() | 0o755); - std::fs::set_permissions(path, perms).with_context(|| format!("chmod +x {}", path.display())) -} - /// libkrun wants a vcpu count; all logical cpus is the sensible default /// for a one-shot machine. fn vcpus() -> u8 { @@ -456,14 +467,6 @@ fn parse_memory_mib(s: &str) -> anyhow::Result { mod tests { use super::*; - /// A scratch with just a shell, for payload_command tests. - fn shell_rootfs() -> tempfile::TempDir { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join("bin")).unwrap(); - std::fs::write(dir.path().join("bin/sh"), "").unwrap(); - dir - } - #[test] fn caller_shares_cannot_take_the_reserved_namespace() { let scratch = tempfile::tempdir().unwrap(); @@ -529,23 +532,26 @@ mod tests { #[test] fn payload_defaults_to_the_image_shell() { - let rootfs = shell_rootfs(); let options = RunOptions::default(); assert_eq!( - payload_command(&options, rootfs.path()), + payload_command(&options, "/bin/sh"), vec!["/bin/sh".to_string()] ); + // Whatever the probe found stands in verbatim. + assert_eq!( + payload_command(&options, "/bin/bash"), + vec!["/bin/bash".to_string()] + ); } #[test] fn payload_argv_is_taken_literally() { - let rootfs = shell_rootfs(); let options = RunOptions { command: vec!["uname".to_string(), "-a".to_string()], ..RunOptions::default() }; assert_eq!( - payload_command(&options, rootfs.path()), + payload_command(&options, "/bin/sh"), vec!["uname".to_string(), "-a".to_string()] ); // Even when it looks like a shell invocation: the consumer @@ -555,20 +561,19 @@ mod tests { ..RunOptions::default() }; assert_eq!( - payload_command(&options, rootfs.path()), + payload_command(&options, "/bin/sh"), vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()] ); } #[test] fn payload_single_quoted_line_runs_through_the_shell() { - let rootfs = shell_rootfs(); let options = RunOptions { command: vec!["apk add fastfetch && fastfetch".to_string()], ..RunOptions::default() }; assert_eq!( - payload_command(&options, rootfs.path()), + payload_command(&options, "/bin/sh"), vec![ "/bin/sh".to_string(), "-c".to_string(), @@ -610,12 +615,11 @@ mod tests { fn compose_env_never_inherits_the_host() { // compose_env reads the host TERM and nothing else from the // process environment. - let dir = std::env::temp_dir(); let mut options = RunOptions::default(); options .env .push(("ECROU_TEST_MARK".to_string(), "present".to_string())); - let env = compose_env(&dir, &options, "ecrou-vm-x"); + let env = compose_env("/bin/sh", &options, "ecrou-vm-x"); assert!(env .iter() .any(|(k, v)| k == "ECROU_TEST_MARK" && v == "present"));