From 22a4ffaa48923abe25b82995cb4be70adc1d0393 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 26 Oct 2026 17:43:12 +0100 Subject: [PATCH 1/3] tests: Allow a prepared repository for integration tests Allow integration tests to use an existing repository through CFSRUN_TEST_REPO so they can run with local image fixtures without a registry pull. Keep the existing repository location as the default. Generated-by: AI Signed-off-by: Alexander Larsson --- composefs-run/tests/integration.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/composefs-run/tests/integration.rs b/composefs-run/tests/integration.rs index c3bb5b1..ecf4b63 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"]) From 37b5329e1b73909ba05317d033c46e6d82b6616e Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 26 Oct 2026 17:44:46 +0100 Subject: [PATCH 2/3] image: Create OCI layers from overlay upper trees Provide reusable image construction APIs so filesystem changes can be stored as child OCI images without a merged overlay mount or a tar file. Normalize the selected overlay xattr namespace against a resolved parent tree, preserving inherited object references and hardlinks. Serialize the canonical tar draft directly into splitstream instructions, hash the logical tar bytes, and publish the destination ref after storing the layer, image configuration, manifest and EROFS image. Keep this code independent of the runner so it can move into composefs-rs later. Add library tests to CI and the unit-test recipe. Generated-by: AI Signed-off-by: Alexander Larsson --- .github/workflows/ci.yml | 2 +- Cargo.lock | 4 + Justfile | 2 +- composefs-run/Cargo.toml | 3 + composefs-run/src/canonical_tar.rs | 311 ++++++++++ composefs-run/src/commit.rs | 130 ++++ composefs-run/src/commit_tests.rs | 915 +++++++++++++++++++++++++++++ composefs-run/src/lib.rs | 27 + composefs-run/src/overlay.rs | 444 ++++++++++++++ 9 files changed, 1836 insertions(+), 2 deletions(-) create mode 100644 composefs-run/src/canonical_tar.rs create mode 100644 composefs-run/src/commit.rs create mode 100644 composefs-run/src/commit_tests.rs create mode 100644 composefs-run/src/lib.rs create mode 100644 composefs-run/src/overlay.rs 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..30afd7e 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", @@ -409,6 +410,8 @@ dependencies = [ "serde", "serde_json", "tar", + "tar-core", + "tempfile", "tokio", ] @@ -1799,6 +1802,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/composefs-run/Cargo.toml b/composefs-run/Cargo.toml index 5eb6ad8..6ec1c0b 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,12 @@ 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"] } +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/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) +} From 75a97ee03c47788f3b212c15331dde579b7622e1 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 26 Oct 2026 17:45:34 +0100 Subject: [PATCH 3/3] run: Save container changes with --commit Allow a run to create a new image based on its pinned starting image, using --commit REF to name the result. Run image creation from crun's poststop hook after releasing runtime resources, recording the resolved workload command in OCI history. Keep the workload exit status and retain the upper directory with diagnostics if image creation fails. Document the option and exercise image reuse, failed commands, recovery and hook logging in integration tests. Generated-by: AI Signed-off-by: Alexander Larsson --- Cargo.lock | 1 + README.md | 20 ++ composefs-run/Cargo.toml | 1 + composefs-run/src/main.rs | 64 +++- composefs-run/src/run.rs | 460 +++++++++++++++++++++++++---- composefs-run/tests/integration.rs | 339 +++++++++++++++++++++ 6 files changed, 826 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30afd7e..c55c69f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -409,6 +409,7 @@ dependencies = [ "rustix", "serde", "serde_json", + "shlex", "tar", "tar-core", "tempfile", 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 6ec1c0b..be614dc 100644 --- a/composefs-run/Cargo.toml +++ b/composefs-run/Cargo.toml @@ -23,6 +23,7 @@ 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"] } 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/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 ecf4b63..da4da98 100644 --- a/composefs-run/tests/integration.rs +++ b/composefs-run/tests/integration.rs @@ -74,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] @@ -111,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 =