From 0198736d70880b9531409b4a7dd752d0735e0ce2 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 13 Feb 2026 10:08:21 -0500 Subject: [PATCH 1/3] oci: Revamp `cfsctl oci` - `cfsctl oci inspect` now outputs JSON with full metadata, and supports `--manifest/--config`. - `cfsctl oci images` now includes referrer count and also supports `--json` - `cfsctl oci layer` is new and can output tar or dumpfile or json metadata Assisted-by: OpenCode (Claude claude-opus-4-5-20250514) Signed-off-by: Colin Walters Signed-off-by: Pragyan Poudyal --- crates/cfsctl/Cargo.toml | 1 + crates/cfsctl/src/main.rs | 120 +++++---- crates/composefs-oci/Cargo.toml | 2 + crates/composefs-oci/src/image.rs | 2 +- crates/composefs-oci/src/lib.rs | 6 +- crates/composefs-oci/src/oci_image.rs | 206 ++++++++++++++- crates/integration-tests/Cargo.toml | 7 +- crates/integration-tests/src/tests/cli.rs | 294 ++++++++++++++++++++++ 8 files changed, 582 insertions(+), 56 deletions(-) diff --git a/crates/cfsctl/Cargo.toml b/crates/cfsctl/Cargo.toml index 850a9a14..eadeec4b 100644 --- a/crates/cfsctl/Cargo.toml +++ b/crates/cfsctl/Cargo.toml @@ -28,6 +28,7 @@ composefs-http = { workspace = true, optional = true } env_logger = { version = "0.11.0", default-features = false } hex = { version = "0.4.0", default-features = false } rustix = { version = "1.0.0", default-features = false, features = ["fs", "process"] } +serde_json = { version = "1.0", default-features = false, features = ["std"] } tokio = { version = "1.24.2", default-features = false } [lints] diff --git a/crates/cfsctl/src/main.rs b/crates/cfsctl/src/main.rs index 512817a2..7f6d140e 100644 --- a/crates/cfsctl/src/main.rs +++ b/crates/cfsctl/src/main.rs @@ -103,12 +103,25 @@ enum OciCommand { }, /// List all tagged OCI images in the repository #[clap(name = "images")] - ListImages, + ListImages { + /// Output as JSON array + #[clap(long)] + json: bool, + }, /// Show information about an OCI image + /// + /// By default, outputs JSON with manifest, config, and referrers. + /// Use --manifest or --config to output just that raw JSON. #[clap(name = "inspect")] Inspect { /// Image reference (tag name or manifest digest) image: String, + /// Output only the raw manifest JSON (as originally stored) + #[clap(long, conflicts_with = "config")] + manifest: bool, + /// Output only the raw config JSON (as originally stored) + #[clap(long, conflicts_with = "manifest")] + config: bool, }, /// Tag an image with a new name Tag { @@ -122,6 +135,21 @@ enum OciCommand { /// Tag name to remove name: String, }, + /// Inspect a stored layer + /// + /// By default, outputs the raw tar stream to stdout. + /// Use --dumpfile for composefs dumpfile format, or --json for metadata. + #[clap(name = "layer")] + LayerInspect { + /// Layer diff_id (sha256:...) + layer: String, + /// Output as composefs dumpfile format (one entry per line) + #[clap(long, conflicts_with = "json")] + dumpfile: bool, + /// Output layer metadata as JSON + #[clap(long, conflicts_with = "dumpfile")] + json: bool, + }, /// Compute the composefs image object id of the rootfs of a stored OCI image ComputeId { #[clap(flatten)] @@ -390,15 +418,17 @@ where println!("verity {}", result.manifest_verity.to_hex()); println!("tagged {tag_name}"); } - OciCommand::ListImages => { + OciCommand::ListImages { json } => { let images = composefs_oci::oci_image::list_images(&repo)?; - if images.is_empty() { + if json { + println!("{}", serde_json::to_string_pretty(&images)?); + } else if images.is_empty() { println!("No images found"); } else { println!( - "{:<30} {:<12} {:<10} {:<8} {:<6}", - "NAME", "DIGEST", "ARCH", "SEALED", "LAYERS" + "{:<30} {:<12} {:<10} {:<8} {:<6} {:<5}", + "NAME", "DIGEST", "ARCH", "SEALED", "LAYERS", "REFS" ); for img in images { let digest_short = img @@ -411,7 +441,7 @@ where digest_short }; println!( - "{:<30} {:<12} {:<10} {:<8} {:<6}", + "{:<30} {:<12} {:<10} {:<8} {:<6} {:<5}", img.name, digest_display, if img.architecture.is_empty() { @@ -420,58 +450,37 @@ where &img.architecture }, if img.sealed { "yes" } else { "no" }, - img.layer_count + img.layer_count, + img.referrer_count ); } } } - OciCommand::Inspect { ref image } => { + OciCommand::Inspect { + ref image, + manifest, + config, + } => { 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}"); - } - } + if manifest { + // Output raw manifest JSON exactly as stored + let manifest_json = img.read_manifest_json(&repo)?; + std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?; + println!(); + } else if config { + // Output raw config JSON exactly as stored + let config_json = img.read_config_json(&repo)?; + std::io::Write::write_all(&mut std::io::stdout(), &config_json)?; + println!(); + } else { + // Default: output combined JSON with manifest, config, and referrers + let output = img.inspect_json(&repo)?; + println!("{}", serde_json::to_string_pretty(&output)?); } } OciCommand::Tag { @@ -485,6 +494,21 @@ where composefs_oci::oci_image::untag_image(&repo, name)?; println!("Removed tag {name}"); } + OciCommand::LayerInspect { + ref layer, + dumpfile, + json, + } => { + if json { + let info = composefs_oci::layer_info(&repo, layer)?; + println!("{}", serde_json::to_string_pretty(&info)?); + } else if dumpfile { + composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?; + } else { + // Default: output raw tar + composefs_oci::layer_tar(&repo, layer, &mut std::io::stdout())?; + } + } OciCommand::Seal { config_opts: OCIConfigOptions { diff --git a/crates/composefs-oci/Cargo.toml b/crates/composefs-oci/Cargo.toml index 6c38d3a9..d654f6aa 100644 --- a/crates/composefs-oci/Cargo.toml +++ b/crates/composefs-oci/Cargo.toml @@ -21,6 +21,8 @@ hex = { version = "0.4.0", default-features = false } indicatif = { version = "0.17.0", default-features = false, features = ["tokio"] } oci-spec = { version = "0.8.0", default-features = false } rustix = { version = "1.0.0", features = ["fs"] } +serde = { version = "1.0", default-features = false, features = ["derive"] } +serde_json = { version = "1.0", default-features = false, features = ["std"] } sha2 = { version = "0.10.1", default-features = false } tar = { version = "0.4.38", default-features = false } tokio = { version = "1.24.2", features = ["rt-multi-thread"] } diff --git a/crates/composefs-oci/src/image.rs b/crates/composefs-oci/src/image.rs index 9fd6f1d0..de4247ea 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, io::Read, path::PathBuf}; + use std::{cell::RefCell, collections::BTreeMap, io::BufRead, path::PathBuf}; use super::*; diff --git a/crates/composefs-oci/src/lib.rs b/crates/composefs-oci/src/lib.rs index bbb41c84..64ae8b87 100644 --- a/crates/composefs-oci/src/lib.rs +++ b/crates/composefs-oci/src/lib.rs @@ -31,9 +31,9 @@ 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, + add_referrer, layer_dumpfile, layer_info, layer_tar, list_images, list_referrers, list_refs, + remove_referrer, remove_referrers_for_subject, resolve_ref, tag_image, untag_image, ImageInfo, + LayerInfo, OciImage, SplitstreamInfo, OCI_REF_PREFIX, }; pub use skopeo::{pull_image, PullResult}; diff --git a/crates/composefs-oci/src/oci_image.rs b/crates/composefs-oci/src/oci_image.rs index 256168ec..aa125d07 100644 --- a/crates/composefs-oci/src/oci_image.rs +++ b/crates/composefs-oci/src/oci_image.rs @@ -46,6 +46,7 @@ use containers_image_proxy::oci_spec::image::{ }; use rustix::fs::{openat, readlinkat, unlinkat, AtFlags, Dir, Mode, OFlags}; use rustix::io::Errno; +use serde::Serialize; use sha2::{Digest, Sha256}; use composefs::{fsverity::FsVerityHashValue, repository::Repository}; @@ -99,6 +100,8 @@ pub struct OciImage { manifest: ImageManifest, /// The config digest (sha256 content hash) config_digest: String, + /// The fs-verity ID of the config splitstream + config_verity: ObjectID, /// The parsed OCI config (may be empty for artifacts) config: Option, /// Map from layer diff_id to its fs-verity object ID @@ -136,13 +139,14 @@ impl OciImage { let config_key = format!("config:{config_digest}"); let config_verity = named_refs .get(config_key.as_str()) - .context("Manifest missing config reference")?; + .context("Manifest missing config reference")? + .clone(); let config_id = crate::config_identifier(&config_digest); let (config_data, config_named_refs) = read_external_splitstream( repo, &config_id, - Some(config_verity), + Some(&config_verity), Some(OCI_CONFIG_CONTENT_TYPE), )?; @@ -181,6 +185,7 @@ impl OciImage { manifest_digest: manifest_digest.to_string(), manifest, config_digest, + config_verity, config, layer_refs, manifest_verity, @@ -334,6 +339,60 @@ impl OciImage { .and_then(|c| c.config().as_ref()) .and_then(|cfg| cfg.labels().as_ref()) } + + /// Reads the raw manifest JSON bytes from the repository. + /// + /// This retrieves the original manifest JSON as stored, which may differ + /// slightly from re-serializing the parsed manifest (e.g., whitespace). + pub fn read_manifest_json(&self, repo: &Repository) -> Result> { + let manifest_id = manifest_identifier(&self.manifest_digest); + let (data, _) = read_external_splitstream( + repo, + &manifest_id, + Some(&self.manifest_verity), + Some(OCI_MANIFEST_CONTENT_TYPE), + )?; + Ok(data) + } + + /// Reads the raw config JSON bytes from the repository. + /// + /// This retrieves the original config JSON as stored, which may differ + /// slightly from re-serializing the parsed config (e.g., whitespace). + pub fn read_config_json(&self, repo: &Repository) -> Result> { + let config_id = crate::config_identifier(&self.config_digest); + let (data, _) = read_external_splitstream( + repo, + &config_id, + Some(&self.config_verity), + Some(OCI_CONFIG_CONTENT_TYPE), + )?; + Ok(data) + } + + /// Returns the full inspect output as a JSON value. + /// + /// This includes the manifest, config, and referrers in a single JSON object. + /// The manifest and config are included as their original JSON structure. + pub fn inspect_json(&self, repo: &Repository) -> Result { + let manifest_json = self.read_manifest_json(repo)?; + let config_json = self.read_config_json(repo)?; + let referrers = list_referrers(repo, &self.manifest_digest)?; + + let manifest_value: serde_json::Value = serde_json::from_slice(&manifest_json)?; + let config_value: serde_json::Value = serde_json::from_slice(&config_json)?; + + let referrers_value: Vec = referrers + .iter() + .map(|(digest, _verity)| serde_json::json!({ "digest": digest })) + .collect(); + + Ok(serde_json::json!({ + "manifest": manifest_value, + "config": config_value, + "referrers": referrers_value, + })) + } } // ============================================================================= @@ -425,7 +484,8 @@ pub fn list_refs( /// 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)] +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct ImageInfo { /// The tag/name of the image pub name: String, @@ -443,6 +503,8 @@ pub struct ImageInfo { pub sealed: bool, /// Number of layers/blobs pub layer_count: usize, + /// Number of OCI referrers (signatures, attestations, etc.) + pub referrer_count: usize, } /// Lists all tagged images with their metadata. @@ -454,6 +516,7 @@ pub fn list_images( for (name, digest) in list_refs(repo)? { match OciImage::open(repo, &digest, None) { Ok(img) => { + let referrer_count = list_referrers(repo, &digest).map(|r| r.len()).unwrap_or(0); images.push(ImageInfo { name, manifest_digest: digest, @@ -463,6 +526,7 @@ pub fn list_images( created: img.created().map(String::from), sealed: img.is_sealed(), layer_count: img.layer_descriptors().len(), + referrer_count, }); } Err(e) => { @@ -857,6 +921,142 @@ pub fn cleanup_dangling_referrers( Ok(removed) } +// ============================================================================= +// Layer Inspection +// ============================================================================= + +/// Metadata about a layer stored in the repository. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LayerInfo { + /// The layer diff_id (sha256 hash of uncompressed content) + pub diff_id: String, + /// The fs-verity hash of the layer splitstream + pub verity: String, + /// Size of the uncompressed tar layer in bytes + pub size: u64, + /// Number of files/entries in the layer + pub entry_count: usize, + /// Splitstream metadata + pub splitstream: SplitstreamInfo, +} + +/// Metadata about the splitstream representation of a layer. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SplitstreamInfo { + /// Number of external object references (large files stored separately) + pub external_objects: usize, + /// Total size of external objects in bytes + pub external_size: u64, + /// Size of inline data in bytes (small files + tar headers) + pub inline_size: u64, +} + +/// Opens a layer by its diff_id and returns metadata about it. +/// +/// The diff_id should be in the `sha256:...` format used by OCI. +pub fn layer_info( + repo: &Repository, + diff_id: &str, +) -> Result { + let content_id = crate::layer_identifier(diff_id); + let verity = repo + .has_stream(&content_id)? + .with_context(|| format!("Layer {diff_id} not found"))?; + + let mut stream = repo.open_stream( + &content_id, + Some(&verity), + Some(crate::skopeo::TAR_LAYER_CONTENT_TYPE), + )?; + + // Get the total size from the splitstream header (this is the merged/tar size) + let size = stream.total_size; + + // Count external object references (this doesn't consume the stream) + let mut external_objects = 0usize; + stream.get_object_refs(|_| external_objects += 1)?; + + // Iterate entries and gather sizes + let mut entry_count = 0usize; + let mut external_size = 0u64; + + while let Some(entry) = crate::tar::get_entry(&mut stream)? { + entry_count += 1; + if let crate::tar::TarItem::Leaf(composefs::tree::LeafContent::Regular( + composefs::tree::RegularFile::External(_, file_size), + )) = entry.item + { + external_size += file_size; + } + } + + // inline_size includes tar headers, small files, and other metadata + let inline_size = size.saturating_sub(external_size); + + Ok(LayerInfo { + diff_id: diff_id.to_string(), + verity: verity.to_hex(), + size, + entry_count, + splitstream: SplitstreamInfo { + external_objects, + external_size, + inline_size, + }, + }) +} + +/// Writes the layer contents in composefs dumpfile format. +/// +/// Each entry is written on its own line in the composefs dumpfile format, +/// which includes path, size, mode, ownership, timestamps, and content references. +pub fn layer_dumpfile( + repo: &Repository, + diff_id: &str, + output: &mut impl std::io::Write, +) -> Result<()> { + let content_id = crate::layer_identifier(diff_id); + let verity = repo + .has_stream(&content_id)? + .with_context(|| format!("Layer {diff_id} not found"))?; + + let mut stream = repo.open_stream( + &content_id, + Some(&verity), + Some(crate::skopeo::TAR_LAYER_CONTENT_TYPE), + )?; + + while let Some(entry) = crate::tar::get_entry(&mut stream)? { + writeln!(output, "{entry}")?; + } + + Ok(()) +} + +/// Reconstitutes and writes the original tar layer. +/// +/// This merges the splitstream back into the original tar format by +/// combining inline data with external object references. +pub fn layer_tar( + repo: &Repository, + diff_id: &str, + output: &mut impl std::io::Write, +) -> Result<()> { + let content_id = crate::layer_identifier(diff_id); + let verity = repo + .has_stream(&content_id)? + .with_context(|| format!("Layer {diff_id} not found"))?; + + repo.merge_splitstream( + &content_id, + Some(&verity), + Some(crate::skopeo::TAR_LAYER_CONTENT_TYPE), + output, + ) +} + #[cfg(test)] mod test { use super::*; diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 21602666..01b24fc9 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -10,12 +10,17 @@ path = "src/main.rs" [dependencies] anyhow = "1" -xshell = "0.2" +cap-std-ext = "4.0" +composefs = { path = "../composefs" } libtest-mimic = "0.8" linkme = "0.3" +ocidir = "0.6" paste = "1" rustix = { version = "1.0.0", default-features = false, features = ["process"] } +serde_json = "1.0" +tar = "0.4" tempfile = "3" +xshell = "0.2" [lints] workspace = true diff --git a/crates/integration-tests/src/tests/cli.rs b/crates/integration-tests/src/tests/cli.rs index a133218d..eb954f26 100644 --- a/crates/integration-tests/src/tests/cli.rs +++ b/crates/integration-tests/src/tests/cli.rs @@ -160,3 +160,297 @@ fn test_gc_dry_run() -> Result<()> { Ok(()) } integration_test!(test_gc_dry_run); + +fn test_oci_images_empty_repo() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + let output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images").read()?; + assert!( + output.contains("No images found"), + "expected 'No images found', got: {output}" + ); + Ok(()) +} +integration_test!(test_oci_images_empty_repo); + +fn test_oci_images_json_empty_repo() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + let output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images --json").read()?; + // Empty JSON array + let parsed: serde_json::Value = serde_json::from_str(&output)?; + assert!( + parsed.as_array().map(|a| a.is_empty()).unwrap_or(false), + "expected empty JSON array, got: {output}" + ); + Ok(()) +} +integration_test!(test_oci_images_json_empty_repo); + +/// Creates a minimal OCI image layout directory for testing using the ocidir crate. +/// +/// Returns the path to the OCI layout directory. +fn create_oci_layout(parent: &std::path::Path) -> Result { + use cap_std_ext::cap_std; + use ocidir::oci_spec::image::{ + ImageConfigurationBuilder, Platform, PlatformBuilder, RootFsBuilder, + }; + + let oci_dir = parent.join("oci-image"); + std::fs::create_dir_all(&oci_dir)?; + + let dir = cap_std::fs::Dir::open_ambient_dir(&oci_dir, cap_std::ambient_authority())?; + let ocidir = ocidir::OciDir::ensure(dir)?; + + // Create a new empty manifest + let mut manifest = ocidir.new_empty_manifest()?.build()?; + + // Create config with architecture and OS + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(Vec::::new()) + .build()?; + let mut config = ImageConfigurationBuilder::default() + .architecture("amd64") + .os("linux") + .rootfs(rootfs) + .build()?; + + // Create a simple layer with one file + let mut layer_builder = ocidir.create_layer(None)?; + { + let data = b"hello from test layer\n"; + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(1234567890); + header.set_cksum(); + layer_builder.append_data(&mut header, "hello.txt", &data[..])?; + } + let layer = layer_builder.into_inner()?.complete()?; + + // Push the layer to manifest and config + ocidir.push_layer(&mut manifest, &mut config, layer, "test layer", None); + + // Create platform for the manifest + let platform: Platform = PlatformBuilder::default() + .architecture("amd64") + .os("linux") + .build()?; + + // Insert manifest and config into the OCI directory + ocidir.insert_manifest_and_config(manifest, config, None, platform)?; + + Ok(oci_dir) +} + +fn test_oci_pull_and_inspect() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + let fixture_dir = tempfile::tempdir()?; + let oci_layout = create_oci_layout(fixture_dir.path())?; + + // Pull from OCI layout + let pull_output = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} test-image" + ) + .read()?; + assert!( + pull_output.contains("manifest sha256:"), + "expected manifest digest in output, got: {pull_output}" + ); + assert!( + pull_output.contains("tagged") && pull_output.contains("test-image"), + "expected tagged confirmation, got: {pull_output}" + ); + + // List images + let list_output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images").read()?; + assert!( + list_output.contains("test-image"), + "expected test-image in list, got: {list_output}" + ); + + // List images as JSON + let json_output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images --json").read()?; + let images: serde_json::Value = serde_json::from_str(&json_output)?; + let arr = images.as_array().expect("expected array"); + assert_eq!(arr.len(), 1, "expected 1 image"); + assert_eq!(arr[0]["name"], "test-image"); + assert_eq!(arr[0]["architecture"], "amd64"); + + // Inspect the image + let inspect_output = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci inspect test-image" + ) + .read()?; + let inspect: serde_json::Value = serde_json::from_str(&inspect_output)?; + assert!( + inspect.get("manifest").is_some(), + "expected manifest in inspect output" + ); + assert!( + inspect.get("config").is_some(), + "expected config in inspect output" + ); + assert!( + inspect.get("referrers").is_some(), + "expected referrers in inspect output" + ); + + // Inspect --manifest + let manifest_output = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci inspect test-image --manifest" + ) + .read()?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_output)?; + assert_eq!(manifest["schemaVersion"], 2); + assert!(manifest.get("config").is_some()); + assert!(manifest.get("layers").is_some()); + + // Inspect --config + let config_output = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci inspect test-image --config" + ) + .read()?; + let config: serde_json::Value = serde_json::from_str(&config_output)?; + assert_eq!(config["architecture"], "amd64"); + assert_eq!(config["os"], "linux"); + + Ok(()) +} +integration_test!(test_oci_pull_and_inspect); + +fn test_oci_layer_inspect() -> Result<()> { + use composefs::dumpfile_parse::{Entry, Item}; + use std::io::Read; + use std::path::Path; + + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + let fixture_dir = tempfile::tempdir()?; + let oci_layout = create_oci_layout(fixture_dir.path())?; + + // Pull from OCI layout + cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} test-image" + ) + .read()?; + + // Get the layer diff_id from the config + let config_output = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci inspect test-image --config" + ) + .read()?; + let config: serde_json::Value = serde_json::from_str(&config_output)?; + let diff_ids = config["rootfs"]["diff_ids"] + .as_array() + .expect("expected diff_ids array"); + assert_eq!(diff_ids.len(), 1, "expected 1 layer"); + let layer_id = diff_ids[0].as_str().expect("expected string"); + + // Test --json output + let json_output = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci layer {layer_id} --json" + ) + .read()?; + let info: serde_json::Value = serde_json::from_str(&json_output)?; + assert_eq!(info["diffId"], layer_id); + assert!(info["verity"].as_str().is_some(), "expected verity hash"); + assert!(info["size"].as_u64().unwrap() > 0, "expected non-zero size"); + assert_eq!( + info["entryCount"].as_u64().unwrap(), + 1, + "expected exactly 1 entry (hello.txt)" + ); + // Check splitstream metadata + let splitstream = info + .get("splitstream") + .expect("expected splitstream metadata"); + assert!( + splitstream["externalObjects"].as_u64().is_some(), + "expected externalObjects" + ); + assert!( + splitstream["externalSize"].as_u64().is_some(), + "expected externalSize" + ); + assert!( + splitstream["inlineSize"].as_u64().is_some(), + "expected inlineSize" + ); + + // Test --dumpfile output - parse each line with the dumpfile parser + let dumpfile_output = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci layer {layer_id} --dumpfile" + ) + .read()?; + + let mut found_hello_txt = false; + for line in dumpfile_output.lines() { + if line.trim().is_empty() { + continue; + } + let entry = Entry::parse(line) + .unwrap_or_else(|e| panic!("failed to parse dumpfile line '{line}': {e}")); + + if entry.path.as_ref() == Path::new("/hello.txt") { + found_hello_txt = true; + // Verify it's a regular file with inline content + match &entry.item { + Item::RegularInline { content, .. } => { + assert_eq!( + content.as_ref(), + b"hello from test layer\n", + "hello.txt content mismatch" + ); + } + other => panic!("expected RegularInline for hello.txt, got {:?}", other), + } + assert_eq!(entry.uid, 0, "expected uid 0"); + assert_eq!(entry.gid, 0, "expected gid 0"); + // Mode 0o644 + regular file bit (0o100000) = 0o100644 = 33188 + assert_eq!(entry.mode, 0o100644, "expected mode 0o100644"); + } + } + assert!(found_hello_txt, "expected to find /hello.txt in dumpfile"); + + // Test raw tar output - parse as actual tar and verify contents + let tar_output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci layer {layer_id}").output()?; + let mut archive = tar::Archive::new(tar_output.stdout.as_slice()); + let mut found_in_tar = false; + for entry in archive.entries()? { + let mut entry = entry?; + let path = entry.path()?; + if path.as_ref() == Path::new("hello.txt") { + found_in_tar = true; + let mut content = String::new(); + entry.read_to_string(&mut content)?; + assert_eq!(content, "hello from test layer\n", "tar content mismatch"); + } + } + assert!(found_in_tar, "expected to find hello.txt in tar output"); + + Ok(()) +} +integration_test!(test_oci_layer_inspect); From 01f2d2440819f2e2ea516243b27cd0830b15abfa Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 13 Feb 2026 11:27:53 -0500 Subject: [PATCH 2/3] cfsctl: Switch to comfy_table So we get correct line wrapping. Signed-off-by: Colin Walters Signed-off-by: Pragyan Poudyal --- crates/cfsctl/Cargo.toml | 1 + crates/cfsctl/src/main.rs | 35 +++++++++++++++++++---------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/crates/cfsctl/Cargo.toml b/crates/cfsctl/Cargo.toml index eadeec4b..2b9309ab 100644 --- a/crates/cfsctl/Cargo.toml +++ b/crates/cfsctl/Cargo.toml @@ -21,6 +21,7 @@ rhel9 = ['composefs/rhel9'] anyhow = { version = "1.0.87", default-features = false } fn-error-context = "0.2" clap = { version = "4.5.0", default-features = false, features = ["std", "help", "usage", "derive", "wrap_help"] } +comfy-table = { version = "7.1", default-features = false } composefs = { workspace = true } composefs-boot = { workspace = true } composefs-oci = { workspace = true, optional = true } diff --git a/crates/cfsctl/src/main.rs b/crates/cfsctl/src/main.rs index 7f6d140e..ef2bb37c 100644 --- a/crates/cfsctl/src/main.rs +++ b/crates/cfsctl/src/main.rs @@ -12,6 +12,7 @@ use std::{ use anyhow::Result; use clap::{Parser, Subcommand, ValueEnum}; +use comfy_table::{presets::UTF8_FULL, Table}; use rustix::fs::CWD; @@ -426,10 +427,10 @@ where } else if images.is_empty() { println!("No images found"); } else { - println!( - "{:<30} {:<12} {:<10} {:<8} {:<6} {:<5}", - "NAME", "DIGEST", "ARCH", "SEALED", "LAYERS", "REFS" - ); + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(["NAME", "DIGEST", "ARCH", "SEALED", "LAYERS", "REFS"]); + for img in images { let digest_short = img .manifest_digest @@ -440,20 +441,22 @@ where } else { digest_short }; - println!( - "{:<30} {:<12} {:<10} {:<8} {:<6} {:<5}", - img.name, + let arch = if img.architecture.is_empty() { + "artifact" + } else { + &img.architecture + }; + let sealed = if img.sealed { "yes" } else { "no" }; + table.add_row([ + img.name.as_str(), digest_display, - if img.architecture.is_empty() { - "artifact" - } else { - &img.architecture - }, - if img.sealed { "yes" } else { "no" }, - img.layer_count, - img.referrer_count - ); + arch, + sealed, + &img.layer_count.to_string(), + &img.referrer_count.to_string(), + ]); } + println!("{table}"); } } OciCommand::Inspect { From 7e44082269b94703e6eeef8bf9ec1042f47be6f9 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 13 Feb 2026 12:26:25 -0500 Subject: [PATCH 3/3] integration-tests: Inherit cargo config Fixes the package check. Signed-off-by: Colin Walters Signed-off-by: Pragyan Poudyal --- crates/integration-tests/Cargo.toml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 01b24fc9..476a096c 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -1,9 +1,13 @@ [package] name = "integration-tests" -version = "0.1.0" -edition = "2021" publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + [[bin]] name = "cfsctl-integration-tests" path = "src/main.rs" @@ -11,7 +15,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" cap-std-ext = "4.0" -composefs = { path = "../composefs" } +composefs = { workspace = true } libtest-mimic = "0.8" linkme = "0.3" ocidir = "0.6"