From 5b54c7986b80c7974d8449d1a52f03f58f39efe6 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sun, 8 Feb 2026 18:10:49 +0000 Subject: [PATCH] oci: Add OCI-native manifest/config storage and image management Switch how we store OCI images to also include and identify images by having explicit `oci/` tags which point to a manifest, which points to a config. We continue to generate splitstream for manifest and config, but we now always store the original context as "external" objects. A big rationale for this is it will align with a future proposal for composefs OCI sealing where we include fsverity signatures for the manifest/config as a detached object. With this for example, bootc can stop storing the manifest on its own. Other big features: - Full general support for OCI artifacts as well - Support for OCI referrers as well (to store sigstore and future composefs signatures) Finally we have an initial sketch for storing multi-arch images. Assisted-by: OpenCode (Opus 4.6) Signed-off-by: Colin Walters --- crates/cfsctl/src/main.rs | 128 +- crates/composefs-oci/Cargo.toml | 1 + crates/composefs-oci/src/image.rs | 317 +++- crates/composefs-oci/src/lib.rs | 163 +- crates/composefs-oci/src/oci_image.rs | 2389 +++++++++++++++++++++++++ crates/composefs-oci/src/skopeo.rs | 235 ++- crates/composefs/src/repository.rs | 49 +- crates/composefs/src/test.rs | 51 +- doc/plans/oci-multiarch-copy.md | 87 + 9 files changed, 3350 insertions(+), 70 deletions(-) create mode 100644 crates/composefs-oci/src/oci_image.rs create mode 100644 doc/plans/oci-multiarch-copy.md diff --git a/crates/cfsctl/src/main.rs b/crates/cfsctl/src/main.rs index 898b7921..512817a2 100644 --- a/crates/cfsctl/src/main.rs +++ b/crates/cfsctl/src/main.rs @@ -101,6 +101,27 @@ enum OciCommand { /// optional reference name for the manifest, use as 'ref/' elsewhere name: Option, }, + /// List all tagged OCI images in the repository + #[clap(name = "images")] + ListImages, + /// Show information about an OCI image + #[clap(name = "inspect")] + Inspect { + /// Image reference (tag name or manifest digest) + image: String, + }, + /// Tag an image with a new name + Tag { + /// Manifest digest (sha256:...) + manifest_digest: String, + /// Tag name to assign + name: String, + }, + /// Remove a tag from an image + Untag { + /// Tag name to remove + name: String, + }, /// Compute the composefs image object id of the rootfs of a stored OCI image ComputeId { #[clap(flatten)] @@ -359,11 +380,110 @@ where println!("{}", image_id.to_id()); } OciCommand::Pull { ref image, name } => { - let (digest, verity) = - composefs_oci::pull(&Arc::new(repo), image, name.as_deref(), None).await?; + // If no explicit name provided, use the image reference as the tag + let tag_name = name.as_deref().unwrap_or(image); + let result = + composefs_oci::pull_image(&Arc::new(repo), image, Some(tag_name), None).await?; - println!("config {digest}"); - println!("verity {}", verity.to_hex()); + println!("manifest {}", result.manifest_digest); + println!("config {}", result.config_digest); + println!("verity {}", result.manifest_verity.to_hex()); + println!("tagged {tag_name}"); + } + OciCommand::ListImages => { + let images = composefs_oci::oci_image::list_images(&repo)?; + + if images.is_empty() { + println!("No images found"); + } else { + println!( + "{:<30} {:<12} {:<10} {:<8} {:<6}", + "NAME", "DIGEST", "ARCH", "SEALED", "LAYERS" + ); + for img in images { + let digest_short = img + .manifest_digest + .strip_prefix("sha256:") + .unwrap_or(&img.manifest_digest); + let digest_display = if digest_short.len() > 12 { + &digest_short[..12] + } else { + digest_short + }; + println!( + "{:<30} {:<12} {:<10} {:<8} {:<6}", + img.name, + digest_display, + if img.architecture.is_empty() { + "artifact" + } else { + &img.architecture + }, + if img.sealed { "yes" } else { "no" }, + img.layer_count + ); + } + } + } + OciCommand::Inspect { ref image } => { + let img = if image.starts_with("sha256:") { + composefs_oci::oci_image::OciImage::open(&repo, image, None)? + } else { + composefs_oci::oci_image::OciImage::open_ref(&repo, image)? + }; + + println!("Manifest: {}", img.manifest_digest()); + println!("Config: {}", img.config_digest()); + println!( + "Type: {}", + if img.is_container_image() { + "container" + } else { + "artifact" + } + ); + + if img.is_container_image() { + println!("Architecture: {}", img.architecture()); + println!("OS: {}", img.os()); + } + + if let Some(created) = img.created() { + println!("Created: {created}"); + } + + println!( + "Sealed: {}", + if img.is_sealed() { "yes" } else { "no" } + ); + if let Some(seal) = img.seal_digest() { + println!("Seal digest: {seal}"); + } + + println!("Layers: {}", img.layer_descriptors().len()); + for (i, layer) in img.layer_descriptors().iter().enumerate() { + println!(" [{i}] {} ({} bytes)", layer.digest(), layer.size()); + } + + if let Some(labels) = img.labels() { + if !labels.is_empty() { + println!("Labels:"); + for (k, v) in labels { + println!(" {k}: {v}"); + } + } + } + } + OciCommand::Tag { + ref manifest_digest, + ref name, + } => { + composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?; + println!("Tagged {manifest_digest} as {name}"); + } + OciCommand::Untag { ref name } => { + composefs_oci::oci_image::untag_image(&repo, name)?; + println!("Removed tag {name}"); } OciCommand::Seal { config_opts: diff --git a/crates/composefs-oci/Cargo.toml b/crates/composefs-oci/Cargo.toml index bf9dbc3a..6c38d3a9 100644 --- a/crates/composefs-oci/Cargo.toml +++ b/crates/composefs-oci/Cargo.toml @@ -30,6 +30,7 @@ tokio-util = { version = "0.7", default-features = false, features = ["io"] } similar-asserts = "1.7.0" composefs = { workspace = true, features = ["test"] } once_cell = "1.21.3" +proptest = "1" tempfile = "3.8.0" [lints] diff --git a/crates/composefs-oci/src/image.rs b/crates/composefs-oci/src/image.rs index aeb0fbde..9fd6f1d0 100644 --- a/crates/composefs-oci/src/image.rs +++ b/crates/composefs-oci/src/image.rs @@ -144,7 +144,7 @@ mod test { fsverity::Sha256HashValue, tree::{LeafContent, RegularFile, Stat}, }; - use std::{cell::RefCell, collections::BTreeMap, io::BufRead, path::PathBuf}; + use std::{cell::RefCell, collections::BTreeMap, io::BufRead, io::Read, path::PathBuf}; use super::*; @@ -188,6 +188,321 @@ mod test { Ok(()) } + fn append_tar_dir(builder: &mut ::tar::Builder>, name: &str) { + let mut header = ::tar::Header::new_ustar(); + header.set_uid(0); + header.set_gid(0); + header.set_mode(0o755); + header.set_entry_type(::tar::EntryType::Directory); + header.set_size(0); + builder + .append_data(&mut header, name, std::io::empty()) + .unwrap(); + } + + /// Append a regular file with explicit content bytes to a tar builder. + fn append_tar_file(builder: &mut ::tar::Builder>, name: &str, content: &[u8]) { + let mut header = ::tar::Header::new_ustar(); + header.set_uid(0); + header.set_gid(0); + header.set_mode(0o644); + header.set_entry_type(::tar::EntryType::Regular); + header.set_size(content.len() as u64); + builder.append_data(&mut header, name, content).unwrap(); + } + + /// Append a symlink entry to a tar builder. + fn append_tar_symlink(builder: &mut ::tar::Builder>, name: &str, target: &str) { + let mut header = ::tar::Header::new_ustar(); + header.set_uid(0); + header.set_gid(0); + header.set_mode(0o777); + header.set_entry_type(::tar::EntryType::Symlink); + header.set_size(0); + builder.append_link(&mut header, name, target).unwrap(); + } + + /// Append a hardlink entry to a tar builder. + fn append_tar_hardlink(builder: &mut ::tar::Builder>, name: &str, target: &str) { + let mut header = ::tar::Header::new_ustar(); + header.set_uid(0); + header.set_gid(0); + header.set_mode(0o644); + header.set_entry_type(::tar::EntryType::Link); + header.set_size(0); + builder.append_link(&mut header, name, target).unwrap(); + } + + /// Build a realistic busybox-like container filesystem as a tar archive. + /// + /// Exercises directories, regular files (both inline and external), symlinks, + /// and hardlinks. Returns `(tar_bytes, "sha256:")`. + fn build_baseimage() -> (Vec, String) { + let mut builder = ::tar::Builder::new(vec![]); + + // Directories (sorted at each level for deterministic output) + append_tar_dir(&mut builder, "bin"); // will be replaced by symlink below + append_tar_dir(&mut builder, "etc"); + append_tar_dir(&mut builder, "tmp"); + append_tar_dir(&mut builder, "usr"); + append_tar_dir(&mut builder, "usr/bin"); + append_tar_dir(&mut builder, "usr/lib"); + append_tar_dir(&mut builder, "usr/share"); + append_tar_dir(&mut builder, "usr/share/doc"); + append_tar_dir(&mut builder, "var"); + append_tar_dir(&mut builder, "var/log"); + + // Regular files — inline (<=64 bytes, the INLINE_CONTENT_MAX threshold) + append_tar_file(&mut builder, "etc/hostname", b"busybox-container\n"); + append_tar_file( + &mut builder, + "etc/resolv.conf", + b"nameserver 8.8.8.8\nnameserver 8.8.4.4\n", + ); + + // Regular files — external (>64 bytes) + append_tar_file( + &mut builder, + "etc/passwd", + b"root:x:0:0:root:/root:/bin/sh\nnobody:x:65534:65534:Nobody:/nonexistent:/usr/sbin/nologin\n\ + daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n", + ); + + // Large external files with recognizable byte patterns + let busybox_content: Vec = (0..65536u64).map(|i| (i % 251) as u8).collect(); + append_tar_file(&mut builder, "usr/bin/busybox", &busybox_content); + + let libc_content: Vec = (0..32768u64).map(|i| (i % 241) as u8).collect(); + append_tar_file(&mut builder, "usr/lib/libc.so", &libc_content); + + let readme_content = "composefs-rs test image\n\ + This is a synthetic busybox-like filesystem used for round-trip testing.\n\ + It exercises inline files, external files, symlinks, and hardlinks.\n\ + The filesystem layout mimics a minimal container image with /usr merge.\n\ + Generated by build_baseimage() in the composefs-oci test suite.\n\ + ----\n\ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod\n\ + tempor incididunt ut labore et dolore magna aliqua.\n"; + append_tar_file( + &mut builder, + "usr/share/doc/README", + readme_content.as_bytes(), + ); + + let messages_content: Vec = (0..8192u64).map(|i| (i % 239) as u8).collect(); + append_tar_file(&mut builder, "var/log/messages", &messages_content); + + // Symlinks (sorted within each directory) + append_tar_symlink(&mut builder, "usr/bin/cat", "busybox"); + append_tar_symlink(&mut builder, "usr/bin/ls", "busybox"); + append_tar_symlink(&mut builder, "usr/bin/sh", "busybox"); + append_tar_symlink(&mut builder, "usr/lib/libc.so.6", "libc.so"); + + // Hardlink: /usr/bin/cp -> /usr/bin/busybox (must appear after busybox) + append_tar_hardlink(&mut builder, "usr/bin/cp", "usr/bin/busybox"); + + // Directory symlink: /bin -> usr/bin (after /usr/bin directory exists) + // We already created /bin as a directory above; overwrite it with a symlink. + // In tar, later entries replace earlier ones, so this replaces the dir. + append_tar_symlink(&mut builder, "bin", "usr/bin"); + + let data = builder.into_inner().unwrap(); + let mut ctx = Sha256::new(); + ctx.update(&data); + let diff_id = format!("sha256:{}", hex::encode(ctx.finalize())); + (data, diff_id) + } + + /// Comprehensive round-trip test: build a busybox-like tar layer via + /// `build_baseimage()`, import it with `import_layer()`, read it back + /// with `get_entry()`, and verify every entry type round-trips correctly. + #[test] + fn test_build_baseimage_roundtrip() -> Result<()> { + use composefs::{repository::Repository, test::tempdir, INLINE_CONTENT_MAX}; + use rustix::fs::CWD; + use std::ffi::OsStr; + use std::sync::Arc; + + let (tar_data, diff_id) = build_baseimage(); + + let repo_dir = tempdir(); + let repo = Arc::new(Repository::::open_path(CWD, &repo_dir)?); + let verity = crate::import_layer(&repo, &diff_id, Some("layer"), &mut tar_data.as_slice())?; + + let mut stream = repo.open_stream("refs/layer", Some(&verity), None)?; + let mut entries = vec![]; + while let Some(entry) = crate::tar::get_entry(&mut stream)? { + entries.push(entry); + } + + // Build a lookup by path for easier assertions + let by_path = |p: &str| -> &TarEntry { + entries + .iter() + .find(|e| e.path == PathBuf::from(p)) + .unwrap_or_else(|| panic!("missing entry for {p}")) + }; + + // --- Directories --- + let expected_dirs = [ + "/bin", // initial dir entry (later overwritten by symlink in tar, but splitstream preserves order) + "/etc", + "/tmp", + "/usr", + "/usr/bin", + "/usr/lib", + "/usr/share", + "/usr/share/doc", + "/var", + "/var/log", + ]; + for dir in &expected_dirs { + let entry = by_path(dir); + assert!( + matches!(entry.item, TarItem::Directory), + "{dir} should be a directory, got {:?}", + entry.item + ); + assert_eq!(entry.stat.st_mode, 0o755, "{dir} mode"); + } + + // --- Inline files (<=INLINE_CONTENT_MAX bytes) --- + let hostname = by_path("/etc/hostname"); + match &hostname.item { + TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(data))) => { + assert_eq!(data.as_ref(), b"busybox-container\n"); + assert!( + data.len() <= INLINE_CONTENT_MAX, + "hostname should be inline ({} bytes <= {INLINE_CONTENT_MAX})", + data.len() + ); + } + other => panic!("expected inline file for /etc/hostname, got {other:?}"), + } + + let resolv = by_path("/etc/resolv.conf"); + match &resolv.item { + TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(data))) => { + assert!(data.starts_with(b"nameserver")); + assert!( + data.len() <= INLINE_CONTENT_MAX, + "resolv.conf should be inline ({} bytes <= {INLINE_CONTENT_MAX})", + data.len() + ); + } + other => panic!("expected inline file for /etc/resolv.conf, got {other:?}"), + } + + // --- External files (>INLINE_CONTENT_MAX bytes) --- + let passwd = by_path("/etc/passwd"); + match &passwd.item { + TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => { + assert!( + *size as usize > INLINE_CONTENT_MAX, + "passwd should be external ({size} bytes > {INLINE_CONTENT_MAX})" + ); + } + other => panic!("expected external file for /etc/passwd, got {other:?}"), + } + + let busybox = by_path("/usr/bin/busybox"); + match &busybox.item { + TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => { + assert_eq!(*size, 65536, "busybox should be 64KB"); + } + other => panic!("expected external file for /usr/bin/busybox, got {other:?}"), + } + + let libc = by_path("/usr/lib/libc.so"); + match &libc.item { + TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => { + assert_eq!(*size, 32768, "libc.so should be 32KB"); + } + other => panic!("expected external file for /usr/lib/libc.so, got {other:?}"), + } + + let readme = by_path("/usr/share/doc/README"); + match &readme.item { + TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => { + assert!( + *size as usize > INLINE_CONTENT_MAX, + "README should be external ({size} bytes)" + ); + } + other => panic!("expected external file for README, got {other:?}"), + } + + let messages = by_path("/var/log/messages"); + match &messages.item { + TarItem::Leaf(LeafContent::Regular(RegularFile::External(_, size))) => { + assert_eq!(*size, 8192, "messages should be 8KB"); + } + other => panic!("expected external file for /var/log/messages, got {other:?}"), + } + + // --- Symlinks --- + let symlinks = [ + ("/usr/bin/cat", "busybox"), + ("/usr/bin/ls", "busybox"), + ("/usr/bin/sh", "busybox"), + ("/usr/lib/libc.so.6", "libc.so"), + ]; + for (path, target) in &symlinks { + let entry = by_path(path); + match &entry.item { + TarItem::Leaf(LeafContent::Symlink(t)) => { + assert_eq!(&**t, OsStr::new(target), "{path} symlink target"); + } + other => panic!("expected symlink for {path}, got {other:?}"), + } + } + + // --- Hardlink --- + // The hardlink /usr/bin/cp -> /usr/bin/busybox appears as a Hardlink variant + let cp = by_path("/usr/bin/cp"); + match &cp.item { + TarItem::Hardlink(target) => { + assert_eq!(target, OsStr::new("/usr/bin/busybox"), "cp hardlink target"); + } + other => panic!("expected hardlink for /usr/bin/cp, got {other:?}"), + } + + // The /bin symlink replaces the earlier /bin directory in the tar stream. + // Both entries appear in the splitstream since it preserves raw tar order. + // Find the *last* /bin entry, which should be the symlink. + let bin_entries: Vec<_> = entries + .iter() + .filter(|e| e.path == PathBuf::from("/bin")) + .collect(); + assert!( + bin_entries.len() >= 2, + "/bin should appear as both a directory and a symlink" + ); + let last_bin = bin_entries.last().unwrap(); + match &last_bin.item { + TarItem::Leaf(LeafContent::Symlink(t)) => { + assert_eq!(&**t, OsStr::new("usr/bin"), "/bin symlink target"); + } + other => panic!("expected symlink for final /bin, got {other:?}"), + } + + // --- Total entry count --- + // 10 dirs + 7 files + 4 symlinks + 1 hardlink + 1 /bin symlink = 23 + // Plus the original /bin dir entry = 24 total + let expected_count = 10 // directories (including initial /bin) + + 7 // regular files + + 4 // symlinks (cat, ls, sh, libc.so.6) + + 1 // hardlink (cp) + + 1; // /bin symlink (replaces the dir) + assert_eq!( + entries.len(), + expected_count, + "total entry count (dirs + files + symlinks + hardlinks)" + ); + + Ok(()) + } + #[test] fn test_process_entry() -> Result<()> { let mut fs = FileSystem::::new(Stat::uninitialized()); diff --git a/crates/composefs-oci/src/lib.rs b/crates/composefs-oci/src/lib.rs index d0992211..2ac5cec0 100644 --- a/crates/composefs-oci/src/lib.rs +++ b/crates/composefs-oci/src/lib.rs @@ -11,6 +11,7 @@ //! - Sealing containers with fs-verity hashes for integrity verification pub mod image; +pub mod oci_image; pub mod skopeo; pub mod tar; @@ -26,6 +27,14 @@ use composefs::{fsverity::FsVerityHashValue, repository::Repository}; use crate::skopeo::{OCI_CONFIG_CONTENT_TYPE, TAR_LAYER_CONTENT_TYPE}; use crate::tar::get_entry; +// Re-export key types for convenience +pub use oci_image::{ + add_referrer, list_images, list_referrers, list_refs, remove_referrer, + remove_referrers_for_subject, resolve_ref, tag_image, untag_image, ImageInfo, OciImage, + OCI_REF_PREFIX, +}; +pub use skopeo::{pull_image, PullResult}; + type ContentAndVerity = (String, ObjectID); fn layer_identifier(diff_id: &str) -> String { @@ -113,29 +122,30 @@ pub fn open_config( config_digest: &str, verity: Option<&ObjectID>, ) -> Result<(ImageConfiguration, HashMap, ObjectID>)> { - let mut stream = repo.open_stream( + let (data, named_refs) = oci_image::read_external_splitstream( + repo, &config_identifier(config_digest), verity, Some(OCI_CONFIG_CONTENT_TYPE), )?; - let config = if verity.is_none() { - // No verity means we need to verify the content hash - let mut data = vec![]; - stream.read_to_end(&mut data)?; - ensure!(config_digest == hash(&data), "Data integrity issue"); - ImageConfiguration::from_reader(&data[..])? - } else { - ImageConfiguration::from_reader(&mut stream)? - }; + if verity.is_none() { + let computed = hash(&data); + ensure!( + config_digest == computed, + "Config integrity check failed: expected {config_digest}, got {computed}" + ); + } - Ok((config, stream.into_named_refs())) + let config = ImageConfiguration::from_reader(&data[..])?; + Ok((config, named_refs)) } /// Writes a container configuration to the repository. /// /// Serializes the image configuration to JSON and stores it as a split stream with the -/// provided layer reference map. The configuration is stored inline since it's typically small. +/// provided layer reference map. The configuration is stored as an external object so +/// fsverity can be independently enabled on it. /// /// Returns a tuple of (sha256 content hash, fs-verity hash value). pub fn write_config( @@ -150,7 +160,7 @@ pub fn write_config( for (name, value) in &refs { stream.add_named_stream_ref(name, value) } - stream.write_inline(json_bytes); + stream.write_external(json_bytes)?; let id = repo.write_stream(stream, &config_identifier(&config_digest), None)?; Ok((config_digest, id)) } @@ -252,4 +262,131 @@ mod test { /file4097 4097 100700 1 0 0 0 0.0 09/3756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743 - 093756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743 "); } + + #[test] + fn test_write_and_open_config() { + use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder}; + + let repo_dir = tempdir(); + let repo = Arc::new(Repository::::open_path(CWD, &repo_dir).unwrap()); + + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(vec!["sha256:abc123def456".to_string()]) + .build() + .unwrap(); + + let config = ImageConfigurationBuilder::default() + .architecture("amd64") + .os("linux") + .rootfs(rootfs) + .build() + .unwrap(); + + let mut refs = HashMap::new(); + refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY); + + let (config_digest, config_verity) = write_config(&repo, &config, refs.clone()).unwrap(); + + assert!(config_digest.starts_with("sha256:")); + + let (opened_config, opened_refs) = + open_config(&repo, &config_digest, Some(&config_verity)).unwrap(); + assert_eq!(opened_config.architecture().to_string(), "amd64"); + assert_eq!(opened_config.os().to_string(), "linux"); + assert_eq!(opened_refs.len(), 1); + assert!(opened_refs.contains_key("sha256:abc123def456")); + + let (opened_config2, _) = open_config(&repo, &config_digest, None).unwrap(); + assert_eq!(opened_config2.architecture().to_string(), "amd64"); + } + + #[test] + fn test_config_stored_as_external_object() { + use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder}; + + let repo_dir = tempdir(); + let repo = Arc::new(Repository::::open_path(CWD, &repo_dir).unwrap()); + + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(vec![]) + .build() + .unwrap(); + + let config = ImageConfigurationBuilder::default() + .architecture("amd64") + .os("linux") + .rootfs(rootfs) + .build() + .unwrap(); + + let (config_digest, config_verity) = write_config(&repo, &config, HashMap::new()).unwrap(); + + // Re-open the splitstream and check that the config JSON is stored + // as an external object reference (not inline). This is important + // because external objects get their own file in objects/, which + // allows fsverity to be independently enabled on the raw content — + // a prerequisite for signing the config by its fsverity digest. + let mut stream = repo + .open_stream( + &config_identifier(&config_digest), + Some(&config_verity), + Some(crate::skopeo::OCI_CONFIG_CONTENT_TYPE), + ) + .unwrap(); + + let mut object_refs = Vec::new(); + stream + .get_object_refs(|id| object_refs.push(id.clone())) + .unwrap(); + + // The config JSON should appear as exactly one external object + assert_eq!( + object_refs.len(), + 1, + "Config should be stored as one external object, got {} refs", + object_refs.len() + ); + + // The external object's fsverity digest should match what we'd + // compute independently from the raw JSON bytes + let json_bytes = config.to_string().unwrap(); + let expected_verity: Sha256HashValue = + composefs::fsverity::compute_verity(json_bytes.as_bytes()); + assert_eq!( + object_refs[0], expected_verity, + "External object verity should match independently computed verity of config JSON" + ); + } + + #[test] + fn test_open_config_bad_hash() { + use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder}; + + let repo_dir = tempdir(); + let repo = Arc::new(Repository::::open_path(CWD, &repo_dir).unwrap()); + + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(vec![]) + .build() + .unwrap(); + + let config = ImageConfigurationBuilder::default() + .architecture("amd64") + .os("linux") + .rootfs(rootfs) + .build() + .unwrap(); + + let (config_digest, _config_verity) = write_config(&repo, &config, HashMap::new()).unwrap(); + + let bad_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + let result = open_config::(&repo, bad_digest, None); + assert!(result.is_err()); + + let result = open_config::(&repo, &config_digest, None); + assert!(result.is_ok()); + } } diff --git a/crates/composefs-oci/src/oci_image.rs b/crates/composefs-oci/src/oci_image.rs new file mode 100644 index 00000000..256168ec --- /dev/null +++ b/crates/composefs-oci/src/oci_image.rs @@ -0,0 +1,2389 @@ +//! OCI image and artifact storage for composefs. +//! +//! This module provides native OCI storage in composefs repositories. The key insight +//! is that OCI is a simple, extensible format that can represent any content - not just +//! container images. By standardizing on OCI, we get: +//! +//! - A well-defined manifest format with content-addressed blobs +//! - Built-in support for signatures (cosign, notation) +//! - Existing tooling (skopeo, crane, oras) +//! - A clear GC model: manifests are roots, everything else is garbage-collectable +//! +//! # Storage Model +//! +//! ```text +//! streams/ +//! oci-manifest-sha256:abc... -> objects/XX/YYY (manifest splitstream) +//! oci-config-sha256:def... -> objects/XX/YYY (config splitstream) +//! oci-layer-sha256:ghi... -> objects/XX/YYY (layer splitstream) +//! refs/ +//! oci/ +//! myimage:latest -> ../../oci-manifest-sha256:abc... (GC root!) +//! myimage:v1.0 -> ../../oci-manifest-sha256:xyz... +//! ``` +//! +//! Named references under `refs/oci/` act as GC roots. Manifests without references +//! will be garbage collected along with their unreferenced configs and layers. +//! +//! # Container Images vs Artifacts +//! +//! Container images have: +//! - Config with `application/vnd.oci.image.config.v1+json` mediaType +//! - Layers that are tar archives (gzip, zstd, or uncompressed) +//! +//! Artifacts can have: +//! - Any config mediaType (or empty config) +//! - Any blob types as "layers" +//! +//! This module handles both transparently. Use `is_container_image()` to check. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use anyhow::{ensure, Context, Result}; +use containers_image_proxy::oci_spec::image::{ + Descriptor, ImageConfiguration, ImageManifest, MediaType, +}; +use rustix::fs::{openat, readlinkat, unlinkat, AtFlags, Dir, Mode, OFlags}; +use rustix::io::Errno; +use sha2::{Digest, Sha256}; + +use composefs::{fsverity::FsVerityHashValue, repository::Repository}; + +use crate::skopeo::{OCI_BLOB_CONTENT_TYPE, OCI_CONFIG_CONTENT_TYPE, OCI_MANIFEST_CONTENT_TYPE}; + +/// Data and named refs from a splitstream with external object storage. +type ExternalData = (Vec, HashMap, ObjectID>); + +/// Open a splitstream that stores its payload as a single external object. +/// +/// Manifests, configs, and blobs are stored as external objects (not inline) +/// so that fsverity can be independently enabled on the raw content. This +/// function opens the splitstream, verifies it contains exactly one external +/// object reference, and returns that object's data along with the stream's +/// named refs (used for GC reachability to configs and layers). +pub(crate) fn read_external_splitstream( + repo: &Repository, + content_id: &str, + verity: Option<&ObjectID>, + expected_content_type: Option, +) -> Result> { + let mut stream = repo.open_stream(content_id, verity, expected_content_type)?; + + let mut object_refs = Vec::new(); + stream.get_object_refs(|id| object_refs.push(id.clone()))?; + ensure!( + object_refs.len() == 1, + "Expected exactly 1 external object in splitstream, got {}", + object_refs.len() + ); + + let data = repo.read_object(&object_refs[0])?; + let named_refs = stream.into_named_refs(); + Ok((data, named_refs)) +} + +/// Prefix for OCI image references in the repository. +pub const OCI_REF_PREFIX: &str = "oci/"; + +/// An OCI image or artifact stored in a composefs repository. +/// +/// This type provides access to the complete OCI structure including +/// manifest, config, and layer/blob references. All metadata is stored +/// locally, eliminating network access for queries. +#[derive(Debug)] +pub struct OciImage { + /// The manifest digest (sha256 content hash) + manifest_digest: String, + /// The parsed OCI manifest + manifest: ImageManifest, + /// The config digest (sha256 content hash) + config_digest: String, + /// The parsed OCI config (may be empty for artifacts) + config: Option, + /// Map from layer diff_id to its fs-verity object ID + layer_refs: HashMap, ObjectID>, + /// The fs-verity ID of the manifest splitstream + manifest_verity: ObjectID, +} + +impl OciImage { + /// Opens an OCI image by its manifest digest. + /// + /// If `verity` is provided, it's used directly for fast lookup. + /// Otherwise, the content is verified against the digest. + pub fn open( + repo: &Repository, + manifest_digest: &str, + verity: Option<&ObjectID>, + ) -> Result { + let manifest_id = manifest_identifier(manifest_digest); + let (data, named_refs) = + read_external_splitstream(repo, &manifest_id, verity, Some(OCI_MANIFEST_CONTENT_TYPE))?; + + // Verify content hash when no verity was provided + if verity.is_none() { + let computed = hash(&data); + ensure!( + manifest_digest == computed, + "Manifest integrity failed: expected {manifest_digest}, got {computed}" + ); + } + + let manifest = ImageManifest::from_reader(&data[..])?; + + let config_digest = manifest.config().digest().to_string(); + let config_key = format!("config:{config_digest}"); + let config_verity = named_refs + .get(config_key.as_str()) + .context("Manifest missing config reference")?; + + let config_id = crate::config_identifier(&config_digest); + let (config_data, config_named_refs) = read_external_splitstream( + repo, + &config_id, + Some(config_verity), + Some(OCI_CONFIG_CONTENT_TYPE), + )?; + + // Try to parse as ImageConfiguration, but don't fail for artifacts + let (config, layer_refs) = match manifest.config().media_type() { + MediaType::ImageConfig => { + let config = ImageConfiguration::from_reader(&config_data[..])?; + (Some(config), config_named_refs) + } + _ => { + // Artifact - layer refs are in the manifest's named refs. + // Filter to only include refs matching known layer digests + // from the manifest, rather than removing the config key + // and hoping nothing else leaks through. + let layer_digests: HashSet<&str> = manifest + .layers() + .iter() + .map(|d| d.digest().as_ref()) + .collect(); + let refs = named_refs + .into_iter() + .filter(|(k, _)| layer_digests.contains(k.as_ref())) + .collect(); + (None, refs) + } + }; + + let manifest_verity = if let Some(v) = verity { + v.clone() + } else { + repo.has_stream(&manifest_id)? + .context("Manifest not found")? + }; + + Ok(Self { + manifest_digest: manifest_digest.to_string(), + manifest, + config_digest, + config, + layer_refs, + manifest_verity, + }) + } + + /// Opens an OCI image by its tag/reference name. + pub fn open_ref(repo: &Repository, name: &str) -> Result { + let (manifest_digest, verity) = resolve_ref(repo, name)?; + Self::open(repo, &manifest_digest, Some(&verity)) + } + + /// Returns true if this is a container image (vs an artifact). + pub fn is_container_image(&self) -> bool { + matches!(self.manifest.config().media_type(), MediaType::ImageConfig) + } + + /// Returns the manifest digest. + pub fn manifest_digest(&self) -> &str { + &self.manifest_digest + } + + /// Returns the manifest fs-verity hash. + pub fn manifest_verity(&self) -> &ObjectID { + &self.manifest_verity + } + + /// Returns the OCI manifest. + pub fn manifest(&self) -> &ImageManifest { + &self.manifest + } + + /// Returns the config digest. + pub fn config_digest(&self) -> &str { + &self.config_digest + } + + /// Returns the OCI config, if this is a container image. + pub fn config(&self) -> Option<&ImageConfiguration> { + self.config.as_ref() + } + + /// Returns the image architecture (empty string for artifacts). + pub fn architecture(&self) -> String { + self.config + .as_ref() + .map(|c| c.architecture().to_string()) + .unwrap_or_default() + } + + /// Returns the image OS (empty string for artifacts). + pub fn os(&self) -> String { + self.config + .as_ref() + .map(|c| c.os().to_string()) + .unwrap_or_default() + } + + /// Returns the creation timestamp. + pub fn created(&self) -> Option<&str> { + self.config.as_ref().and_then(|c| c.created().as_deref()) + } + + /// Returns the composefs seal digest, if sealed. + pub fn seal_digest(&self) -> Option<&str> { + self.config + .as_ref() + .and_then(|c| c.get_config_annotation("containers.composefs.fsverity")) + } + + /// Returns whether this image has been sealed. + pub fn is_sealed(&self) -> bool { + self.seal_digest().is_some() + } + + /// Opens an artifact layer's backing object by index, returning a + /// read-only file descriptor to the raw blob data. + /// + /// This only works for non-tar layers (OCI artifacts). Returns an + /// error for tar layers — use the splitstream API for those. + pub fn open_layer_fd( + &self, + repo: &Repository, + index: usize, + ) -> Result { + let descriptor = self + .manifest + .layers() + .get(index) + .with_context(|| format!("Layer index {index} out of range"))?; + + ensure!( + !is_tar_media_type(descriptor.media_type()), + "open_layer_fd does not support tar layers (media type: {}); \ + use the splitstream API instead", + descriptor.media_type() + ); + + let diff_id: &str = descriptor.digest().as_ref(); + let layer_verity = self + .layer_verity(diff_id) + .with_context(|| format!("No verity for layer {diff_id}"))?; + + let content_id = crate::layer_identifier(diff_id); + let mut stream = repo.open_stream(&content_id, Some(layer_verity), None)?; + + // Artifact layers are stored as a single object; the splitstream + // exists only for GC tracking. + let mut object_refs = vec![]; + stream.get_object_refs(|id| object_refs.push(id.clone()))?; + ensure!( + object_refs.len() == 1, + "Expected exactly 1 external ref for artifact layer, got {}", + object_refs.len() + ); + repo.open_object(&object_refs[0]) + } + + /// Returns the layer diff_ids (for container images). + pub fn layer_diff_ids(&self) -> Vec<&str> { + self.config + .as_ref() + .map(|c| c.rootfs().diff_ids().iter().map(|s| s.as_str()).collect()) + .unwrap_or_default() + } + + /// Returns the fs-verity ID for a layer. + pub fn layer_verity(&self, diff_id: &str) -> Option<&ObjectID> { + self.layer_refs.get(diff_id) + } + + /// Returns layer descriptors from the manifest. + pub fn layer_descriptors(&self) -> &[Descriptor] { + self.manifest.layers() + } + + /// Returns a label from the config. + pub fn label(&self, key: &str) -> Option<&str> { + self.config.as_ref().and_then(|c| { + c.config() + .as_ref() + .and_then(|cfg| cfg.labels().as_ref()) + .and_then(|labels| labels.get(key).map(|s| s.as_str())) + }) + } + + /// Returns all labels from the config. + pub fn labels(&self) -> Option<&HashMap> { + self.config + .as_ref() + .and_then(|c| c.config().as_ref()) + .and_then(|cfg| cfg.labels().as_ref()) + } +} + +// ============================================================================= +// Reference Management (GC Roots) +// ============================================================================= + +/// Tags an image with a name, making it a GC root. +/// +/// The name should be in the format `image:tag` or just `image` (implies `:latest`). +pub fn tag_image( + repo: &Repository, + manifest_digest: &str, + name: &str, +) -> Result<()> { + let manifest_id = manifest_identifier(manifest_digest); + let ref_name = oci_ref_path(name); + repo.name_stream(&manifest_id, &ref_name) +} + +/// Removes a tag from an image. +/// +/// The image data is not deleted; it becomes eligible for garbage collection +/// if no other references point to it. +pub fn untag_image( + repo: &Repository, + name: &str, +) -> Result<()> { + let ref_path = format!("streams/refs/{}", oci_ref_path(name)); + unlinkat(repo.repo_fd(), &ref_path, AtFlags::empty()) + .with_context(|| format!("Failed to remove tag {name}"))?; + Ok(()) +} + +/// Resolves a reference name to (manifest_digest, verity). +pub fn resolve_ref( + repo: &Repository, + name: &str, +) -> Result<(String, ObjectID)> { + let ref_path = format!("streams/refs/{}", oci_ref_path(name)); + + // Read the symlink to get the manifest path + let target = readlinkat(repo.repo_fd(), &ref_path, vec![]) + .with_context(|| format!("Reference {name} not found"))?; + + let target_str = target + .to_str() + .context("Invalid UTF-8 in reference target")?; + + // Extract manifest digest from path like "../../oci-manifest-sha256:abc" + let manifest_part = target_str + .rsplit('/') + .next() + .context("Invalid reference target")?; + + let digest = manifest_part + .strip_prefix("oci-manifest-") + .with_context(|| format!("Invalid manifest reference: {manifest_part}"))?; + + // Get the verity by looking up the manifest + let verity = repo + .has_stream(&manifest_identifier(digest))? + .with_context(|| format!("Manifest {digest} not found"))?; + + Ok((digest.to_string(), verity)) +} + +/// Lists all tagged OCI images. +/// +/// Returns (name, manifest_digest) pairs for each tag. +pub fn list_refs( + repo: &Repository, +) -> Result> { + let mut refs = Vec::new(); + + // Use the repository's ref listing method + for (name, target) in repo.list_stream_refs("oci")? { + // Extract manifest digest from target path + let manifest_part = target.rsplit('/').next().unwrap_or(&target); + if let Some(digest) = manifest_part.strip_prefix("oci-manifest-") { + // Decode the tag name from filesystem-safe encoding + refs.push((decode_tag(&name), digest.to_string())); + } + } + + Ok(refs) +} + +/// Summary information about a stored OCI image. +/// FIXME change this to just have a struct of manifest+config JSON +/// plus a few helper methods. We shouldn't be re-parsing created timestamp here +/// callers should directly access that etc +#[derive(Debug, Clone)] +pub struct ImageInfo { + /// The tag/name of the image + pub name: String, + /// The manifest digest + pub manifest_digest: String, + /// Whether this is a container image (vs artifact) + pub is_container: bool, + /// Architecture (empty for artifacts) + pub architecture: String, + /// OS (empty for artifacts) + pub os: String, + /// Creation timestamp + pub created: Option, + /// Whether sealed with composefs + pub sealed: bool, + /// Number of layers/blobs + pub layer_count: usize, +} + +/// Lists all tagged images with their metadata. +pub fn list_images( + repo: &Repository, +) -> Result> { + let mut images = Vec::new(); + + for (name, digest) in list_refs(repo)? { + match OciImage::open(repo, &digest, None) { + Ok(img) => { + images.push(ImageInfo { + name, + manifest_digest: digest, + is_container: img.is_container_image(), + architecture: img.architecture(), + os: img.os(), + created: img.created().map(String::from), + sealed: img.is_sealed(), + layer_count: img.layer_descriptors().len(), + }); + } + Err(e) => { + eprintln!("Warning: skipping image {name}: {e:#}"); + continue; + } + } + } + + Ok(images) +} + +// ============================================================================= +// Manifest Storage +// ============================================================================= + +/// Writes a manifest to the repository. +/// +/// The manifest JSON is stored as an external object (not inline) so that +/// fsverity can be independently enabled on it. This is important for signing: +/// a signature can reference the fsverity digest of the manifest content directly. +/// +/// The manifest becomes a GC root only if a `reference` name is provided. +pub fn write_manifest( + repo: &Arc>, + manifest: &ImageManifest, + manifest_digest: &str, + config_verity: &ObjectID, + layer_verities: &HashMap, ObjectID>, + reference: Option<&str>, +) -> Result<(String, ObjectID)> { + let content_id = manifest_identifier(manifest_digest); + + if let Some(verity) = repo.has_stream(&content_id)? { + // Already exists - just add the reference if requested + if let Some(name) = reference { + tag_image(repo, manifest_digest, name)?; + } + return Ok((manifest_digest.to_string(), verity)); + } + + let json = manifest.to_string()?; + let json_bytes = json.as_bytes(); + + let computed = hash(json_bytes); + ensure!( + manifest_digest == computed, + "Manifest digest mismatch: expected {manifest_digest}, got {computed}" + ); + + let mut stream = repo.create_stream(OCI_MANIFEST_CONTENT_TYPE); + + let config_key = format!("config:{}", manifest.config().digest()); + stream.add_named_stream_ref(&config_key, config_verity); + + for (diff_id, verity) in layer_verities { + stream.add_named_stream_ref(diff_id, verity); + } + + stream.write_external(json_bytes)?; + + let oci_ref = reference.map(oci_ref_path); + let id = repo.write_stream(stream, &content_id, oci_ref.as_deref())?; + + Ok((manifest_digest.to_string(), id)) +} + +/// Checks if a manifest exists. +pub fn has_manifest( + repo: &Repository, + manifest_digest: &str, +) -> Result> { + repo.has_stream(&manifest_identifier(manifest_digest)) +} + +/// Returns the content identifier for a manifest. +pub fn manifest_identifier(digest: &str) -> String { + format!("oci-manifest-{digest}") +} + +/// Returns true if this is a tar-based layer media type. +pub(crate) fn is_tar_media_type(media_type: &MediaType) -> bool { + matches!( + media_type, + MediaType::ImageLayer + | MediaType::ImageLayerGzip + | MediaType::ImageLayerZstd + | MediaType::ImageLayerNonDistributable + | MediaType::ImageLayerNonDistributableGzip + | MediaType::ImageLayerNonDistributableZstd + ) +} + +/// Returns the reference path for an OCI name. +fn oci_ref_path(name: &str) -> String { + format!("{OCI_REF_PREFIX}{}", encode_tag(name)) +} + +/// Encode a tag name for safe filesystem storage. +/// +/// Uses percent-encoding for characters that are problematic in paths: +/// - `/` becomes `%2F` +/// - `%` becomes `%25` (must be first to avoid double-encoding) +fn encode_tag(name: &str) -> String { + name.replace('%', "%25").replace('/', "%2F") +} + +/// Decode a tag name from filesystem storage. +/// +/// Uses single-pass percent decoding to avoid order-dependent replacement bugs. +/// For example, `%252F` must decode to `%2F` (not `/`). +fn decode_tag(encoded: &str) -> String { + let mut result = String::with_capacity(encoded.len()); + let mut chars = encoded.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' { + let hex: String = chars.by_ref().take(2).collect(); + match hex.as_str() { + "2F" => result.push('/'), + "25" => result.push('%'), + _ => { + result.push('%'); + result.push_str(&hex); + } + } + } else { + result.push(c); + } + } + result +} + +/// Computes sha256 hash. +fn hash(bytes: &[u8]) -> String { + let mut context = Sha256::new(); + context.update(bytes); + format!("sha256:{}", hex::encode(context.finalize())) +} + +// ============================================================================= +// Arbitrary Blob Storage (for OCI Artifacts) +// ============================================================================= + +/// Returns the content identifier for an arbitrary blob. +pub fn blob_identifier(digest: &str) -> String { + format!("oci-blob-{digest}") +} + +/// Writes an arbitrary blob to the repository. +/// +/// This is used for OCI artifacts with non-tar media types. The blob is stored +/// as an external object so that fsverity can be independently enabled on the +/// raw content. +/// +/// Returns (sha256 digest, fs-verity hash). +pub fn write_blob( + repo: &Arc>, + data: &[u8], +) -> Result<(String, ObjectID)> { + let digest = hash(data); + let content_id = blob_identifier(&digest); + + if let Some(verity) = repo.has_stream(&content_id)? { + return Ok((digest, verity)); + } + + let mut stream = repo.create_stream(OCI_BLOB_CONTENT_TYPE); + stream.write_external(data)?; + let verity = repo.write_stream(stream, &content_id, None)?; + + Ok((digest, verity)) +} + +/// Opens an arbitrary blob from the repository. +/// +/// Returns the blob data. If verity is provided, it's used for fast lookup; +/// otherwise, the content hash is verified against the digest. +pub fn open_blob( + repo: &Repository, + digest: &str, + verity: Option<&ObjectID>, +) -> Result> { + let content_id = blob_identifier(digest); + let (data, _named_refs) = + read_external_splitstream(repo, &content_id, verity, Some(OCI_BLOB_CONTENT_TYPE))?; + + if verity.is_none() { + let computed = hash(&data); + ensure!( + digest == computed, + "Blob integrity failed: expected {digest}, got {computed}" + ); + } + + Ok(data) +} + +// ============================================================================= +// Referrer Index (for OCI Artifacts with subject field) +// ============================================================================= + +/// Prefix for referrer index references. +const REFERRER_REF_PREFIX: &str = "oci-referrers/"; + +/// Records a referrer relationship: an artifact references a subject image. +/// +/// Creates a symlink at `streams/refs/oci-referrers/{subject_digest}/{artifact_digest}` +/// pointing to the artifact's manifest stream. This enables discovery of all artifacts +/// that reference a given image (e.g. finding all signature artifacts for an image). +/// +/// Both digests should be in the `sha256:...` format used by OCI. +pub fn add_referrer( + repo: &Repository, + subject_digest: &str, + artifact_manifest_digest: &str, +) -> Result<()> { + let ref_name = format!( + "{REFERRER_REF_PREFIX}{}/{}", + encode_tag(subject_digest), + encode_tag(artifact_manifest_digest) + ); + let manifest_id = manifest_identifier(artifact_manifest_digest); + repo.name_stream(&manifest_id, &ref_name) +} + +/// Lists all artifacts that reference the given subject manifest digest. +/// +/// Returns `(artifact_manifest_digest, artifact_manifest_verity)` pairs for +/// each artifact that declared the subject as its referrer. The digests are +/// in `sha256:...` format. +pub fn list_referrers( + repo: &Repository, + subject_digest: &str, +) -> Result> { + let prefix = format!("{REFERRER_REF_PREFIX}{}", encode_tag(subject_digest)); + + let mut referrers = Vec::new(); + + for (name, target) in repo.list_stream_refs(&prefix)? { + // The name is the encoded artifact manifest digest + let artifact_digest = decode_tag(&name); + + // Extract verity from the symlink target — it points to + // a manifest stream path like "../../oci-manifest-sha256:abc..." + let manifest_part = target.rsplit('/').next().unwrap_or(&target); + if let Some(digest) = manifest_part.strip_prefix("oci-manifest-") { + // Verify consistency: the ref name should match the target + if digest != artifact_digest { + continue; + } + } + + // Look up the verity for this manifest + match repo.has_stream(&manifest_identifier(&artifact_digest))? { + Some(verity) => referrers.push((artifact_digest, verity)), + None => { + continue; + } + } + } + + Ok(referrers) +} + +/// Removes a specific referrer index entry. +/// +/// Idempotent — returns Ok if the entry doesn't exist. +pub fn remove_referrer( + repo: &Repository, + subject_digest: &str, + artifact_digest: &str, +) -> Result<()> { + let ref_path = format!( + "streams/refs/{REFERRER_REF_PREFIX}{}/{}", + encode_tag(subject_digest), + encode_tag(artifact_digest) + ); + match unlinkat(repo.repo_fd(), &ref_path, AtFlags::empty()) { + Ok(()) => Ok(()), + Err(Errno::NOENT) => Ok(()), + Err(e) => Err(e).with_context(|| format!("Failed to remove referrer {artifact_digest}")), + } +} + +/// Removes all referrer index entries for a subject. +/// +/// Removes each referrer symlink and tries to remove the empty subject +/// directory afterwards. Idempotent — returns Ok if no entries exist. +pub fn remove_referrers_for_subject( + repo: &Repository, + subject_digest: &str, +) -> Result<()> { + let referrers = list_referrers(repo, subject_digest)?; + for (artifact_digest, _verity) in &referrers { + remove_referrer(repo, subject_digest, artifact_digest)?; + } + // Try to remove the now-empty subject directory (ignore errors) + let subject_dir = format!( + "streams/refs/{REFERRER_REF_PREFIX}{}", + encode_tag(subject_digest) + ); + let _ = unlinkat(repo.repo_fd(), &subject_dir, AtFlags::REMOVEDIR); + Ok(()) +} + +/// Removes referrer index entries whose subject manifest no longer exists. +/// +/// When a subject image is untagged and garbage collected, its referrer +/// artifacts become orphaned — their referrer symlinks under +/// `streams/refs/oci-referrers/{subject_digest}/` still act as GC roots, +/// preventing the artifact manifests from being collected. +/// +/// Call this **before** running GC to ensure orphaned referrer artifacts +/// are also eligible for collection. The typical workflow is: +/// +/// ```text +/// cleanup_dangling_referrers(&repo)?; +/// repo.gc(&[])?; +/// ``` +/// +/// Returns the number of referrer entries removed. +pub fn cleanup_dangling_referrers( + repo: &Repository, +) -> Result { + let referrers_path = format!("streams/refs/{REFERRER_REF_PREFIX}"); + + // Open the oci-referrers directory; if it doesn't exist, there's nothing to do + let referrers_dir = match openat( + repo.repo_fd(), + &*referrers_path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(fd) => fd, + Err(Errno::NOENT) => return Ok(0), + Err(e) => return Err(e).context("Opening oci-referrers directory")?, + }; + + let mut removed = 0u64; + + // Collect subject directory names first to avoid borrowing issues + let mut subject_dirs = Vec::new(); + for item in Dir::read_from(&referrers_dir).context("Reading oci-referrers directory")? { + let entry = item.context("Reading oci-referrers entry")?; + let name = entry.file_name(); + if name == c"." || name == c".." { + continue; + } + if let Ok(s) = std::str::from_utf8(name.to_bytes()) { + subject_dirs.push(s.to_string()); + } + } + + for encoded_subject in &subject_dirs { + let subject_digest = decode_tag(encoded_subject); + + // Check if the subject manifest still exists in the repository + if has_manifest(repo, &subject_digest)?.is_some() { + continue; + } + + // Subject is gone — remove all referrer entries in this directory + let subject_dir_fd = match openat( + &referrers_dir, + encoded_subject.as_str(), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(fd) => fd, + Err(Errno::NOENT) => continue, + Err(e) => { + return Err(e).context(format!("Opening referrer subject dir {encoded_subject}"))? + } + }; + + for item in Dir::read_from(&subject_dir_fd).context("Reading referrer subject directory")? { + let entry = item.context("Reading referrer entry")?; + let name = entry.file_name(); + if name == c"." || name == c".." { + continue; + } + unlinkat(&subject_dir_fd, name, AtFlags::empty()) + .with_context(|| format!("Removing referrer entry {name:?}"))?; + removed += 1; + } + + // Remove the now-empty subject directory + unlinkat(&referrers_dir, encoded_subject.as_str(), AtFlags::REMOVEDIR) + .with_context(|| format!("Removing empty referrer subject dir {encoded_subject}"))?; + } + + Ok(removed) +} + +#[cfg(test)] +mod test { + use super::*; + use composefs::fsverity::Sha256HashValue; + use composefs::test::TestRepo; + use containers_image_proxy::oci_spec::image::{ + ConfigBuilder, DescriptorBuilder, Digest as OciDigest, ImageConfigurationBuilder, + ImageManifestBuilder, RootFsBuilder, + }; + use std::fs::File; + use std::io::Read; + use std::str::FromStr; + + /// Helper to create a synthetic container image in the repository. + /// + /// Creates a minimal but valid container image with: + /// - A single "layer" (stored as an external object) + /// - Proper OCI manifest and config structure + /// - Optional tag + /// + /// Returns (manifest_digest, manifest_verity, config_digest). + fn create_test_image( + repo: &Arc>, + tag: Option<&str>, + arch: &str, + ) -> (String, Sha256HashValue, String) { + // Create a fake layer - in real usage this would be a tar splitstream + // For testing the manifest/config storage, we just need valid references + let layer_data = format!("fake-layer-{arch}").into_bytes(); + let layer_digest = hash(&layer_data); + + let mut layer_stream = repo.create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE); + layer_stream.write_external(&layer_data).unwrap(); + let layer_verity = repo + .write_stream(layer_stream, &crate::layer_identifier(&layer_digest), None) + .unwrap(); + + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(vec![layer_digest.clone()]) + .build() + .unwrap(); + + let cfg = ConfigBuilder::default().build().unwrap(); + + let config = ImageConfigurationBuilder::default() + .architecture(arch) + .os("linux") + .rootfs(rootfs) + .config(cfg) + .build() + .unwrap(); + + let config_json = config.to_string().unwrap(); + let config_digest = hash(config_json.as_bytes()); + + let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE); + config_stream.add_named_stream_ref(&layer_digest, &layer_verity); + config_stream + .write_external(config_json.as_bytes()) + .unwrap(); + let config_verity = repo + .write_stream( + config_stream, + &crate::config_identifier(&config_digest), + None, + ) + .unwrap(); + + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::ImageConfig) + .digest(OciDigest::from_str(&config_digest).unwrap()) + .size(config_json.len() as u64) + .build() + .unwrap(); + + let layer_descriptor = DescriptorBuilder::default() + .media_type(MediaType::ImageLayerGzip) + .digest(OciDigest::from_str(&layer_digest).unwrap()) + .size(layer_data.len() as u64) + .build() + .unwrap(); + + let manifest = ImageManifestBuilder::default() + .schema_version(2u32) + .media_type(MediaType::ImageManifest) + .config(config_descriptor) + .layers(vec![layer_descriptor]) + .build() + .unwrap(); + + let mut layer_verities = HashMap::new(); + layer_verities.insert(layer_digest.into_boxed_str(), layer_verity); + + let manifest_json = manifest.to_string().unwrap(); + let manifest_digest = hash(manifest_json.as_bytes()); + + let (_stored_digest, manifest_verity) = write_manifest( + repo, + &manifest, + &manifest_digest, + &config_verity, + &layer_verities, + tag, + ) + .unwrap(); + + (manifest_digest, manifest_verity, config_digest) + } + + #[test] + fn test_manifest_identifier() { + assert_eq!( + manifest_identifier("sha256:abc123"), + "oci-manifest-sha256:abc123" + ); + } + + #[test] + fn test_oci_ref_path() { + assert_eq!(oci_ref_path("myimage:latest"), "oci/myimage:latest"); + // Slashes get encoded + assert_eq!(oci_ref_path("library/nginx"), "oci/library%2Fnginx"); + assert_eq!(oci_ref_path("docker://busybox"), "oci/docker:%2F%2Fbusybox"); + } + + #[test] + fn test_encode_decode_tag() { + // Simple names pass through + assert_eq!(encode_tag("myimage:latest"), "myimage:latest"); + assert_eq!(decode_tag("myimage:latest"), "myimage:latest"); + + // Slashes get encoded + assert_eq!(encode_tag("library/nginx"), "library%2Fnginx"); + assert_eq!(decode_tag("library%2Fnginx"), "library/nginx"); + + // Double slashes + assert_eq!(encode_tag("docker://busybox"), "docker:%2F%2Fbusybox"); + assert_eq!(decode_tag("docker:%2F%2Fbusybox"), "docker://busybox"); + + // Percent signs get encoded first to avoid conflicts + assert_eq!(encode_tag("test%2F"), "test%252F"); + assert_eq!(decode_tag("test%252F"), "test%2F"); + + // Round-trip including tricky inputs where order-dependent + // replacement would produce wrong results + let names = [ + "simple", + "with:tag", + "registry.io/image:v1", + "docker://busybox:latest", + "containers-storage:myimage", + "weird%name/with/slashes", + "%2F", + "a/b%c", + "100%", + "normal:tag", + "%25already-encoded", + "double%%percent", + ]; + for name in names { + assert_eq!( + decode_tag(&encode_tag(name)), + name, + "round-trip failed for {name}" + ); + } + } + + #[test] + fn test_hash() { + assert_eq!( + hash(b"hello world"), + "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } + + #[test] + fn test_blob_identifier() { + assert_eq!(blob_identifier("sha256:abc123"), "oci-blob-sha256:abc123"); + } + + #[test] + fn test_write_and_read_blob() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let data = b"This is some arbitrary blob data for an OCI artifact."; + let (digest, verity) = write_blob(repo, data).unwrap(); + + assert!(digest.starts_with("sha256:")); + + // Read back with verity (fast path) + let read_data = open_blob(&repo, &digest, Some(&verity)).unwrap(); + assert_eq!(read_data, data); + + // Read back without verity (verifies content hash) + let read_data2 = open_blob(&repo, &digest, None).unwrap(); + assert_eq!(read_data2, data); + } + + #[test] + fn test_write_blob_deduplication() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let data = b"duplicate blob content"; + + let (digest1, verity1) = write_blob(repo, data).unwrap(); + let (digest2, verity2) = write_blob(repo, data).unwrap(); + + assert_eq!(digest1, digest2); + assert_eq!(verity1, verity2); + } + + #[test] + fn test_open_blob_bad_digest() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let data = b"some blob data"; + let (_digest, _verity) = write_blob(repo, data).unwrap(); + + let bad_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + let result = open_blob::(&repo, bad_digest, None); + assert!(result.is_err()); + } + + /// Verify that manifest JSON is stored as an external object, not inline. + /// + /// External storage gives each manifest its own file in objects/, allowing + /// fsverity to be independently enabled on the raw content. This is a + /// prerequisite for signing: a signature can reference the fsverity digest + /// of the manifest bytes directly. + #[test] + fn test_manifest_stored_as_external_object() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (manifest_digest, manifest_verity, _) = + create_test_image(repo, Some("ext-test"), "amd64"); + + let manifest_id = manifest_identifier(&manifest_digest); + let mut stream = repo + .open_stream(&manifest_id, Some(&manifest_verity), None) + .unwrap(); + + let mut object_refs = Vec::new(); + stream + .get_object_refs(|id| object_refs.push(id.clone())) + .unwrap(); + + // Should have at least one external object (the manifest JSON itself) + assert!( + !object_refs.is_empty(), + "Manifest splitstream should contain external object references" + ); + + let img = OciImage::open(&repo, &manifest_digest, Some(&manifest_verity)).unwrap(); + let manifest_json = img.manifest().to_string().unwrap(); + let expected_verity: Sha256HashValue = + composefs::fsverity::compute_verity(manifest_json.as_bytes()); + + assert!( + object_refs.contains(&expected_verity), + "Manifest JSON fsverity digest should appear in splitstream object refs" + ); + } + + /// Verify that blob content is stored as an external object. + #[test] + fn test_blob_stored_as_external_object() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let data = b"artifact blob content for external storage test"; + let (digest, verity) = write_blob(repo, data).unwrap(); + + let content_id = blob_identifier(&digest); + let mut stream = repo.open_stream(&content_id, Some(&verity), None).unwrap(); + + let mut object_refs = Vec::new(); + stream + .get_object_refs(|id| object_refs.push(id.clone())) + .unwrap(); + + assert_eq!( + object_refs.len(), + 1, + "Blob should be stored as exactly one external object" + ); + + let expected_verity: Sha256HashValue = composefs::fsverity::compute_verity(data); + assert_eq!( + object_refs[0], expected_verity, + "External object verity should match independently computed verity of blob data" + ); + } + + /// Test storing and retrieving an OCI artifact with non-tar media type. + /// + /// This simulates what would happen when storing something like a + /// Helm chart, WASM module, or other non-container artifact. + #[test] + fn test_oci_artifact_roundtrip() { + use containers_image_proxy::oci_spec::image::{ + DescriptorBuilder, Digest as OciDigest, ImageManifestBuilder, + }; + use std::str::FromStr; + + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + // Create an artifact with a custom media type (simulating a WASM module) + let wasm_bytes = b"\x00asm\x01\x00\x00\x00"; // WASM magic header + let (blob_digest, blob_verity) = write_blob(repo, wasm_bytes).unwrap(); + + // Create an empty config (common for artifacts) + let empty_config = b"{}"; + let config_digest = hash(empty_config); + + let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE); + config_stream.write_external(empty_config).unwrap(); + let config_verity = repo + .write_stream( + config_stream, + &crate::config_identifier(&config_digest), + None, + ) + .unwrap(); + + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other( + "application/vnd.wasm.config.v1+json".to_string(), + )) + .digest(OciDigest::from_str(&config_digest).unwrap()) + .size(empty_config.len() as u64) + .build() + .unwrap(); + + let blob_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other("application/wasm".to_string())) + .digest(OciDigest::from_str(&blob_digest).unwrap()) + .size(wasm_bytes.len() as u64) + .build() + .unwrap(); + + let manifest = ImageManifestBuilder::default() + .schema_version(2u32) + .media_type(MediaType::ImageManifest) + .config(config_descriptor) + .layers(vec![blob_descriptor]) + .build() + .unwrap(); + + let mut layer_verities = HashMap::new(); + // For artifacts, we use the blob digest as the "diff_id" equivalent + layer_verities.insert(blob_digest.clone().into_boxed_str(), blob_verity.clone()); + + let manifest_json = manifest.to_string().unwrap(); + let manifest_digest = hash(manifest_json.as_bytes()); + + let (stored_digest, manifest_verity) = write_manifest( + &repo, + &manifest, + &manifest_digest, + &config_verity, + &layer_verities, + Some("my-wasm-artifact:v1"), + ) + .unwrap(); + + assert_eq!(stored_digest, manifest_digest); + + let opened = OciImage::open(&repo, &manifest_digest, Some(&manifest_verity)).unwrap(); + + assert!(!opened.is_container_image()); // Not a container image + assert_eq!(opened.manifest_digest(), manifest_digest); + assert_eq!(opened.config_digest(), config_digest); + assert_eq!(opened.layer_descriptors().len(), 1); + assert_eq!( + opened.layer_descriptors()[0].media_type(), + &MediaType::Other("application/wasm".to_string()) + ); + + let by_tag = OciImage::open_ref(&repo, "my-wasm-artifact:v1").unwrap(); + assert_eq!(by_tag.manifest_digest(), manifest_digest); + + let images = list_images(&repo).unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].name, "my-wasm-artifact:v1"); + assert!(!images[0].is_container); + + let read_wasm = open_blob(&repo, &blob_digest, Some(&blob_verity)).unwrap(); + assert_eq!(read_wasm, wasm_bytes); + } + + /// Test the OCI 1.1 empty config artifact pattern from the spec: + /// config is `application/vnd.oci.empty.v1+json`, layers use custom + /// media types, and layer digests are used as diff_ids. + /// See: https://github.com/opencontainers/image-spec/blob/main/artifacts-guidance.md + #[test] + fn test_oci_artifact_empty_config() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let sbom_data = br#"{"spdxVersion":"SPDX-2.3","name":"example"}"#; + let layer_digest = hash(sbom_data); + + // Store the raw layer as an object with external ref splitstream + let blob_object_id = repo.ensure_object(sbom_data).unwrap(); + let layer_content_id = crate::layer_identifier(&layer_digest); + let mut layer_stream = repo.create_stream(crate::skopeo::OCI_BLOB_CONTENT_TYPE); + layer_stream.add_external_size(sbom_data.len() as u64); + layer_stream + .write_reference(blob_object_id.clone()) + .unwrap(); + let layer_verity = repo + .write_stream(layer_stream, &layer_content_id, None) + .unwrap(); + + // The OCI 1.1 empty config: `{}` with the well-known digest + let empty_config = b"{}"; + let config_digest = hash(empty_config); + assert_eq!( + config_digest, + "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + ); + + // Store the config — for artifacts we still write it as a config + // splitstream, but it contains no diff_ids-derived named refs. + // Instead, the layer refs come from the manifest layer digests. + let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE); + config_stream.write_external(empty_config).unwrap(); + let config_verity = repo + .write_stream( + config_stream, + &crate::config_identifier(&config_digest), + None, + ) + .unwrap(); + + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::EmptyJSON) + .digest(OciDigest::from_str(&config_digest).unwrap()) + .size(empty_config.len() as u64) + .build() + .unwrap(); + + let layer_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other("text/spdx+json".to_string())) + .digest(OciDigest::from_str(&layer_digest).unwrap()) + .size(sbom_data.len() as u64) + .build() + .unwrap(); + + let manifest = ImageManifestBuilder::default() + .schema_version(2u32) + .media_type(MediaType::ImageManifest) + .config(config_descriptor.clone()) + .layers(vec![layer_descriptor]) + .build() + .unwrap(); + + assert_ne!(*config_descriptor.media_type(), MediaType::ImageConfig); + + // Store manifest — layer_verities uses the layer digest as key + // (same logic as ensure_config_with_layers when !is_image_config) + let mut layer_verities = HashMap::new(); + layer_verities.insert(layer_digest.clone().into_boxed_str(), layer_verity.clone()); + + let manifest_json = manifest.to_string().unwrap(); + let manifest_digest = hash(manifest_json.as_bytes()); + + let (_stored_digest, manifest_verity) = write_manifest( + &repo, + &manifest, + &manifest_digest, + &config_verity, + &layer_verities, + Some("my-sbom:v1"), + ) + .unwrap(); + + let opened = OciImage::open(&repo, &manifest_digest, Some(&manifest_verity)).unwrap(); + assert!(!opened.is_container_image()); + assert_eq!(opened.layer_descriptors().len(), 1); + assert_eq!( + opened.layer_descriptors()[0].media_type(), + &MediaType::Other("text/spdx+json".to_string()) + ); + + let fd = opened.open_layer_fd(&repo, 0).unwrap(); + let mut recovered = vec![]; + File::from(fd).read_to_end(&mut recovered).unwrap(); + assert_eq!(recovered, sbom_data); + + assert!(opened.open_layer_fd(&repo, 1).is_err()); + + let gc = repo.gc(&[]).unwrap(); + assert_eq!(gc.objects_removed, 0); + + untag_image(&repo, "my-sbom:v1").unwrap(); + let gc = repo.gc(&[]).unwrap(); + assert!(gc.objects_removed > 0); + } + + /// Test that open_layer_fd rejects tar layers. + #[test] + fn test_open_layer_fd_rejects_tar() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (digest, verity, _) = create_test_image(repo, Some("myimage:v1"), "amd64"); + let img = OciImage::open(&repo, &digest, Some(&verity)).unwrap(); + assert!(img.is_container_image()); + + // Tar layer should be rejected + let err = img.open_layer_fd(&repo, 0).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("does not support tar layers"), "got: {msg}"); + } + + /// Test storing a non-tar layer as a splitstream with a single + /// external reference, simulating how `ensure_layer` handles + /// non-tar media types. The raw bytes go into objects/ and a + /// tiny splitstream holds the reference for GC tracking. + #[test] + fn test_non_tar_layer_storage() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let sbom_data = br#"{"spdxVersion":"SPDX-2.3","name":"example"}"#; + let diff_id = hash(sbom_data); + + let object_id = repo.ensure_object(sbom_data).unwrap(); + + let content_id = crate::layer_identifier(&diff_id); + let mut stream = repo.create_stream(crate::skopeo::OCI_BLOB_CONTENT_TYPE); + stream.add_external_size(sbom_data.len() as u64); + stream.write_reference(object_id.clone()).unwrap(); + let stream_verity = repo.write_stream(stream, &content_id, None).unwrap(); + + let found = repo.has_stream(&content_id).unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap(), stream_verity); + + let mut reader = repo + .open_stream( + &content_id, + Some(&stream_verity), + Some(crate::skopeo::OCI_BLOB_CONTENT_TYPE), + ) + .unwrap(); + let mut refs = vec![]; + reader.get_object_refs(|id| refs.push(id.clone())).unwrap(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0], object_id); + + let mut recovered = vec![]; + File::from(repo.open_object(&object_id).unwrap()) + .read_to_end(&mut recovered) + .unwrap(); + assert_eq!(recovered, sbom_data); + } + + /// Test that a non-tar artifact layer (stored as an external ref) + /// is preserved by GC when referenced from a tagged manifest. + #[test] + fn test_non_tar_artifact_gc() { + use containers_image_proxy::oci_spec::image::{ + DescriptorBuilder, Digest as OciDigest, ImageManifestBuilder, + }; + use std::str::FromStr; + + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let sbom_data = br#"{"spdxVersion":"SPDX-2.3","name":"example"}"#; + let diff_id = hash(sbom_data); + let blob_object_id = repo.ensure_object(sbom_data).unwrap(); + + let layer_content_id = crate::layer_identifier(&diff_id); + let mut layer_stream = repo.create_stream(crate::skopeo::OCI_BLOB_CONTENT_TYPE); + layer_stream.add_external_size(sbom_data.len() as u64); + layer_stream + .write_reference(blob_object_id.clone()) + .unwrap(); + let layer_verity = repo + .write_stream(layer_stream, &layer_content_id, None) + .unwrap(); + + let config_bytes = b"{}"; + let config_digest = hash(config_bytes); + let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE); + config_stream.write_external(config_bytes).unwrap(); + let config_verity = repo + .write_stream( + config_stream, + &crate::config_identifier(&config_digest), + None, + ) + .unwrap(); + + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::ImageConfig) + .digest(OciDigest::from_str(&config_digest).unwrap()) + .size(config_bytes.len() as u64) + .build() + .unwrap(); + let layer_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other("text/spdx+json".to_string())) + .digest(OciDigest::from_str(&diff_id).unwrap()) + .size(sbom_data.len() as u64) + .build() + .unwrap(); + let manifest = ImageManifestBuilder::default() + .schema_version(2u32) + .media_type(MediaType::ImageManifest) + .config(config_descriptor) + .layers(vec![layer_descriptor]) + .build() + .unwrap(); + + let mut layer_verities = HashMap::new(); + layer_verities.insert(diff_id.clone().into_boxed_str(), layer_verity); + + let manifest_json = manifest.to_string().unwrap(); + let manifest_digest = hash(manifest_json.as_bytes()); + + let (_stored_digest, _manifest_verity) = write_manifest( + &repo, + &manifest, + &manifest_digest, + &config_verity, + &layer_verities, + Some("my-sbom:v1"), + ) + .unwrap(); + + // GC should preserve everything — the blob object is reachable via + // manifest → config named ref → layer splitstream → external ref + let gc = repo.gc(&[]).unwrap(); + assert_eq!(gc.objects_removed, 0, "tagged artifact should be preserved"); + + let mut recovered = vec![]; + File::from(repo.open_object(&blob_object_id).unwrap()) + .read_to_end(&mut recovered) + .unwrap(); + assert_eq!(recovered, sbom_data); + } + + /// Test storing and listing multiple container images. + #[test] + fn test_multiple_images() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (digest1, verity1, _) = create_test_image(repo, Some("app:v1"), "amd64"); + let (digest2, verity2, _) = create_test_image(repo, Some("app:v2"), "amd64"); + let (digest3, verity3, _) = create_test_image(repo, Some("other:latest"), "arm64"); + + let images = list_images(repo).unwrap(); + assert_eq!(images.len(), 3); + + let names: Vec<_> = images.iter().map(|i| i.name.as_str()).collect(); + assert!(names.contains(&"app:v1")); + assert!(names.contains(&"app:v2")); + assert!(names.contains(&"other:latest")); + + for img in &images { + if img.name == "other:latest" { + assert_eq!(img.architecture, "arm64"); + } else { + assert_eq!(img.architecture, "amd64"); + } + assert!(img.is_container); + } + + let img1 = OciImage::open_ref(repo, "app:v1").unwrap(); + assert_eq!(img1.manifest_digest(), digest1); + assert_eq!(img1.manifest_verity(), &verity1); + + let img2 = OciImage::open_ref(repo, "app:v2").unwrap(); + assert_eq!(img2.manifest_digest(), digest2); + assert_eq!(img2.manifest_verity(), &verity2); + + let img3 = OciImage::open_ref(repo, "other:latest").unwrap(); + assert_eq!(img3.manifest_digest(), digest3); + assert_eq!(img3.manifest_verity(), &verity3); + } + + /// Test that untagging removes the image from listing but preserves data. + #[test] + fn test_untag_image() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (digest1, verity1, _) = create_test_image(repo, Some("myapp:v1"), "amd64"); + let (digest2, _verity2, _) = create_test_image(repo, Some("myapp:v2"), "amd64"); + + let images = list_images(repo).unwrap(); + assert_eq!(images.len(), 2); + + untag_image(repo, "myapp:v1").unwrap(); + + let images = list_images(repo).unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].name, "myapp:v2"); + assert_eq!(images[0].manifest_digest, digest2); + + let img = OciImage::open(repo, &digest1, Some(&verity1)).unwrap(); + assert_eq!(img.manifest_digest(), digest1); + + let result = OciImage::open_ref(repo, "myapp:v1"); + assert!(result.is_err()); + } + + /// Test resolving refs and listing refs. + #[test] + fn test_refs() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (digest, verity, _) = create_test_image(repo, Some("test:latest"), "amd64"); + + let refs = list_refs(repo).unwrap(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, "test:latest"); + assert_eq!(refs[0].1, digest); + + let (resolved_digest, resolved_verity) = resolve_ref(repo, "test:latest").unwrap(); + assert_eq!(resolved_digest, digest); + assert_eq!(resolved_verity, verity); + + let result = resolve_ref::(repo, "nonexistent:tag"); + assert!(result.is_err()); + } + + /// Test that tagging an existing manifest with a new name works. + #[test] + fn test_tag_existing_manifest() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (digest, verity, _) = create_test_image(repo, Some("original:v1"), "amd64"); + + tag_image(repo, &digest, "alias:latest").unwrap(); + + let (d1, v1) = resolve_ref(repo, "original:v1").unwrap(); + let (d2, v2) = resolve_ref(repo, "alias:latest").unwrap(); + assert_eq!(d1, d2); + assert_eq!(v1, v2); + assert_eq!(d1, digest); + assert_eq!(v1, verity); + + let images = list_images(repo).unwrap(); + assert_eq!(images.len(), 2); + + untag_image(repo, "original:v1").unwrap(); + let (d3, _) = resolve_ref(repo, "alias:latest").unwrap(); + assert_eq!(d3, digest); + + let images = list_images(repo).unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].name, "alias:latest"); + } + + /// Test opening image by manifest digest (no tag required). + #[test] + fn test_open_by_digest() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (digest, verity, config_digest) = create_test_image(repo, None, "amd64"); + + let images = list_images(repo).unwrap(); + assert!(images.is_empty()); + + let img = OciImage::open(repo, &digest, Some(&verity)).unwrap(); + assert_eq!(img.manifest_digest(), digest); + assert_eq!(img.config_digest(), config_digest); + assert!(img.is_container_image()); + assert_eq!(img.architecture(), "amd64"); + + let img2 = OciImage::open(repo, &digest, None).unwrap(); + assert_eq!(img2.manifest_digest(), digest); + } + + /// Test fetching manifest and config from stored image. + #[test] + fn test_fetch_manifest_config() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (digest, verity, config_digest) = + create_test_image(repo, Some("fetchtest:v1"), "amd64"); + + let img = OciImage::open_ref(repo, "fetchtest:v1").unwrap(); + + assert_eq!(img.manifest_digest(), digest); + assert_eq!(img.manifest_verity(), &verity); + let manifest = img.manifest(); + assert_eq!(manifest.schema_version(), 2u32); + assert_eq!(manifest.layers().len(), 1); + + assert_eq!(img.config_digest(), config_digest); + let config = img.config().expect("should have config"); + assert_eq!(config.architecture().to_string(), "amd64"); + assert_eq!(config.os().to_string(), "linux"); + assert_eq!(config.rootfs().diff_ids().len(), 1); + + let diff_ids = img.layer_diff_ids(); + assert_eq!(diff_ids.len(), 1); + let layer_verity = img.layer_verity(diff_ids[0]); + assert!(layer_verity.is_some()); + } + + /// Test that has_manifest correctly detects existing manifests. + #[test] + fn test_has_manifest() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let nonexistent = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + assert!(has_manifest(repo, nonexistent).unwrap().is_none()); + + let (digest, verity, _) = create_test_image(repo, None, "amd64"); + + let found = has_manifest(repo, &digest).unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap(), verity); + + assert!(has_manifest(repo, nonexistent).unwrap().is_none()); + } + + /// Test empty repository behavior. + #[test] + fn test_empty_repo() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + // List should return empty vec, not error + let images = list_images(repo).unwrap(); + assert!(images.is_empty()); + + let refs = list_refs(repo).unwrap(); + assert!(refs.is_empty()); + } + + /// Test untagging non-existent tag. + #[test] + fn test_untag_nonexistent() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let result = untag_image(repo, "nonexistent:tag"); + assert!(result.is_err()); + } + + // ==================== GC Integration Tests ==================== + // + // These tests verify that garbage collection correctly handles OCI images: + // - Tagged images are preserved (tags act as GC roots) + // - Untagged images can be collected + // - Shared layers between images are handled correctly + + /// Test that GC preserves a tagged OCI image and all its components. + #[test] + fn test_gc_preserves_tagged_oci_image() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (manifest_digest, manifest_verity, config_digest) = + create_test_image(repo, Some("myapp:v1"), "amd64"); + + let gc_result = repo.gc(&[]).unwrap(); + + assert_eq!(gc_result.objects_removed, 0); + assert_eq!(gc_result.streams_pruned, 0); + + let img = OciImage::open_ref(repo, "myapp:v1").unwrap(); + assert_eq!(img.manifest_digest(), manifest_digest); + assert_eq!(img.manifest_verity(), &manifest_verity); + assert_eq!(img.config_digest(), config_digest); + + let diff_ids = img.layer_diff_ids(); + assert_eq!(diff_ids.len(), 1); + assert!(img.layer_verity(diff_ids[0]).is_some()); + } + + /// Test that GC removes an untagged OCI image. + #[test] + fn test_gc_removes_untagged_oci_image() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (manifest_digest, manifest_verity, _config_digest) = + create_test_image(repo, None, "amd64"); + + let img = OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).unwrap(); + let diff_ids = img.layer_diff_ids(); + assert_eq!(diff_ids.len(), 1); + drop(img); + + let gc_result = repo.gc(&[]).unwrap(); + + assert!(gc_result.objects_removed > 0); + + let result = has_manifest(repo, &manifest_digest); + assert!( + result.unwrap().is_none(), + "manifest should be gone after GC" + ); + } + + /// Test that untagging an image makes it eligible for GC. + #[test] + fn test_gc_after_untag_removes_image() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (manifest_digest, manifest_verity, _) = + create_test_image(repo, Some("temporary:v1"), "amd64"); + + let gc_result = repo.gc(&[]).unwrap(); + assert_eq!(gc_result.objects_removed, 0); + + untag_image(repo, "temporary:v1").unwrap(); + + assert!(OciImage::open_ref(repo, "temporary:v1").is_err()); + + assert!(OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).is_ok()); + + let gc_result = repo.gc(&[]).unwrap(); + assert!(gc_result.objects_removed > 0); + + assert!(has_manifest(repo, &manifest_digest).unwrap().is_none()); + } + + /// Test GC with two images sharing layers - removing one preserves shared layers. + #[test] + fn test_gc_with_shared_layers() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let shared_layer_data = b"shared-base-layer-content"; + let shared_layer_digest = hash(shared_layer_data); + + let mut shared_layer_stream = repo.create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE); + shared_layer_stream + .write_external(shared_layer_data) + .unwrap(); + let shared_layer_verity = repo + .write_stream( + shared_layer_stream, + &crate::layer_identifier(&shared_layer_digest), + None, + ) + .unwrap(); + + // Helper to create an image using the shared layer + let create_image_with_shared_layer = |repo: &Arc>, + tag: Option<&str>, + extra_data: &[u8]| + -> (String, Sha256HashValue) { + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(vec![shared_layer_digest.clone()]) + .build() + .unwrap(); + + let cfg = ConfigBuilder::default().build().unwrap(); + + // Add unique data to make configs different + let config = ImageConfigurationBuilder::default() + .architecture("amd64") + .os("linux") + .rootfs(rootfs) + .config(cfg) + .created(String::from_utf8_lossy(extra_data).to_string()) + .build() + .unwrap(); + + let config_json = config.to_string().unwrap(); + let config_digest = hash(config_json.as_bytes()); + + let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE); + config_stream.add_named_stream_ref(&shared_layer_digest, &shared_layer_verity); + config_stream + .write_external(config_json.as_bytes()) + .unwrap(); + let config_verity = repo + .write_stream( + config_stream, + &crate::config_identifier(&config_digest), + None, + ) + .unwrap(); + + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::ImageConfig) + .digest(OciDigest::from_str(&config_digest).unwrap()) + .size(config_json.len() as u64) + .build() + .unwrap(); + + let layer_descriptor = DescriptorBuilder::default() + .media_type(MediaType::ImageLayerGzip) + .digest(OciDigest::from_str(&shared_layer_digest).unwrap()) + .size(shared_layer_data.len() as u64) + .build() + .unwrap(); + + let manifest = ImageManifestBuilder::default() + .schema_version(2u32) + .media_type(MediaType::ImageManifest) + .config(config_descriptor) + .layers(vec![layer_descriptor]) + .build() + .unwrap(); + + let mut layer_verities = HashMap::new(); + layer_verities.insert( + shared_layer_digest.clone().into_boxed_str(), + shared_layer_verity.clone(), + ); + + let manifest_json = manifest.to_string().unwrap(); + let manifest_digest = hash(manifest_json.as_bytes()); + + let (_stored_digest, manifest_verity) = write_manifest( + repo, + &manifest, + &manifest_digest, + &config_verity, + &layer_verities, + tag, + ) + .unwrap(); + + (manifest_digest, manifest_verity) + }; + + let (digest1, verity1) = create_image_with_shared_layer(repo, Some("tagged:v1"), b"image1"); + let (digest2, _verity2) = create_image_with_shared_layer(repo, None, b"image2"); + + assert!(has_manifest(repo, &digest1).unwrap().is_some()); + assert!(has_manifest(repo, &digest2).unwrap().is_some()); + + let gc_result = repo.gc(&[]).unwrap(); + + assert!(gc_result.objects_removed > 0); + + let img1 = OciImage::open(repo, &digest1, Some(&verity1)).unwrap(); + assert_eq!(img1.layer_diff_ids().len(), 1); + assert!(img1.layer_verity(&shared_layer_digest).is_some()); + + assert!(has_manifest(repo, &digest2).unwrap().is_none()); + + // Shared layer still exists because the tagged image references it + assert!(repo + .has_stream(&crate::layer_identifier(&shared_layer_digest)) + .unwrap() + .is_some()); + } + + /// Test that multiple tags on the same manifest are handled correctly. + #[test] + fn test_gc_with_multiple_tags_same_manifest() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + // Create an image with one tag + let (manifest_digest, manifest_verity, _) = + create_test_image(repo, Some("original:v1"), "amd64"); + + tag_image(repo, &manifest_digest, "alias:latest").unwrap(); + + assert_eq!(list_images(repo).unwrap().len(), 2); + + untag_image(repo, "original:v1").unwrap(); + + let gc_result = repo.gc(&[]).unwrap(); + + assert_eq!(gc_result.objects_removed, 0); + + let img = OciImage::open_ref(repo, "alias:latest").unwrap(); + assert_eq!(img.manifest_digest(), manifest_digest); + assert_eq!(img.manifest_verity(), &manifest_verity); + + let diff_ids = img.layer_diff_ids(); + assert!(img.layer_verity(diff_ids[0]).is_some()); + + untag_image(repo, "alias:latest").unwrap(); + + let gc_result = repo.gc(&[]).unwrap(); + + assert!(gc_result.objects_removed > 0); + assert!(has_manifest(repo, &manifest_digest).unwrap().is_none()); + } + + /// Test gc_dry_run with OCI images. + #[test] + fn test_gc_dry_run_oci_image() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + // Create one tagged and one untagged image with DIFFERENT architectures + // to ensure they have unique layer content (create_test_image uses arch in layer data) + let (tagged_digest, tagged_verity, _) = create_test_image(repo, Some("keep:v1"), "amd64"); + let (untagged_digest, _untagged_verity, _) = create_test_image(repo, None, "arm64"); + + assert!(has_manifest(repo, &tagged_digest).unwrap().is_some()); + assert!(has_manifest(repo, &untagged_digest).unwrap().is_some()); + + let dry_run_result = repo.gc_dry_run(&[]).unwrap(); + assert!( + dry_run_result.objects_removed > 0, + "dry-run should report objects to remove, got {:?}", + dry_run_result + ); + + // But nothing should actually be removed + assert!(has_manifest(repo, &tagged_digest).unwrap().is_some()); + assert!(has_manifest(repo, &untagged_digest).unwrap().is_some()); + + let img = OciImage::open(repo, &tagged_digest, Some(&tagged_verity)).unwrap(); + assert!(img.layer_verity(img.layer_diff_ids()[0]).is_some()); + + let real_result = repo.gc(&[]).unwrap(); + + assert_eq!(real_result.objects_removed, dry_run_result.objects_removed); + + assert!(has_manifest(repo, &untagged_digest).unwrap().is_none()); + assert!(has_manifest(repo, &tagged_digest).unwrap().is_some()); + } + + /// Test referrer index: store an artifact, add a referrer entry, + /// then discover it via list_referrers. + #[test] + fn test_referrer_index_roundtrip() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64"); + + let empty_config = b"{}"; + let config_digest = hash(empty_config); + let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE); + config_stream.write_external(empty_config).unwrap(); + let config_verity = repo + .write_stream( + config_stream, + &crate::config_identifier(&config_digest), + None, + ) + .unwrap(); + + let mut artifact_digests = Vec::new(); + for i in 0..2u8 { + let blob_data = format!("artifact-blob-{i}").into_bytes(); + let (blob_digest, blob_verity) = write_blob(repo, &blob_data).unwrap(); + + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::EmptyJSON) + .digest(OciDigest::from_str(&config_digest).unwrap()) + .size(empty_config.len() as u64) + .build() + .unwrap(); + + let layer_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other("application/octet-stream".to_string())) + .digest(OciDigest::from_str(&blob_digest).unwrap()) + .size(blob_data.len() as u64) + .build() + .unwrap(); + + let manifest = ImageManifestBuilder::default() + .schema_version(2u32) + .media_type(MediaType::ImageManifest) + .config(config_descriptor) + .layers(vec![layer_descriptor]) + .build() + .unwrap(); + + let mut layer_verities = HashMap::new(); + layer_verities.insert(blob_digest.into_boxed_str(), blob_verity); + + let manifest_json = manifest.to_string().unwrap(); + let manifest_digest = hash(manifest_json.as_bytes()); + + write_manifest( + repo, + &manifest, + &manifest_digest, + &config_verity, + &layer_verities, + None, + ) + .unwrap(); + + add_referrer(repo, &subject_digest, &manifest_digest).unwrap(); + artifact_digests.push(manifest_digest); + } + + let referrers = list_referrers(repo, &subject_digest).unwrap(); + assert_eq!(referrers.len(), 2); + + let found_digests: Vec<&str> = referrers.iter().map(|(d, _)| d.as_str()).collect(); + for expected in &artifact_digests { + assert!( + found_digests.contains(&expected.as_str()), + "Missing artifact {expected} in referrers" + ); + } + } + + /// Helper to create a minimal OCI artifact manifest in the repository. + /// + /// Returns (manifest_digest, manifest_verity). + fn create_test_artifact( + repo: &Arc>, + blob_data: &[u8], + ) -> (String, Sha256HashValue) { + let (blob_digest, blob_verity) = write_blob(repo, blob_data).unwrap(); + + let empty_config = b"{}"; + let config_digest = hash(empty_config); + + let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE); + config_stream.write_external(empty_config).unwrap(); + let config_verity = repo + .write_stream( + config_stream, + &crate::config_identifier(&config_digest), + None, + ) + .unwrap(); + + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::EmptyJSON) + .digest(OciDigest::from_str(&config_digest).unwrap()) + .size(empty_config.len() as u64) + .build() + .unwrap(); + + let layer_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other("application/octet-stream".to_string())) + .digest(OciDigest::from_str(&blob_digest).unwrap()) + .size(blob_data.len() as u64) + .build() + .unwrap(); + + let manifest = ImageManifestBuilder::default() + .schema_version(2u32) + .media_type(MediaType::ImageManifest) + .config(config_descriptor) + .layers(vec![layer_descriptor]) + .build() + .unwrap(); + + let mut layer_verities = HashMap::new(); + layer_verities.insert(blob_digest.into_boxed_str(), blob_verity); + + let manifest_json = manifest.to_string().unwrap(); + let manifest_digest = hash(manifest_json.as_bytes()); + + let (_stored_digest, manifest_verity) = write_manifest( + repo, + &manifest, + &manifest_digest, + &config_verity, + &layer_verities, + None, + ) + .unwrap(); + + (manifest_digest, manifest_verity) + } + + /// Test that GC collects referrer artifacts when their subject is untagged. + /// + /// Referrer symlinks under `streams/refs/oci-referrers/` act as GC roots, + /// so orphaned referrer entries must be cleaned up before GC to allow + /// the artifact manifests and their objects to be collected. + #[test] + fn test_gc_cleans_referrer_artifacts() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + // 1. Create a subject image with a tag + let (subject_digest, _subject_verity, _) = + create_test_image(repo, Some("subject:v1"), "amd64"); + + // 2. Create an artifact referencing the subject + let (artifact_digest, _artifact_verity) = + create_test_artifact(repo, b"fake-signature-data"); + + // 3. Register the referrer relationship + add_referrer(repo, &subject_digest, &artifact_digest).unwrap(); + + // 4. Verify the referrer is discoverable + let referrers = list_referrers(repo, &subject_digest).unwrap(); + assert_eq!(referrers.len(), 1); + assert_eq!(referrers[0].0, artifact_digest); + + // Verify GC preserves everything while subject is tagged + let gc = repo.gc(&[]).unwrap(); + assert_eq!(gc.objects_removed, 0, "nothing should be collected yet"); + + // Artifact should still be accessible + assert!( + has_manifest(repo, &artifact_digest).unwrap().is_some(), + "artifact manifest should exist" + ); + + // 5. Untag the subject image + untag_image(repo, "subject:v1").unwrap(); + + // 6. First GC pass: collects the subject's objects and cleans up + // its broken stream symlink. The artifact survives because the + // referrer symlink still acts as a GC root. + let gc1 = repo.gc(&[]).unwrap(); + assert!(gc1.objects_removed > 0, "should collect subject objects"); + assert!( + has_manifest(repo, &subject_digest).unwrap().is_none(), + "subject manifest should be gone after first GC" + ); + // Artifact is still alive — rooted by referrer symlink + assert!( + has_manifest(repo, &artifact_digest).unwrap().is_some(), + "artifact should survive first GC (referrer symlink roots it)" + ); + + // 7. Clean up dangling referrers (subject no longer exists) + let cleaned = cleanup_dangling_referrers(repo).unwrap(); + assert_eq!(cleaned, 1, "should remove 1 dangling referrer entry"); + + // 8. Second GC pass: now collects the artifact (no longer rooted) + let gc2 = repo.gc(&[]).unwrap(); + assert!(gc2.objects_removed > 0, "should collect artifact objects"); + + // 9. Verify the artifact manifest is gone + assert!( + has_manifest(repo, &artifact_digest).unwrap().is_none(), + "artifact manifest should be collected" + ); + + // 10. Verify list_referrers returns empty + let referrers = list_referrers(repo, &subject_digest).unwrap(); + assert!(referrers.is_empty(), "no referrers should remain after GC"); + + // Also verify the subject manifest is gone + assert!( + has_manifest(repo, &subject_digest).unwrap().is_none(), + "subject manifest should be collected" + ); + } + + /// Test that cleanup_dangling_referrers preserves referrers for tagged subjects. + #[test] + fn test_cleanup_referrers_preserves_tagged_subjects() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + // Create a tagged subject + let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64"); + + // Create an artifact and register it as a referrer + let (artifact_digest, _) = create_test_artifact(repo, b"sig-data"); + add_referrer(repo, &subject_digest, &artifact_digest).unwrap(); + + // Cleanup should not remove anything — subject is still tagged + let cleaned = cleanup_dangling_referrers(repo).unwrap(); + assert_eq!(cleaned, 0, "should not remove referrers for tagged subject"); + + // Referrer should still be discoverable + let referrers = list_referrers(repo, &subject_digest).unwrap(); + assert_eq!(referrers.len(), 1); + } + + /// Test that cleanup handles multiple subjects, only removing dangling ones. + #[test] + fn test_cleanup_referrers_mixed_subjects() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + // Create two subjects + let (subject1_digest, _, _) = create_test_image(repo, Some("subject1:v1"), "amd64"); + let (subject2_digest, _, _) = create_test_image(repo, Some("subject2:v1"), "arm64"); + + // Create artifacts for both + let (artifact1_digest, _) = create_test_artifact(repo, b"sig-for-subject1"); + let (artifact2_digest, _) = create_test_artifact(repo, b"sig-for-subject2"); + + add_referrer(repo, &subject1_digest, &artifact1_digest).unwrap(); + add_referrer(repo, &subject2_digest, &artifact2_digest).unwrap(); + + // Untag only subject1 + untag_image(repo, "subject1:v1").unwrap(); + + // First GC pass to actually remove subject1's manifest stream + // (cleanup_dangling_referrers checks has_manifest, which checks the + // stream symlink; GC removes the broken symlink after object deletion) + repo.gc(&[]).unwrap(); + + // Now cleanup should only remove referrers for subject1 + let cleaned = cleanup_dangling_referrers(repo).unwrap(); + assert_eq!(cleaned, 1, "should remove 1 referrer for untagged subject"); + + // Run GC again to collect the now-unrooted artifact1 + let gc = repo.gc(&[]).unwrap(); + assert!(gc.objects_removed > 0); + + // subject2's referrer should still exist + let referrers2 = list_referrers(repo, &subject2_digest).unwrap(); + assert_eq!(referrers2.len(), 1); + assert_eq!(referrers2[0].0, artifact2_digest); + + // subject1's artifact should be gone + assert!(has_manifest(repo, &artifact1_digest).unwrap().is_none()); + // subject2's artifact should still exist + assert!(has_manifest(repo, &artifact2_digest).unwrap().is_some()); + } + + /// Test that cleanup_dangling_referrers is a no-op on an empty repository. + #[test] + fn test_cleanup_referrers_empty_repo() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let cleaned = cleanup_dangling_referrers(repo).unwrap(); + assert_eq!(cleaned, 0); + } + + /// Test removing a single referrer: add, remove, verify gone, and + /// confirm that a second remove is idempotent (no error). + #[test] + fn test_remove_referrer() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64"); + let (artifact_digest, _) = create_test_artifact(repo, b"sig-remove-test"); + + add_referrer(repo, &subject_digest, &artifact_digest).unwrap(); + assert_eq!(list_referrers(repo, &subject_digest).unwrap().len(), 1); + + // Remove the referrer + remove_referrer(repo, &subject_digest, &artifact_digest).unwrap(); + assert!(list_referrers(repo, &subject_digest).unwrap().is_empty()); + + // Second remove is idempotent + remove_referrer(repo, &subject_digest, &artifact_digest).unwrap(); + } + + // ==================== Property Tests ==================== + + mod proptests { + use super::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn encode_decode_tag_roundtrip(s in "\\PC*") { + prop_assert_eq!(decode_tag(&encode_tag(&s)), s); + } + + #[test] + fn encode_tag_no_slashes(s in "\\PC*") { + prop_assert!(!encode_tag(&s).contains('/')); + } + + #[test] + fn hash_deterministic_and_prefixed(data in proptest::collection::vec(any::(), 0..4096)) { + let h1 = hash(&data); + let h2 = hash(&data); + prop_assert_eq!(&h1, &h2); + prop_assert!(h1.starts_with("sha256:")); + } + + #[test] + fn manifest_identifier_format(digest in "\\PC*") { + let id = manifest_identifier(&digest); + prop_assert!(id.starts_with("oci-manifest-")); + prop_assert!(id.ends_with(&digest)); + } + + #[test] + fn blob_identifier_format(digest in "\\PC*") { + let id = blob_identifier(&digest); + prop_assert!(id.starts_with("oci-blob-")); + prop_assert!(id.ends_with(&digest)); + } + + #[test] + fn write_read_blob_roundtrip(data in proptest::collection::vec(any::(), 1..4096)) { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (digest, verity) = write_blob(repo, &data).unwrap(); + let read_back = open_blob(repo, &digest, Some(&verity)).unwrap(); + prop_assert_eq!(read_back, data); + } + } + } + + /// Test removing all referrers for a subject at once. + #[test] + fn test_remove_referrers_for_subject() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64"); + let (artifact1_digest, _) = create_test_artifact(repo, b"sig-bulk-1"); + let (artifact2_digest, _) = create_test_artifact(repo, b"sig-bulk-2"); + + add_referrer(repo, &subject_digest, &artifact1_digest).unwrap(); + add_referrer(repo, &subject_digest, &artifact2_digest).unwrap(); + assert_eq!(list_referrers(repo, &subject_digest).unwrap().len(), 2); + + // Remove all referrers for this subject + remove_referrers_for_subject(repo, &subject_digest).unwrap(); + assert!(list_referrers(repo, &subject_digest).unwrap().is_empty()); + + // Idempotent: calling again on an already-empty subject is fine + remove_referrers_for_subject(repo, &subject_digest).unwrap(); + } +} diff --git a/crates/composefs-oci/src/skopeo.rs b/crates/composefs-oci/src/skopeo.rs index 7c9b7668..d616a864 100644 --- a/crates/composefs-oci/src/skopeo.rs +++ b/crates/composefs-oci/src/skopeo.rs @@ -12,28 +12,65 @@ use std::{cmp::Reverse, process::Command, thread::available_parallelism}; use std::{iter::zip, sync::Arc}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use async_compression::tokio::bufread::{GzipDecoder, ZstdDecoder}; use containers_image_proxy::{ ConvertedLayerInfo, ImageProxy, ImageProxyConfig, OpenedImage, Transport, }; use fn_error_context::context; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use oci_spec::image::{Descriptor, ImageConfiguration, ImageManifest, MediaType}; +use oci_spec::image::{Descriptor, ImageConfiguration, MediaType}; use rustix::process::geteuid; use tokio::{ - io::{AsyncReadExt, BufReader}, + io::{AsyncReadExt, AsyncWriteExt, BufReader}, sync::Semaphore, task::JoinSet, }; use composefs::{fsverity::FsVerityHashValue, repository::Repository}; -use crate::{config_identifier, layer_identifier, tar::split_async, ContentAndVerity}; +use crate::{ + config_identifier, layer_identifier, + oci_image::{is_tar_media_type, manifest_identifier, tag_image}, + tar::split_async, + ContentAndVerity, +}; + +/// Result of pulling an OCI image. +/// +/// Contains digests and fs-verity IDs for both the manifest and config, +/// allowing callers to access either level of the image structure. +#[derive(Debug, Clone)] +pub struct PullResult { + /// The sha256 content digest of the manifest. + pub manifest_digest: String, + /// The fs-verity ID of the manifest splitstream. + pub manifest_verity: ObjectID, + /// The sha256 content digest of the config. + pub config_digest: String, + /// The fs-verity ID of the config splitstream. + pub config_verity: ObjectID, +} + +impl PullResult { + /// Returns (config_digest, config_verity) for backward compatibility. + pub fn into_config(self) -> ContentAndVerity { + (self.config_digest, self.config_verity) + } + + /// Returns (manifest_digest, manifest_verity). + pub fn into_manifest(self) -> ContentAndVerity { + (self.manifest_digest, self.manifest_verity) + } +} -// Content type identifiers stored as ASCII in the splitstream file +// Content type identifiers stored as ASCII in the splitstream file. +// These are arbitrary 8-byte ASCII strings for identification. pub(crate) const TAR_LAYER_CONTENT_TYPE: u64 = u64::from_le_bytes(*b"ocilayer"); pub(crate) const OCI_CONFIG_CONTENT_TYPE: u64 = u64::from_le_bytes(*b"ociconfg"); +pub(crate) const OCI_MANIFEST_CONTENT_TYPE: u64 = u64::from_le_bytes(*b"ocimanif"); +/// Content type for arbitrary blobs (OCI artifacts with non-tar media types). +pub(crate) const OCI_BLOB_CONTENT_TYPE: u64 = u64::from_le_bytes(*b"oci_blob"); struct ImageOp { repo: Arc>, @@ -152,19 +189,45 @@ impl ImageOp { let progress = bar.wrap_async_read(blob_reader); self.progress.println(format!("Fetching layer {diff_id}"))?; - let reader: Box = - match descriptor.media_type() { - MediaType::ImageLayer => Box::new(BufReader::new(progress)), - MediaType::ImageLayerGzip => { + let media_type = descriptor.media_type(); + let object_id = if is_tar_media_type(media_type) { + // Tar layers: decompress and split into a splitstream + let reader: Box = match media_type { + MediaType::ImageLayer | MediaType::ImageLayerNonDistributable => { + Box::new(BufReader::new(progress)) + } + MediaType::ImageLayerGzip | MediaType::ImageLayerNonDistributableGzip => { Box::new(BufReader::new(GzipDecoder::new(BufReader::new(progress)))) } - MediaType::ImageLayerZstd => { + MediaType::ImageLayerZstd | MediaType::ImageLayerNonDistributableZstd => { Box::new(BufReader::new(ZstdDecoder::new(BufReader::new(progress)))) } - other => bail!("Unsupported layer media type {other:?}"), + _ => unreachable!("is_tar_media_type returned true"), }; - - let object_id = split_async(reader, self.repo.clone(), TAR_LAYER_CONTENT_TYPE).await?; + split_async(reader, self.repo.clone(), TAR_LAYER_CONTENT_TYPE).await? + } else { + // Non-tar layers (OCI artifacts like SBOMs, disk images, + // etc.): stream the raw bytes into a repository object and + // create a splitstream with a single external reference. + // This avoids buffering arbitrarily large blobs in memory + // and lets callers get an fd to the object directly via + // open_object(). + let tmpfile = self.repo.create_object_tmpfile()?; + let mut writer = tokio::fs::File::from(std::fs::File::from(tmpfile)); + let mut reader = progress; + let size = tokio::io::copy(&mut reader, &mut writer).await?; + writer.flush().await?; + let tmpfile = writer.into_std().await; + driver.await?; + let object_id = self.repo.finalize_object_tmpfile(tmpfile, size)?; + + let mut stream = self.repo.create_stream(OCI_BLOB_CONTENT_TYPE); + stream.add_external_size(size); + stream.write_reference(object_id)?; + // write_stream handles both object storage and stream + // registration, so we return directly. + return self.repo.write_stream(stream, &content_id, None); + }; // skopeo is doing data checksums for us to make sure the content we received is equal // to the claimed diff_id. We trust it, but we need to check it by awaiting the driver. @@ -179,23 +242,40 @@ impl ImageOp { } } - pub async fn ensure_config( + /// Ensure config is present and return layer verities along with config info. + async fn ensure_config_with_layers( self: &Arc, manifest_layers: &[Descriptor], descriptor: &Descriptor, - ) -> Result> { + ) -> Result<( + String, + ObjectID, + // FIXME change this string to be Digest - actually we may want to go stronger and have a + // struct DiffID(Digest) newtype + std::collections::HashMap, + )> { let config_digest: &str = descriptor.digest().as_ref(); let content_id = config_identifier(config_digest); if let Some(config_id) = self.repo.has_stream(&content_id)? { - // We already got this config? Nice. + // We already got this config - need to read the layer refs from it self.progress .println(format!("Already have container config {config_digest}"))?; - Ok((config_digest.to_string(), config_id)) + + let stream = self.repo.open_stream( + &content_id, + Some(&config_id), + Some(OCI_CONFIG_CONTENT_TYPE), + )?; + let layer_refs: std::collections::HashMap = stream + .into_named_refs() + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(); + + Ok((config_digest.to_string(), config_id, layer_refs)) } else { - // We need to add the config to the repo. We need to parse the config and make sure we - // have all of the layers first. - // + // We need to add the config to the repo self.progress .println(format!("Fetching config {config_digest}"))?; @@ -208,14 +288,27 @@ impl ImageOp { let (config, driver) = tokio::join!(config, driver); let _: () = driver?; let raw_config = config?; - let config = ImageConfiguration::from_reader(&raw_config[..])?; - // We want to sort the layers based on size so we can get started on the big layers - // first. The last thing we want is to start on the biggest layer right at the end. - let mut layers: Vec<_> = zip(manifest_layers, config.rootfs().diff_ids()).collect(); + // Per the OCI artifacts guidance [1], artifact configs use the + // empty descriptor (`application/vnd.oci.empty.v1+json`) or a + // custom media type — not a standard image config. In that case + // there are no diff_ids, so we use the manifest layer digests. + // [1]: https://github.com/opencontainers/image-spec/blob/main/artifacts-guidance.md + let is_image_config = *descriptor.media_type() == MediaType::ImageConfig; + let diff_ids: Vec = if is_image_config { + let config = ImageConfiguration::from_reader(&raw_config[..])?; + config.rootfs().diff_ids().to_vec() + } else { + manifest_layers + .iter() + .map(|d| d.digest().to_string()) + .collect() + }; + + // Sort layers by size for parallel fetching + let mut layers: Vec<_> = zip(manifest_layers, &diff_ids).collect(); layers.sort_by_key(|(mld, ..)| Reverse(mld.size())); - // Bound the number of tasks to the available parallelism. let threads = available_parallelism()?; let sem = Arc::new(Semaphore::new(threads.into())); let mut layer_tasks = JoinSet::new(); @@ -249,7 +342,7 @@ impl ImageOp { }); } - // Collect results and sort by original index for deterministic ordering + // Collect results and sort by index for deterministic ordering let mut results: Vec<_> = layer_tasks .join_all() .await @@ -258,53 +351,105 @@ impl ImageOp { results.sort_by_key(|(idx, _, _)| *idx); let mut splitstream = self.repo.create_stream(OCI_CONFIG_CONTENT_TYPE); + let mut layer_refs = std::collections::HashMap::new(); for (_, diff_id, verity) in results { splitstream.add_named_stream_ref(&diff_id, &verity); + layer_refs.insert(diff_id, verity); } - // NB: We trust that skopeo has verified that raw_config has the correct digest - splitstream.write_inline(&raw_config); - + // Store config as external object for independent fsverity + splitstream.write_external(&raw_config)?; let config_id = self.repo.write_stream(splitstream, &content_id, None)?; - Ok((config_digest.to_string(), config_id)) + Ok((config_digest.to_string(), config_id, layer_refs)) } } - pub async fn pull(self: &Arc) -> Result> { - let (_manifest_digest, raw_manifest) = self + /// Pull the image, storing manifest, config, and all layers. + pub async fn pull(self: &Arc) -> Result> { + let (manifest_digest, raw_manifest) = self .proxy .fetch_manifest_raw_oci(&self.img) .await .context("Fetching manifest")?; - // We need to add the manifest to the repo. We need to parse the manifest and make - // sure we have the config first (which will also pull in the layers). - let manifest = ImageManifest::from_reader(raw_manifest.as_slice())?; + let manifest = oci_spec::image::ImageManifest::from_reader(raw_manifest.as_slice())?; let config_descriptor = manifest.config(); let layers = manifest.layers(); - self.ensure_config(layers, config_descriptor) + let (config_digest, config_verity, layer_verities) = self + .ensure_config_with_layers(layers, config_descriptor) .await - .with_context(|| format!("Failed to pull config {config_descriptor:?}")) + .with_context(|| format!("Failed to pull config {config_descriptor:?}"))?; + + let manifest_content_id = manifest_identifier(&manifest_digest); + let manifest_verity = if let Some(verity) = self.repo.has_stream(&manifest_content_id)? { + self.progress + .println(format!("Already have manifest {manifest_digest}"))?; + verity + } else { + self.progress + .println(format!("Storing manifest {manifest_digest}"))?; + + let mut splitstream = self.repo.create_stream(OCI_MANIFEST_CONTENT_TYPE); + + let config_key = format!("config:{}", config_descriptor.digest()); + splitstream.add_named_stream_ref(&config_key, &config_verity); + + for (diff_id, verity) in &layer_verities { + splitstream.add_named_stream_ref(diff_id, verity); + } + + // Store the raw manifest bytes as an external object for fsverity + splitstream.write_external(&raw_manifest)?; + self.repo + .write_stream(splitstream, &manifest_content_id, None)? + }; + + Ok(PullResult { + manifest_digest, + manifest_verity, + config_digest, + config_verity, + }) } } -/// Pull the target image, and add the provided tag. If this is a mountable -/// image (i.e. not an artifact), it is *not* unpacked by default. -#[context("Pulling image {imgref}")] -pub async fn pull( +/// Pull the target image, storing manifest, config, and layers. +/// +/// Returns `PullResult` containing both manifest and config digests/verities. +/// If `reference` is provided, the manifest is also stored under that name. +/// +/// Note: For backward compatibility, use `.into_config()` on the result to get +/// the (config_digest, config_verity) tuple that was previously returned. +pub async fn pull_image( repo: &Arc>, imgref: &str, reference: Option<&str>, img_proxy_config: Option, -) -> Result<(String, ObjectID)> { +) -> Result> { let op = Arc::new(ImageOp::new(repo, imgref, img_proxy_config).await?); - let (sha256, id) = op + let result = op .pull() .await .with_context(|| format!("Unable to pull container image {imgref}"))?; if let Some(name) = reference { - repo.name_stream(&sha256, name)?; + tag_image(repo, &result.manifest_digest, name)?; } - Ok((sha256, id)) + Ok(result) +} + +/// Pull the target image, and add the provided tag. If this is a mountable +/// image (i.e. not an artifact), it is *not* unpacked by default. +/// +/// Returns (config_digest, config_verity) for backward compatibility. +/// Consider using `pull_image` for access to manifest information. +#[context("Pulling image {imgref}")] +pub async fn pull( + repo: &Arc>, + imgref: &str, + reference: Option<&str>, + img_proxy_config: Option, +) -> Result<(String, ObjectID)> { + let result = pull_image(repo, imgref, reference, img_proxy_config).await?; + Ok(result.into_config()) } diff --git a/crates/composefs/src/repository.rs b/crates/composefs/src/repository.rs index 5247d26c..054b9b51 100644 --- a/crates/composefs/src/repository.rs +++ b/crates/composefs/src/repository.rs @@ -17,12 +17,12 @@ //! ├── images/ # Composefs (erofs) image tracking //! │ ├── 4e67eaccd9fd... → ../objects/4e/67eaccd9fd... //! │ └── refs/ -//! │ └── myimage → ../4e67eaccd9fd... +//! │ └── myimage → ../../4e67eaccd9fd... //! └── streams/ # Splitstream storage //! ├── oci-config-sha256:... → ../objects/XX/YYY... //! ├── oci-layer-sha256:... → ../objects/XX/YYY... //! └── refs/ # Named references (GC roots) -//! └── mytarball → ../oci-layer-sha256:... +//! └── mytarball → ../../oci-layer-sha256:... //! ``` //! //! # Object Storage @@ -83,7 +83,7 @@ use std::{ fs::{canonicalize, File}, io::{Read, Write}, os::{ - fd::{AsFd, OwnedFd}, + fd::{AsFd, BorrowedFd, OwnedFd}, unix::ffi::OsStrExt, }, path::{Path, PathBuf}, @@ -1413,6 +1413,49 @@ impl Repository { // fn fsck(&self) -> Result<()> { // unimplemented!() // } + + /// Returns a borrowed file descriptor for the repository root. + /// + /// This allows low-level operations on the repository directory. + pub fn repo_fd(&self) -> BorrowedFd<'_> { + self.repository.as_fd() + } + + /// Lists all named stream references under a given prefix. + /// + /// Returns (name, target) pairs where name is relative to the prefix. + pub fn list_stream_refs(&self, prefix: &str) -> Result> { + let ref_path = format!("streams/refs/{prefix}"); + + let dir_fd = match self.openat(&ref_path, OFlags::RDONLY | OFlags::DIRECTORY) { + Ok(fd) => fd, + Err(Errno::NOENT) => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + + let mut refs = Vec::new(); + for item in Dir::read_from(&dir_fd)? { + let entry = item?; + let name_bytes = entry.file_name().to_bytes(); + + if name_bytes == b"." || name_bytes == b".." { + continue; + } + + let name = match std::str::from_utf8(name_bytes) { + Ok(s) => s.to_string(), + Err(_) => continue, + }; + + if let Ok(target) = readlinkat(&dir_fd, name_bytes, vec![]) { + if let Ok(target_str) = target.into_string() { + refs.push((name, target_str)); + } + } + } + + Ok(refs) + } } #[cfg(test)] diff --git a/crates/composefs/src/test.rs b/crates/composefs/src/test.rs index af04a924..136c4cce 100644 --- a/crates/composefs/src/test.rs +++ b/crates/composefs/src/test.rs @@ -1,10 +1,15 @@ -//! Tests +//! Test utilities for composefs. +//! +//! This module provides helpers for writing tests, including temporary +//! directory allocation and repository initialization. -use std::{ffi::OsString, fs::create_dir_all, path::PathBuf}; +use std::{ffi::OsString, fs::create_dir_all, path::PathBuf, sync::Arc}; +use once_cell::sync::Lazy; +use rustix::fs::CWD; use tempfile::TempDir; -use once_cell::sync::Lazy; +use crate::{fsverity::FsVerityHashValue, repository::Repository}; static TMPDIR: Lazy = Lazy::new(|| { if let Some(path) = std::env::var_os("CFS_TEST_TMPDIR") { @@ -22,7 +27,10 @@ static TMPDIR: Lazy = Lazy::new(|| { } }); -/// Allocate a temporary directory +/// Allocate a temporary directory. +/// +/// This creates a temporary directory in a location that supports fs-verity +/// when possible (avoiding tmpfs and overlayfs). pub fn tempdir() -> TempDir { TempDir::with_prefix_in("composefs-test-", TMPDIR.as_os_str()).unwrap() } @@ -31,3 +39,38 @@ pub fn tempdir() -> TempDir { pub(crate) fn tempfile() -> std::fs::File { tempfile::tempfile_in(TMPDIR.as_os_str()).unwrap() } + +/// A test repository with its backing temporary directory. +/// +/// The repository is configured in insecure mode so tests can run on +/// filesystems that don't support fs-verity. The temporary directory +/// is cleaned up when this struct is dropped. +#[derive(Debug)] +pub struct TestRepo { + /// The repository, wrapped in Arc for sharing. + pub repo: Arc>, + /// The backing temporary directory (kept alive for the repo's lifetime). + _tempdir: TempDir, +} + +impl TestRepo { + /// Create a new test repository in insecure mode. + /// + /// The repository is created in a temporary directory and configured + /// to work without fs-verity support. + pub fn new() -> Self { + let dir = tempdir(); + let mut repo = Repository::open_path(CWD, dir.path()).unwrap(); + repo.set_insecure(true); + Self { + repo: Arc::new(repo), + _tempdir: dir, + } + } +} + +impl Default for TestRepo { + fn default() -> Self { + Self::new() + } +} diff --git a/doc/plans/oci-multiarch-copy.md b/doc/plans/oci-multiarch-copy.md new file mode 100644 index 00000000..191ffc1c --- /dev/null +++ b/doc/plans/oci-multiarch-copy.md @@ -0,0 +1,87 @@ +# Multi-Architecture Image Support + +This document sketches the design for storing multi-arch (manifest list / OCI index) images in the composefs repository. + +## Problem + +The composefs repository currently stores only single-platform images. When pulling via the skopeo proxy, manifest lists are resolved to the native platform before we ever see them — we receive a single-platform manifest and its layers. This is fine for running containers locally, but insufficient for mirroring and local caching use cases where the goal is to preserve all platforms in a single logical image. + +Round-trip fidelity matters here: if a user pulls a multi-arch image from a registry, stores it locally, and later pushes it back, the result should be identical to the original. That requires storing the index and all per-platform manifests with their original JSON bytes intact. + +## Import Path + +The skopeo proxy resolves platform at the transport level, so we can't use it to obtain the raw index. Instead, the import path for multi-arch images uses OCI layout directories directly. The workflow is: + +``` +skopeo copy docker://registry/image:tag oci:local-dir:tag +cfsctl import-oci local-dir --all-platforms +``` + +The `skopeo copy` to an `oci:` destination preserves the full manifest list in the layout's `index.json`. We then read the layout ourselves: parse `index.json`, walk descriptors, and fetch blobs by digest from `blobs/sha256/`. This is straightforward — an OCI layout is just a directory of content-addressed blobs plus a small index. + +We'll add a dep on `ocidir` for this. + +## Storage Model + +### Index Splitstreams + +An OCI index gets a new content type `"ociindx"` (i.e. `u64::from_le_bytes(*b"ociindx\0")`), stored alongside the existing `ocimanif` and `ociconfg` types. The stream identifier follows the same pattern: `oci-index-sha256:`. + +Like manifests and configs, the index JSON is stored as an external object — the splitstream holds a single object reference plus named refs to each per-platform manifest. This reuses the existing `read_external_splitstream` / `write_external_splitstream` pattern unchanged. + +The named refs in the index splitstream point to manifest streams (`oci-manifest-sha256:...`), which in turn point to their configs and layers. GC reachability works transitively through this chain with no changes to the collector. + +### Tags and Resolution + +Today, tags are symlinks pointing at manifest streams. With index support, a tag may point at either an `oci-index-sha256:...` or an `oci-manifest-sha256:...` stream. The `resolve_ref` function needs to distinguish these cases. + +The cleanest approach is an `OciRef` enum: + +```rust +pub enum OciRef { + Manifest { digest: Sha256HashValue, verity: Option }, + Index { digest: Sha256HashValue, verity: Option }, +} +``` + +Callers that only work with single-platform manifests (like `mount`) would call a convenience method that resolves through the index to the native platform. Callers that care about the full index (like mirror/export) use the enum directly. `list_images` returns both kinds, possibly with a flag or filter. + +### Layer Storage: Copy vs Pull + +A manifest list copy is a pure storage operation — no layers are splitstreamed, not even for the native architecture. All layer blobs are stored as-is in their original compressed form (gzip, zstd, etc.) via `write_blob`. This keeps the copy fast and preserves bit-identical round-trip fidelity for push-back to registries. + +When a user later explicitly pulls a specific per-arch image (e.g. `cfsctl pull`), the pull logic can detect that the compressed layer blobs already exist locally (they were stored during the manifest list copy). Instead of re-fetching from the registry, it decompresses them locally and imports them as splitstreams. This is an optimization — the normal pull path still works, it just avoids redundant network transfer. + +This two-phase approach (copy stores compressed blobs, pull splitstreams them on demand) cleanly separates mirroring from mounting. OCI artifacts follow the same pattern: manifest → blob, always flat (no artifact → artifact chaining in the spec), so a manifest list is the only real case of multi-level indirection. + +Cross-platform layer dedup happens automatically via content addressing — if two platforms share a layer, the blob is stored once. + +## Key Design Decisions + +**Content hash fidelity.** Index and manifest JSON must be stored as raw bytes, never re-serialized. OCI registries compute digests over the exact byte sequence; re-serialization would break signatures and digest references. The external object pattern already handles this correctly. + +**Nested indexes.** The OCI spec allows indexes to reference other indexes. This is rare in practice (mostly theoretical). The initial implementation should handle one level of index → manifest. Nested indexes can be added later by making `write_index` recursive. Not worth the complexity up front. + +**Tag ambiguity.** The `OciRef` enum described above is the primary mechanism. An alternative is separate tag namespaces, but that fragments the user-facing model unnecessarily. A single tag pointing to either type is simpler. + +**`ocidir` dependency.** We'll use the `ocidir` crate for OCI layout reading and eventually writing (push-to-OCI-layout). + +## Rough Change List + +`crates/composefs-oci/src/skopeo.rs` — add `OCI_INDEX_CONTENT_TYPE` constant alongside the existing content types. + +`crates/composefs-oci/src/oci_image.rs` — add `OciIndex` type mirroring `OciImage`, with `write_index()` and `has_index()` functions following the `write_manifest()` / `has_manifest()` pattern. Update `resolve_ref` to return `OciRef` enum. Update `list_images` for index awareness. + +`crates/composefs-oci/src/oci_layout.rs` (new) — OCI layout directory reading: parse `index.json`, resolve descriptors to blobs, iterate manifests within an index. + +`crates/composefs-oci/src/import.rs` or extend `skopeo.rs` — multi-arch import logic: walk the index, store all layers as compressed blobs. On per-arch pull, detect locally-available compressed blobs and decompress/splitstream them instead of re-fetching. + +`crates/cfsctl/src/main.rs` — `--all-platforms` flag on import, `--platform` selector on mount, display index metadata in `list`. + +## Open Questions + +How should `cfsctl list` display multi-arch images? One row per index with a platform count, or expanded per-platform rows? Probably the former by default with a `--all-platforms` flag to expand. + +Should we support partial platform import (e.g. `--platform linux/amd64,linux/arm64`)? Useful for constrained mirrors. Straightforward to implement as a filter during import. + +What's the mount behavior when a tag points to an index? Resolve to native platform automatically, or require explicit `--platform`? Automatic resolution with a warning if ambiguous seems right.