Skip to content

Commit d5ac320

Browse files
committed
oci: Extend with more OCI-native manifest/config storage
Previously, composefs-oci only stored the config and layer data but discarded the OCI manifest after pulling. This forced downstream consumers (like bootc) to implement manifest storage internally, which doesn't really make sense. My opinion is that while the true "composefs core" is today and should remain logically flexible, there's absolutely not reason for us not to have a very opinionated mechanism to store OCI. Very very key to this is that OCI is not just container images (with tar media types) - there's nowadays OCI artifacts which allow storing arbitrary data easily - and I think mapping "foreign" types to OCI makes a ton of sense. So as part of this the composefs-oci layer also learns how to handle non-tar layers from manifests. - Manifests stored at `oci-manifest-{digest}` with refs to config/layers - Named tags under `streams/refs/oci/{name}` act as GC roots - New `OciImage` type provides high-level access to stored images - `pull_image()` returns `PullResult` with both manifest and config info - Original `pull()` maintained for backward compatibility CLI additions (cfsctl oci): - `images` - list all tagged OCI images - `inspect <image>` - show image details (arch, labels, layers, etc.) - `tag <digest> <name>` - tag an image by manifest digest - `untag <name>` - remove a tag The storage model follows OCI semantics: everything is content-addressed, manifests reference their components, and tags are just named pointers to manifests. This enables offline access to all image metadata and proper garbage collection of unreferenced content. See-also: bootc-dev/bootc#1830 Assisted-by: OpenCode (Opus 4.5) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent da143a6 commit d5ac320

7 files changed

Lines changed: 1882 additions & 45 deletions

File tree

