diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 586dfaa..07b68bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - name: Clippy run: cargo clippy --workspace -- -D warnings - name: Unit tests - run: cargo test --bin cfsrun + run: cargo test --lib --bin cfsrun - name: Build cfsrun run: cargo build - name: Build cfsctl diff --git a/Cargo.lock b/Cargo.lock index 4bd9a3f..c55c69f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,7 @@ name = "composefs-run" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "clap", "clap_complete", "composefs", @@ -408,7 +409,10 @@ dependencies = [ "rustix", "serde", "serde_json", + "shlex", "tar", + "tar-core", + "tempfile", "tokio", ] @@ -1799,6 +1803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Justfile b/Justfile index 9dc0612..c3ca6d0 100644 --- a/Justfile +++ b/Justfile @@ -15,7 +15,7 @@ build-release: # Run unit tests only (no container image needed) test-unit: - cargo test --bin cfsrun + cargo test --lib --bin cfsrun # Run integration tests (pulls test image on first run, requires cfsctl) test-integration: build diff --git a/README.md b/README.md index ebdec0c..562a964 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Options: --device Add host device --read-only Read-only rootfs --overlay-dir Persistent overlay directory + --commit Save the run's changes as a new image --network host|pasta|bridge|private --hostname Container hostname --privileged Full capabilities, no seccomp/SELinux @@ -77,6 +78,25 @@ Options: --add-host Extra /etc/hosts entries ``` +### Committing a run + +```bash +cfsrun --repo /tmp/repo --commit updated cs10 -- sh -c 'echo hello > /hello' +cfsrun --repo /tmp/repo updated -- cat /hello +``` + +Use `--commit ` to save the container's changes as a new image in the same +repository. The new image is based on the image used to start the container and +keeps its default configuration. + +Changes are saved after the container exits, including when its command fails. +cfsrun preserves the container's exit status even if saving the image fails. +In that case, it reports an error and keeps the writable directory for recovery. +Detailed diagnostics may be saved to a log at the reported path. + +`--commit` cannot be combined with `--read-only` or `--rootfs`. Changes to bind +mounts and temporary filesystems are excluded. + ### Testing ```bash diff --git a/composefs-run/Cargo.toml b/composefs-run/Cargo.toml index 5eb6ad8..be614dc 100644 --- a/composefs-run/Cargo.toml +++ b/composefs-run/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] anyhow = { version = "1.0", default-features = false } +chrono = { version = "0.4", default-features = false, features = ["std"] } clap = { version = "4.5", default-features = false, features = ["std", "help", "usage", "derive"] } clap_complete = { version = "4.5", default-features = false, features = ["unstable-dynamic"] } composefs = { workspace = true } @@ -22,10 +23,13 @@ libc = { version = "0.2", default-features = false } rustix = { version = "1.0.0", default-features = false, features = ["fs", "process", "mount", "pipe"] } serde = { version = "1.0", default-features = false, features = ["derive"] } serde_json = { version = "1.0", default-features = false, features = ["std"] } +shlex = "2" +tar-core = "0.1" tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "net", "time"] } [dev-dependencies] tar = { version = "0.4", default-features = false } +tempfile = "3" rustix = { version = "1.0.0", default-features = false, features = ["process"] } [lints.rust] diff --git a/composefs-run/src/canonical_tar.rs b/composefs-run/src/canonical_tar.rs new file mode 100644 index 0000000..9cc45dd --- /dev/null +++ b/composefs-run/src/canonical_tar.rs @@ -0,0 +1,311 @@ +//! Canonical OCI layer serialization, independent of the container runner. +//! +//! Implements the draft at . +//! Only metadata and padding enter the splitstream; file objects are referenced +//! directly and read once to compute the digest of the logical tar stream. + +use std::{collections::HashMap, fs::File, io, os::unix::ffi::OsStrExt, path::PathBuf}; + +use anyhow::{Context, Result, bail, ensure}; +use composefs::{ + digest::{Digest, Sha256}, + fsverity::FsVerityHashValue, + generic_tree::LeafId, + repository::Repository, + splitstream::SplitStreamWriter, + tree::{Directory, FileSystem, Inode, LeafContent, RegularFile, Stat}, + util::DigestWrite, +}; +use composefs_oci::{ + OciDigest, + tar::{TarEntry, TarItem}, +}; +use tar_core::{ + EntryType, Header, + builder::{HeaderBuilder, PaxBuilder}, +}; + +const BLOCK: usize = 512; +const NAME_MAX: usize = 100; +const ID_MAX: u64 = 0o7777777; +const SIZE_MAX: u64 = 0o77777777777; +// Currently private in composefs-oci::skopeo; this is its splitstream wire tag. +pub(crate) const TAR_LAYER_CONTENT_TYPE: u64 = u64::from_le_bytes(*b"ocilayer"); + +/// Walk a layer tree in canonical order, retaining hardlink identity. +pub(crate) fn entries(fs: &FileSystem) -> Vec> { + fn walk( + fs: &FileSystem, + dir: &Directory, + path: PathBuf, + seen: &mut HashMap, + result: &mut Vec>, + ) { + result.push(TarEntry { + path: path.clone(), + stat: dir.stat.clone(), + item: TarItem::Directory, + }); + for (name, inode) in dir.entries() { + let path = path.join(name); + match inode { + Inode::Directory(dir) => walk(fs, dir, path, seen, result), + Inode::Leaf(id, _) => { + let leaf = fs.leaf(*id); + let item = if let Some(target) = seen.get(id) { + TarItem::Hardlink(target.clone().into_os_string()) + } else { + seen.insert(*id, path.clone()); + TarItem::Leaf(leaf.content.clone()) + }; + result.push(TarEntry { + path, + stat: leaf.stat.clone(), + item, + }); + } + } + } + } + let mut result = Vec::new(); + walk( + fs, + &fs.root, + PathBuf::from("/"), + &mut HashMap::new(), + &mut result, + ); + result +} + +/// A tar sink which hashes logical bytes while writing splitstream instructions. +struct LayerWriter { + stream: SplitStreamWriter, + hash: DigestWrite, + size: u64, +} + +impl LayerWriter { + fn inline(&mut self, bytes: &[u8]) { + self.hash.0.update(bytes); + self.size += bytes.len() as u64; + self.stream.write_inline(bytes); + } + + fn padding(&mut self, size: u64) { + self.inline(&[0; BLOCK][..((BLOCK as u64 - size % BLOCK as u64) % BLOCK as u64) as usize]); + } + + fn header(&mut self, mut header: Header) { + // tar-core uses seven octal digits + NUL. The draft specifies the + // traditional six digits + NUL + space checksum encoding. + let checksum = format!("{:06o}\0 ", header.compute_checksum()); + header + .as_ustar_mut() + .cksum + .copy_from_slice(checksum.as_bytes()); + self.inline(header.as_bytes()); + } + + fn pax(&mut self, name: &[u8], kind: EntryType, data: &[u8]) -> Result<()> { + let mut header = base_header(name, 0, 0, 0, data.len() as u64, 0)?; + header.entry_type(kind); + self.header(header.finish()); + self.inline(data); + self.padding(data.len() as u64); + Ok(()) + } + + fn entry(&mut self, entry: TarEntry) -> Result<()> { + let mut path = b".".to_vec(); + path.extend_from_slice(entry.path.as_os_str().as_bytes()); + if matches!(entry.item, TarItem::Directory) && !path.ends_with(b"/") { + path.push(b'/'); + } + let (kind, size, link, device) = match &entry.item { + TarItem::Directory => (EntryType::Directory, 0, &b""[..], 0), + TarItem::Hardlink(target) => { + let target = [b".", target.as_bytes()].concat(); + return self.link_entry(&path, &entry.stat, EntryType::Link, &target); + } + TarItem::Leaf(LeafContent::Symlink(target)) => { + (EntryType::Symlink, 0, target.as_bytes(), 0) + } + TarItem::Leaf(LeafContent::Regular(file)) => { + (EntryType::Regular, file.file_size(), &b""[..], 0) + } + TarItem::Leaf(LeafContent::BlockDevice(dev)) => (EntryType::Block, 0, &b""[..], *dev), + TarItem::Leaf(LeafContent::CharacterDevice(dev)) => { + (EntryType::Char, 0, &b""[..], *dev) + } + TarItem::Leaf(LeafContent::Fifo) => (EntryType::Fifo, 0, &b""[..], 0), + TarItem::Leaf(LeafContent::Socket) => { + bail!("OCI layers cannot represent socket {:?}", entry.path) + } + }; + self.entry_header(&path, &entry.stat, kind, size, link, device)?; + if let TarItem::Leaf(LeafContent::Regular(file)) = entry.item { + match file { + RegularFile::Inline(data) => self.inline(&data), + RegularFile::External(id, size) | RegularFile::ExternalNoVerity(id, size) => { + let mut file = File::from(self.stream.repo().open_object(&id)?); + let copied = io::copy(&mut file, &mut self.hash)?; + ensure!( + copied == size, + "Object size mismatch: expected {size}, read {copied}" + ); + self.stream.write_reference(id)?; + self.stream.add_external_size(size); + self.size += size; + } + RegularFile::Sparse(_) => bail!("Layer file has no content: {:?}", entry.path), + } + self.padding(size); + } + Ok(()) + } + + fn link_entry(&mut self, path: &[u8], stat: &Stat, kind: EntryType, link: &[u8]) -> Result<()> { + self.entry_header(path, stat, kind, 0, link, 0) + } + + fn entry_header( + &mut self, + path: &[u8], + stat: &Stat, + kind: EntryType, + size: u64, + link: &[u8], + dev: u64, + ) -> Result<()> { + ensure!( + stat.st_mtim_sec >= 0, + "Canonical tar requires a nonnegative mtime" + ); + ensure!( + stat.st_mtim_nsec < 1_000_000_000, + "Invalid mtime nanoseconds" + ); + let mtime = stat.st_mtim_sec as u64; + let mut pax = PaxBuilder::new(); + if path.len() > NAME_MAX { + pax.path(path); + } + if link.len() > NAME_MAX { + pax.linkpath(link); + } + if size > SIZE_MAX { + pax.size(size); + } + if u64::from(stat.st_uid) > ID_MAX { + pax.uid(stat.st_uid.into()); + } + if u64::from(stat.st_gid) > ID_MAX { + pax.gid(stat.st_gid.into()); + } + if stat.st_mtim_nsec != 0 { + let time = format!("{mtime}.{:09}", stat.st_mtim_nsec); + pax.add("mtime", time.trim_end_matches('0').as_bytes()); + } else if mtime > SIZE_MAX { + pax.mtime(mtime); + } + for (key, value) in &stat.xattrs { + let key = key.to_str().context("Non-UTF-8 xattr name")?; + pax.add(&format!("SCHILY.xattr.{key}"), value); + } + if !pax.as_bytes().is_empty() { + let basename = path + .strip_suffix(b"/") + .unwrap_or(path) + .rsplit(|b| *b == b'/') + .next() + .unwrap_or(b""); + let mut name = b"PaxHeaders.0/".to_vec(); + name.extend_from_slice(basename); + name.truncate(NAME_MAX); + self.pax(&name, EntryType::XHeader, pax.as_bytes())?; + } + let mut header = base_header( + &path[..path.len().min(NAME_MAX)], + stat.st_mode & 0o7777, + if u64::from(stat.st_uid) > ID_MAX { + 0 + } else { + stat.st_uid.into() + }, + if u64::from(stat.st_gid) > ID_MAX { + 0 + } else { + stat.st_gid.into() + }, + if size > SIZE_MAX { 0 } else { size }, + if mtime > SIZE_MAX { 0 } else { mtime }, + )?; + header + .entry_type(kind) + .link_name(&link[..link.len().min(NAME_MAX)])?; + header.device(rustix::fs::major(dev), rustix::fs::minor(dev))?; + self.header(header.finish()); + Ok(()) + } +} + +fn base_header( + path: &[u8], + mode: u32, + uid: u64, + gid: u64, + size: u64, + mtime: u64, +) -> Result { + let mut header = HeaderBuilder::new_ustar(); + header + .path(path)? + .mode(mode)? + .uid(uid)? + .gid(gid)? + .size(size)? + .mtime(mtime)? + .device(0, 0)?; + Ok(header) +} + +/// Store a layer without materializing a tar archive; return diff ID, verity and size. +/// +/// Currently rejects sockets, negative modification times, and non-UTF-8 xattr +/// names. +pub fn write_layer( + repo: &std::sync::Arc>, + fs: &FileSystem, +) -> Result<(OciDigest, H, u64)> { + let mut writer = LayerWriter { + stream: repo.create_stream(TAR_LAYER_CONTENT_TYPE)?, + hash: DigestWrite(Sha256::new()), + size: 0, + }; + let mut global = PaxBuilder::new(); + global.add("canonical-tar", b"1"); + // The draft leaves the global header's name unspecified; keep it fixed. + writer.pax(b"GlobalHead.0", EntryType::XGlobalHeader, global.as_bytes())?; + for entry in entries(fs) { + writer.entry(entry)?; + } + writer.inline(&[0; BLOCK * 2]); + let digest: OciDigest = format!( + "sha256:{}", + writer + .hash + .0 + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + .parse()?; + let verity = repo.write_stream( + writer.stream, + &composefs_oci::layer_content_id(&digest), + None, + )?; + Ok((digest, verity, writer.size)) +} diff --git a/composefs-run/src/commit.rs b/composefs-run/src/commit.rs new file mode 100644 index 0000000..2664fe2 --- /dev/null +++ b/composefs-run/src/commit.rs @@ -0,0 +1,130 @@ +//! Create a child OCI image from a layer tree and its parent image. +//! +//! This module depends on composefs APIs, not runner state, so that the image +//! construction can eventually move to composefs-oci alongside layer generation. + +use anyhow::{Context, Result, ensure}; +use chrono::{DateTime, SecondsFormat, Utc}; +use composefs::{ + erofs::format::FormatVersion, fsverity::FsVerityHashValue, repository::Repository, + tree::FileSystem, +}; +use composefs_oci::{OciDigest, OciImage, oci_image::write_manifest}; +use oci_spec::image::{DescriptorBuilder, HistoryBuilder, MediaType}; +use std::os::unix::ffi::OsStrExt; +use std::{collections::HashMap, sync::Arc, time::SystemTime}; + +/// Append a changeset to the parent's layers and publish the ref only once the +/// complete image, including its mountable EROFS, has been stored. +/// `created_by` describes the command responsible for the changeset. +pub fn create_image( + repo: &Arc>, + parent: &OciImage, + layer: &FileSystem, + reference: &str, + created_by: &str, +) -> Result { + ensure!( + !reference.is_empty() + && !reference.starts_with('@') + && !reference.contains('\0') + && !reference + .split('/') + .any(|part| part.is_empty() || part == "." || part == ".."), + "Invalid OCI commit reference {reference:?}" + ); + ensure!( + parent.is_container_image(), + "Parent is not an OCI container image" + ); + let (diff_id, layer_verity, size) = crate::canonical_tar::write_layer(repo, layer)?; + let mut config = + composefs_oci::open_config(repo, parent.config_digest(), Some(parent.config_verity()))? + .config; + let mut rootfs = config.rootfs().clone(); + let mut diff_ids = rootfs.diff_ids().clone(); + diff_ids.push(diff_id.to_string()); + rootfs.set_diff_ids(diff_ids); + config.set_rootfs(rootfs); + // A layered image may validly omit history. Preserve that choice rather + // than creating a partial history that accounts for only the new layer. + if let Some(mut history) = config + .history() + .clone() + .filter(|history| !history.is_empty() || parent.manifest().layers().is_empty()) + { + history.push( + HistoryBuilder::default() + .created( + DateTime::::from(SystemTime::now()) + .to_rfc3339_opts(SecondsFormat::Nanos, true), + ) + .created_by(created_by) + .build()?, + ); + config.set_history(Some(history)); + } + let mut refs = parent.layer_refs().clone(); + refs.insert(diff_id.to_string().into_boxed_str(), layer_verity); + let mut fs = parent.read_filesystem(repo, false)?; + // Whiteouts affect only the parent. Apply them before adding any upper + // entries: sorted names can precede an opaque marker in canonical tar. + let (whiteouts, entries): (Vec<_>, Vec<_>) = crate::canonical_tar::entries(layer) + .into_iter() + .partition(|entry| { + entry + .path + .file_name() + .is_some_and(|name| name.as_bytes().starts_with(b".wh.")) + }); + for entry in whiteouts { + let path = entry.path.parent().context("Whiteout without parent")?; + if crate::overlay::parent_directory(&fs, path).is_some() { + composefs_oci::image::process_entry(&mut fs, entry)?; + } + } + for entry in entries { + composefs_oci::image::process_entry(&mut fs, entry)?; + } + fs.transform_for_oci(&Default::default())?; + let images = fs.commit_images(repo, None)?; + let (config_digest, config_verity) = composefs_oci::write_config( + repo, + &config, + refs.clone(), + images.get(&FormatVersion::V2), + images.get(&FormatVersion::V1), + &HashMap::new(), + )?; + let mut manifest = parent.manifest().clone(); + manifest.set_media_type(Some(MediaType::ImageManifest)); + manifest.set_config( + DescriptorBuilder::default() + .media_type(MediaType::ImageConfig) + .digest(config_digest) + .size(config.to_string()?.len() as u64) + .build()?, + ); + let mut layers = manifest.layers().clone(); + layers.push( + DescriptorBuilder::default() + .media_type(MediaType::ImageLayer) + .digest(diff_id) + .size(size) + .build()?, + ); + manifest.set_layers(layers); + let digest = composefs_oci::sha256_content_digest(manifest.to_string()?.as_bytes()); + let mut refs: Vec<_> = refs.into_iter().collect(); + refs.sort_by(|a, b| a.0.cmp(&b.0)); + write_manifest( + repo, + &manifest, + &digest, + &config_verity, + &refs, + Some(reference), + ) + .context("Publishing committed image")?; + Ok(digest) +} diff --git a/composefs-run/src/commit_tests.rs b/composefs-run/src/commit_tests.rs new file mode 100644 index 0000000..3d1b620 --- /dev/null +++ b/composefs-run/src/commit_tests.rs @@ -0,0 +1,915 @@ +//! Layer serialization, overlay translation and OCI image construction tests. + +use crate::overlay::{Xattr, XattrNamespace}; +use anyhow::Result; +use composefs::{ + erofs::{ + reader::erofs_to_filesystem, + writer::{ValidatedFileSystem, mkfs_erofs}, + }, + fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue}, + repository::{Repository, RepositoryConfig}, + tree::{Directory, FileSystem, Inode, LeafContent, RegularFile, Stat}, +}; +use composefs_oci::{ + OciImage, + tar::{TarEntry, TarItem}, +}; +use oci_spec::image::{HistoryBuilder, ImageConfiguration, ImageManifest}; +use rustix::fs::CWD; +use std::{ + collections::HashMap, ffi::OsStr, io::Read, os::unix::ffi::OsStrExt, path::Path, sync::Arc, +}; + +fn stat() -> Stat { + Stat { + st_mode: 0o755, + st_uid: 0, + st_gid: 0, + st_mtim_sec: 0, + st_mtim_nsec: 0, + xattrs: Default::default(), + } +} + +fn add(fs: &mut FileSystem, path: &str, content: LeafContent) { + let id = fs.push_leaf(stat(), content); + let (dir, name) = fs.root.split_mut(OsStr::new(path)).unwrap(); + dir.insert(name, Inode::leaf(id)); +} + +fn file(fs: &mut FileSystem, path: &str, data: &[u8]) { + add( + fs, + path, + LeafContent::Regular(RegularFile::Inline(data.into())), + ); +} + +fn directory(fs: &mut FileSystem, name: &str) { + let (parent, name) = fs.root.split_mut(OsStr::new(name)).unwrap(); + parent.insert(name, Inode::Directory(Box::new(Directory::new(stat())))); +} + +fn read_layer(repo: &Repository, id: &H) -> Result>> { + let mut stream = repo.open_stream( + "", + Some(id), + Some(crate::canonical_tar::TAR_LAYER_CONTENT_TYPE), + )?; + let mut entries = Vec::new(); + while let Some(entry) = composefs_oci::tar::get_entry(&mut stream)? { + entries.push(entry); + } + Ok(entries) +} + +fn bytes( + fs: &FileSystem, + path: &str, + repo: &Repository, +) -> Result> { + let (dir, name) = fs.root.split(OsStr::new(path))?; + let leaf = fs.leaf(dir.leaf_id(name)?); + match &leaf.content { + LeafContent::Regular(RegularFile::Inline(data)) => Ok(data.to_vec()), + LeafContent::Regular(RegularFile::External(id, _)) => repo.read_object(id), + content => anyhow::bail!("Unexpected content {content:?}"), + } +} + +#[test] +fn canonical_layer_roundtrip() -> Result<()> { + fn run() -> Result<()> { + let temp = tempfile::tempdir()?; + let (repo, _) = Repository::::init_path( + CWD, + temp.path().join("repo"), + RepositoryConfig::new(H::ALGORITHM).set_insecure(), + )?; + let repo = Arc::new(repo); + let mut fs = FileSystem::new(stat()); + directory(&mut fs, "a"); + file(&mut fs, "/a/inline", b"hello"); + let data = vec![b'x'; 8193]; + let object = repo.ensure_object(&data)?; + add( + &mut fs, + "/a/external", + LeafContent::Regular(RegularFile::External(object.clone(), data.len() as u64)), + ); + let id = fs + .root + .get_directory(OsStr::new("a"))? + .leaf_id(OsStr::new("external"))?; + fs.root.insert(OsStr::new("hardlink"), Inode::leaf(id)); + let long_name = "z".repeat(130); + file(&mut fs, &format!("/{long_name}"), b"long"); + add( + &mut fs, + "/symlink", + LeafContent::Symlink(OsStr::new(&"s".repeat(120)).into()), + ); + add(&mut fs, "/fifo", LeafContent::Fifo); + add( + &mut fs, + "/device", + LeafContent::CharacterDevice(rustix::fs::makedev(1, 3)), + ); + fs.root.stat.st_uid = u32::MAX; + fs.root.stat.st_gid = u32::MAX; + fs.root.stat.st_mtim_sec = 8_589_934_592; + fs.root.stat.st_mtim_nsec = 123_400_000; + fs.root.stat.xattrs.insert( + OsStr::new("user.binary").into(), + Box::from(b"\0\xff\n=value".as_slice()), + ); + let (digest, verity, size) = crate::canonical_tar::write_layer(&repo, &fs)?; + let mut raw = Vec::new(); + let mut stream = repo.open_stream("", Some(&verity), None)?; + stream.cat(&repo, &mut raw)?; + assert_eq!(raw.len() as u64, size); + assert_eq!(digest, composefs_oci::sha256_content_digest(&raw)); + assert_eq!(raw[156], b'g'); + assert_eq!(&raw[512..531], b"19 canonical-tar=1\n"); + assert!(raw[raw.len() - 1024..].iter().all(|b| *b == 0)); + assert_eq!(&raw[148..156][6..], b"\0 "); + assert_eq!(&raw[257..265], b"ustar\000"); + assert_eq!(&raw[345..500], &[0; 155]); + let mut objects = Vec::new(); + stream = repo.open_stream("", Some(&verity), None)?; + stream.get_object_refs(|id| objects.push(id.clone()))?; + assert_eq!(objects, [object]); + let entries = read_layer(&repo, &verity)?; + let paths: Vec<_> = entries + .iter() + .map(|e| { + e.path + .components() + .collect::() + .to_string_lossy() + .into_owned() + }) + .collect(); + assert_eq!( + paths, + [ + "/", + "/a", + "/a/external", + "/a/inline", + "/device", + "/fifo", + "/hardlink", + "/symlink", + &format!("/{long_name}") + ] + ); + assert_eq!(entries[0].stat.st_uid, u32::MAX); + assert_eq!(entries[0].stat.st_mtim_nsec, 123_400_000); + assert_eq!(entries[0].stat.xattrs, fs.root.stat.xattrs); + assert!( + matches!(&entries[6].item, TarItem::Hardlink(target) if Path::new(target) == Path::new("/a/external")) + ); + let mut archive = tar::Archive::new(raw.as_slice()); + let mut saw_content = false; + for entry in archive.entries()? { + let mut entry = entry?; + if entry.path()?.as_ref() == Path::new("./a/external") { + let mut content = Vec::new(); + entry.read_to_end(&mut content)?; + assert_eq!(content, data); + saw_content = true; + } + } + assert!(saw_content); + let again = crate::canonical_tar::write_layer(&repo, &fs)?; + assert_eq!((digest, verity, size), again); + Ok(()) + } + run::()?; + run::() +} + +#[test] +fn overlay_whiteouts() -> Result<()> { + for (namespace, prefix) in [ + (XattrNamespace::Trusted, "trusted.overlay."), + (XattrNamespace::User, "user.overlay."), + ] { + let mut fs = FileSystem::::new(stat()); + directory(&mut fs, "opaque"); + fs.root + .get_directory_mut(OsStr::new("opaque"))? + .stat + .xattrs + .insert( + OsStr::new(&format!("{prefix}opaque")).into(), + Box::from(b"y".as_slice()), + ); + file(&mut fs, "/opaque/!keep", b"present"); + add(&mut fs, "/deleted", LeafContent::CharacterDevice(0)); + file(&mut fs, "/xdeleted", b""); + let id = fs.root.leaf_id(OsStr::new("xdeleted"))?; + fs.leaf_mut(id).stat.xattrs.insert( + OsStr::new(&format!("{prefix}whiteout")).into(), + Box::default(), + ); + fs.root.insert(OsStr::new("xdeleted2"), Inode::leaf(id)); + fs.root.stat.xattrs.insert( + OsStr::new(&format!("{prefix}origin")).into(), + Box::from(b"internal".as_slice()), + ); + let temp = tempfile::tempdir()?; + let (repo, _) = Repository::::init_path( + CWD, + temp.path().join("repo"), + RepositoryConfig::default().set_insecure(), + )?; + let fs = crate::overlay::normalize(&repo, &FileSystem::new(stat()), fs, namespace)?; + let entries = crate::canonical_tar::entries(&fs); + assert_eq!( + entries + .iter() + .map(|e| e.path.to_str().unwrap()) + .collect::>(), + [ + "/", + "/.wh.deleted", + "/.wh.xdeleted", + "/.wh.xdeleted2", + "/opaque", + "/opaque/!keep", + "/opaque/.wh..wh..opq" + ] + ); + for entry in entries.iter().filter(|e| { + e.path + .file_name() + .is_some_and(|n| n.as_bytes().starts_with(b".wh.")) + }) { + assert_eq!(entry.stat.st_mode, 0o644); + assert_eq!(entry.stat.st_mtim_sec, 0); + assert!( + matches!(&entry.item, TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(data))) if data.is_empty()) + ); + } + fs.for_each_stat(|s| assert!(s.xattrs.is_empty())); + } + Ok(()) +} + +#[test] +fn child_image_preserves_parent_and_applies_layer() -> Result<()> { + let temp = tempfile::tempdir()?; + let (repo, _) = Repository::::init_path( + CWD, + temp.path().join("repo"), + RepositoryConfig::default().set_insecure(), + )?; + let repo = Arc::new(repo); + let mut base = FileSystem::new(stat()); + directory(&mut base, "usr"); + directory(&mut base, "opaque"); + file(&mut base, "/kept", b"unchanged"); + file(&mut base, "/deleted", b"delete me"); + file(&mut base, "/changed", b"before"); + file(&mut base, "/opaque/old", b"hidden"); + let (diff, layer_id, size) = crate::canonical_tar::write_layer(&repo, &base)?; + let images = base.commit_images(&repo, None)?; + let config: ImageConfiguration = serde_json::from_value( + serde_json::json!({"architecture":"amd64", "os":"linux", "config":{"Env":["BASE=preserved"], "Cmd":["/bin/sh"]}, "rootfs":{"type":"layers", "diff_ids":[diff]}}), + )?; + let refs = HashMap::from([(diff.to_string().into_boxed_str(), layer_id)]); + for history in [ + None, + Some(vec![]), + Some(vec![ + HistoryBuilder::default() + .created("2025-05-13T07:48:55.650698239Z") + .created_by("parent") + .build()?, + ]), + ] { + let mut config = config.clone(); + config.set_history(history.clone()); + let (config_digest, config_id) = composefs_oci::write_config( + &repo, + &config, + refs.clone(), + images.get(&composefs::erofs::format::FormatVersion::V2), + images.get(&composefs::erofs::format::FormatVersion::V1), + &HashMap::new(), + )?; + let manifest: ImageManifest = serde_json::from_value( + serde_json::json!({"schemaVersion":2, "mediaType":"application/vnd.oci.image.manifest.v1+json", "config":{"mediaType":"application/vnd.oci.image.config.v1+json", "digest":config_digest, "size":config.to_string()?.len()}, "layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar", "digest":diff, "size":size}]}), + )?; + let digest = composefs_oci::sha256_content_digest(manifest.to_string()?.as_bytes()); + composefs_oci::oci_image::write_manifest( + &repo, + &manifest, + &digest, + &config_id, + &refs.clone().into_iter().collect::>(), + Some("base"), + )?; + let parent = OciImage::open_ref(&repo, "base")?; + let mut upper = FileSystem::new(stat()); + directory(&mut upper, "opaque"); + upper + .root + .get_directory_mut(OsStr::new("opaque"))? + .stat + .xattrs + .insert( + OsStr::new(XattrNamespace::User.name(Xattr::Opaque)).into(), + Box::from(b"y".as_slice()), + ); + file(&mut upper, "/opaque/!keep", b"new"); + directory(&mut upper, "/opaque/sub"); + upper + .root + .get_directory_mut(OsStr::new("/opaque/sub"))? + .stat + .xattrs + .insert( + OsStr::new(XattrNamespace::User.name(Xattr::Opaque)).into(), + Box::from(b"y".as_slice()), + ); + directory(&mut upper, "/opaque/sub/empty"); + file(&mut upper, "/opaque/sub/file", b"nested"); + file(&mut upper, "/changed", b"after"); + add(&mut upper, "/deleted", LeafContent::CharacterDevice(0)); + let upper = crate::overlay::normalize(&repo, &base, upper, XattrNamespace::User)?; + let created_by = "sh -c 'printf after > /changed'"; + let before = std::time::SystemTime::now(); + let committed = crate::commit::create_image(&repo, &parent, &upper, "child", created_by)?; + let after = std::time::SystemTime::now(); + let child = OciImage::open_ref(&repo, "child")?; + assert_eq!(child.manifest_digest(), &committed); + assert_eq!( + &child.manifest().layers()[0], + &parent.manifest().layers()[0] + ); + assert_eq!(child.manifest().layers().len(), 2); + let child_config = child.config().unwrap(); + assert_eq!(child_config.config(), config.config()); + assert_eq!(child_config.rootfs().diff_ids().len(), 2); + match history { + None => assert!(child_config.history().is_none()), + Some(history) if history.is_empty() => { + assert_eq!(child_config.history().as_ref().unwrap().len(), 0) + } + Some(history) => { + let child_history = child_config.history().as_ref().unwrap(); + assert_eq!(&child_history[..history.len()], history); + assert_eq!(child_history.len(), history.len() + 1); + assert_eq!( + child_history.last().unwrap().created_by().as_deref(), + Some(created_by) + ); + let created = child_history.last().unwrap().created().as_deref().unwrap(); + let timestamp = chrono::DateTime::parse_from_rfc3339(created)?; + assert!(created.ends_with('Z')); + let timestamp: std::time::SystemTime = timestamp.into(); + assert!((before..=after).contains(×tamp)); + } + } + let merged = child.read_filesystem(&repo, false)?; + assert_eq!(bytes(&merged, "/kept", &repo)?, b"unchanged"); + assert_eq!(bytes(&merged, "/changed", &repo)?, b"after"); + assert_eq!(bytes(&merged, "/opaque/!keep", &repo)?, b"new"); + assert_eq!(bytes(&merged, "/opaque/sub/file", &repo)?, b"nested"); + assert!( + merged + .root + .get_directory(OsStr::new("/opaque/sub/empty")) + .is_ok() + ); + assert!(merged.root.lookup(OsStr::new("deleted")).is_none()); + assert!( + merged + .root + .get_directory(OsStr::new("opaque"))? + .lookup(OsStr::new("old")) + .is_none() + ); + assert_eq!( + bytes(&parent.read_filesystem(&repo, false)?, "/changed", &repo)?, + b"before" + ); + let mut unsupported = FileSystem::new(stat()); + add(&mut unsupported, "/socket", LeafContent::Socket); + assert!( + crate::commit::create_image(&repo, &parent, &unsupported, "child", created_by).is_err() + ); + assert_eq!( + OciImage::open_ref(&repo, "child")?.manifest_digest(), + &committed + ); + // Reusing an existing ref and committing from a previously committed image. + crate::commit::create_image(&repo, &child, &FileSystem::new(stat()), "base", "true")?; + assert_eq!( + OciImage::open_ref(&repo, "base")?.manifest().layers().len(), + 3 + ); + for reference in ["../escape", "", "/absolute", "a//b", "@digest", "bad\0ref"] { + assert!( + crate::commit::create_image(&repo, &parent, &upper, reference, created_by).is_err() + ); + } + } + Ok(()) +} + +fn xattr(fs: &mut FileSystem, path: &str, key: &str, value: &[u8]) { + let (dir, name) = fs.root.split(OsStr::new(path)).unwrap(); + let id = dir.leaf_id(name).unwrap(); + fs.leaf_mut(id) + .stat + .xattrs + .insert(OsStr::new(key).into(), value.into()); +} + +fn redirect(fs: &mut FileSystem, path: &str, prefix: &str, target: &[u8]) { + fs.root + .get_directory_mut(OsStr::new(path)) + .unwrap() + .stat + .xattrs + .insert( + OsStr::new(&format!("{prefix}redirect")).into(), + target.into(), + ); +} + +#[test] +fn normalize_without_mounts() -> Result<()> { + let temp = tempfile::tempdir()?; + let (repo, _) = Repository::::init_path( + CWD, + temp.path().join("repo"), + RepositoryConfig::default().set_insecure(), + )?; + let data = vec![b'm'; 8193]; + let content_id = repo.ensure_object(&data)?; + let mut parent = FileSystem::new(stat()); + directory(&mut parent, "/old"); + directory(&mut parent, "/old/nested"); + directory(&mut parent, "/elsewhere"); + directory(&mut parent, "/destination"); + file(&mut parent, "/destination/unrelated", b"must be hidden"); + file(&mut parent, "/old/keep", b"inherited"); + file(&mut parent, "/old/deleted", b"deleted"); + file(&mut parent, "/old/nested/lower", b"nested data"); + file(&mut parent, "/elsewhere/other", b"absolute redirect"); + add( + &mut parent, + "/metadata", + LeafContent::Regular(RegularFile::External(content_id.clone(), data.len() as u64)), + ); + let parent_id = parent.root.leaf_id(OsStr::new("metadata"))?; + parent + .root + .get_directory_mut(OsStr::new("old"))? + .insert(OsStr::new("linked"), Inode::leaf(parent_id)); + parent + .root + .get_directory_mut(OsStr::new("elsewhere"))? + .insert(OsStr::new("linked"), Inode::leaf(parent_id)); + for (namespace, prefix) in [ + (XattrNamespace::Trusted, "trusted.overlay."), + (XattrNamespace::User, "user.overlay."), + ] { + let mut upper = FileSystem::new(stat()); + directory(&mut upper, "/destination"); + redirect(&mut upper, "/destination", prefix, b"old"); + directory(&mut upper, "/destination/relative"); + redirect(&mut upper, "/destination/relative", prefix, b"nested"); + directory(&mut upper, "/destination/absolute"); + redirect(&mut upper, "/destination/absolute", prefix, b"/elsewhere"); + file(&mut upper, "/destination/added", b"upper"); + add( + &mut upper, + "/destination/deleted", + LeafContent::CharacterDevice(0), + ); + add(&mut upper, "/old", LeafContent::CharacterDevice(0)); + file(&mut upper, "/metadata", b""); + xattr(&mut upper, "/metadata", &format!("{prefix}metacopy"), b""); + let id = upper.root.leaf_id(OsStr::new("metadata"))?; + upper.leaf_mut(id).stat.st_mode = 0o600; + upper + .root + .insert(OsStr::new("a-new-alias"), Inode::leaf(id)); + upper + .root + .get_directory_mut(OsStr::new("destination"))? + .insert(OsStr::new("upper-alias"), Inode::leaf(id)); + file(&mut upper, "/renamed-file", b""); + xattr( + &mut upper, + "/renamed-file", + &format!("{prefix}metacopy"), + b"", + ); + xattr( + &mut upper, + "/renamed-file", + &format!("{prefix}redirect"), + b"/metadata", + ); + file(&mut upper, "/renamed-and-written", b"new data"); + xattr( + &mut upper, + "/renamed-and-written", + &format!("{prefix}redirect"), + b"/metadata", + ); + let layer = crate::overlay::normalize(&repo, &parent, upper, namespace)?; + assert_eq!(bytes(&layer, "/metadata", &repo)?, data); + assert_eq!(bytes(&layer, "/renamed-file", &repo)?, data); + assert_eq!(bytes(&layer, "/renamed-and-written", &repo)?, b"new data"); + assert_eq!(bytes(&layer, "/destination/keep", &repo)?, b"inherited"); + assert_eq!(bytes(&layer, "/destination/added", &repo)?, b"upper"); + assert_eq!( + bytes(&layer, "/destination/relative/lower", &repo)?, + b"nested data" + ); + assert_eq!( + bytes(&layer, "/destination/absolute/other", &repo)?, + b"absolute redirect" + ); + let destination = layer.root.get_directory(OsStr::new("destination"))?; + assert!(destination.lookup(OsStr::new("deleted")).is_none()); + assert!(destination.lookup(OsStr::new("unrelated")).is_none()); + assert!(destination.lookup(OsStr::new(".wh.deleted")).is_some()); + assert!(destination.lookup(OsStr::new(".wh..wh..opq")).is_some()); + let id = layer.root.leaf_id(OsStr::new("metadata"))?; + assert_eq!(layer.root.leaf_id(OsStr::new("a-new-alias"))?, id); + assert_eq!(destination.leaf_id(OsStr::new("upper-alias"))?, id); + assert_eq!(layer.leaf(id).stat.st_mode, 0o600); + assert!( + matches!(&layer.leaf(id).content, LeafContent::Regular(RegularFile::External(object, size)) if *object == content_id && *size == data.len() as u64) + ); + assert_eq!( + destination.leaf_id(OsStr::new("linked"))?, + destination + .get_directory(OsStr::new("absolute"))? + .leaf_id(OsStr::new("linked"))? + ); + layer.for_each_stat(|s| assert!(s.xattrs.is_empty())); + } + assert_eq!(bytes(&parent, "/old/deleted", &repo)?, b"deleted"); + Ok(()) +} + +#[test] +fn reused_parent_nodes_keep_resolved_contents() -> Result<()> { + let temp = tempfile::tempdir()?; + let (repo, _) = Repository::::init_path( + CWD, + temp.path().join("repo"), + RepositoryConfig::default().set_insecure(), + )?; + let data = vec![b'p'; 8193]; + let object = repo.ensure_object(&data)?; + let mut parent = FileSystem::new(stat()); + directory(&mut parent, "/source"); + directory(&mut parent, "/source/nested"); + add( + &mut parent, + "/source/nested/file", + LeafContent::Regular(RegularFile::External(object.clone(), data.len() as u64)), + ); + let id = parent + .root + .get_directory(OsStr::new("/source/nested"))? + .leaf_id(OsStr::new("file"))?; + parent.root.insert(OsStr::new("metadata"), Inode::leaf(id)); + for prefix in ["trusted.overlay.", "user.overlay."] { + // These are image metadata: the EROFS writer escapes reserved names. + xattr( + &mut parent, + "/metadata", + &format!("{prefix}redirect"), + b"/missing", + ); + redirect(&mut parent, "/source/nested", prefix, b"/missing"); + } + let object_path = object.to_object_pathname(); + let (dir, name) = object_path.split_once('/').unwrap(); + directory(&mut parent, dir); + file( + &mut parent, + &format!("/{dir}/{name}"), + b"wrong logical data", + ); + + // Use the same EROFS decoder as OciImage::read_filesystem. The lower + // inode's object reference must not resolve through its own image tree. + let image = mkfs_erofs(&ValidatedFileSystem::new(parent)?); + let parent: FileSystem = erofs_to_filesystem(&image)?; + let parent_id = parent.root.leaf_id(OsStr::new("metadata"))?; + assert!(matches!( + &parent.leaf(parent_id).content, + LeafContent::Regular(RegularFile::External(id, size)) + if *id == object && *size == data.len() as u64 + )); + for (namespace, prefix) in [ + (XattrNamespace::Trusted, "trusted.overlay."), + (XattrNamespace::User, "user.overlay."), + ] { + let mut upper = FileSystem::new(stat()); + directory(&mut upper, "/destination"); + redirect(&mut upper, "/destination", prefix, b"/source"); + file(&mut upper, "/metadata", b""); + xattr(&mut upper, "/metadata", &format!("{prefix}metacopy"), b""); + file(&mut upper, "/renamed", b""); + xattr(&mut upper, "/renamed", &format!("{prefix}metacopy"), b""); + xattr( + &mut upper, + "/renamed", + &format!("{prefix}redirect"), + b"/metadata", + ); + let layer = crate::overlay::normalize(&repo, &parent, upper, namespace)?; + assert_eq!(bytes(&layer, "/metadata", &repo)?, data); + assert_eq!(bytes(&layer, "/renamed", &repo)?, data); + assert_eq!(bytes(&layer, "/destination/nested/file", &repo)?, data); + let id = layer + .root + .get_directory(OsStr::new("/destination/nested"))? + .leaf_id(OsStr::new("file"))?; + assert_eq!( + layer.leaf(id).stat.xattrs, + parent.leaf(parent_id).stat.xattrs + ); + assert_eq!( + layer + .root + .get_directory(OsStr::new("/destination/nested"))? + .stat + .xattrs, + parent + .root + .get_directory(OsStr::new("/source/nested"))? + .stat + .xattrs, + ); + } + Ok(()) +} + +#[test] +fn normalization_uses_selected_xattr_namespace() -> Result<()> { + let temp = tempfile::tempdir()?; + let (repo, _) = Repository::::init_path( + CWD, + temp.path().join("repo"), + RepositoryConfig::default().set_insecure(), + )?; + let mut parent = FileSystem::new(stat()); + file(&mut parent, "/metadata", b"parent data"); + for (namespace, prefix, other) in [ + (XattrNamespace::Trusted, "trusted.overlay.", "user.overlay."), + (XattrNamespace::User, "user.overlay.", "trusted.overlay."), + ] { + let mut upper = FileSystem::new(stat()); + file(&mut upper, "/metadata", b""); + xattr(&mut upper, "/metadata", &format!("{prefix}metacopy"), b""); + xattr( + &mut upper, + "/metadata", + &format!("{prefix}redirect"), + b"/metadata", + ); + xattr( + &mut upper, + "/metadata", + &format!("{other}redirect"), + b"/missing", + ); + xattr( + &mut upper, + "/metadata", + &format!("{prefix}overlay.metacopy"), + b"ordinary image metadata", + ); + file(&mut upper, "/regular", b"upper data"); + for key in ["metacopy", "whiteout", "redirect", "overlay.redirect"] { + xattr(&mut upper, "/regular", &format!("{other}{key}"), b""); + } + directory(&mut upper, "/dir"); + redirect(&mut upper, "/dir", other, b"/missing"); + let dir = upper.root.get_directory_mut(OsStr::new("dir"))?; + dir.stat.xattrs.insert( + OsStr::new(&format!("{prefix}opaque")).into(), + Box::from(b"x".as_slice()), + ); + dir.stat.xattrs.insert( + OsStr::new(&format!("{other}opaque")).into(), + Box::from(b"y".as_slice()), + ); + let mut expected_dir = dir.stat.xattrs.clone(); + expected_dir.retain(|key, _| key.as_bytes().starts_with(other.as_bytes())); + dir.stat.xattrs.insert( + OsStr::new(&format!("{prefix}overlay.redirect")).into(), + Box::from(b"/ordinary".as_slice()), + ); + expected_dir.insert( + OsStr::new(namespace.name(Xattr::Redirect)).into(), + Box::from(b"/ordinary".as_slice()), + ); + let regular_id = upper.root.leaf_id(OsStr::new("regular"))?; + let mut expected_regular = upper.leaf(regular_id).stat.xattrs.clone(); + for suffix in ["redirect", "overlay.redirect"] { + xattr( + &mut upper, + "/regular", + &format!("{prefix}overlay.{suffix}"), + b"/ordinary", + ); + expected_regular.insert( + OsStr::new(&format!("{prefix}{suffix}")).into(), + Box::from(b"/ordinary".as_slice()), + ); + } + + let layer = crate::overlay::normalize(&repo, &parent, upper, namespace)?; + assert_eq!(bytes(&layer, "/metadata", &repo)?, b"parent data"); + assert_eq!(bytes(&layer, "/regular", &repo)?, b"upper data"); + let id = layer.root.leaf_id(OsStr::new("regular"))?; + assert_eq!(layer.leaf(id).stat.xattrs, expected_regular); + let id = layer.root.leaf_id(OsStr::new("metadata"))?; + assert_eq!(layer.leaf(id).stat.xattrs.len(), 2); + assert_eq!( + layer.leaf(id).stat.xattrs[OsStr::new(namespace.name(Xattr::Metacopy))].as_ref(), + b"ordinary image metadata" + ); + assert_eq!( + layer.leaf(id).stat.xattrs[OsStr::new(&format!("{other}redirect"))].as_ref(), + b"/missing" + ); + let dir = layer.root.get_directory(OsStr::new("dir"))?; + assert_eq!(dir.stat.xattrs, expected_dir); + assert!(dir.entries().next().is_none()); + } + Ok(()) +} + +#[test] +fn read_raw_upper_skips_metacopy_payload() -> Result<()> { + let temp = tempfile::tempdir()?; + let (repo, _) = Repository::::init_path( + CWD, + temp.path().join("repo"), + RepositoryConfig::default().set_insecure(), + )?; + let repo = Arc::new(repo); + let upper = temp.path().join("upper"); + std::fs::create_dir(&upper)?; + let fd = std::fs::File::create(upper.join("metadata"))?; + fd.set_len(128 * 1024 * 1024)?; + drop(fd); + rustix::fs::setxattr( + upper.join("metadata"), + XattrNamespace::User.name(Xattr::Metacopy), + b"", + rustix::fs::XattrFlags::empty(), + )?; + std::fs::hard_link(upper.join("metadata"), upper.join("alias"))?; + let runtime = tokio::runtime::Runtime::new()?; + let raw = runtime.block_on(crate::overlay::read_upper( + &repo, + &upper, + XattrNamespace::User, + ))?; + let id = raw.root.leaf_id(OsStr::new("metadata"))?; + assert!( + matches!(&raw.leaf(id).content, LeafContent::Regular(RegularFile::External(id, _)) if *id == Sha256HashValue::EMPTY) + ); + let mut parent = FileSystem::new(stat()); + file(&mut parent, "/metadata", b"real parent content"); + let normalized = crate::overlay::normalize(&repo, &parent, raw, XattrNamespace::User)?; + assert_eq!(bytes(&normalized, "/alias", &repo)?, b"real parent content"); + assert_eq!( + std::fs::metadata(upper.join("metadata"))?.len(), + 128 * 1024 * 1024 + ); + assert!(repo.open_object(&Sha256HashValue::EMPTY).is_err()); + // A user xattr on a trusted-overlay upper must not suppress real data. + let other_upper = temp.path().join("other-upper"); + std::fs::create_dir(&other_upper)?; + let data = vec![b'd'; 8193]; + std::fs::write(other_upper.join("regular"), &data)?; + rustix::fs::setxattr( + other_upper.join("regular"), + XattrNamespace::User.name(Xattr::Metacopy), + b"", + rustix::fs::XattrFlags::empty(), + )?; + let raw = runtime.block_on(crate::overlay::read_upper( + &repo, + &other_upper, + XattrNamespace::Trusted, + ))?; + assert_eq!(bytes(&raw, "/regular", &repo)?, data); + let normalized = crate::overlay::normalize(&repo, &parent, raw, XattrNamespace::Trusted)?; + assert_eq!(bytes(&normalized, "/regular", &repo)?, data); + let id = normalized.root.leaf_id(OsStr::new("regular"))?; + assert!( + normalized + .leaf(id) + .stat + .xattrs + .contains_key(OsStr::new(XattrNamespace::User.name(Xattr::Metacopy))) + ); + // Normal lower files win collisions; data-only redirects must be absolute. + let object_data = vec![b'o'; 1024]; + let object = repo.ensure_object(&object_data)?; + let parent_data = b"different logical file".as_slice(); + for (prefix, parent_data, expected) in [ + ("/", None, Some(object_data.as_slice())), + ("/", Some(parent_data), Some(parent_data)), + ("", Some(parent_data), Some(parent_data)), + ("", None, None), + ] { + let mut upper = FileSystem::new(stat()); + file(&mut upper, "/object", b""); + xattr( + &mut upper, + "/object", + XattrNamespace::User.name(Xattr::Metacopy), + b"", + ); + xattr( + &mut upper, + "/object", + XattrNamespace::User.name(Xattr::Redirect), + format!("{prefix}{}", object.to_object_pathname()).as_bytes(), + ); + let mut parent = FileSystem::new(stat()); + if let Some(data) = parent_data { + let object_path = object.to_object_pathname(); + let (dir, name) = object_path.split_once('/').unwrap(); + directory(&mut parent, dir); + file(&mut parent, &format!("/{dir}/{name}"), data); + } + let normalized = crate::overlay::normalize(&repo, &parent, upper, XattrNamespace::User); + match expected { + Some(expected) => assert_eq!(bytes(&normalized?, "/object", &repo)?, expected), + None => assert!(normalized.is_err()), + } + } + Ok(()) +} + +#[test] +fn normalization_rejects_unresolved_or_invalid_upper() -> Result<()> { + let temp = tempfile::tempdir()?; + let (repo, _) = Repository::::init_path( + CWD, + temp.path().join("repo"), + RepositoryConfig::default().set_insecure(), + )?; + let parent = FileSystem::new(stat()); + for target in [ + b"".as_slice(), + b"../escape", + b"/a/../b", + b"/a/./b", + b"/bad\0path", + b"/missing", + ] { + let mut upper = FileSystem::new(stat()); + directory(&mut upper, "/dir"); + redirect(&mut upper, "/dir", "user.overlay.", target); + assert!( + crate::overlay::normalize(&repo, &parent, upper, XattrNamespace::User).is_err(), + "redirect {target:?}" + ); + } + for (name, key, value) in [ + ( + "/missing", + XattrNamespace::User.name(Xattr::Metacopy), + b"".as_slice(), + ), + ( + "/bad-whiteout", + XattrNamespace::User.name(Xattr::Whiteout), + b"", + ), + ("/.wh.reserved", "user.foo", b"bar"), + ] { + let mut upper = FileSystem::new(stat()); + file(&mut upper, name, b"not empty"); + xattr(&mut upper, name, key, value); + assert!( + crate::overlay::normalize(&repo, &parent, upper, XattrNamespace::User).is_err(), + "upper {name}" + ); + } + Ok(()) +} diff --git a/composefs-run/src/lib.rs b/composefs-run/src/lib.rs new file mode 100644 index 0000000..f755bd7 --- /dev/null +++ b/composefs-run/src/lib.rs @@ -0,0 +1,27 @@ +//! OCI image construction from overlay upper trees. +//! +//! These APIs operate without a mounted overlay filesystem. Read an upper with +//! [`overlay::read_upper`], resolve its overlay metadata with +//! [`overlay::normalize`], then store a layer or child OCI image. +//! Both operations take the overlay xattr namespace selected for the mount. +//! +//! The reusable operations are separated from the runner's lifecycle handling +//! so they can eventually move into composefs-rs: +//! +//! - [`overlay::read_upper`] uses the composefs scanner without importing +//! metacopy placeholder data. +//! - [`overlay::normalize`] resolves metacopy files and directory redirects +//! against an immutable parent tree and repository, preserving hardlinks and +//! translating whiteouts into OCI entries. +//! - [`canonical_tar::write_layer`] emits canonical tar headers and padding +//! inline in a splitstream and references file-content objects. It computes +//! SHA-256 over the logical tar bytes without writing a tar archive. +//! - [`commit::create_image`] stores a child image's config, manifest and EROFS +//! image before assigning the destination ref. + +pub mod canonical_tar; +pub mod commit; +pub mod overlay; + +#[cfg(test)] +mod commit_tests; diff --git a/composefs-run/src/main.rs b/composefs-run/src/main.rs index f51cf6c..4847c61 100644 --- a/composefs-run/src/main.rs +++ b/composefs-run/src/main.rs @@ -7,6 +7,8 @@ mod seccomp; mod selinux; mod userns; +use composefs_run::{commit, overlay}; + use std::os::unix::fs::DirBuilderExt; use std::path::PathBuf; use std::sync::Arc; @@ -36,6 +38,30 @@ pub(crate) struct CleanupArgs { /// PID of the pasta process to kill on cleanup #[clap(long)] pasta_pid: Option, + + /// Destination ref for a poststop commit + #[clap(long, value_parser = parse_commit_ref, requires_all = ["commit_repo", "commit_parent", "commit_upper", "commit_command"])] + commit: Option, + + /// Repository containing the parent and destination images + #[clap(long, requires = "commit")] + commit_repo: Option, + + /// Digest of the image used to start the container + #[clap(long, requires = "commit")] + commit_parent: Option, + + /// Upper directory to read after container exit + #[clap(long, requires = "commit")] + commit_upper: Option, + + /// JSON file containing the resolved workload arguments + #[clap(long, requires = "commit")] + commit_command: Option, + + /// Read overlay metadata from the user namespace of xattrs + #[clap(long, requires = "commit")] + commit_userxattr: bool, } #[derive(Clone, Debug)] @@ -311,6 +337,10 @@ pub(crate) struct Cli { #[clap(long)] overlay_dir: Option, + /// Commit the overlay changes as a child OCI image at this repository ref + #[clap(long, value_name = "REF", conflicts_with_all = ["read_only", "rootfs"], value_parser = parse_commit_ref)] + commit: Option, + /// Bind mount a host directory into the container (-v HOST:CONTAINER[:ro]) #[clap(short = 'v', long = "volume", value_parser = clap::value_parser!(VolumeSpec))] volumes: Vec, @@ -416,6 +446,18 @@ pub(crate) struct Cli { cmd: Vec, } +fn parse_commit_ref(value: &str) -> Result { + if value.is_empty() + || value.starts_with('@') + || value + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err("commit ref must be a nonempty repository name without @ prefix, empty, . or .. components".into()); + } + Ok(value.into()) +} + fn main() -> Result<()> { env_logger::init(); clap_complete::CompleteEnv::with_factory(Cli::command).complete(); @@ -451,6 +493,7 @@ fn main() -> Result<()> { ResolvedImage { oci_config: oci_spec::image::ImageConfiguration::default(), erofs_hex: None, + manifest_digest: None, } } else { let repo = ResolvedRepo::open(&repo_path)?; @@ -483,7 +526,7 @@ fn main() -> Result<()> { &image, ); - if result.is_err() { + if result.is_err() && cli.commit.is_none() { let _ = std::fs::remove_dir_all(&container_dir); } @@ -574,6 +617,7 @@ impl ResolvedRepo { pub(crate) struct ResolvedImage { pub(crate) oci_config: oci_spec::image::ImageConfiguration, pub(crate) erofs_hex: Option, + pub(crate) manifest_digest: Option, } async fn pull_image_typed( @@ -608,6 +652,7 @@ fn resolve_image_typed( Ok(ResolvedImage { oci_config: config.config, erofs_hex: Some(erofs_id.to_hex()), + manifest_digest: Some(img.manifest_digest().clone()), }) } @@ -706,6 +751,23 @@ mod tests { result } + #[test] + fn commit_cli_validation() { + use clap::Parser; + let cli = + crate::Cli::try_parse_from(["cfsrun", "--commit", "derived:latest", "base"]).unwrap(); + assert_eq!(cli.commit.as_deref(), Some("derived:latest")); + for args in [ + vec!["--read-only", "--commit", "child", "base"], + vec!["--rootfs", "/tmp", "--commit", "child"], + vec!["--commit", "../escape", "base"], + vec!["--commit", "@digest", "base"], + vec!["--commit", "", "base"], + ] { + assert!(crate::Cli::try_parse_from(std::iter::once("cfsrun").chain(args)).is_err()); + } + } + #[test] fn pull_cli() { assert!( diff --git a/composefs-run/src/overlay.rs b/composefs-run/src/overlay.rs new file mode 100644 index 0000000..973bf90 --- /dev/null +++ b/composefs-run/src/overlay.rs @@ -0,0 +1,444 @@ +//! Normalize an overlay upper tree into an OCI changeset without mounting it. +//! +//! Parent paths resolve against the immutable parent filesystem. Metacopy files +//! reuse its content references; directory redirects materialize inherited +//! entries in the changeset. Neither operation reads a merged overlay mount. + +use std::{ + collections::HashMap, + ffi::{OsStr, OsString}, + os::unix::ffi::OsStrExt, + path::{Component, Path, PathBuf}, + sync::Arc, +}; + +use anyhow::{Context, Result, ensure}; +use composefs::{ + fs::{ObjectStore, ReadFilesystemOpts}, + fsverity::FsVerityHashValue, + generic_tree::LeafId, + repository::Repository, + tree::{Directory, FileSystem, Inode, Leaf, LeafContent, RegularFile, Stat}, +}; +use rustix::{ + fd::OwnedFd, + fs::{Mode, OFlags}, +}; + +/// Overlay metadata namespace selected when mounting the filesystem. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum XattrNamespace { + /// `trusted.overlay.*`, used without the `userxattr` mount option. + Trusted, + /// `user.overlay.*`, used with the `userxattr` mount option. + User, +} + +#[derive(Clone, Copy)] +pub(crate) enum Xattr { + Metacopy, + Redirect, + Opaque, + Whiteout, +} + +const XATTR_ESCAPE_PREFIX: &[u8] = b"overlay."; + +impl XattrNamespace { + fn prefix(self) -> &'static str { + match self { + Self::Trusted => "trusted.overlay.", + Self::User => "user.overlay.", + } + } + + pub(crate) fn name(self, attr: Xattr) -> &'static str { + match (self, attr) { + (Self::Trusted, Xattr::Metacopy) => "trusted.overlay.metacopy", + (Self::Trusted, Xattr::Redirect) => "trusted.overlay.redirect", + (Self::Trusted, Xattr::Opaque) => "trusted.overlay.opaque", + (Self::Trusted, Xattr::Whiteout) => "trusted.overlay.whiteout", + (Self::User, Xattr::Metacopy) => "user.overlay.metacopy", + (Self::User, Xattr::Redirect) => "user.overlay.redirect", + (Self::User, Xattr::Opaque) => "user.overlay.opaque", + (Self::User, Xattr::Whiteout) => "user.overlay.whiteout", + } + } + + fn attr(self, stat: &Stat, key: Xattr) -> Option<&[u8]> { + stat.xattrs + .get(OsStr::new(self.name(key))) + .map(AsRef::as_ref) + } + + fn normalize_stat(self, stat: &mut Stat) { + let prefix = self.prefix().as_bytes(); + stat.xattrs = std::mem::take(&mut stat.xattrs) + .into_iter() + .filter_map(|(key, value)| { + if let Some(suffix) = key.as_bytes().strip_prefix(prefix) { + // Private overlay metadata is hidden, but escaped image + // xattrs are exposed after removing one escape level. + let suffix = suffix.strip_prefix(XATTR_ESCAPE_PREFIX)?; + let name = [prefix, suffix].concat(); + Some((OsStr::from_bytes(&name).into(), value)) + } else { + Some((key, value)) + } + }) + .collect(); + } +} + +fn is_whiteout(leaf: &Leaf, namespace: XattrNamespace) -> Result { + let xwhiteout = namespace.attr(&leaf.stat, Xattr::Whiteout).is_some(); + ensure!( + !xwhiteout || matches!(&leaf.content, LeafContent::Regular(file) if file.file_size() == 0), + "Invalid overlay xattr whiteout" + ); + Ok(matches!(leaf.content, LeafContent::CharacterDevice(0)) || xwhiteout) +} + +fn redirect_path(parent: &Path, value: &[u8]) -> Result { + ensure!( + !value.is_empty() && !value.contains(&0), + "Invalid empty or NUL-containing overlay redirect" + ); + ensure!( + !value + .split(|b| *b == b'/') + .any(|part| part == b"." || part == b".."), + "Overlay redirect contains . or .." + ); + let path = Path::new(OsStr::from_bytes(value)); + ensure!( + path.components() + .all(|c| matches!(c, Component::RootDir | Component::Normal(_))), + "Invalid overlay redirect" + ); + Ok(if path.is_absolute() { + path.to_owned() + } else { + parent.join(path) + }) +} + +pub(crate) fn parent_directory<'a, H: FsVerityHashValue>( + parent: &'a FileSystem, + path: &Path, +) -> Option<&'a Directory> { + let mut dir = &parent.root; + for component in path.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => match dir.lookup(name)? { + Inode::Directory(child) => dir = child, + _ => return None, + }, + _ => return None, + } + } + Some(dir) +} + +fn parent_file<'a, H: FsVerityHashValue>( + parent: &'a FileSystem, + path: &Path, +) -> Option<&'a RegularFile> { + let dir = parent_directory(parent, path.parent()?)?; + match dir.lookup(path.file_name()?)? { + Inode::Leaf(id, _) => match &parent.leaf(*id).content { + LeafContent::Regular(content) => Some(content), + _ => None, + }, + _ => None, + } +} + +/// Convert a raw upper tree to OCI entries, preserving upper hardlinks and +/// parent hardlinks among materialized entries. The parent is never modified. +/// +/// The parent must already contain resolved content references, as returned by +/// `OciImage::read_filesystem`. Its internal data-only redirects must be decoded +/// directly to repository objects, without searching the logical parent tree. +/// Reused parent nodes are copied without interpreting their xattrs. +/// +/// Literal upper filenames beginning with `.wh.` are rejected because OCI +/// reserves that prefix for whiteouts. Redirects resolve against the parent +/// first, then composefs data-only redirects of the form `/XX/OBJECT` fall back +/// to repository objects. +/// Only metadata in `namespace` is interpreted and removed. Escaped image +/// xattrs are unescaped once; other xattrs are preserved. +pub fn normalize( + repo: &Repository, + parent: &FileSystem, + upper: FileSystem, + namespace: XattrNamespace, +) -> Result> { + let upper_leaf_count = upper.leaves.len(); + let mut normalizer = Normalizer { + parent, + namespace, + leaves: upper.leaves, + inherited: HashMap::new(), + metacopies: HashMap::new(), + }; + let root = normalizer.directory(upper.root, Path::new("/"), false, true)?; + // A hardlinked metacopy may first appear at a newly created name. Gather + // all source candidates before resolving each upper inode exactly once. + for (id, paths) in &normalizer.metacopies { + let leaf = &normalizer.leaves[id.0]; + ensure!( + matches!(leaf.content, LeafContent::Regular(_)), + "Metacopy inode is not a regular file" + ); + // Regular lower layers are searched before data-only layers, even + // when the redirect looks like a repository object pathname. + let mut content = paths + .iter() + .find_map(|path| parent_file(parent, path)) + .cloned(); + if let Some(path) = namespace + .attr(&leaf.stat, Xattr::Redirect) + .and_then(|redirect| redirect.strip_prefix(b"/")) + .filter(|_| content.is_none()) + && path.len() == 2 * size_of::() + 1 + && let Ok(object) = H::from_object_pathname(path) + { + match repo.open_object(&object) { + Ok(fd) => { + let size = rustix::fs::fstat(&fd)?.st_size as u64; + content = Some(if size <= composefs::INLINE_CONTENT_MAX_V0 as u64 { + RegularFile::Inline(repo.read_object(&object)?.into_boxed_slice()) + } else { + RegularFile::External(object, size) + }); + } + Err(error) + if error.downcast_ref::() + == Some(&rustix::io::Errno::NOENT) => {} + Err(error) => { + return Err(error).context("Resolving metacopy object redirect"); + } + } + } + normalizer.leaves[id.0].content = + LeafContent::Regular(content.with_context(|| { + format!("Cannot resolve metacopy inode from parent at {paths:?}") + })?); + } + // Inherited nodes are already decoded. Only upper xattrs are metadata + // for this overlay; parent xattrs are ordinary image metadata. + for leaf in &mut normalizer.leaves[..upper_leaf_count] { + namespace.normalize_stat(&mut leaf.stat); + } + let mut result = FileSystem { + root, + leaves: normalizer.leaves, + }; + result.compact(); + result.fsck()?; + Ok(result) +} + +struct Normalizer<'a, H: FsVerityHashValue> { + parent: &'a FileSystem, + namespace: XattrNamespace, + leaves: Vec>, + inherited: HashMap, + metacopies: HashMap>, +} + +impl Normalizer<'_, H> { + fn whiteout(&mut self) -> Inode { + let id = LeafId(self.leaves.len()); + self.leaves.push(Leaf { + stat: Stat { + st_mode: 0o644, + st_uid: 0, + st_gid: 0, + st_mtim_sec: 0, + st_mtim_nsec: 0, + xattrs: Default::default(), + }, + content: LeafContent::Regular(RegularFile::Inline(Box::default())), + }); + Inode::leaf(id) + } + + fn inherited_inode(&mut self, inode: &Inode) -> Result> { + Ok(match inode { + Inode::Directory(dir) => { + let mut result = Directory::new(dir.stat.clone()); + for (name, inode) in dir.entries() { + ensure!( + !name.as_bytes().starts_with(b".wh."), + "OCI reserves filename {name:?}" + ); + result.insert(name, self.inherited_inode(inode)?); + } + Inode::Directory(Box::new(result)) + } + Inode::Leaf(id, _) => { + let mapped = *self.inherited.entry(*id).or_insert_with(|| { + let mapped = LeafId(self.leaves.len()); + self.leaves.push(self.parent.leaf(*id).clone()); + mapped + }); + Inode::leaf(mapped) + } + }) + } + + fn directory( + &mut self, + mut dir: Directory, + source: &Path, + materialize: bool, + visible: bool, + ) -> Result> { + let redirected = self + .namespace + .attr(&dir.stat, Xattr::Redirect) + .map(|value| redirect_path(source.parent().unwrap_or(Path::new("/")), value)) + .transpose()?; + let opaque = self.namespace.attr(&dir.stat, Xattr::Opaque); + ensure!( + opaque.is_none_or(|v| v == b"y" || v == b"x"), + "Unsupported overlay opaque value" + ); + let opaque = opaque == Some(b"y"); + let source = redirected.as_deref().unwrap_or(source); + let lower = if (visible || redirected.is_some()) && !opaque { + parent_directory(self.parent, source) + } else { + None + }; + ensure!( + redirected.is_none() || opaque || lower.is_some(), + "Missing redirected parent directory {source:?}" + ); + let materialize = materialize || redirected.is_some(); + let mut result = Directory::new(dir.stat.clone()); + self.namespace.normalize_stat(&mut result.stat); + let names: Vec = dir.entries().map(|(name, _)| name.to_owned()).collect(); + for name in names { + ensure!( + !name.as_bytes().starts_with(b".wh."), + "OCI reserves filename {name:?}" + ); + let inode = dir.pop(&name).expect("entry exists"); + let lower_inode = lower.and_then(|dir| dir.lookup(&name)); + let inode = match inode { + Inode::Directory(child) => Inode::Directory(Box::new(self.directory( + *child, + &source.join(&name), + materialize, + matches!(lower_inode, Some(Inode::Directory(_))), + )?)), + Inode::Leaf(id, _) => { + let leaf = &self.leaves[id.0]; + if is_whiteout(leaf, self.namespace)? { + let whiteout_name = [b".wh.".as_slice(), name.as_bytes()].concat(); + result.insert(OsStr::from_bytes(&whiteout_name), self.whiteout()); + continue; + } + if self.namespace.attr(&leaf.stat, Xattr::Metacopy).is_some() { + let path = if let Some(redirect) = + self.namespace.attr(&leaf.stat, Xattr::Redirect) + { + Some(redirect_path(source, redirect)?) + } else if lower_inode.is_some() { + Some(source.join(&name)) + } else { + None + }; + let paths = self.metacopies.entry(id).or_default(); + if let Some(path) = path { + paths.push(path); + } + } + Inode::leaf(id) + } + }; + result.insert(&name, inode); + } + if materialize && let Some(lower) = lower { + for (name, inode) in lower.entries() { + // A source whiteout in the upper hides this inherited name. + if result.lookup(name).is_some() + || result + .lookup(OsStr::from_bytes( + &[b".wh.".as_slice(), name.as_bytes()].concat(), + )) + .is_some() + { + continue; + } + ensure!( + !name.as_bytes().starts_with(b".wh."), + "OCI reserves filename {name:?}" + ); + result.insert(name, self.inherited_inode(inode)?); + } + } + if opaque || redirected.is_some() { + result.insert(OsStr::new(".wh..wh..opq"), self.whiteout()); + } + Ok(result) + } +} + +/// Store adapter for the standard scanner. Metacopy payloads are sparse +/// placeholders, potentially very large, and must never be imported as data. +struct UpperStore { + repo: Arc>, + namespace: XattrNamespace, +} + +impl ObjectStore for UpperStore { + fn ensure_object_from_fd(&self, fd: OwnedFd, size: u64) -> Result { + let key = self.namespace.name(Xattr::Metacopy); + match rustix::fs::fgetxattr(&fd, key, &mut [0u8; 0]) { + Ok(_) | Err(rustix::io::Errno::RANGE) => return Ok(H::EMPTY), + Err(rustix::io::Errno::NODATA) => {} + Err(error) => return Err(error).context("Checking upper file metacopy marker"), + } + ObjectStore::ensure_object_from_fd(self.repo.as_ref(), fd, size) + } + + fn write_semaphore(&self) -> Arc { + self.repo.write_semaphore() + } +} + +/// Read the raw upper tree using composefs's scanner. A subsequent call to +/// `normalize` must resolve its metacopy placeholders against the parent, +/// using the same `namespace` selected for this read and the original mount. +pub async fn read_upper( + repo: &Arc>, + upper: &Path, + namespace: XattrNamespace, +) -> Result> { + let fd = rustix::fs::open( + ".", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + let mut fs = composefs::fs::read_filesystem_with_opts( + fd, + upper.to_owned(), + ReadFilesystemOpts { + store: Some(Arc::new(UpperStore { + repo: repo.clone(), + namespace, + })), + ..Default::default() + }, + ) + .await + .context("Reading overlay upper directory")?; + // Runtime SELinux labels describe this run's host isolation, not the image. + fs.filter_xattrs(|key| key != OsStr::new("security.selinux")); + Ok(fs) +} diff --git a/composefs-run/src/run.rs b/composefs-run/src/run.rs index c2c6f01..8614363 100644 --- a/composefs-run/src/run.rs +++ b/composefs-run/src/run.rs @@ -9,8 +9,9 @@ use anyhow::{Context, Result, ensure}; use clap::Parser; use composefs::mount::MountOptions; use oci_spec::runtime::{ - Capability, HookBuilder, HooksBuilder, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxNamespace, - LinuxNamespaceType, Mount, MountBuilder, ProcessBuilder, RootBuilder, Spec, UserBuilder, + Capability, Hook, HookBuilder, HooksBuilder, LinuxBuilder, LinuxCapabilitiesBuilder, + LinuxNamespace, LinuxNamespaceType, Mount, MountBuilder, ProcessBuilder, RootBuilder, Spec, + UserBuilder, }; use rustix::fs::{CWD, Mode, OFlags}; use rustix::mount::{ @@ -20,7 +21,7 @@ use rustix::mount::{ use crate::{ CleanupArgs, Cli, HostEntry, NetworkMode, PortSpec, ResolvedImage, SeccompPolicy, SystemdMode, - UserSpec, fuse, netavark, seccomp, selinux, userns, + UserSpec, commit, fuse, netavark, overlay, seccomp, selinux, userns, }; pub fn run( @@ -115,18 +116,15 @@ pub fn run( cli.tty }; - let (mut spec, optional_mounts) = build_runtime_spec( + let (mut spec, optional_mounts, command) = build_runtime_spec( cli, oci_config, container_id, - container_dir, &bundle_dir, tty, rootless, &network, netns_path.as_deref(), - container_ip, - pasta_pid, )?; let mount_label = spec @@ -170,23 +168,249 @@ pub fn run( ) .context("Opening mounted rootfs")?; add_optional_mounts(&mut spec, optional_mounts, &rootfs)?; + drop(rootfs); + let mut cleanup_args = CleanupArgs { + container_dir: Some(container_dir.to_owned()), + container_id: Some(container_id.to_owned()), + container_ip, + pasta_pid, + commit: cli.commit.clone(), + commit_repo: None, + commit_parent: None, + commit_upper: None, + commit_command: None, + commit_userxattr: cli.commit.is_some() && rootless, + }; + if cli.commit.is_some() { + // Keep argv in a file: a single quoted history argument can exceed + // Linux's per-argument exec limit even when the workload argv is valid. + let command_path = container_dir.join("command.json"); + fs::write(&command_path, serde_json::to_vec(&command)?)?; + cleanup_args.commit_command = Some(command_path); + // Hooks run with the bundle as cwd, so pass absolute paths and the + // resolved parent digest rather than the possibly changing source ref. + cleanup_args.commit_repo = Some(fs::canonicalize(repo_path)?); + cleanup_args.commit_upper = Some(fs::canonicalize(overlay_dir.join("upper"))?); + cleanup_args.commit_parent = Some( + image + .manifest_digest + .clone() + .context("Missing parent OCI manifest")?, + ); + // crun opens hook stderr as a separate append-only file description. + // Sockets cannot be reopened. A non-append regular file has a separate + // offset, allowing crun's later writes to overwrite the hook's output. + let stderr = std::io::stderr(); + let kind = rustix::fs::FileType::from_raw_mode(rustix::fs::fstat(stderr.as_fd())?.st_mode); + let flags = rustix::fs::fcntl_getfl(stderr.as_fd())?; + let safe = kind != rustix::fs::FileType::Socket + && (kind != rustix::fs::FileType::RegularFile || flags.contains(OFlags::APPEND)); + let hook_stderr = if safe { + match rustix::fs::open( + "/proc/self/fd/2", + OFlags::WRONLY | OFlags::APPEND | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(_) => Some(PathBuf::from("/proc/self/fd/2")), + Err(error) => { + eprintln!("Cannot reopen stderr for commit diagnostics: {error}"); + None + } + } + } else { + None + }; + let hook_stderr = hook_stderr.unwrap_or_else(|| { + let log = container_dir.join("commit.log"); + eprintln!( + "Commit diagnostics retained at {} if committing fails", + log.display() + ); + log + }); + let mut annotations = spec.annotations().clone().unwrap_or_default(); + annotations.insert( + "run.oci.hooks.stderr".into(), + hook_stderr + .to_str() + .context("Non-UTF-8 hook log path")? + .into(), + ); + spec.set_annotations(Some(annotations)); + } + spec.set_hooks(Some( + HooksBuilder::default() + .poststop(vec![cleanup_hook(&cleanup_args)?]) + .build()?, + )); let config_json = serde_json::to_string_pretty(&spec)?; fs::write(bundle_dir.join("config.json"), &config_json)?; let err = Command::new("crun") .arg("run") .arg("--bundle") - .arg(bundle_dir) + .arg(&bundle_dir) .arg(container_id) .exec(); Err(err).context("Failed to exec crun") } -/// OCI poststop hook: unmount and remove the container state directory. +/// Construct the poststop invocation, leaving commit hooks without a timeout. +fn cleanup_hook(args: &CleanupArgs) -> Result { + let mut hook_args = vec!["cfsrun".into(), "--internal-cleanup".into()]; + if let Some(dir) = &args.container_dir { + hook_args.extend([ + "--container-dir".into(), + dir.to_str() + .context("Non-UTF-8 container directory")? + .into(), + ]); + } + if let Some(id) = &args.container_id { + hook_args.extend(["--container-id".into(), id.clone()]); + } + if let Some(ip) = args.container_ip { + hook_args.extend(["--container-ip".into(), ip.to_string()]); + } + if let Some(pid) = args.pasta_pid { + hook_args.extend(["--pasta-pid".into(), pid.to_string()]); + } + if let Some(reference) = &args.commit { + hook_args.push(format!("--commit={reference}")); + if args.commit_userxattr { + hook_args.push("--commit-userxattr".into()); + } + hook_args.extend([ + "--commit-command".into(), + args.commit_command + .as_ref() + .context("Missing workload command file")? + .to_str() + .context("Non-UTF-8 workload command path")? + .into(), + "--commit-repo".into(), + args.commit_repo + .as_ref() + .context("Missing commit repository")? + .to_str() + .context("Non-UTF-8 repository path")? + .into(), + "--commit-parent".into(), + args.commit_parent + .as_ref() + .context("Missing commit parent")? + .to_string(), + "--commit-upper".into(), + args.commit_upper + .as_ref() + .context("Missing commit upper directory")? + .to_str() + .context("Non-UTF-8 upper path")? + .into(), + ]); + } + let mut builder = HookBuilder::default() + .path(std::env::current_exe().context("Resolving own binary path")?) + .args(hook_args); + if args.commit.is_none() { + builder = builder.timeout(10); + } + Ok(builder.build()?) +} + +/// OCI poststop hook: release runtime resources, optionally commit, then remove +/// container state. A failed commit leaves the upper available for recovery. pub fn cleanup() -> Result<()> { let args = CleanupArgs::parse_from(std::iter::once("cleanup".into()).chain(std::env::args().skip(2))); + cleanup_container(&args, args.commit.is_some())?; + if let Some(reference) = &args.commit { + let upper = args + .commit_upper + .as_ref() + .context("Missing commit upper directory")?; + let digest = commit_upper(&args).with_context(|| { + format!( + "Committing changes; overlay retained at {}", + upper.display() + ) + })?; + eprintln!("Committed {reference}: {digest}"); + if let Some(dir) = &args.container_dir { + fs::remove_dir_all(dir).context("Removing committed container state")?; + } + } + Ok(()) +} + +fn commit_upper(args: &CleanupArgs) -> Result { + let repo = crate::ResolvedRepo::open( + args.commit_repo + .as_ref() + .context("Missing commit repository")?, + )?; + let digest = args + .commit_parent + .as_ref() + .context("Missing commit parent")?; + let upper = args + .commit_upper + .as_ref() + .context("Missing commit upper directory")?; + let reference = args.commit.as_deref().context("Missing commit reference")?; + let command_path = args + .commit_command + .as_ref() + .context("Missing workload command file")?; + let command: Vec = serde_json::from_slice( + &fs::read(command_path).context("Reading workload command for history")?, + ) + .context("Decoding workload command for history")?; + let created_by = shlex::try_join(command.iter().map(String::as_str)) + .context("Formatting workload command for history")?; + let namespace = if args.commit_userxattr { + overlay::XattrNamespace::User + } else { + overlay::XattrNamespace::Trusted + }; + let runtime = tokio::runtime::Runtime::new()?; + async fn commit( + repo: composefs::repository::Repository, + digest: &composefs_oci::OciDigest, + upper: &Path, + reference: &str, + created_by: &str, + namespace: overlay::XattrNamespace, + ) -> Result { + let repo = std::sync::Arc::new(repo); + let parent = composefs_oci::OciImage::open(&repo, digest, None)?; + let parent_fs = parent.read_filesystem(&repo, false)?; + let upper = overlay::read_upper(&repo, upper, namespace).await?; + let layer = overlay::normalize(&repo, &parent_fs, upper, namespace)?; + commit::create_image(&repo, &parent, &layer, reference, created_by) + } + match repo { + crate::ResolvedRepo::Sha256(repo) => runtime.block_on(commit( + repo, + digest, + upper, + reference, + &created_by, + namespace, + )), + crate::ResolvedRepo::Sha512(repo) => runtime.block_on(commit( + repo, + digest, + upper, + reference, + &created_by, + namespace, + )), + } +} + +fn cleanup_container(args: &CleanupArgs, keep_overlay: bool) -> Result<()> { if let Some(dir) = &args.container_dir { ensure!( dir.parent() == Some(Path::new("/var/tmp")) @@ -210,7 +434,11 @@ pub fn cleanup() -> Result<()> { } let bundle = dir.join("bundle"); let _ = rustix::mount::unmount(&bundle, rustix::mount::UnmountFlags::DETACH); - let _ = fs::remove_dir_all(dir); + if keep_overlay { + let _ = fs::remove_dir(dir.join("bundle")); + } else { + let _ = fs::remove_dir_all(dir); + } } Ok(()) } @@ -658,35 +886,39 @@ fn is_systemd_command(args: &[String]) -> bool { .unwrap_or(false) } +/// Resolve the workload before adding the optional init wrapper. +fn resolve_command(cli: &Cli, config: &oci_spec::image::Config) -> Result> { + let args = if cli.cmd.is_empty() { + let mut args = config.entrypoint().clone().unwrap_or_default(); + args.extend(config.cmd().clone().unwrap_or_default()); + args + } else { + cli.cmd.clone() + }; + ensure!( + !args.is_empty(), + "No entrypoint or cmd in image config and no command specified" + ); + Ok(args) +} + #[allow(clippy::too_many_arguments)] fn build_runtime_spec( cli: &Cli, oci_config: &oci_spec::image::Config, container_id: &str, - container_dir: &Path, bundle_dir: &Path, tty: bool, rootless: bool, network: &NetworkMode, netns_path: Option<&Path>, - container_ip: Option, - pasta_pid: Option, -) -> Result<(Spec, Vec)> { +) -> Result<(Spec, Vec, Vec)> { use std::collections::HashSet; // ── Resolve image + CLI into effective values ─────────────────────── - let mut args = if cli.cmd.is_empty() { - let mut args = oci_config.entrypoint().clone().unwrap_or_default(); - args.extend(oci_config.cmd().clone().unwrap_or_default()); - ensure!( - !args.is_empty(), - "No entrypoint or cmd in image config and no command specified" - ); - args - } else { - cli.cmd.clone() - }; + let command = resolve_command(cli, oci_config)?; + let mut args = command.clone(); let mut env: Vec = oci_config.env().clone().unwrap_or_else(|| { vec!["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into()] @@ -850,7 +1082,7 @@ fn build_runtime_spec( .build()?; let mut process = ProcessBuilder::default() - .args(args.clone()) + .args(args) .env(env) .cwd(&cwd) .terminal(tty) @@ -1087,40 +1319,13 @@ fn build_runtime_spec( spec.set_mounts(Some(mounts)); - // Poststop hook: unmount bundle and clean up the container directory - { - let self_exe = std::env::current_exe().context("Resolving own binary path")?; - let mut hook_args = vec![ - "cfsrun".into(), - "--internal-cleanup".into(), - "--container-dir".into(), - container_dir.display().to_string(), - "--container-id".into(), - container_id.into(), - ]; - if let Some(ip) = container_ip { - hook_args.push("--container-ip".into()); - hook_args.push(ip.to_string()); - } - if let Some(pid) = pasta_pid { - hook_args.push("--pasta-pid".into()); - hook_args.push(pid.to_string()); - } - let hook = HookBuilder::default() - .path(self_exe) - .args(hook_args) - .timeout(10) - .build()?; - spec.set_hooks(Some(HooksBuilder::default().poststop(vec![hook]).build()?)); - } - if !cli.publish.is_empty() && host_network { eprintln!( "note: --publish has no effect with --network=host (ports are already accessible)" ); } - Ok((spec, optional_mounts)) + Ok((spec, optional_mounts, command)) } fn parse_capability(s: &str) -> Result { @@ -1251,6 +1456,147 @@ fn add_device_to_spec(spec: &mut Spec, src: &Path, dst: &Path, perms: &str) -> R mod tests { use super::*; + #[test] + fn resolved_history_command() -> Result<()> { + let cases = [ + ( + vec!["/entrypoint"], + vec!["--default", "two words"], + vec![], + vec!["/entrypoint", "--default", "two words"], + ), + ( + vec![], + vec!["image-command", ""], + vec![], + vec!["image-command", ""], + ), + (vec!["/entrypoint"], vec![], vec![], vec!["/entrypoint"]), + ( + vec!["/entrypoint"], + vec!["default"], + vec![ + "sh", + "-c", + "printf '%s\\n' \"$@\"", + "", + "single'quote", + "line\nbreak", + ], + vec![ + "sh", + "-c", + "printf '%s\\n' \"$@\"", + "", + "single'quote", + "line\nbreak", + ], + ), + ]; + for (entrypoint, defaults, overrides, expected) in cases { + let config = oci_spec::image::ConfigBuilder::default() + .entrypoint(entrypoint.into_iter().map(String::from).collect::>()) + .cmd(defaults.into_iter().map(String::from).collect::>()) + .build()?; + for no_init in [false, true] { + let mut cli = Cli::try_parse_from( + ["cfsrun", "image", "--"] + .into_iter() + .chain(overrides.iter().copied()), + )?; + cli.no_init = no_init; + let command = resolve_command(&cli, &config)?; + assert_eq!(command, expected); + let created_by = shlex::try_join(command.iter().map(String::as_str))?; + assert_eq!(shlex::split(&created_by).unwrap(), command); + } + } + let cli = Cli::try_parse_from(["cfsrun", "image"])?; + assert!(resolve_command(&cli, &oci_spec::image::Config::default()).is_err()); + Ok(()) + } + + #[test] + fn cleanup_hook_arguments_and_timeout() -> Result<()> { + let mut args = CleanupArgs::try_parse_from([ + "cleanup", + "--container-dir", + "/var/tmp/cfsrun-test", + "--container-id", + "test", + "--container-ip", + "10.0.0.2", + "--pasta-pid", + "123", + ])?; + assert_eq!(cleanup_hook(&args)?.timeout(), Some(10)); + args.commit = Some("--result".into()); + args.commit_repo = Some(PathBuf::from("/repo with spaces")); + args.commit_upper = Some(PathBuf::from("/upper with spaces")); + args.commit_parent = Some(format!("sha256:{}", "ab".repeat(32)).parse()?); + args.commit_command = Some(PathBuf::from("/command with spaces.json")); + for userxattr in [false, true] { + args.commit_userxattr = userxattr; + let hook = cleanup_hook(&args)?; + let parsed = CleanupArgs::try_parse_from( + std::iter::once("cleanup").chain( + hook.args() + .as_ref() + .unwrap() + .iter() + .skip(2) + .map(String::as_str), + ), + )?; + assert_eq!(parsed.commit_userxattr, userxattr); + } + let hook = cleanup_hook(&args)?; + assert_eq!(hook.timeout(), None); + let parsed = CleanupArgs::try_parse_from( + std::iter::once("cleanup").chain( + hook.args() + .as_ref() + .unwrap() + .iter() + .skip(2) + .map(String::as_str), + ), + )?; + assert_eq!(parsed.commit, args.commit); + assert_eq!(parsed.commit_repo, args.commit_repo); + assert_eq!(parsed.commit_upper, args.commit_upper); + assert_eq!(parsed.commit_parent, args.commit_parent); + assert_eq!(parsed.commit_command, args.commit_command); + assert_eq!(parsed.container_dir, args.container_dir); + assert_eq!(parsed.container_id, args.container_id); + assert_eq!(parsed.container_ip, args.container_ip); + assert_eq!(parsed.pasta_pid, args.pasta_pid); + for missing in [ + "--commit-repo", + "--commit-parent", + "--commit-upper", + "--commit-command", + ] { + let mut incomplete = vec!["cleanup"]; + let values = hook.args().as_ref().unwrap(); + let mut iter = values.iter().skip(2); + while let Some(value) = iter.next() { + if value == missing { + iter.next(); + } else if value.starts_with(&format!("{missing}=")) { + continue; + } else { + incomplete.push(value); + } + } + assert!( + CleanupArgs::try_parse_from(incomplete).is_err(), + "missing {missing}" + ); + } + Ok(()) + } + #[test] fn missing_etc_mounts() -> Result<()> { let dir = @@ -1283,19 +1629,17 @@ mod tests { "true", ])?; cli.read_only = read_only; - let (mut spec, optional_mounts) = build_runtime_spec( + let (mut spec, optional_mounts, command) = build_runtime_spec( &cli, &oci_spec::image::Config::default(), "test-container", - &dir, &bundle, false, true, &NetworkMode::Host, None, - None, - None, )?; + assert_eq!(command, ["true"]); assert_eq!(optional_mounts.len(), if read_only { 3 } else { 0 }); assert_eq!(spec.root().as_ref().unwrap().readonly(), Some(read_only)); let rootfs_fd = rustix::fs::open( diff --git a/composefs-run/tests/integration.rs b/composefs-run/tests/integration.rs index c3bb5b1..da4da98 100644 --- a/composefs-run/tests/integration.rs +++ b/composefs-run/tests/integration.rs @@ -5,6 +5,8 @@ //! //! Requires `cfsctl` in $PATH (or set CFSCTL env var). //! On first run, pulls quay.io/centos/centos:stream10 into a test repo. +//! Set CFSRUN_TEST_REPO to a prepared repository containing the `test` image +//! to avoid a registry pull. use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -20,7 +22,9 @@ fn cfsctl() -> String { fn repo() -> &'static Path { TEST_REPO.get_or_init(|| { - let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("test-repo"); + let dir = std::env::var_os("CFSRUN_TEST_REPO") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("test-repo")); if !dir.join("meta.json").exists() { let status = Command::new(cfsctl()) .args(["init", "--insecure"]) @@ -70,6 +74,21 @@ fn stdout(output: &Output) -> String { String::from_utf8_lossy(&output.stdout).to_string() } +fn remove_retained_overlay(path: &Path) { + if is_root() { + std::fs::remove_dir_all(path).unwrap(); + } else { + // Overlayfs work directories can have mode 000. Re-enter a user + // namespace to recover the permissions held by the original runner. + let status = Command::new("unshare") + .args(["--user", "--map-root-user", "--", "rm", "-rf", "--"]) + .arg(path) + .status() + .unwrap(); + assert!(status.success(), "Removing test overlay {}", path.display()); + } +} + // ── Tests for both rootless and rootful ───────────────────────────────── #[test] @@ -107,6 +126,330 @@ fn exit_code() { assert_eq!(output.status.code(), Some(42)); } +#[test] +fn commit_run_and_reuse_image() { + let first = format!("commit-base-{}", std::process::id()); + let child = format!("commit-child-{}", std::process::id()); + let first_script = "mkdir -p /commit-old/sub; printf original > /commit-old/sub/file; printf remove > /commit-delete; printf metadata > /commit-meta; ln /commit-meta /commit-alias"; + let second_script = "chmod 600 /commit-meta; mv /commit-old /commit-new; mv /commit-delete /commit-written; printf changed > /commit-written; rm /commit-new/sub/file; printf new > /commit-new/new; exit 42"; + let mut command = Command::new(env!("CARGO_BIN_EXE_cfsrun")); + command + .current_dir(repo().parent().unwrap()) + .arg("--repo") + .arg(repo().file_name().unwrap()); + let output = run_ok(command.args([ + "--network", + "host", + "--privileged", + "--commit", + &first, + IMAGE, + "--", + "sh", + "-ec", + first_script, + ])); + assert!( + String::from_utf8_lossy(&output.stderr).contains(&format!("Committed {first}: sha256:")), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let output = cfsrun() + .args([ + "--network", + "host", + "--no-init", + "--privileged", + "--commit", + &child, + &first, + "--", + "sh", + "-ec", + second_script, + ]) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(42), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + for (image, script) in [(&first, first_script), (&child, second_script)] { + let output = Command::new(cfsctl()) + .arg("--repo") + .arg(repo()) + .args(["oci", "inspect", "--config", image]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let config: oci_spec::image::ImageConfiguration = + serde_json::from_slice(&output.stdout).unwrap(); + let created_by = config + .history() + .as_ref() + .unwrap() + .last() + .unwrap() + .created_by() + .as_deref() + .unwrap(); + assert_eq!(shlex::split(created_by).unwrap(), ["sh", "-ec", script]); + } + let output = run_ok(cfsrun().args(["--network", "host", "--no-init", "--privileged", &child, "--", "sh", "-ec", + "test ! -e /commit-old; test ! -e /commit-delete; test ! -e /commit-new/sub/file; test -d /commit-new/sub; test \"$(cat /commit-meta)\" = metadata; test \"$(stat -c %a /commit-meta)\" = 600; test \"$(cat /commit-written)\" = changed; cat /commit-new/new"])); + assert_eq!(stdout(&output), "new"); + let output = run_ok(cfsrun().args([ + "--network", + "host", + "--no-init", + "--privileged", + &first, + "--", + "cat", + "/commit-old/sub/file", + ])); + assert_eq!(stdout(&output), "original"); +} + +#[test] +fn commit_large_command_history() { + let reference = format!("commit-large-command-{}", std::process::id()); + let args = vec!["x".repeat(1024); 200]; + run_ok( + cfsrun() + .args([ + "--network", + "host", + "--no-init", + "--privileged", + "--commit", + &reference, + IMAGE, + "--", + "true", + ]) + .args(&args), + ); + let output = Command::new(cfsctl()) + .arg("--repo") + .arg(repo()) + .args(["oci", "inspect", "--config", &reference]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let config: oci_spec::image::ImageConfiguration = + serde_json::from_slice(&output.stdout).unwrap(); + let created_by = config + .history() + .as_ref() + .unwrap() + .last() + .unwrap() + .created_by() + .as_deref() + .unwrap(); + assert!(created_by.len() > 128 * 1024); + let mut expected = vec!["true".to_owned()]; + expected.extend(args); + assert_eq!(shlex::split(created_by).unwrap(), expected); +} + +#[test] +fn commit_failure_retains_upper_and_exit_status() { + for persistent in [false, true] { + for status in [0, 42] { + // Match the runner's storage: /tmp can itself be overlayfs in CI. + let overlay = tempfile::tempdir_in("/var/tmp").unwrap(); + let reference = format!("commit-failed-{}-{persistent}-{status}", std::process::id()); + let mut command = cfsrun(); + if persistent { + command + .current_dir(overlay.path().parent().unwrap()) + .arg("--overlay-dir") + .arg(overlay.path().file_name().unwrap()); + } + command + .args([ + "--network", + "host", + "--no-init", + "--privileged", + "--commit", + &reference, + IMAGE, + "--", + "sh", + "-ec", + &format!("printf recovery > /.wh.reserved; exit {status}"), + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let child = command.spawn().unwrap(); + let container_dir = PathBuf::from(format!("/var/tmp/cfsrun-composefs-{}", child.id())); + let output = child.wait_with_output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(status), "{stderr}"); + assert!(stderr.contains("OCI reserves filename"), "{stderr}"); + let upper = if persistent { + overlay.path().join("upper") + } else { + container_dir.join("upper") + }; + assert!( + stderr.contains(&format!("overlay retained at {}", upper.display())), + "{stderr}" + ); + assert_eq!( + std::fs::read(upper.join(".wh.reserved")).unwrap(), + b"recovery" + ); + assert!(!container_dir.join("bundle").exists()); + assert!( + !cfsrun() + .args(["--network", "host", &reference, "--", "true"]) + .output() + .unwrap() + .status + .success() + ); + remove_retained_overlay(&container_dir); + if persistent { + remove_retained_overlay(overlay.path()); + } + } + } +} + +#[test] +fn commit_socket_stderr() { + use std::{ + io::Read, + os::{fd::OwnedFd, unix::net::UnixStream}, + process::Stdio, + }; + for fail in [false, true] { + let reference = format!("commit-socket-{}-{fail}", std::process::id()); + let (mut reader, writer) = UnixStream::pair().unwrap(); + let writer: OwnedFd = writer.into(); + let mut command = cfsrun(); + command + .args([ + "--network", + "host", + "--no-init", + "--privileged", + "--commit", + &reference, + IMAGE, + "--", + "sh", + "-ec", + if fail { + "printf recovery > /.wh.reserved" + } else { + "printf success > /commit-socket" + }, + ]) + .stderr(Stdio::from(writer)) + .stdout(Stdio::piped()); + let child = command.spawn().unwrap(); + drop(command); + let container_dir = PathBuf::from(format!("/var/tmp/cfsrun-composefs-{}", child.id())); + let status = child.wait_with_output().unwrap().status; + let mut stderr = String::new(); + reader.read_to_string(&mut stderr).unwrap(); + assert_eq!(status.code(), Some(0), "{stderr}"); + let log = container_dir.join("commit.log"); + assert!(stderr.contains(log.to_str().unwrap()), "{stderr}"); + if fail { + let diagnostics = std::fs::read_to_string(log).unwrap(); + assert!( + diagnostics.contains("OCI reserves filename"), + "{diagnostics}" + ); + assert!(container_dir.join("upper/.wh.reserved").exists()); + remove_retained_overlay(&container_dir); + } else { + assert!(!container_dir.exists()); + let output = run_ok(cfsrun().args([ + "--network", + "host", + "--no-init", + "--privileged", + &reference, + "--", + "cat", + "/commit-socket", + ])); + assert_eq!(stdout(&output), "success"); + } + } +} + +#[test] +fn commit_file_stderr() { + use std::process::Stdio; + for append in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let stderr = temp.path().join("stderr.log"); + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .append(append) + .truncate(!append) + .open(&stderr) + .unwrap(); + let reference = format!("commit-file-{}-{append}", std::process::id()); + let mut command = cfsrun(); + command + .args([ + "--network", + "host", + "--no-init", + "--privileged", + "--commit", + &reference, + IMAGE, + "--", + "sh", + "-ec", + "printf recovery > /.wh.reserved", + ]) + .stderr(Stdio::from(file)) + .stdout(Stdio::piped()); + let child = command.spawn().unwrap(); + drop(command); + let container_dir = PathBuf::from(format!("/var/tmp/cfsrun-composefs-{}", child.id())); + let status = child.wait_with_output().unwrap().status; + assert_eq!(status.code(), Some(0)); + let output = std::fs::read_to_string(stderr).unwrap(); + let diagnostics = if append { + output.clone() + } else { + let log = container_dir.join("commit.log"); + assert!(output.contains(log.to_str().unwrap()), "{output}"); + std::fs::read_to_string(log).unwrap() + }; + assert!( + diagnostics.contains("OCI reserves filename"), + "{diagnostics}" + ); + assert!(output.contains("poststop hook failed"), "{output}"); + assert!(container_dir.join("upper/.wh.reserved").exists()); + remove_retained_overlay(&container_dir); + } +} + #[test] fn env_override() { let output =