Skip to content

Commit 2f93e4a

Browse files
committed
cfsctl: Fix seal to update tag, improve verify and inspect UX
Three fixes based on user walkthrough feedback: 1. oci seal now creates a new manifest referencing the sealed config and updates the tag, so oci images correctly shows sealed: yes. Previously seal created an orphaned config that nothing referenced. 2. oci verify without --cert now prints a clear warning that only digest matching was performed and signatures were not cryptographically verified, with guidance to use --cert. 3. oci inspect referrers now include artifactType alongside the digest, so users can see what kind of referrer each entry is (e.g. application/vnd.composefs.erofs-alongside.v1 for composefs artifacts). Assisted-by: OpenCode (Claude claude-opus-4-6)
1 parent 45f81dd commit 2f93e4a

5 files changed

Lines changed: 104 additions & 13 deletions

File tree

crates/cfsctl/src/lib.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -779,11 +779,8 @@ where
779779
}
780780
OciCommand::Seal { ref image } => {
781781
let repo = Arc::new(repo);
782-
let img = composefs_oci::OciImage::open_ref(&repo, image)?;
783-
let config_digest = img.config_digest().to_string();
784-
let (digest, verity) = composefs_oci::seal(&repo, &config_digest, None)?;
785-
println!("config {digest}");
786-
println!("verity {}", verity.to_id());
782+
let manifest_digest = composefs_oci::seal_image(&repo, image)?;
783+
println!("Sealed {image} -> {manifest_digest}");
787784
}
788785
OciCommand::Mount {
789786
ref name,
@@ -1062,6 +1059,9 @@ where
10621059

10631060
if verifier.is_some() {
10641061
println!("\nVerification passed ({verified_count} signatures verified)");
1062+
} else {
1063+
println!("\nDigest check passed. NOTE: no certificate provided, signatures were NOT cryptographically verified.");
1064+
println!("To verify signatures, use: cfsctl oci verify {image} --cert <certificate.pem>");
10651065
}
10661066
}
10671067
OciCommand::Push {

crates/composefs-oci/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ pub use image::{
4343
pub use oci_image::{
4444
add_referrer, export_image_to_oci_layout, export_referrers_to_oci_layout, layer_dumpfile,
4545
layer_info, layer_tar, list_images, list_referrers, list_refs, remove_referrer,
46-
remove_referrers_for_subject, resolve_ref, tag_image, untag_image, ImageInfo, LayerInfo,
47-
OciImage, SplitstreamInfo, OCI_REF_PREFIX,
46+
remove_referrers_for_subject, resolve_ref, seal_image, tag_image, untag_image, ImageInfo,
47+
LayerInfo, OciImage, SplitstreamInfo, OCI_REF_PREFIX,
4848
};
4949
pub use skopeo::pull_image;
5050

crates/composefs-oci/src/oci_image.rs

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@
3838
//! This module handles both transparently. Use `is_container_image()` to check.
3939
4040
use std::collections::{HashMap, HashSet};
41+
use std::str::FromStr;
4142
use std::sync::Arc;
4243

4344
use anyhow::{ensure, Context, Result};
4445
use containers_image_proxy::oci_spec::image::{
45-
Descriptor, ImageConfiguration, ImageManifest, MediaType,
46+
Descriptor, DescriptorBuilder, Digest as OciDigest, ImageConfiguration, ImageManifest,
47+
ImageManifestBuilder, MediaType,
4648
};
4749
use rustix::fs::{openat, readlinkat, unlinkat, AtFlags, Dir, Mode, OFlags};
4850
use rustix::io::Errno;
@@ -389,7 +391,15 @@ impl<ObjectID: FsVerityHashValue> OciImage<ObjectID> {
389391

390392
let referrers_value: Vec<serde_json::Value> = referrers
391393
.iter()
392-
.map(|(digest, _verity)| serde_json::json!({ "digest": digest }))
394+
.map(|(digest, verity)| {
395+
let mut entry = serde_json::json!({ "digest": digest });
396+
if let Ok(referrer_img) = OciImage::open(repo, digest, Some(verity)) {
397+
if let Some(artifact_type) = referrer_img.manifest().artifact_type() {
398+
entry["artifactType"] = serde_json::json!(artifact_type.to_string());
399+
}
400+
}
401+
entry
402+
})
393403
.collect();
394404

395405
Ok(serde_json::json!({
@@ -599,6 +609,76 @@ pub fn write_manifest<ObjectID: FsVerityHashValue>(
599609
Ok((manifest_digest.to_string(), id))
600610
}
601611

612+
/// Seals an image by tag: creates a sealed config, a new manifest referencing it,
613+
/// and updates the tag to point to the new manifest.
614+
///
615+
/// This is the complete seal workflow. It:
616+
/// 1. Opens the image by tag to get the original manifest and layer refs
617+
/// 2. Calls `seal()` to create a config with the fsverity label
618+
/// 3. Builds a new manifest referencing the sealed config (same layers)
619+
/// 4. Stores the new manifest and updates the tag
620+
///
621+
/// Returns the new manifest digest.
622+
pub fn seal_image<ObjectID: FsVerityHashValue>(
623+
repo: &Arc<Repository<ObjectID>>,
624+
name: &str,
625+
) -> Result<String> {
626+
let img = OciImage::open_ref(repo, name)?;
627+
ensure!(
628+
img.is_container_image(),
629+
"Can only seal container images, not artifacts"
630+
);
631+
632+
let config_digest = img.config_digest().to_string();
633+
let (sealed_config_digest, sealed_config_verity) =
634+
crate::seal(repo, &config_digest, None)?;
635+
636+
// Build a new config descriptor for the sealed config
637+
let sealed_config_json = {
638+
let config_id = crate::config_identifier(&sealed_config_digest);
639+
let (data, _) = read_external_splitstream(
640+
repo,
641+
&config_id,
642+
Some(&sealed_config_verity),
643+
Some(OCI_CONFIG_CONTENT_TYPE),
644+
)?;
645+
data
646+
};
647+
648+
let new_config_descriptor = DescriptorBuilder::default()
649+
.media_type(MediaType::ImageConfig)
650+
.digest(
651+
OciDigest::from_str(&sealed_config_digest)
652+
.context("parsing sealed config digest")?,
653+
)
654+
.size(sealed_config_json.len() as u64)
655+
.build()
656+
.context("building config descriptor")?;
657+
658+
// Build new manifest with same layers but sealed config
659+
let new_manifest = ImageManifestBuilder::default()
660+
.schema_version(2u32)
661+
.media_type(MediaType::ImageManifest)
662+
.config(new_config_descriptor)
663+
.layers(img.manifest().layers().to_vec())
664+
.build()
665+
.context("building sealed manifest")?;
666+
667+
let new_manifest_json = new_manifest.to_string()?;
668+
let new_manifest_digest = hash(new_manifest_json.as_bytes());
669+
670+
write_manifest(
671+
repo,
672+
&new_manifest,
673+
&new_manifest_digest,
674+
&sealed_config_verity,
675+
img.layer_refs(),
676+
Some(name),
677+
)?;
678+
679+
Ok(new_manifest_digest)
680+
}
681+
602682
/// Checks if a manifest exists.
603683
pub fn has_manifest<ObjectID: FsVerityHashValue>(
604684
repo: &Repository<ObjectID>,

crates/integration-tests/src/tests/podman.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,11 @@ fn test_podman_build_seal_sign_verify() -> Result<()> {
136136
)
137137
.read()?;
138138

139-
// 3. Seal — produces a new config with fsverity digest baked in
139+
// 3. Seal — creates sealed config + new manifest, updates the tag
140140
let seal_output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci seal test-podman").read()?;
141141
assert!(
142-
seal_output.contains("config") && seal_output.contains("verity"),
143-
"expected config/verity in seal output, got: {seal_output}"
142+
seal_output.contains("Sealed") && seal_output.contains("sha256:"),
143+
"expected sealed manifest digest in seal output, got: {seal_output}"
144144
);
145145

146146
// 4. Sign

crates/integration-tests/src/tests/signing.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ fn test_verify_without_cert() -> Result<()> {
195195
verify_output.contains("digest matches"),
196196
"expected digest-only verification, got: {verify_output}"
197197
);
198+
assert!(
199+
verify_output.contains("no certificate provided"),
200+
"expected warning about missing certificate, got: {verify_output}"
201+
);
198202
Ok(())
199203
}
200204
integration_test!(test_verify_without_cert);
@@ -710,7 +714,7 @@ fn test_inspect_shows_referrer_info() -> Result<()> {
710714
"expected at least one referrer after signing"
711715
);
712716

713-
// Each referrer should have a digest field
717+
// Each referrer should have a digest field and artifactType
714718
for referrer in referrers {
715719
let digest = referrer["digest"]
716720
.as_str()
@@ -719,6 +723,13 @@ fn test_inspect_shows_referrer_info() -> Result<()> {
719723
digest.starts_with("sha256:"),
720724
"referrer digest should start with sha256:, got: {digest}"
721725
);
726+
let artifact_type = referrer["artifactType"]
727+
.as_str()
728+
.expect("expected artifactType field in referrer");
729+
assert!(
730+
!artifact_type.is_empty(),
731+
"artifactType should not be empty"
732+
);
722733
}
723734

724735
Ok(())

0 commit comments

Comments
 (0)