crates/cfsctl/src/main.rs

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,34 @@ enum OciCommand {
7373
#[clap(long)]
7474
bootable: bool,
7575
},
76+
/// Pull an OCI image from a registry
7677
Pull {
78+
/// Image reference (e.g., docker://registry.io/image:tag)
7779
image: String,
80+
/// Tag to assign to the image in the local repository
7881
name: Option<String>,
7982
},
83+
/// List all tagged OCI images in the repository
84+
#[clap(name = "images")]
85+
ListImages,
86+
/// Show information about an OCI image
87+
#[clap(name = "inspect")]
88+
Inspect {
89+
/// Image reference (tag name or manifest digest)
90+
image: String,
91+
},
92+
/// Tag an image with a new name
93+
Tag {
94+
/// Manifest digest (sha256:...)
95+
manifest_digest: String,
96+
/// Tag name to assign
97+
name: String,
98+
},
99+
/// Remove a tag from an image
100+
Untag {
101+
/// Tag name to remove
102+
name: String,
103+
},
80104
ComputeId {
81105
config_name: String,
82106
config_verity: Option<String>,
@@ -298,11 +322,110 @@ where
298322
println!("{}", image_id.to_id());
299323
}
300324
OciCommand::Pull { ref image, name } => {
301-
let (digest, verity) =
302-
composefs_oci::pull(&Arc::new(repo), image, name.as_deref(), None).await?;
325+
// If no explicit name provided, use the image reference as the tag
326+
let tag_name = name.as_deref().unwrap_or(image);
327+
let result =
328+
composefs_oci::pull_image(&Arc::new(repo), image, Some(tag_name), None).await?;
303329

304-
println!("config {digest}");
305-
println!("verity {}", verity.to_hex());
330+
println!("manifest {}", result.manifest_digest);
331+
println!("config {}", result.config_digest);
332+
println!("verity {}", result.manifest_verity.to_hex());
333+
println!("tagged {tag_name}");
334+
}
335+
OciCommand::ListImages => {
336+
let images = composefs_oci::oci_image::list_images(&repo)?;
337+
338+
if images.is_empty() {
339+
println!("No images found");
340+
} else {
341+
println!(
342+
"{:<30} {:<12} {:<10} {:<8} {:<6}",
343+
"NAME", "DIGEST", "ARCH", "SEALED", "LAYERS"
344+
);
345+
for img in images {
346+
let digest_short = img
347+
.manifest_digest
348+
.strip_prefix("sha256:")
349+
.unwrap_or(&img.manifest_digest);
350+
let digest_display = if digest_short.len() > 12 {
351+
&digest_short[..12]
352+
} else {
353+
digest_short
354+
};
355+
println!(
356+
"{:<30} {:<12} {:<10} {:<8} {:<6}",
357+
img.name,
358+
digest_display,
359+
if img.architecture.is_empty() {
360+
"artifact"
361+
} else {
362+
&img.architecture
363+
},
364+
if img.sealed { "yes" } else { "no" },
365+
img.layer_count
366+
);
367+
}
368+
}
369+
}
370+
OciCommand::Inspect { ref image } => {
371+
let img = if image.starts_with("sha256:") {
372+
composefs_oci::oci_image::OciImage::open(&repo, image, None)?
373+
} else {
374+
composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
375+
};
376+
377+
println!("Manifest: {}", img.manifest_digest());
378+
println!("Config: {}", img.config_digest());
379+
println!(
380+
"Type: {}",
381+
if img.is_container_image() {
382+
"container"
383+
} else {
384+
"artifact"
385+
}
386+
);
387+
388+
if img.is_container_image() {
389+
println!("Architecture: {}", img.architecture());
390+
println!("OS: {}", img.os());
391+
}
392+
393+
if let Some(created) = img.created() {
394+
println!("Created: {created}");
395+
}
396+
397+
println!(
398+
"Sealed: {}",
399+
if img.is_sealed() { "yes" } else { "no" }
400+
);
401+
if let Some(seal) = img.seal_digest() {
402+
println!("Seal digest: {seal}");
403+
}
404+
405+
println!("Layers: {}", img.layer_descriptors().len());
406+
for (i, layer) in img.layer_descriptors().iter().enumerate() {
407+
println!(" [{i}] {} ({} bytes)", layer.digest(), layer.size());
408+
}
409+
410+
if let Some(labels) = img.labels() {
411+
if !labels.is_empty() {
412+
println!("Labels:");
413+
for (k, v) in labels {
414+
println!(" {k}: {v}");
415+
}
416+
}
417+
}
418+
}
419+
OciCommand::Tag {
420+
ref manifest_digest,
421+
ref name,
422+
} => {
423+
composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
424+
println!("Tagged {manifest_digest} as {name}");
425+
}
426+
OciCommand::Untag { ref name } => {
427+
composefs_oci::oci_image::untag_image(&repo, name)?;
428+
println!("Removed tag {name}");
306429
}
307430
OciCommand::Seal {
308431
ref config_name,

crates/composefs-oci/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ tokio-util = { version = "0.7", default-features = false, features = ["io"] }
2929
[dev-dependencies]
3030
similar-asserts = "1.7.0"
3131
composefs = { workspace = true, features = ["test"] }
32+
ocidir = "0.6.0"
3233
once_cell = "1.21.3"
3334
tempfile = "3.8.0"
3435

crates/composefs-oci/src/lib.rs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//! - Sealing containers with fs-verity hashes for integrity verification
1212
1313
pub mod image;
14+
pub mod oci_image;
1415
pub mod skopeo;
1516
pub mod tar;
1617

@@ -26,6 +27,13 @@ use composefs::{fsverity::FsVerityHashValue, repository::Repository};
2627
use crate::skopeo::{OCI_CONFIG_CONTENT_TYPE, TAR_LAYER_CONTENT_TYPE};
2728
use crate::tar::get_entry;
2829

30+
// Re-export key types for convenience
31+
pub use oci_image::{
32+
list_images, list_refs, resolve_ref, tag_image, untag_image, ImageInfo, OciImage,
33+
OCI_REF_PREFIX,
34+
};
35+
pub use skopeo::{pull_image, PullResult};
36+
2937
type ContentAndVerity<ObjectID> = (String, ObjectID);
3038

3139
fn layer_identifier(diff_id: &str) -> String {
@@ -123,7 +131,11 @@ pub fn open_config<ObjectID: FsVerityHashValue>(
123131
// No verity means we need to verify the content hash
124132
let mut data = vec![];
125133
stream.read_to_end(&mut data)?;
126-
ensure!(config_digest == hash(&data), "Data integrity issue");
134+
let computed = hash(&data);
135+
ensure!(
136+
config_digest == computed,
137+
"Config integrity check failed: expected {config_digest}, got {computed}"
138+
);
127139
ImageConfiguration::from_reader(&data[..])?
128140
} else {
129141
ImageConfiguration::from_reader(&mut stream)?
@@ -252,4 +264,81 @@ mod test {
252264
/file4097 4097 100700 1 0 0 0 0.0 09/3756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743 - 093756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743
253265
");
254266
}
267+
268+
#[test]
269+
fn test_write_and_open_config() {
270+
use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
271+
272+
let repo_dir = tempdir();
273+
let repo = Arc::new(Repository::<Sha256HashValue>::open_path(CWD, &repo_dir).unwrap());
274+
275+
// Create a minimal config
276+
let rootfs = RootFsBuilder::default()
277+
.typ("layers")
278+
.diff_ids(vec!["sha256:abc123def456".to_string()])
279+
.build()
280+
.unwrap();
281+
282+
let config = ImageConfigurationBuilder::default()
283+
.architecture("amd64")
284+
.os("linux")
285+
.rootfs(rootfs)
286+
.build()
287+
.unwrap();
288+
289+
// Create layer refs (simulating what happens during pull)
290+
let mut refs = HashMap::new();
291+
refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY);
292+
293+
// Write the config
294+
let (config_digest, config_verity) = write_config(&repo, &config, refs.clone()).unwrap();
295+
296+
// Verify the digest format
297+
assert!(config_digest.starts_with("sha256:"));
298+
299+
// Open the config with verity (fast path)
300+
let (opened_config, opened_refs) =
301+
open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
302+
assert_eq!(opened_config.architecture().to_string(), "amd64");
303+
assert_eq!(opened_config.os().to_string(), "linux");
304+
assert_eq!(opened_refs.len(), 1);
305+
assert!(opened_refs.contains_key("sha256:abc123def456"));
306+
307+
// Open the config without verity (verifies content hash)
308+
let (opened_config2, _) = open_config(&repo, &config_digest, None).unwrap();
309+
assert_eq!(opened_config2.architecture().to_string(), "amd64");
310+
}
311+
312+
#[test]
313+
fn test_open_config_bad_hash() {
314+
use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
315+
316+
let repo_dir = tempdir();
317+
let repo = Arc::new(Repository::<Sha256HashValue>::open_path(CWD, &repo_dir).unwrap());
318+
319+
// Create and write a config
320+
let rootfs = RootFsBuilder::default()
321+
.typ("layers")
322+
.diff_ids(vec![])
323+
.build()
324+
.unwrap();
325+
326+
let config = ImageConfigurationBuilder::default()
327+
.architecture("amd64")
328+
.os("linux")
329+
.rootfs(rootfs)
330+
.build()
331+
.unwrap();
332+
333+
let (config_digest, _config_verity) = write_config(&repo, &config, HashMap::new()).unwrap();
334+
335+
// Try to open with a wrong digest (should fail)
336+
let bad_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
337+
let result = open_config::<Sha256HashValue>(&repo, bad_digest, None);
338+
assert!(result.is_err());
339+
340+
// Opening with correct digest should work
341+
let result = open_config::<Sha256HashValue>(&repo, &config_digest, None);
342+
assert!(result.is_ok());
343+
}
255344
}

0 commit comments

Comments
 (0)