Skip to main content

composefs_oci/
oci_image.rs

1//! OCI image and artifact storage for composefs.
2//!
3//! This module provides native OCI storage in composefs repositories. The key insight
4//! is that OCI is a simple, extensible format that can represent any content - not just
5//! container images. By standardizing on OCI, we get:
6//!
7//! - A well-defined manifest format with content-addressed blobs
8//! - Built-in support for signatures (cosign, notation)
9//! - Existing tooling (skopeo, crane, oras)
10//! - A clear GC model: manifests are roots, everything else is garbage-collectable
11//!
12//! # Storage Model
13//!
14//! ```text
15//! streams/
16//!   oci-manifest-sha256:abc...  -> objects/XX/YYY  (manifest splitstream)
17//!   oci-config-sha256:def...    -> objects/XX/YYY  (config splitstream)  
18//!   oci-layer-sha256:ghi...     -> objects/XX/YYY  (layer splitstream)
19//!   refs/
20//!     oci/
21//!       myimage:latest          -> ../../oci-manifest-sha256:abc...  (GC root!)
22//!       myimage:v1.0            -> ../../oci-manifest-sha256:xyz...
23//! ```
24//!
25//! Named references under `refs/oci/` act as GC roots. Manifests without references
26//! will be garbage collected along with their unreferenced configs and layers.
27//!
28//! # Container Images vs Artifacts
29//!
30//! Container images have:
31//! - Config with `application/vnd.oci.image.config.v1+json` mediaType
32//! - Layers that are tar archives (gzip, zstd, or uncompressed)
33//!
34//! Artifacts can have:
35//! - Any config mediaType (or empty config)
36//! - Any blob types as "layers"
37//!
38//! This module handles both transparently. Use `is_container_image()` to check.
39
40use std::collections::{HashMap, HashSet};
41use std::sync::Arc;
42
43use anyhow::{Context, Result, ensure};
44use containers_image_proxy::oci_spec::image::{
45    Descriptor, Digest as OciDigest, ImageConfiguration, ImageManifest, MediaType,
46};
47use rustix::fs::{AtFlags, Dir, Mode, OFlags, openat, readlinkat, unlinkat};
48use rustix::io::Errno;
49use serde::Serialize;
50
51use composefs::{
52    erofs::format::{FormatEpoch, FormatVersion},
53    fsverity::FsVerityHashValue,
54    generic_tree::XattrFiltering,
55    repository::Repository,
56};
57
58use crate::ContentAndVerity;
59use crate::layer::is_tar_media_type;
60use crate::skopeo::{OCI_BLOB_CONTENT_TYPE, OCI_CONFIG_CONTENT_TYPE, OCI_MANIFEST_CONTENT_TYPE};
61
62/// Error marker: an OCI reference (tag) does not exist.
63#[derive(Debug, thiserror::Error)]
64#[error("OCI reference not found: {name}")]
65pub struct OciRefNotFound {
66    /// The reference name that was not found.
67    pub name: String,
68}
69
70/// Error marker: an OCI manifest/image does not exist in the repository.
71#[derive(Debug, thiserror::Error)]
72#[error("OCI image not found: {digest}")]
73pub struct OciImageNotFound {
74    /// The manifest digest that was not found.
75    pub digest: String,
76}
77
78/// Data and named refs from a splitstream with external object storage.
79type ExternalData<ObjectID> = (Vec<u8>, HashMap<Box<str>, ObjectID>);
80
81/// Open a splitstream that stores its payload as a single external object.
82///
83/// Manifests, configs, and blobs are stored as external objects (not inline)
84/// so that fsverity can be independently enabled on the raw content. This
85/// function opens the splitstream, verifies it contains exactly one external
86/// object reference, and returns that object's data along with the stream's
87/// named refs (used for GC reachability to configs and layers).
88pub(crate) fn read_external_splitstream<ObjectID: FsVerityHashValue>(
89    repo: &Repository<ObjectID>,
90    content_id: &str,
91    verity: Option<&ObjectID>,
92    expected_content_type: Option<u64>,
93) -> Result<ExternalData<ObjectID>> {
94    let mut stream = repo.open_stream(content_id, verity, expected_content_type)?;
95
96    let mut object_refs = Vec::new();
97    stream.get_object_refs(|id| object_refs.push(id.clone()))?;
98    ensure!(
99        object_refs.len() == 1,
100        "Expected exactly 1 external object in splitstream, got {}",
101        object_refs.len()
102    );
103
104    let data = repo.read_object(&object_refs[0])?;
105    let named_refs = stream.into_named_refs();
106    Ok((data, named_refs))
107}
108
109/// Prefix for OCI image references in the repository.
110pub const OCI_REF_PREFIX: &str = "oci/";
111
112/// An OCI image or artifact stored in a composefs repository.
113///
114/// This type provides access to the complete OCI structure including
115/// manifest, config, and layer/blob references. All metadata is stored
116/// locally, eliminating network access for queries.
117#[derive(Debug)]
118pub struct OciImage<ObjectID: FsVerityHashValue> {
119    /// The manifest digest (sha256 content hash)
120    manifest_digest: OciDigest,
121    /// The parsed OCI manifest
122    manifest: ImageManifest,
123    /// The config digest (sha256 content hash)
124    config_digest: OciDigest,
125    /// The fs-verity ID of the config splitstream
126    config_verity: ObjectID,
127    /// The parsed OCI config (may be empty for artifacts)
128    config: Option<ImageConfiguration>,
129    /// Map from layer diff_id to its fs-verity object ID
130    layer_refs: HashMap<Box<str>, ObjectID>,
131    /// The V2 EROFS image ObjectID linked to this config, if any
132    image_ref: Option<ObjectID>,
133    /// The V1 EROFS image ObjectID linked to this config, if any
134    image_ref_v1: Option<ObjectID>,
135    /// Boot EROFS image refs linked to this config, keyed by their named-ref
136    /// key (which encodes both the format version and the xattr filtering
137    /// mode used to build it).
138    boot_image_refs: HashMap<Box<str>, ObjectID>,
139    /// The fs-verity ID of the manifest splitstream
140    manifest_verity: ObjectID,
141}
142
143impl<ObjectID: FsVerityHashValue> OciImage<ObjectID> {
144    /// Opens an OCI image by its manifest digest.
145    ///
146    /// If `verity` is provided, it's used directly for fast lookup.
147    /// Otherwise, the content is verified against the digest.
148    pub fn open(
149        repo: &Repository<ObjectID>,
150        manifest_digest: &OciDigest,
151        verity: Option<&ObjectID>,
152    ) -> Result<Self> {
153        let manifest_id = manifest_identifier(manifest_digest);
154        let (data, named_refs) =
155            read_external_splitstream(repo, &manifest_id, verity, Some(OCI_MANIFEST_CONTENT_TYPE))?;
156
157        // Verify content hash when no verity was provided
158        if verity.is_none() {
159            let computed = hash_sha256(&data);
160            ensure!(
161                *manifest_digest == computed,
162                "Manifest integrity failed: expected {manifest_digest}, got {computed}"
163            );
164        }
165
166        let manifest = ImageManifest::from_reader(&data[..])?;
167
168        let config_digest = manifest.config().digest().clone();
169        let config_key = format!("config:{config_digest}");
170        let config_verity = named_refs
171            .get(config_key.as_str())
172            .context("Manifest missing config reference")?
173            .clone();
174
175        let config_id = crate::config_identifier(&config_digest);
176        let (config_data, config_named_refs) = read_external_splitstream(
177            repo,
178            &config_id,
179            Some(&config_verity),
180            Some(OCI_CONFIG_CONTENT_TYPE),
181        )?;
182
183        // Try to parse as ImageConfiguration, but don't fail for artifacts
184        let (config, mut layer_refs) = match manifest.config().media_type() {
185            MediaType::ImageConfig => {
186                let config = ImageConfiguration::from_reader(&config_data[..])?;
187                (Some(config), config_named_refs)
188            }
189            _ => {
190                // Artifact - layer refs are in the manifest's named refs.
191                // Filter to only include refs matching known layer digests
192                // from the manifest, rather than removing the config key
193                // and hoping nothing else leaks through.
194                let layer_digests: HashSet<&str> = manifest
195                    .layers()
196                    .iter()
197                    .map(|d| d.digest().as_ref())
198                    .collect();
199                let refs = named_refs
200                    .into_iter()
201                    .filter(|(k, _)| layer_digests.contains(k.as_ref()))
202                    .collect();
203                (None, refs)
204            }
205        };
206
207        // Strip the EROFS image refs from layer_refs (they're not layers)
208        let image_ref = layer_refs.remove(crate::IMAGE_REF_KEY);
209        let image_ref_v1 = layer_refs.remove(crate::IMAGE_REF_KEY_V1);
210        let (boot_image_refs, layer_refs) = crate::take_boot_image_refs(layer_refs);
211
212        let manifest_verity = if let Some(v) = verity {
213            v.clone()
214        } else {
215            match repo.has_stream(&manifest_id)? {
216                Some(v) => v,
217                None => {
218                    return Err(anyhow::Error::new(OciImageNotFound {
219                        digest: manifest_digest.to_string(),
220                    }));
221                }
222            }
223        };
224
225        Ok(Self {
226            manifest_digest: manifest_digest.clone(),
227            manifest,
228            config_digest,
229            config_verity,
230            config,
231            layer_refs,
232            image_ref,
233            image_ref_v1,
234            boot_image_refs,
235            manifest_verity,
236        })
237    }
238
239    /// Opens an OCI image by its tag/reference name.
240    pub fn open_ref(repo: &Repository<ObjectID>, name: &str) -> Result<Self> {
241        let (manifest_digest, verity) = resolve_ref(repo, name)?;
242        Self::open(repo, &manifest_digest, Some(&verity))
243    }
244
245    /// Returns true if this is a container image (vs an artifact).
246    pub fn is_container_image(&self) -> bool {
247        matches!(self.manifest.config().media_type(), MediaType::ImageConfig)
248    }
249
250    /// Returns the manifest digest.
251    pub fn manifest_digest(&self) -> &OciDigest {
252        &self.manifest_digest
253    }
254
255    /// Returns the manifest fs-verity hash.
256    pub fn manifest_verity(&self) -> &ObjectID {
257        &self.manifest_verity
258    }
259
260    /// Returns the OCI manifest.
261    pub fn manifest(&self) -> &ImageManifest {
262        &self.manifest
263    }
264
265    /// Returns the config digest.
266    pub fn config_digest(&self) -> &OciDigest {
267        &self.config_digest
268    }
269
270    /// Returns the config fs-verity hash.
271    pub fn config_verity(&self) -> &ObjectID {
272        &self.config_verity
273    }
274
275    /// Returns the OCI config, if this is a container image.
276    pub fn config(&self) -> Option<&ImageConfiguration> {
277        self.config.as_ref()
278    }
279
280    /// Returns the layer refs map (diff_id → fs-verity ObjectID).
281    pub fn layer_refs(&self) -> &HashMap<Box<str>, ObjectID> {
282        &self.layer_refs
283    }
284
285    /// Returns the EROFS image ObjectID for `version`, if present.
286    ///
287    /// Maps `version` to its on-disk storage slot via [`FormatVersion::epoch`]:
288    /// epoch1 (V0/V1) resolves the V1 ref; epoch2 (V2) resolves the V2 ref.
289    /// No fallback — returns `None` if that specific format was not generated.
290    pub fn image_ref(&self, version: FormatVersion) -> Option<&ObjectID> {
291        match version.epoch() {
292            FormatEpoch::Epoch1 => self.image_ref_v1.as_ref(),
293            FormatEpoch::Epoch2 => self.image_ref.as_ref(),
294        }
295    }
296
297    /// Returns the V2 EROFS image ObjectID linked to this config, if any.
298    pub fn image_ref_v2(&self) -> Option<&ObjectID> {
299        self.image_ref.as_ref()
300    }
301
302    /// Returns the V1 EROFS image ObjectID linked to this config, if any.
303    pub fn image_ref_v1(&self) -> Option<&ObjectID> {
304        self.image_ref_v1.as_ref()
305    }
306
307    /// Returns the boot EROFS image ObjectID for `version` built with the
308    /// default ([`XattrFiltering::AllowlistOnly`]) xattr filtering mode, if
309    /// present.
310    ///
311    /// Maps `version` to its on-disk storage slot via [`FormatVersion::epoch`].
312    /// No fallback — returns `None` if that specific format was not generated.
313    pub fn boot_image_ref(&self, version: FormatVersion) -> Option<&ObjectID> {
314        self.boot_image_ref_for_mode(version, XattrFiltering::AllowlistOnly)
315    }
316
317    /// Returns the boot EROFS image ObjectID for `version` built with the
318    /// given xattr filtering `mode`, if present.
319    pub fn boot_image_ref_for_mode(
320        &self,
321        version: FormatVersion,
322        mode: XattrFiltering,
323    ) -> Option<&ObjectID> {
324        self.boot_image_refs
325            .get(&*crate::boot_image_ref_key(version, mode))
326    }
327
328    /// Returns the V2 boot EROFS image ObjectID linked to this config (default
329    /// xattr filtering mode), if any.
330    pub fn boot_image_ref_v2(&self) -> Option<&ObjectID> {
331        self.boot_image_ref(FormatVersion::V2)
332    }
333
334    /// Returns the V1 boot EROFS image ObjectID linked to this config (default
335    /// xattr filtering mode), if any.
336    pub fn boot_image_ref_v1(&self) -> Option<&ObjectID> {
337        self.boot_image_ref(FormatVersion::V1)
338    }
339
340    /// Returns all boot EROFS image refs linked to this config (every format
341    /// version and xattr filtering mode that has been cached), keyed by their
342    /// named-ref key.
343    pub(crate) fn boot_image_refs(&self) -> &HashMap<Box<str>, ObjectID> {
344        &self.boot_image_refs
345    }
346
347    /// Returns the image architecture (empty string for artifacts).
348    pub fn architecture(&self) -> String {
349        self.config
350            .as_ref()
351            .map(|c| c.architecture().to_string())
352            .unwrap_or_default()
353    }
354
355    /// Returns the image OS (empty string for artifacts).
356    pub fn os(&self) -> String {
357        self.config
358            .as_ref()
359            .map(|c| c.os().to_string())
360            .unwrap_or_default()
361    }
362
363    /// Returns the creation timestamp.
364    pub fn created(&self) -> Option<&str> {
365        self.config.as_ref().and_then(|c| c.created().as_deref())
366    }
367
368    /// Opens an artifact layer's backing object by index, returning a
369    /// read-only file descriptor to the raw blob data.
370    ///
371    /// This only works for non-tar layers (OCI artifacts). Returns an
372    /// error for tar layers — use the splitstream API for those.
373    pub fn open_layer_fd(
374        &self,
375        repo: &Repository<ObjectID>,
376        index: usize,
377    ) -> Result<rustix::fd::OwnedFd> {
378        let descriptor = self
379            .manifest
380            .layers()
381            .get(index)
382            .with_context(|| format!("Layer index {index} out of range"))?;
383
384        ensure!(
385            !is_tar_media_type(descriptor.media_type()),
386            "open_layer_fd does not support tar layers (media type: {}); \
387             use the splitstream API instead",
388            descriptor.media_type()
389        );
390
391        let diff_id = descriptor.digest();
392        let layer_verity = self
393            .layer_verity(diff_id.as_ref())
394            .with_context(|| format!("No verity for layer {diff_id}"))?;
395
396        let content_id = crate::layer_identifier(diff_id);
397        let mut stream = repo.open_stream(&content_id, Some(layer_verity), None)?;
398
399        // Artifact layers are stored as a single object; the splitstream
400        // exists only for GC tracking.
401        let mut object_refs = vec![];
402        stream.get_object_refs(|id| object_refs.push(id.clone()))?;
403        ensure!(
404            object_refs.len() == 1,
405            "Expected exactly 1 external ref for artifact layer, got {}",
406            object_refs.len()
407        );
408        repo.open_object(&object_refs[0])
409    }
410
411    /// Returns the layer diff_ids (for container images).
412    pub fn layer_diff_ids(&self) -> Vec<&str> {
413        self.config
414            .as_ref()
415            .map(|c| c.rootfs().diff_ids().iter().map(|s| s.as_str()).collect())
416            .unwrap_or_default()
417    }
418
419    /// Returns the fs-verity ID for a layer.
420    pub fn layer_verity(&self, diff_id: &str) -> Option<&ObjectID> {
421        self.layer_refs.get(diff_id)
422    }
423
424    /// Returns layer descriptors from the manifest.
425    pub fn layer_descriptors(&self) -> &[Descriptor] {
426        self.manifest.layers()
427    }
428
429    /// Returns a label from the config.
430    pub fn label(&self, key: &str) -> Option<&str> {
431        self.config.as_ref().and_then(|c| {
432            c.config()
433                .as_ref()
434                .and_then(|cfg| cfg.labels().as_ref())
435                .and_then(|labels| labels.get(key).map(|s| s.as_str()))
436        })
437    }
438
439    /// Returns all labels from the config.
440    pub fn labels(&self) -> Option<&HashMap<String, String>> {
441        self.config
442            .as_ref()
443            .and_then(|c| c.config().as_ref())
444            .and_then(|cfg| cfg.labels().as_ref())
445    }
446
447    /// Reads the raw manifest JSON bytes from the repository.
448    ///
449    /// This retrieves the original manifest JSON as stored, which may differ
450    /// slightly from re-serializing the parsed manifest (e.g., whitespace).
451    pub fn read_manifest_json(&self, repo: &Repository<ObjectID>) -> Result<Vec<u8>> {
452        let manifest_id = manifest_identifier(&self.manifest_digest);
453        let (data, _) = read_external_splitstream(
454            repo,
455            &manifest_id,
456            Some(&self.manifest_verity),
457            Some(OCI_MANIFEST_CONTENT_TYPE),
458        )?;
459        Ok(data)
460    }
461
462    /// Reads the raw config JSON bytes from the repository.
463    ///
464    /// This retrieves the original config JSON as stored, which may differ
465    /// slightly from re-serializing the parsed config (e.g., whitespace).
466    pub fn read_config_json(&self, repo: &Repository<ObjectID>) -> Result<Vec<u8>> {
467        let config_id = crate::config_identifier(&self.config_digest);
468
469        let (data, _) = read_external_splitstream(
470            repo,
471            &config_id,
472            Some(&self.config_verity),
473            Some(OCI_CONFIG_CONTENT_TYPE),
474        )?;
475        Ok(data)
476    }
477
478    /// Returns the full inspect output as a JSON value.
479    ///
480    /// This includes the manifest, config, and referrers in a single JSON object.
481    /// The manifest and config are included as their original JSON structure.
482    pub fn inspect_json(&self, repo: &Repository<ObjectID>) -> Result<serde_json::Value> {
483        let manifest_json = self.read_manifest_json(repo)?;
484        let config_json = self.read_config_json(repo)?;
485        let referrers = list_referrers(repo, &self.manifest_digest)?;
486
487        let manifest_value: serde_json::Value = serde_json::from_slice(&manifest_json)?;
488        let config_value: serde_json::Value = serde_json::from_slice(&config_json)?;
489
490        let referrers_value: Vec<serde_json::Value> = referrers
491            .iter()
492            .map(|(digest, _verity)| serde_json::json!({ "digest": digest }))
493            .collect();
494
495        let mut result = serde_json::json!({
496            "manifest": manifest_value,
497            "config": config_value,
498            "referrers": referrers_value,
499        });
500
501        if let Some(erofs_id) = self.image_ref(repo.erofs_version()) {
502            result["composefs_erofs"] = serde_json::json!(erofs_id.to_hex());
503        }
504
505        if let Some(boot_id) = self.boot_image_ref(repo.erofs_version()) {
506            result["composefs_boot_erofs"] = serde_json::json!(boot_id.to_hex());
507        }
508
509        Ok(result)
510    }
511}
512
513// =============================================================================
514// Reference Management (GC Roots)
515// =============================================================================
516
517/// Validate that a ref name doesn't start with `@`, which is reserved as
518/// the digest prefix (e.g. `@sha256:abc...`).
519fn validate_ref_name(name: &str) -> Result<()> {
520    ensure!(
521        !name.starts_with('@'),
522        "Invalid ref name {name:?}: leading '@' is reserved for digest references"
523    );
524    Ok(())
525}
526
527/// Tags an image with a name, making it a GC root.
528///
529/// The name should be in the format `image:tag` or just `image` (implies `:latest`).
530/// Names must not contain `@`, which is reserved for digest references.
531pub fn tag_image<ObjectID: FsVerityHashValue>(
532    repo: &Repository<ObjectID>,
533    manifest_digest: &OciDigest,
534    name: &str,
535) -> Result<()> {
536    validate_ref_name(name)?;
537    let manifest_id = manifest_identifier(manifest_digest);
538    let ref_name = oci_ref_path(name);
539    repo.name_stream(&manifest_id, &ref_name)
540}
541
542/// Removes a tag from an image.
543///
544/// The image data is not deleted; it becomes eligible for garbage collection
545/// if no other references point to it.
546pub fn untag_image<ObjectID: FsVerityHashValue>(
547    repo: &Repository<ObjectID>,
548    name: &str,
549) -> Result<()> {
550    let ref_path = format!("streams/refs/{}", oci_ref_path(name));
551    unlinkat(repo.repo_fd(), &ref_path, AtFlags::empty())
552        .with_context(|| format!("Failed to remove tag {name}"))?;
553    Ok(())
554}
555
556/// Resolves a reference name to (manifest_digest, verity).
557pub fn resolve_ref<ObjectID: FsVerityHashValue>(
558    repo: &Repository<ObjectID>,
559    name: &str,
560) -> Result<(OciDigest, ObjectID)> {
561    let ref_path = format!("streams/refs/{}", oci_ref_path(name));
562
563    // Read the symlink to get the manifest path
564    let target = match readlinkat(repo.repo_fd(), &ref_path, vec![]) {
565        Ok(t) => t,
566        Err(Errno::NOENT) => {
567            return Err(anyhow::Error::new(OciRefNotFound {
568                name: name.to_string(),
569            }));
570        }
571        Err(e) => {
572            return Err(e).with_context(|| format!("Reference {name} not found"));
573        }
574    };
575
576    let target_str = target
577        .to_str()
578        .context("Invalid UTF-8 in reference target")?;
579
580    // Extract manifest digest from path like "../../oci-manifest-sha256:abc"
581    let manifest_part = target_str
582        .rsplit('/')
583        .next()
584        .context("Invalid reference target")?;
585
586    let digest_str = manifest_part
587        .strip_prefix("oci-manifest-")
588        .with_context(|| format!("Invalid manifest reference: {manifest_part}"))?;
589
590    let digest: OciDigest = digest_str
591        .parse()
592        .with_context(|| format!("Invalid OCI digest in reference: {digest_str}"))?;
593
594    // Get the verity by looking up the manifest
595    let verity = repo
596        .has_stream(&manifest_identifier(&digest))?
597        .with_context(|| format!("Manifest {digest} not found"))?;
598
599    Ok((digest, verity))
600}
601
602/// Lists all tagged OCI images.
603///
604/// Returns (name, manifest_digest) pairs for each tag.
605pub fn list_refs<ObjectID: FsVerityHashValue>(
606    repo: &Repository<ObjectID>,
607) -> Result<Vec<(String, OciDigest)>> {
608    let mut refs = Vec::new();
609
610    // Use the repository's ref listing method
611    for (name, target) in repo.list_stream_refs("oci")? {
612        // Extract manifest digest from target path
613        let manifest_part = target.rsplit('/').next().unwrap_or(&target);
614        if let Some(digest_str) = manifest_part.strip_prefix("oci-manifest-")
615            && let Ok(digest) = digest_str.parse()
616        {
617            // Decode the tag name from filesystem-safe encoding
618            refs.push((decode_tag(&name), digest));
619        }
620    }
621
622    Ok(refs)
623}
624
625/// Summary information about a stored OCI image.
626/// FIXME change this to just have a struct of manifest+config JSON
627/// plus a few helper methods. We shouldn't be re-parsing created timestamp here
628/// callers should directly access that etc
629#[derive(Debug, Clone, Serialize)]
630#[serde(rename_all = "camelCase")]
631pub struct ImageInfo {
632    /// The tag/name of the image
633    pub name: String,
634    /// The manifest digest
635    pub manifest_digest: OciDigest,
636    /// Whether this is a container image (vs artifact)
637    pub is_container: bool,
638    /// Architecture (empty for artifacts)
639    pub architecture: String,
640    /// OS (empty for artifacts)
641    pub os: String,
642    /// Creation timestamp
643    pub created: Option<String>,
644    /// Number of layers/blobs
645    pub layer_count: usize,
646    /// Number of OCI referrers (signatures, attestations, etc.)
647    pub referrer_count: usize,
648}
649
650/// Lists all tagged images with their metadata.
651pub fn list_images<ObjectID: FsVerityHashValue>(
652    repo: &Repository<ObjectID>,
653) -> Result<Vec<ImageInfo>> {
654    let mut images = Vec::new();
655
656    for (name, digest) in list_refs(repo)? {
657        match OciImage::open(repo, &digest, None) {
658            Ok(img) => {
659                let referrer_count = list_referrers(repo, &digest).map(|r| r.len()).unwrap_or(0);
660                images.push(ImageInfo {
661                    name,
662                    manifest_digest: digest,
663                    is_container: img.is_container_image(),
664                    architecture: img.architecture(),
665                    os: img.os(),
666                    created: img.created().map(String::from),
667                    layer_count: img.layer_descriptors().len(),
668                    referrer_count,
669                });
670            }
671            Err(e) => {
672                tracing::warn!("skipping image {name}: {e:#}");
673                continue;
674            }
675        }
676    }
677
678    Ok(images)
679}
680
681// =============================================================================
682// Manifest Storage
683// =============================================================================
684
685/// Writes a manifest to the repository.
686///
687/// The manifest JSON is stored as an external object (not inline) so that
688/// fsverity can be independently enabled on it. This is important for signing:
689/// a signature can reference the fsverity digest of the manifest content directly.
690///
691/// The manifest becomes a GC root only if a `reference` name is provided.
692/// The reference name must not contain `@`, which is reserved for digest
693/// references.
694pub fn write_manifest<ObjectID: FsVerityHashValue, S: AsRef<str>>(
695    repo: &Arc<Repository<ObjectID>>,
696    manifest: &ImageManifest,
697    manifest_digest: &OciDigest,
698    config_verity: &ObjectID,
699    layer_verities: &[(S, ObjectID)],
700    reference: Option<&str>,
701) -> Result<ContentAndVerity<ObjectID>> {
702    if let Some(name) = reference {
703        validate_ref_name(name)?;
704    }
705
706    let content_id = manifest_identifier(manifest_digest);
707
708    if let Some(verity) = repo.has_stream(&content_id)? {
709        // Already exists - just add the reference if requested
710        if let Some(name) = reference {
711            tag_image(repo, manifest_digest, name)?;
712        }
713        return Ok((manifest_digest.clone(), verity));
714    }
715
716    let json = manifest.to_string()?;
717    let json_bytes = json.as_bytes();
718
719    let computed = hash_sha256(json_bytes);
720    ensure!(
721        *manifest_digest == computed,
722        "Manifest digest mismatch: expected {manifest_digest}, got {computed}"
723    );
724
725    let mut stream = repo.create_stream(OCI_MANIFEST_CONTENT_TYPE)?;
726
727    let config_key = format!("config:{}", manifest.config().digest());
728    stream.add_named_stream_ref(&config_key, config_verity);
729
730    for (diff_id, verity) in layer_verities {
731        stream.add_named_stream_ref(diff_id.as_ref(), verity);
732    }
733
734    stream.write_external(json_bytes)?;
735
736    let oci_ref = reference.map(oci_ref_path);
737    let id = repo.write_stream(stream, &content_id, oci_ref.as_deref())?;
738
739    Ok((computed, id))
740}
741
742/// Rewrites a manifest splitstream with updated named refs.
743///
744/// Unlike [`write_manifest`], this always writes the splitstream even if the
745/// content identifier already exists. This is needed when the manifest JSON
746/// hasn't changed but the config splitstream's verity has (e.g., because an
747/// EROFS image ref was added to the config).
748///
749/// If `reference` is provided, the manifest is also tagged with that name.
750pub(crate) fn rewrite_manifest<ObjectID: FsVerityHashValue, S: AsRef<str>>(
751    repo: &Arc<Repository<ObjectID>>,
752    manifest_json: &[u8],
753    manifest_digest: &OciDigest,
754    config_verity: &ObjectID,
755    layer_verities: &[(S, ObjectID)],
756    reference: Option<&str>,
757) -> Result<(OciDigest, ObjectID)> {
758    let content_id = manifest_identifier(manifest_digest);
759
760    let config_digest = {
761        let manifest = ImageManifest::from_reader(manifest_json)?;
762        manifest.config().digest().to_string()
763    };
764
765    let mut stream = repo.create_stream(OCI_MANIFEST_CONTENT_TYPE)?;
766
767    let config_key = format!("config:{config_digest}");
768    stream.add_named_stream_ref(&config_key, config_verity);
769
770    for (diff_id, verity) in layer_verities {
771        stream.add_named_stream_ref(diff_id.as_ref(), verity);
772    }
773
774    stream.write_external(manifest_json)?;
775
776    let oci_ref = reference.map(oci_ref_path);
777    let id = repo.write_stream(stream, &content_id, oci_ref.as_deref())?;
778
779    Ok((manifest_digest.clone(), id))
780}
781
782/// Checks if a manifest exists.
783pub fn has_manifest<ObjectID: FsVerityHashValue>(
784    repo: &Repository<ObjectID>,
785    manifest_digest: &OciDigest,
786) -> Result<Option<ObjectID>> {
787    repo.has_stream(&manifest_identifier(manifest_digest))
788}
789
790/// Returns the content identifier for a manifest.
791pub fn manifest_identifier(digest: &OciDigest) -> String {
792    format!("oci-manifest-{digest}")
793}
794
795/// Returns the reference path for an OCI name.
796fn oci_ref_path(name: &str) -> String {
797    format!("{OCI_REF_PREFIX}{}", encode_tag(name))
798}
799
800/// Encode a tag name for safe filesystem storage.
801///
802/// Uses percent-encoding for characters that are problematic in paths:
803/// - `/` becomes `%2F`
804/// - `%` becomes `%25` (must be first to avoid double-encoding)
805fn encode_tag(name: &str) -> String {
806    name.replace('%', "%25").replace('/', "%2F")
807}
808
809/// Decode a tag name from filesystem storage.
810///
811/// Uses single-pass percent decoding to avoid order-dependent replacement bugs.
812/// For example, `%252F` must decode to `%2F` (not `/`).
813fn decode_tag(encoded: &str) -> String {
814    let mut result = String::with_capacity(encoded.len());
815    let mut chars = encoded.chars().peekable();
816    while let Some(c) = chars.next() {
817        if c == '%' {
818            let hex: String = chars.by_ref().take(2).collect();
819            match hex.as_str() {
820                "2F" => result.push('/'),
821                "25" => result.push('%'),
822                _ => {
823                    result.push('%');
824                    result.push_str(&hex);
825                }
826            }
827        } else {
828            result.push(c);
829        }
830    }
831    result
832}
833
834/// Computes sha256 content hash, returning an OCI `Digest`.
835fn hash_sha256(bytes: &[u8]) -> OciDigest {
836    crate::sha256_content_digest(bytes)
837}
838
839// =============================================================================
840// Arbitrary Blob Storage (for OCI Artifacts)
841// =============================================================================
842
843/// Returns the content identifier for an arbitrary blob.
844pub fn blob_identifier(digest: &OciDigest) -> String {
845    format!("oci-blob-{digest}")
846}
847
848/// Writes an arbitrary blob to the repository.
849///
850/// This is used for OCI artifacts with non-tar media types. The blob is stored
851/// as an external object so that fsverity can be independently enabled on the
852/// raw content.
853///
854/// Returns (sha256 digest, fs-verity hash).
855pub fn write_blob<ObjectID: FsVerityHashValue>(
856    repo: &Arc<Repository<ObjectID>>,
857    data: &[u8],
858) -> Result<(OciDigest, ObjectID)> {
859    let digest = hash_sha256(data);
860    let content_id = blob_identifier(&digest);
861
862    if let Some(verity) = repo.has_stream(&content_id)? {
863        return Ok((digest, verity));
864    }
865
866    let mut stream = repo.create_stream(OCI_BLOB_CONTENT_TYPE)?;
867    stream.write_external(data)?;
868    let verity = repo.write_stream(stream, &content_id, None)?;
869
870    Ok((digest, verity))
871}
872
873/// Opens an arbitrary blob from the repository.
874///
875/// Returns the blob data. If verity is provided, it's used for fast lookup;
876/// otherwise, the content hash is verified against the digest.
877pub fn open_blob<ObjectID: FsVerityHashValue>(
878    repo: &Repository<ObjectID>,
879    digest: &OciDigest,
880    verity: Option<&ObjectID>,
881) -> Result<Vec<u8>> {
882    let content_id = blob_identifier(digest);
883    let (data, _named_refs) =
884        read_external_splitstream(repo, &content_id, verity, Some(OCI_BLOB_CONTENT_TYPE))?;
885
886    if verity.is_none() {
887        let computed = hash_sha256(&data);
888        ensure!(
889            *digest == computed,
890            "Blob integrity failed: expected {digest}, got {computed}"
891        );
892    }
893
894    Ok(data)
895}
896
897// =============================================================================
898// Referrer Index (for OCI Artifacts with subject field)
899// =============================================================================
900
901/// Prefix for referrer index references.
902const REFERRER_REF_PREFIX: &str = "oci-referrers/";
903
904/// Records a referrer relationship: an artifact references a subject image.
905///
906/// Creates a symlink at `streams/refs/oci-referrers/{subject_digest}/{artifact_digest}`
907/// pointing to the artifact's manifest stream. This enables discovery of all artifacts
908/// that reference a given image (e.g. finding all signature artifacts for an image).
909///
910/// Both digests should be in the `sha256:...` format used by OCI.
911pub fn add_referrer<ObjectID: FsVerityHashValue>(
912    repo: &Repository<ObjectID>,
913    subject_digest: &OciDigest,
914    artifact_manifest_digest: &OciDigest,
915) -> Result<()> {
916    let subject_str: &str = subject_digest.as_ref();
917    let artifact_str: &str = artifact_manifest_digest.as_ref();
918    let ref_name = format!(
919        "{REFERRER_REF_PREFIX}{}/{}",
920        encode_tag(subject_str),
921        encode_tag(artifact_str)
922    );
923    let manifest_id = manifest_identifier(artifact_manifest_digest);
924    repo.name_stream(&manifest_id, &ref_name)
925}
926
927/// Lists all artifacts that reference the given subject manifest digest.
928///
929/// Returns `(artifact_manifest_digest, artifact_manifest_verity)` pairs for
930/// each artifact that declared the subject as its referrer. The digests are
931/// in `sha256:...` format.
932pub fn list_referrers<ObjectID: FsVerityHashValue>(
933    repo: &Repository<ObjectID>,
934    subject_digest: &OciDigest,
935) -> Result<Vec<(OciDigest, ObjectID)>> {
936    let subject_str: &str = subject_digest.as_ref();
937    let prefix = format!("{REFERRER_REF_PREFIX}{}", encode_tag(subject_str));
938
939    let mut referrers = Vec::new();
940
941    for (name, target) in repo.list_stream_refs(&prefix)? {
942        // The name is the encoded artifact manifest digest
943        let artifact_digest_str = decode_tag(&name);
944
945        // Extract verity from the symlink target — it points to
946        // a manifest stream path like "../../oci-manifest-sha256:abc..."
947        let manifest_part = target.rsplit('/').next().unwrap_or(&target);
948        if let Some(digest) = manifest_part.strip_prefix("oci-manifest-") {
949            // Verify consistency: the ref name should match the target
950            if digest != artifact_digest_str {
951                continue;
952            }
953        }
954
955        // Look up the verity for this manifest
956        let artifact_digest: OciDigest = artifact_digest_str
957            .parse()
958            .with_context(|| format!("Parsing referrer digest '{artifact_digest_str}'"))?;
959        match repo.has_stream(&manifest_identifier(&artifact_digest))? {
960            Some(verity) => referrers.push((artifact_digest, verity)),
961            None => {
962                continue;
963            }
964        }
965    }
966
967    Ok(referrers)
968}
969
970/// Removes a specific referrer index entry.
971///
972/// Idempotent — returns Ok if the entry doesn't exist.
973pub fn remove_referrer<ObjectID: FsVerityHashValue>(
974    repo: &Repository<ObjectID>,
975    subject_digest: &OciDigest,
976    artifact_digest: &OciDigest,
977) -> Result<()> {
978    let subject_str: &str = subject_digest.as_ref();
979    let artifact_str: &str = artifact_digest.as_ref();
980    let ref_path = format!(
981        "streams/refs/{REFERRER_REF_PREFIX}{}/{}",
982        encode_tag(subject_str),
983        encode_tag(artifact_str)
984    );
985    match unlinkat(repo.repo_fd(), &ref_path, AtFlags::empty()) {
986        Ok(()) => Ok(()),
987        Err(Errno::NOENT) => Ok(()),
988        Err(e) => Err(e).with_context(|| format!("Failed to remove referrer {artifact_digest}")),
989    }
990}
991
992/// Removes all referrer index entries for a subject.
993///
994/// Removes each referrer symlink and tries to remove the empty subject
995/// directory afterwards. Idempotent — returns Ok if no entries exist.
996pub fn remove_referrers_for_subject<ObjectID: FsVerityHashValue>(
997    repo: &Repository<ObjectID>,
998    subject_digest: &OciDigest,
999) -> Result<()> {
1000    let referrers = list_referrers(repo, subject_digest)?;
1001    for (artifact_digest, _verity) in &referrers {
1002        remove_referrer(repo, subject_digest, artifact_digest)?;
1003    }
1004    // Try to remove the now-empty subject directory (ignore errors)
1005    let subject_str: &str = subject_digest.as_ref();
1006    let subject_dir = format!(
1007        "streams/refs/{REFERRER_REF_PREFIX}{}",
1008        encode_tag(subject_str)
1009    );
1010    let _ = unlinkat(repo.repo_fd(), &subject_dir, AtFlags::REMOVEDIR);
1011    Ok(())
1012}
1013
1014/// Removes referrer index entries whose subject manifest no longer exists.
1015///
1016/// When a subject image is untagged and garbage collected, its referrer
1017/// artifacts become orphaned — their referrer symlinks under
1018/// `streams/refs/oci-referrers/{subject_digest}/` still act as GC roots,
1019/// preventing the artifact manifests from being collected.
1020///
1021/// Call this **before** running GC to ensure orphaned referrer artifacts
1022/// are also eligible for collection. The typical workflow is:
1023///
1024/// ```text
1025/// cleanup_dangling_referrers(&repo)?;
1026/// repo.gc(&[])?;
1027/// ```
1028///
1029/// Returns the number of referrer entries removed.
1030pub fn cleanup_dangling_referrers<ObjectID: FsVerityHashValue>(
1031    repo: &Repository<ObjectID>,
1032) -> Result<u64> {
1033    let referrers_path = format!("streams/refs/{REFERRER_REF_PREFIX}");
1034
1035    // Open the oci-referrers directory; if it doesn't exist, there's nothing to do
1036    let referrers_dir = match openat(
1037        repo.repo_fd(),
1038        &*referrers_path,
1039        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1040        Mode::empty(),
1041    ) {
1042        Ok(fd) => fd,
1043        Err(Errno::NOENT) => return Ok(0),
1044        Err(e) => return Err(e).context("Opening oci-referrers directory")?,
1045    };
1046
1047    let mut removed = 0u64;
1048
1049    // Collect subject directory names first to avoid borrowing issues
1050    let mut subject_dirs = Vec::new();
1051    for item in Dir::read_from(&referrers_dir).context("Reading oci-referrers directory")? {
1052        let entry = item.context("Reading oci-referrers entry")?;
1053        let name = entry.file_name();
1054        if name == c"." || name == c".." {
1055            continue;
1056        }
1057        if let Ok(s) = std::str::from_utf8(name.to_bytes()) {
1058            subject_dirs.push(s.to_string());
1059        }
1060    }
1061
1062    for encoded_subject in &subject_dirs {
1063        let subject_digest_str = decode_tag(encoded_subject);
1064        let subject_digest: OciDigest = subject_digest_str
1065            .parse()
1066            .with_context(|| format!("Parsing subject digest '{subject_digest_str}'"))?;
1067
1068        // Check if the subject manifest still exists in the repository
1069        if has_manifest(repo, &subject_digest)?.is_some() {
1070            continue;
1071        }
1072
1073        // Subject is gone — remove all referrer entries in this directory
1074        let subject_dir_fd = match openat(
1075            &referrers_dir,
1076            encoded_subject.as_str(),
1077            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1078            Mode::empty(),
1079        ) {
1080            Ok(fd) => fd,
1081            Err(Errno::NOENT) => continue,
1082            Err(e) => {
1083                return Err(e)
1084                    .context(format!("Opening referrer subject dir {encoded_subject}"))?;
1085            }
1086        };
1087
1088        for item in Dir::read_from(&subject_dir_fd).context("Reading referrer subject directory")? {
1089            let entry = item.context("Reading referrer entry")?;
1090            let name = entry.file_name();
1091            if name == c"." || name == c".." {
1092                continue;
1093            }
1094            unlinkat(&subject_dir_fd, name, AtFlags::empty())
1095                .with_context(|| format!("Removing referrer entry {name:?}"))?;
1096            removed += 1;
1097        }
1098
1099        // Remove the now-empty subject directory
1100        unlinkat(&referrers_dir, encoded_subject.as_str(), AtFlags::REMOVEDIR)
1101            .with_context(|| format!("Removing empty referrer subject dir {encoded_subject}"))?;
1102    }
1103
1104    Ok(removed)
1105}
1106
1107// =============================================================================
1108// Filesystem Consistency Checks (fsck)
1109// =============================================================================
1110
1111/// A structured error found during an OCI-level consistency check.
1112///
1113/// Each variant corresponds to a specific kind of OCI metadata integrity
1114/// problem. The `Display` implementation produces a kebab-case error type
1115/// prefix followed by the image name/context and any relevant details.
1116#[derive(Debug, Clone, serde::Serialize, thiserror::Error)]
1117#[serde(tag = "type", rename_all = "kebab-case")]
1118#[non_exhaustive]
1119#[allow(missing_docs)]
1120pub enum OciFsckError {
1121    #[error("fsck: manifest-read-failed: {name}: {detail}")]
1122    ManifestReadFailed { name: String, detail: String },
1123
1124    #[error("fsck: manifest-digest-mismatch: {name}: expected {expected}, got {actual}")]
1125    ManifestDigestMismatch {
1126        name: String,
1127        expected: String,
1128        actual: String,
1129    },
1130
1131    #[error("fsck: manifest-parse-failed: {name}: {detail}")]
1132    ManifestParseFailed { name: String, detail: String },
1133
1134    #[error("fsck: config-ref-missing: {name}: {digest}")]
1135    ConfigRefMissing { name: String, digest: String },
1136
1137    #[error("fsck: config-read-failed: {name}: {detail}")]
1138    ConfigReadFailed { name: String, detail: String },
1139
1140    #[error("fsck: config-digest-mismatch: {name}: expected {expected}, got {actual}")]
1141    ConfigDigestMismatch {
1142        name: String,
1143        expected: String,
1144        actual: String,
1145    },
1146
1147    #[error("fsck: config-parse-failed: {name}: {detail}")]
1148    ConfigParseFailed { name: String, detail: String },
1149
1150    #[error("fsck: layer-ref-missing: {name}: {diff_id}")]
1151    #[serde(rename_all = "camelCase")]
1152    LayerRefMissing { name: String, diff_id: String },
1153
1154    #[error("fsck: layer-stream-missing: {name}: {diff_id}")]
1155    #[serde(rename_all = "camelCase")]
1156    LayerStreamMissing { name: String, diff_id: String },
1157
1158    #[error("fsck: layer-check-failed: {name}: {diff_id}: {detail}")]
1159    #[serde(rename_all = "camelCase")]
1160    LayerCheckFailed {
1161        name: String,
1162        diff_id: String,
1163        detail: String,
1164    },
1165
1166    #[error("fsck: layer-object-missing: {name}: {diff_id}: {detail}")]
1167    #[serde(rename_all = "camelCase")]
1168    LayerObjectMissing {
1169        name: String,
1170        diff_id: String,
1171        detail: String,
1172    },
1173
1174    #[error("fsck: seal-image-missing: {name}: {digest}: {detail}")]
1175    SealImageMissing {
1176        name: String,
1177        digest: String,
1178        detail: String,
1179    },
1180
1181    #[error("fsck: artifact-layer-ref-missing: {name}: {digest}")]
1182    ArtifactLayerRefMissing { name: String, digest: String },
1183
1184    #[error("fsck: artifact-layer-object-missing: {name}: {digest}: {detail}")]
1185    ArtifactLayerObjectMissing {
1186        name: String,
1187        digest: String,
1188        detail: String,
1189    },
1190
1191    #[error("fsck: ref-resolve-failed: {name}: {detail}")]
1192    RefResolveFailed { name: String, detail: String },
1193
1194    #[error("fsck: invalid-ref-name: {name}: leading '@' is reserved for digest references")]
1195    InvalidRefName { name: String },
1196}
1197
1198/// Results from an OCI-level filesystem consistency check.
1199///
1200/// Returned by [`oci_fsck`] and [`oci_fsck_image`] to report integrity status
1201/// of OCI images stored in the repository. This includes checks at both the
1202/// OCI metadata level (manifest/config digests, layer references) and the
1203/// underlying repository level (object integrity, splitstream validity).
1204#[derive(Debug, Clone, Default, Serialize)]
1205#[serde(rename_all = "camelCase")]
1206pub struct OciFsckResult {
1207    pub(crate) repo_result: composefs::repository::FsckResult,
1208    pub(crate) images_checked: u64,
1209    pub(crate) images_corrupted: u64,
1210    pub(crate) errors: Vec<OciFsckError>,
1211}
1212
1213impl OciFsckResult {
1214    /// Returns true if no corruption or errors were found at any level.
1215    pub fn is_ok(&self) -> bool {
1216        debug_assert!(
1217            self.images_corrupted == 0 || !self.errors.is_empty(),
1218            "images_corrupted is non-zero but no OCI error messages recorded"
1219        );
1220        self.repo_result.is_ok() && self.errors.is_empty()
1221    }
1222
1223    /// Results from the underlying repository fsck.
1224    pub fn repo_result(&self) -> &composefs::repository::FsckResult {
1225        &self.repo_result
1226    }
1227
1228    /// Number of OCI images checked.
1229    pub fn images_checked(&self) -> u64 {
1230        self.images_checked
1231    }
1232
1233    /// Number of OCI images with issues.
1234    pub fn images_corrupted(&self) -> u64 {
1235        self.images_corrupted
1236    }
1237
1238    /// OCI-level errors found during the check.
1239    pub fn errors(&self) -> &[OciFsckError] {
1240        &self.errors
1241    }
1242}
1243
1244impl std::fmt::Display for OciFsckResult {
1245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1246        write!(f, "{}", self.repo_result)?;
1247        writeln!(
1248            f,
1249            "oci images: {}/{} ok",
1250            self.images_checked.saturating_sub(self.images_corrupted),
1251            self.images_checked
1252        )?;
1253        if !self.errors.is_empty() {
1254            writeln!(f, "oci errors: {}", self.errors.len())?;
1255            for err in &self.errors {
1256                writeln!(f, "  - {err}")?;
1257            }
1258        }
1259        Ok(())
1260    }
1261}
1262
1263/// Run a full OCI-aware consistency check on the repository.
1264///
1265/// This performs the underlying repository fsck (object integrity, splitstream
1266/// validation, symlink checks) and then additionally validates all tagged OCI
1267/// images: manifest digest verification, config digest verification, layer
1268/// reference existence, and seal consistency.
1269pub async fn oci_fsck<ObjectID: FsVerityHashValue>(
1270    repo: &Repository<ObjectID>,
1271) -> Result<OciFsckResult> {
1272    let repo_result = repo.fsck().await?;
1273    let mut result = OciFsckResult {
1274        repo_result,
1275        ..Default::default()
1276    };
1277
1278    // Check all tagged OCI images
1279    let refs = list_refs(repo).context("listing OCI refs")?;
1280    for (name, manifest_digest) in refs {
1281        if name.starts_with('@') {
1282            result.images_checked += 1;
1283            result.images_corrupted += 1;
1284            result
1285                .errors
1286                .push(OciFsckError::InvalidRefName { name: name.clone() });
1287            continue;
1288        }
1289        fsck_single_image(repo, &name, &manifest_digest, &mut result);
1290    }
1291
1292    Ok(result)
1293}
1294
1295/// Run an OCI-aware consistency check on a single image by tag name.
1296///
1297/// Performs the underlying repository fsck, then validates the specified image.
1298pub async fn oci_fsck_image<ObjectID: FsVerityHashValue>(
1299    repo: &Repository<ObjectID>,
1300    name: &str,
1301) -> Result<OciFsckResult> {
1302    let repo_result = repo.fsck().await?;
1303    let mut result = OciFsckResult {
1304        repo_result,
1305        ..Default::default()
1306    };
1307
1308    let (manifest_digest, _verity) = match resolve_ref(repo, name) {
1309        Ok(v) => v,
1310        Err(e) => {
1311            result.images_corrupted += 1;
1312            result.images_checked += 1;
1313            result.errors.push(OciFsckError::RefResolveFailed {
1314                name: name.to_string(),
1315                detail: e.to_string(),
1316            });
1317            return Ok(result);
1318        }
1319    };
1320
1321    fsck_single_image(repo, name, &manifest_digest, &mut result);
1322    Ok(result)
1323}
1324
1325/// Internal: validate a single OCI image's metadata integrity.
1326fn fsck_single_image<ObjectID: FsVerityHashValue>(
1327    repo: &Repository<ObjectID>,
1328    name: &str,
1329    manifest_digest: &OciDigest,
1330    result: &mut OciFsckResult,
1331) {
1332    result.images_checked += 1;
1333    let error_count_before = result.errors.len();
1334
1335    // 1. Verify manifest content hash
1336    let manifest_id = manifest_identifier(manifest_digest);
1337    let (manifest_data, manifest_named_refs) = match read_external_splitstream(
1338        repo,
1339        &manifest_id,
1340        None,
1341        Some(OCI_MANIFEST_CONTENT_TYPE),
1342    ) {
1343        Ok(v) => v,
1344        Err(e) => {
1345            result.images_corrupted += 1;
1346            result.errors.push(OciFsckError::ManifestReadFailed {
1347                name: name.to_string(),
1348                detail: e.to_string(),
1349            });
1350            return;
1351        }
1352    };
1353
1354    let computed_digest = hash_sha256(&manifest_data);
1355    if *manifest_digest != computed_digest {
1356        result.images_corrupted += 1;
1357        result.errors.push(OciFsckError::ManifestDigestMismatch {
1358            name: name.to_string(),
1359            expected: manifest_digest.to_string(),
1360            actual: computed_digest.to_string(),
1361        });
1362        return;
1363    }
1364
1365    // 2. Parse manifest
1366    let manifest = match ImageManifest::from_reader(&manifest_data[..]) {
1367        Ok(m) => m,
1368        Err(e) => {
1369            result.images_corrupted += 1;
1370            result.errors.push(OciFsckError::ManifestParseFailed {
1371                name: name.to_string(),
1372                detail: e.to_string(),
1373            });
1374            return;
1375        }
1376    };
1377
1378    // 3. Verify config reference exists in manifest's named refs
1379    let config_digest = manifest.config().digest().clone();
1380    let config_key = format!("config:{config_digest}");
1381    let config_verity = match manifest_named_refs.get(config_key.as_str()) {
1382        Some(v) => v.clone(),
1383        None => {
1384            result.images_corrupted += 1;
1385            result.errors.push(OciFsckError::ConfigRefMissing {
1386                name: name.to_string(),
1387                digest: config_digest.to_string(),
1388            });
1389            return;
1390        }
1391    };
1392
1393    // 4. Verify config content hash
1394    let config_id = crate::config_identifier(&config_digest);
1395    let (config_data, config_named_refs) = match read_external_splitstream(
1396        repo,
1397        &config_id,
1398        Some(&config_verity),
1399        Some(OCI_CONFIG_CONTENT_TYPE),
1400    ) {
1401        Ok(v) => v,
1402        Err(e) => {
1403            result.images_corrupted += 1;
1404            result.errors.push(OciFsckError::ConfigReadFailed {
1405                name: name.to_string(),
1406                detail: e.to_string(),
1407            });
1408            return;
1409        }
1410    };
1411
1412    let computed_config = hash_sha256(&config_data);
1413    if config_digest != computed_config {
1414        result.images_corrupted += 1;
1415        result.errors.push(OciFsckError::ConfigDigestMismatch {
1416            name: name.to_string(),
1417            expected: config_digest.to_string(),
1418            actual: computed_config.to_string(),
1419        });
1420        return;
1421    }
1422
1423    // 5. Parse config and verify layer references
1424    let is_container = matches!(manifest.config().media_type(), MediaType::ImageConfig);
1425
1426    if is_container {
1427        let config = match ImageConfiguration::from_reader(&config_data[..]) {
1428            Ok(c) => c,
1429            Err(e) => {
1430                result.images_corrupted += 1;
1431                result.errors.push(OciFsckError::ConfigParseFailed {
1432                    name: name.to_string(),
1433                    detail: e.to_string(),
1434                });
1435                return;
1436            }
1437        };
1438
1439        // Verify each layer diff_id has a corresponding named ref and stream
1440        for diff_id_str in config.rootfs().diff_ids() {
1441            let layer_verity = match config_named_refs.get(diff_id_str.as_str()) {
1442                Some(v) => v,
1443                None => {
1444                    result.errors.push(OciFsckError::LayerRefMissing {
1445                        name: name.to_string(),
1446                        diff_id: diff_id_str.to_string(),
1447                    });
1448                    continue;
1449                }
1450            };
1451
1452            let diff_id: OciDigest = match diff_id_str.parse() {
1453                Ok(d) => d,
1454                Err(e) => {
1455                    result.errors.push(OciFsckError::LayerCheckFailed {
1456                        name: name.to_string(),
1457                        diff_id: diff_id_str.to_string(),
1458                        detail: format!("Invalid diff_id: {e}"),
1459                    });
1460                    continue;
1461                }
1462            };
1463
1464            // Check the layer stream exists
1465            let layer_id = crate::layer_identifier(&diff_id);
1466            match repo.has_stream(&layer_id) {
1467                Ok(Some(_)) => {}
1468                Ok(None) => {
1469                    result.errors.push(OciFsckError::LayerStreamMissing {
1470                        name: name.to_string(),
1471                        diff_id: diff_id.to_string(),
1472                    });
1473                }
1474                Err(e) => {
1475                    result.errors.push(OciFsckError::LayerCheckFailed {
1476                        name: name.to_string(),
1477                        diff_id: diff_id.to_string(),
1478                        detail: e.to_string(),
1479                    });
1480                }
1481            }
1482
1483            // Verify the layer's object exists
1484            match repo.open_object(layer_verity) {
1485                Ok(_) => {}
1486                Err(e) => {
1487                    result.errors.push(OciFsckError::LayerObjectMissing {
1488                        name: name.to_string(),
1489                        diff_id: diff_id.to_string(),
1490                        detail: e.to_string(),
1491                    });
1492                }
1493            }
1494        }
1495
1496        // 6. If sealed, verify the seal image exists
1497        if let Some(seal_digest) = config.get_config_annotation("containers.composefs.fsverity") {
1498            match repo.open_image(seal_digest) {
1499                Ok(_) => {}
1500                Err(e) => {
1501                    result.errors.push(OciFsckError::SealImageMissing {
1502                        name: name.to_string(),
1503                        digest: seal_digest.to_string(),
1504                        detail: e.to_string(),
1505                    });
1506                }
1507            }
1508        }
1509    } else {
1510        // Artifact: verify layer references from manifest named refs
1511        for layer_desc in manifest.layers() {
1512            let layer_digest = layer_desc.digest().to_string();
1513            match manifest_named_refs.get(layer_digest.as_str()) {
1514                Some(verity) => {
1515                    // Verify the layer object exists
1516                    match repo.open_object(verity) {
1517                        Ok(_) => {}
1518                        Err(e) => {
1519                            result
1520                                .errors
1521                                .push(OciFsckError::ArtifactLayerObjectMissing {
1522                                    name: name.to_string(),
1523                                    digest: layer_digest,
1524                                    detail: e.to_string(),
1525                                });
1526                        }
1527                    }
1528                }
1529                None => {
1530                    result.errors.push(OciFsckError::ArtifactLayerRefMissing {
1531                        name: name.to_string(),
1532                        digest: layer_digest,
1533                    });
1534                }
1535            }
1536        }
1537    }
1538
1539    // Count at most once per image
1540    if result.errors.len() > error_count_before {
1541        result.images_corrupted += 1;
1542    }
1543}
1544
1545// =============================================================================
1546// Layer Inspection
1547// =============================================================================
1548
1549/// Metadata about a layer stored in the repository.
1550#[derive(Debug, Clone, Serialize)]
1551#[serde(rename_all = "camelCase")]
1552pub struct LayerInfo {
1553    /// The layer diff_id (sha256 hash of uncompressed content)
1554    pub diff_id: String,
1555    /// The fs-verity hash of the layer splitstream
1556    pub verity: String,
1557    /// Size of the uncompressed tar layer in bytes
1558    pub size: u64,
1559    /// Number of files/entries in the layer
1560    pub entry_count: usize,
1561    /// Splitstream metadata
1562    pub splitstream: SplitstreamInfo,
1563}
1564
1565/// Metadata about the splitstream representation of a layer.
1566#[derive(Debug, Clone, Serialize)]
1567#[serde(rename_all = "camelCase")]
1568pub struct SplitstreamInfo {
1569    /// Number of external object references (large files stored separately)
1570    pub external_objects: usize,
1571    /// Total size of external objects in bytes
1572    pub external_size: u64,
1573    /// Size of inline data in bytes (small files + tar headers)
1574    pub inline_size: u64,
1575}
1576
1577/// Opens a layer by its diff_id and returns metadata about it.
1578///
1579/// The diff_id should be in the `sha256:...` format used by OCI.
1580pub fn layer_info<ObjectID: FsVerityHashValue>(
1581    repo: &Repository<ObjectID>,
1582    diff_id: &OciDigest,
1583) -> Result<LayerInfo> {
1584    let content_id = crate::layer_identifier(diff_id);
1585    let verity = repo
1586        .has_stream(&content_id)?
1587        .with_context(|| format!("Layer {diff_id} not found"))?;
1588
1589    let mut stream = repo.open_stream(
1590        &content_id,
1591        Some(&verity),
1592        Some(crate::skopeo::TAR_LAYER_CONTENT_TYPE),
1593    )?;
1594
1595    // Get the total size from the splitstream header (this is the merged/tar size)
1596    let size = stream.total_size;
1597
1598    // Count external object references (this doesn't consume the stream)
1599    let mut external_objects = 0usize;
1600    stream.get_object_refs(|_| external_objects += 1)?;
1601
1602    // Iterate entries and gather sizes
1603    let mut entry_count = 0usize;
1604    let mut external_size = 0u64;
1605
1606    while let Some(entry) = crate::tar::get_entry(&mut stream)? {
1607        entry_count += 1;
1608        if let crate::tar::TarItem::Leaf(composefs::tree::LeafContent::Regular(
1609            composefs::tree::RegularFile::External(_, file_size),
1610        )) = entry.item
1611        {
1612            external_size += file_size;
1613        }
1614    }
1615
1616    // inline_size includes tar headers, small files, and other metadata
1617    let inline_size = size.saturating_sub(external_size);
1618
1619    Ok(LayerInfo {
1620        diff_id: diff_id.to_string(),
1621        verity: verity.to_hex(),
1622        size,
1623        entry_count,
1624        splitstream: SplitstreamInfo {
1625            external_objects,
1626            external_size,
1627            inline_size,
1628        },
1629    })
1630}
1631
1632/// Writes the layer contents in composefs dumpfile format.
1633///
1634/// Each entry is written on its own line in the composefs dumpfile format,
1635/// which includes path, size, mode, ownership, timestamps, and content references.
1636pub fn layer_dumpfile<ObjectID: FsVerityHashValue>(
1637    repo: &Repository<ObjectID>,
1638    diff_id: &OciDigest,
1639    output: &mut impl std::io::Write,
1640) -> Result<()> {
1641    let content_id = crate::layer_identifier(diff_id);
1642    let verity = repo
1643        .has_stream(&content_id)?
1644        .with_context(|| format!("Layer {diff_id} not found"))?;
1645
1646    let mut stream = repo.open_stream(
1647        &content_id,
1648        Some(&verity),
1649        Some(crate::skopeo::TAR_LAYER_CONTENT_TYPE),
1650    )?;
1651
1652    while let Some(entry) = crate::tar::get_entry(&mut stream)? {
1653        writeln!(output, "{entry}")?;
1654    }
1655
1656    Ok(())
1657}
1658
1659/// Reconstitutes and writes the original tar layer.
1660///
1661/// This merges the splitstream back into the original tar format by
1662/// combining inline data with external object references.
1663pub fn layer_tar<ObjectID: FsVerityHashValue>(
1664    repo: &Repository<ObjectID>,
1665    diff_id: &OciDigest,
1666    output: &mut impl std::io::Write,
1667) -> Result<()> {
1668    let content_id = crate::layer_identifier(diff_id);
1669    let verity = repo
1670        .has_stream(&content_id)?
1671        .with_context(|| format!("Layer {diff_id} not found"))?;
1672
1673    repo.merge_splitstream(
1674        &content_id,
1675        Some(&verity),
1676        Some(crate::skopeo::TAR_LAYER_CONTENT_TYPE),
1677        output,
1678    )
1679}
1680
1681#[cfg(test)]
1682mod test {
1683    use super::*;
1684    use composefs::fsverity::Sha256HashValue;
1685    use composefs::test::TestRepo;
1686    use containers_image_proxy::oci_spec::image::{
1687        ConfigBuilder, DescriptorBuilder, ImageConfigurationBuilder, ImageManifestBuilder,
1688        RootFsBuilder,
1689    };
1690    use std::fs::File;
1691    use std::io::Read;
1692
1693    /// Helper to create a synthetic container image in the repository.
1694    ///
1695    /// Creates a minimal but valid container image with:
1696    /// - A single "layer" (stored as an external object)
1697    /// - Proper OCI manifest and config structure
1698    /// - Optional tag
1699    ///
1700    /// Returns (manifest_digest, manifest_verity, config_digest).
1701    fn create_test_image(
1702        repo: &Arc<Repository<Sha256HashValue>>,
1703        tag: Option<&str>,
1704        arch: &str,
1705    ) -> (OciDigest, Sha256HashValue, OciDigest) {
1706        // Create a fake layer - in real usage this would be a tar splitstream
1707        // For testing the manifest/config storage, we just need valid references
1708        let layer_data = format!("fake-layer-{arch}").into_bytes();
1709        let layer_digest = hash_sha256(&layer_data);
1710
1711        let mut layer_stream = repo
1712            .create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE)
1713            .unwrap();
1714        layer_stream.write_external(&layer_data).unwrap();
1715        let layer_verity = repo
1716            .write_stream(layer_stream, &crate::layer_identifier(&layer_digest), None)
1717            .unwrap();
1718
1719        let rootfs = RootFsBuilder::default()
1720            .typ("layers")
1721            .diff_ids(vec![layer_digest.to_string()])
1722            .build()
1723            .unwrap();
1724
1725        let cfg = ConfigBuilder::default().build().unwrap();
1726
1727        let config = ImageConfigurationBuilder::default()
1728            .architecture(arch)
1729            .os("linux")
1730            .rootfs(rootfs)
1731            .config(cfg)
1732            .build()
1733            .unwrap();
1734
1735        let config_json = config.to_string().unwrap();
1736        let config_digest = hash_sha256(config_json.as_bytes());
1737
1738        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
1739        config_stream.add_named_stream_ref(layer_digest.as_ref(), &layer_verity);
1740        config_stream
1741            .write_external(config_json.as_bytes())
1742            .unwrap();
1743        let config_verity = repo
1744            .write_stream(
1745                config_stream,
1746                &crate::config_identifier(&config_digest),
1747                None,
1748            )
1749            .unwrap();
1750
1751        let config_descriptor = DescriptorBuilder::default()
1752            .media_type(MediaType::ImageConfig)
1753            .digest(config_digest.clone())
1754            .size(config_json.len() as u64)
1755            .build()
1756            .unwrap();
1757
1758        let layer_descriptor = DescriptorBuilder::default()
1759            .media_type(MediaType::ImageLayerGzip)
1760            .digest(layer_digest.clone())
1761            .size(layer_data.len() as u64)
1762            .build()
1763            .unwrap();
1764
1765        let manifest = ImageManifestBuilder::default()
1766            .schema_version(2u32)
1767            .media_type(MediaType::ImageManifest)
1768            .config(config_descriptor)
1769            .layers(vec![layer_descriptor])
1770            .build()
1771            .unwrap();
1772
1773        let layer_verities = [(layer_digest, layer_verity)];
1774
1775        let manifest_json = manifest.to_string().unwrap();
1776        let manifest_digest = hash_sha256(manifest_json.as_bytes());
1777
1778        let (_stored_digest, manifest_verity) = write_manifest(
1779            repo,
1780            &manifest,
1781            &manifest_digest,
1782            &config_verity,
1783            &layer_verities,
1784            tag,
1785        )
1786        .unwrap();
1787
1788        (manifest_digest, manifest_verity, config_digest)
1789    }
1790
1791    #[test]
1792    fn test_manifest_identifier() {
1793        let digest: OciDigest =
1794            "sha256:abc1230000000000000000000000000000000000000000000000000000000000"
1795                .parse()
1796                .unwrap();
1797        assert_eq!(
1798            manifest_identifier(&digest),
1799            "oci-manifest-sha256:abc1230000000000000000000000000000000000000000000000000000000000"
1800        );
1801    }
1802
1803    #[test]
1804    fn test_oci_ref_path() {
1805        assert_eq!(oci_ref_path("myimage:latest"), "oci/myimage:latest");
1806        // Slashes get encoded
1807        assert_eq!(oci_ref_path("library/nginx"), "oci/library%2Fnginx");
1808        assert_eq!(oci_ref_path("docker://busybox"), "oci/docker:%2F%2Fbusybox");
1809    }
1810
1811    #[test]
1812    fn test_encode_decode_tag() {
1813        // Simple names pass through
1814        assert_eq!(encode_tag("myimage:latest"), "myimage:latest");
1815        assert_eq!(decode_tag("myimage:latest"), "myimage:latest");
1816
1817        // Slashes get encoded
1818        assert_eq!(encode_tag("library/nginx"), "library%2Fnginx");
1819        assert_eq!(decode_tag("library%2Fnginx"), "library/nginx");
1820
1821        // Double slashes
1822        assert_eq!(encode_tag("docker://busybox"), "docker:%2F%2Fbusybox");
1823        assert_eq!(decode_tag("docker:%2F%2Fbusybox"), "docker://busybox");
1824
1825        // Percent signs get encoded first to avoid conflicts
1826        assert_eq!(encode_tag("test%2F"), "test%252F");
1827        assert_eq!(decode_tag("test%252F"), "test%2F");
1828
1829        // Round-trip including tricky inputs where order-dependent
1830        // replacement would produce wrong results
1831        let names = [
1832            "simple",
1833            "with:tag",
1834            "registry.io/image:v1",
1835            "docker://busybox:latest",
1836            "containers-storage:myimage",
1837            "weird%name/with/slashes",
1838            "%2F",
1839            "a/b%c",
1840            "100%",
1841            "normal:tag",
1842            "%25already-encoded",
1843            "double%%percent",
1844        ];
1845        for name in names {
1846            assert_eq!(
1847                decode_tag(&encode_tag(name)),
1848                name,
1849                "round-trip failed for {name}"
1850            );
1851        }
1852    }
1853
1854    #[test]
1855    fn test_hash_sha256() {
1856        assert_eq!(
1857            hash_sha256(b"hello world").as_ref(),
1858            "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1859        );
1860    }
1861
1862    #[test]
1863    fn test_blob_identifier() {
1864        let digest: OciDigest =
1865            "sha256:abc1230000000000000000000000000000000000000000000000000000000000"
1866                .parse()
1867                .unwrap();
1868        assert_eq!(
1869            blob_identifier(&digest),
1870            "oci-blob-sha256:abc1230000000000000000000000000000000000000000000000000000000000"
1871        );
1872    }
1873
1874    #[test]
1875    fn test_write_and_read_blob() {
1876        let test_repo = TestRepo::<Sha256HashValue>::new();
1877        let repo = &test_repo.repo;
1878
1879        let data = b"This is some arbitrary blob data for an OCI artifact.";
1880        let (digest, verity) = write_blob(repo, data).unwrap();
1881
1882        assert!(digest.as_ref().starts_with("sha256:"));
1883
1884        // Read back with verity (fast path)
1885        let read_data = open_blob(&repo, &digest, Some(&verity)).unwrap();
1886        assert_eq!(read_data, data);
1887
1888        // Read back without verity (verifies content hash)
1889        let read_data2 = open_blob(&repo, &digest, None).unwrap();
1890        assert_eq!(read_data2, data);
1891    }
1892
1893    #[test]
1894    fn test_write_blob_deduplication() {
1895        let test_repo = TestRepo::<Sha256HashValue>::new();
1896        let repo = &test_repo.repo;
1897
1898        let data = b"duplicate blob content";
1899
1900        let (digest1, verity1) = write_blob(repo, data).unwrap();
1901        let (digest2, verity2) = write_blob(repo, data).unwrap();
1902
1903        assert_eq!(digest1, digest2);
1904        assert_eq!(verity1, verity2);
1905    }
1906
1907    #[test]
1908    fn test_open_blob_bad_digest() {
1909        let test_repo = TestRepo::<Sha256HashValue>::new();
1910        let repo = &test_repo.repo;
1911
1912        let data = b"some blob data";
1913        let (_digest, _verity) = write_blob(repo, data).unwrap();
1914
1915        let bad_digest: OciDigest =
1916            "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1917                .parse()
1918                .unwrap();
1919        let result = open_blob::<Sha256HashValue>(&repo, &bad_digest, None);
1920        assert!(result.is_err());
1921    }
1922
1923    /// Verify that manifest JSON is stored as an external object, not inline.
1924    ///
1925    /// External storage gives each manifest its own file in objects/, allowing
1926    /// fsverity to be independently enabled on the raw content. This is a
1927    /// prerequisite for signing: a signature can reference the fsverity digest
1928    /// of the manifest bytes directly.
1929    #[test]
1930    fn test_manifest_stored_as_external_object() {
1931        let test_repo = TestRepo::<Sha256HashValue>::new();
1932        let repo = &test_repo.repo;
1933
1934        let (manifest_digest, manifest_verity, _) =
1935            create_test_image(repo, Some("ext-test"), "amd64");
1936
1937        let manifest_id = manifest_identifier(&manifest_digest);
1938        let mut stream = repo
1939            .open_stream(&manifest_id, Some(&manifest_verity), None)
1940            .unwrap();
1941
1942        let mut object_refs = Vec::new();
1943        stream
1944            .get_object_refs(|id| object_refs.push(id.clone()))
1945            .unwrap();
1946
1947        // Should have at least one external object (the manifest JSON itself)
1948        assert!(
1949            !object_refs.is_empty(),
1950            "Manifest splitstream should contain external object references"
1951        );
1952
1953        let img = OciImage::open(&repo, &manifest_digest, Some(&manifest_verity)).unwrap();
1954        let manifest_json = img.manifest().to_string().unwrap();
1955        let expected_verity: Sha256HashValue =
1956            composefs::fsverity::compute_verity(manifest_json.as_bytes());
1957
1958        assert!(
1959            object_refs.contains(&expected_verity),
1960            "Manifest JSON fsverity digest should appear in splitstream object refs"
1961        );
1962    }
1963
1964    /// Verify that blob content is stored as an external object.
1965    #[test]
1966    fn test_blob_stored_as_external_object() {
1967        let test_repo = TestRepo::<Sha256HashValue>::new();
1968        let repo = &test_repo.repo;
1969
1970        let data = b"artifact blob content for external storage test";
1971        let (digest, verity) = write_blob(repo, data).unwrap();
1972
1973        let content_id = blob_identifier(&digest);
1974        let mut stream = repo.open_stream(&content_id, Some(&verity), None).unwrap();
1975
1976        let mut object_refs = Vec::new();
1977        stream
1978            .get_object_refs(|id| object_refs.push(id.clone()))
1979            .unwrap();
1980
1981        assert_eq!(
1982            object_refs.len(),
1983            1,
1984            "Blob should be stored as exactly one external object"
1985        );
1986
1987        let expected_verity: Sha256HashValue = composefs::fsverity::compute_verity(data);
1988        assert_eq!(
1989            object_refs[0], expected_verity,
1990            "External object verity should match independently computed verity of blob data"
1991        );
1992    }
1993
1994    /// Test storing and retrieving an OCI artifact with non-tar media type.
1995    ///
1996    /// This simulates what would happen when storing something like a
1997    /// Helm chart, WASM module, or other non-container artifact.
1998    #[test]
1999    fn test_oci_artifact_roundtrip() {
2000        use containers_image_proxy::oci_spec::image::{DescriptorBuilder, ImageManifestBuilder};
2001
2002        let test_repo = TestRepo::<Sha256HashValue>::new();
2003        let repo = &test_repo.repo;
2004
2005        // Create an artifact with a custom media type (simulating a WASM module)
2006        let wasm_bytes = b"\x00asm\x01\x00\x00\x00"; // WASM magic header
2007        let (blob_digest, blob_verity) = write_blob(repo, wasm_bytes).unwrap();
2008
2009        // Create an empty config (common for artifacts)
2010        let empty_config = b"{}";
2011        let config_digest = hash_sha256(empty_config);
2012
2013        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2014        config_stream.write_external(empty_config).unwrap();
2015        let config_verity = repo
2016            .write_stream(
2017                config_stream,
2018                &crate::config_identifier(&config_digest),
2019                None,
2020            )
2021            .unwrap();
2022
2023        let config_descriptor = DescriptorBuilder::default()
2024            .media_type(MediaType::Other(
2025                "application/vnd.wasm.config.v1+json".to_string(),
2026            ))
2027            .digest(config_digest.clone())
2028            .size(empty_config.len() as u64)
2029            .build()
2030            .unwrap();
2031
2032        let blob_descriptor = DescriptorBuilder::default()
2033            .media_type(MediaType::Other("application/wasm".to_string()))
2034            .digest(blob_digest.clone())
2035            .size(wasm_bytes.len() as u64)
2036            .build()
2037            .unwrap();
2038
2039        let manifest = ImageManifestBuilder::default()
2040            .schema_version(2u32)
2041            .media_type(MediaType::ImageManifest)
2042            .config(config_descriptor)
2043            .layers(vec![blob_descriptor])
2044            .build()
2045            .unwrap();
2046
2047        // For artifacts, we use the blob digest as the "diff_id" equivalent
2048        let layer_verities = [(blob_digest.clone(), blob_verity.clone())];
2049
2050        let manifest_json = manifest.to_string().unwrap();
2051        let manifest_digest = hash_sha256(manifest_json.as_bytes());
2052
2053        let (stored_digest, manifest_verity) = write_manifest(
2054            &repo,
2055            &manifest,
2056            &manifest_digest,
2057            &config_verity,
2058            &layer_verities,
2059            Some("my-wasm-artifact:v1"),
2060        )
2061        .unwrap();
2062
2063        assert_eq!(stored_digest, manifest_digest);
2064
2065        let opened = OciImage::open(&repo, &manifest_digest, Some(&manifest_verity)).unwrap();
2066
2067        assert!(!opened.is_container_image()); // Not a container image
2068        assert_eq!(opened.manifest_digest(), &manifest_digest);
2069        assert_eq!(opened.config_digest(), &config_digest);
2070        assert_eq!(opened.layer_descriptors().len(), 1);
2071        assert_eq!(
2072            opened.layer_descriptors()[0].media_type(),
2073            &MediaType::Other("application/wasm".to_string())
2074        );
2075
2076        let by_tag = OciImage::open_ref(&repo, "my-wasm-artifact:v1").unwrap();
2077        assert_eq!(by_tag.manifest_digest(), &manifest_digest);
2078
2079        let images = list_images(&repo).unwrap();
2080        assert_eq!(images.len(), 1);
2081        assert_eq!(images[0].name, "my-wasm-artifact:v1");
2082        assert!(!images[0].is_container);
2083
2084        let read_wasm = open_blob(&repo, &blob_digest, Some(&blob_verity)).unwrap();
2085        assert_eq!(read_wasm, wasm_bytes);
2086    }
2087
2088    /// Test the OCI 1.1 empty config artifact pattern from the spec:
2089    /// config is `application/vnd.oci.empty.v1+json`, layers use custom
2090    /// media types, and layer digests are used as diff_ids.
2091    /// See: https://github.com/opencontainers/image-spec/blob/main/artifacts-guidance.md
2092    #[test]
2093    fn test_oci_artifact_empty_config() {
2094        let test_repo = TestRepo::<Sha256HashValue>::new();
2095        let repo = &test_repo.repo;
2096
2097        let sbom_data = br#"{"spdxVersion":"SPDX-2.3","name":"example"}"#;
2098        let layer_digest = hash_sha256(sbom_data);
2099
2100        // Store the raw layer as an object with external ref splitstream
2101        let blob_object_id = repo.ensure_object(sbom_data).unwrap();
2102        let layer_content_id = crate::layer_identifier(&layer_digest);
2103        let mut layer_stream = repo
2104            .create_stream(crate::skopeo::OCI_BLOB_CONTENT_TYPE)
2105            .unwrap();
2106        layer_stream.add_external_size(sbom_data.len() as u64);
2107        layer_stream
2108            .write_reference(blob_object_id.clone())
2109            .unwrap();
2110        let layer_verity = repo
2111            .write_stream(layer_stream, &layer_content_id, None)
2112            .unwrap();
2113
2114        // The OCI 1.1 empty config: `{}` with the well-known digest
2115        let empty_config = b"{}";
2116        let config_digest = hash_sha256(empty_config);
2117        assert_eq!(
2118            config_digest.as_ref(),
2119            "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
2120        );
2121
2122        // Store the config — for artifacts we still write it as a config
2123        // splitstream, but it contains no diff_ids-derived named refs.
2124        // Instead, the layer refs come from the manifest layer digests.
2125        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2126        config_stream.write_external(empty_config).unwrap();
2127        let config_verity = repo
2128            .write_stream(
2129                config_stream,
2130                &crate::config_identifier(&config_digest),
2131                None,
2132            )
2133            .unwrap();
2134
2135        let config_descriptor = DescriptorBuilder::default()
2136            .media_type(MediaType::EmptyJSON)
2137            .digest(config_digest.clone())
2138            .size(empty_config.len() as u64)
2139            .build()
2140            .unwrap();
2141
2142        let layer_descriptor = DescriptorBuilder::default()
2143            .media_type(MediaType::Other("text/spdx+json".to_string()))
2144            .digest(layer_digest.clone())
2145            .size(sbom_data.len() as u64)
2146            .build()
2147            .unwrap();
2148
2149        let manifest = ImageManifestBuilder::default()
2150            .schema_version(2u32)
2151            .media_type(MediaType::ImageManifest)
2152            .config(config_descriptor.clone())
2153            .layers(vec![layer_descriptor])
2154            .build()
2155            .unwrap();
2156
2157        assert_ne!(*config_descriptor.media_type(), MediaType::ImageConfig);
2158
2159        // Store manifest — layer_verities uses the layer digest as key
2160        // (same logic as ensure_config_with_layers when !is_image_config)
2161        let layer_verities = [(layer_digest.clone(), layer_verity.clone())];
2162
2163        let manifest_json = manifest.to_string().unwrap();
2164        let manifest_digest = hash_sha256(manifest_json.as_bytes());
2165
2166        let (_stored_digest, manifest_verity) = write_manifest(
2167            &repo,
2168            &manifest,
2169            &manifest_digest,
2170            &config_verity,
2171            &layer_verities,
2172            Some("my-sbom:v1"),
2173        )
2174        .unwrap();
2175
2176        let opened = OciImage::open(&repo, &manifest_digest, Some(&manifest_verity)).unwrap();
2177        assert!(!opened.is_container_image());
2178        assert_eq!(opened.layer_descriptors().len(), 1);
2179        assert_eq!(
2180            opened.layer_descriptors()[0].media_type(),
2181            &MediaType::Other("text/spdx+json".to_string())
2182        );
2183
2184        let fd = opened.open_layer_fd(&repo, 0).unwrap();
2185        let mut recovered = vec![];
2186        File::from(fd).read_to_end(&mut recovered).unwrap();
2187        assert_eq!(recovered, sbom_data);
2188
2189        assert!(opened.open_layer_fd(&repo, 1).is_err());
2190
2191        let gc = repo.gc(&[]).unwrap();
2192        assert_eq!(gc.objects_removed, 0);
2193
2194        untag_image(&repo, "my-sbom:v1").unwrap();
2195        let gc = repo.gc(&[]).unwrap();
2196        assert!(gc.objects_removed > 0);
2197    }
2198
2199    /// Test that open_layer_fd rejects tar layers.
2200    #[test]
2201    fn test_open_layer_fd_rejects_tar() {
2202        let test_repo = TestRepo::<Sha256HashValue>::new();
2203        let repo = &test_repo.repo;
2204
2205        let (digest, verity, _) = create_test_image(repo, Some("myimage:v1"), "amd64");
2206        let img = OciImage::open(&repo, &digest, Some(&verity)).unwrap();
2207        assert!(img.is_container_image());
2208
2209        // Tar layer should be rejected
2210        let err = img.open_layer_fd(&repo, 0).unwrap_err();
2211        let msg = format!("{err}");
2212        assert!(msg.contains("does not support tar layers"), "got: {msg}");
2213    }
2214
2215    /// Test storing a non-tar layer as a splitstream with a single
2216    /// external reference, simulating how `ensure_layer` handles
2217    /// non-tar media types. The raw bytes go into objects/ and a
2218    /// tiny splitstream holds the reference for GC tracking.
2219    #[test]
2220    fn test_non_tar_layer_storage() {
2221        let test_repo = TestRepo::<Sha256HashValue>::new();
2222        let repo = &test_repo.repo;
2223
2224        let sbom_data = br#"{"spdxVersion":"SPDX-2.3","name":"example"}"#;
2225        let diff_id = hash_sha256(sbom_data);
2226
2227        let object_id = repo.ensure_object(sbom_data).unwrap();
2228
2229        let content_id = crate::layer_identifier(&diff_id);
2230        let mut stream = repo
2231            .create_stream(crate::skopeo::OCI_BLOB_CONTENT_TYPE)
2232            .unwrap();
2233        stream.add_external_size(sbom_data.len() as u64);
2234        stream.write_reference(object_id.clone()).unwrap();
2235        let stream_verity = repo.write_stream(stream, &content_id, None).unwrap();
2236
2237        let found = repo.has_stream(&content_id).unwrap();
2238        assert!(found.is_some());
2239        assert_eq!(found.unwrap(), stream_verity);
2240
2241        let mut reader = repo
2242            .open_stream(
2243                &content_id,
2244                Some(&stream_verity),
2245                Some(crate::skopeo::OCI_BLOB_CONTENT_TYPE),
2246            )
2247            .unwrap();
2248        let mut refs = vec![];
2249        reader.get_object_refs(|id| refs.push(id.clone())).unwrap();
2250        assert_eq!(refs.len(), 1);
2251        assert_eq!(refs[0], object_id);
2252
2253        let mut recovered = vec![];
2254        File::from(repo.open_object(&object_id).unwrap())
2255            .read_to_end(&mut recovered)
2256            .unwrap();
2257        assert_eq!(recovered, sbom_data);
2258    }
2259
2260    /// Test that a non-tar artifact layer (stored as an external ref)
2261    /// is preserved by GC when referenced from a tagged manifest.
2262    #[test]
2263    fn test_non_tar_artifact_gc() {
2264        use containers_image_proxy::oci_spec::image::{DescriptorBuilder, ImageManifestBuilder};
2265
2266        let test_repo = TestRepo::<Sha256HashValue>::new();
2267        let repo = &test_repo.repo;
2268
2269        let sbom_data = br#"{"spdxVersion":"SPDX-2.3","name":"example"}"#;
2270        let diff_id = hash_sha256(sbom_data);
2271        let blob_object_id = repo.ensure_object(sbom_data).unwrap();
2272
2273        let layer_content_id = crate::layer_identifier(&diff_id);
2274        let mut layer_stream = repo
2275            .create_stream(crate::skopeo::OCI_BLOB_CONTENT_TYPE)
2276            .unwrap();
2277        layer_stream.add_external_size(sbom_data.len() as u64);
2278        layer_stream
2279            .write_reference(blob_object_id.clone())
2280            .unwrap();
2281        let layer_verity = repo
2282            .write_stream(layer_stream, &layer_content_id, None)
2283            .unwrap();
2284
2285        let config_bytes = b"{}";
2286        let config_digest = hash_sha256(config_bytes);
2287        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2288        config_stream.write_external(config_bytes).unwrap();
2289        let config_verity = repo
2290            .write_stream(
2291                config_stream,
2292                &crate::config_identifier(&config_digest),
2293                None,
2294            )
2295            .unwrap();
2296
2297        let config_descriptor = DescriptorBuilder::default()
2298            .media_type(MediaType::ImageConfig)
2299            .digest(config_digest.clone())
2300            .size(config_bytes.len() as u64)
2301            .build()
2302            .unwrap();
2303        let layer_descriptor = DescriptorBuilder::default()
2304            .media_type(MediaType::Other("text/spdx+json".to_string()))
2305            .digest(diff_id.clone())
2306            .size(sbom_data.len() as u64)
2307            .build()
2308            .unwrap();
2309        let manifest = ImageManifestBuilder::default()
2310            .schema_version(2u32)
2311            .media_type(MediaType::ImageManifest)
2312            .config(config_descriptor)
2313            .layers(vec![layer_descriptor])
2314            .build()
2315            .unwrap();
2316
2317        let layer_verities = [(diff_id.clone(), layer_verity)];
2318
2319        let manifest_json = manifest.to_string().unwrap();
2320        let manifest_digest = hash_sha256(manifest_json.as_bytes());
2321
2322        let (_stored_digest, _manifest_verity) = write_manifest(
2323            &repo,
2324            &manifest,
2325            &manifest_digest,
2326            &config_verity,
2327            &layer_verities,
2328            Some("my-sbom:v1"),
2329        )
2330        .unwrap();
2331
2332        // GC should preserve everything — the blob object is reachable via
2333        // manifest → config named ref → layer splitstream → external ref
2334        let gc = repo.gc(&[]).unwrap();
2335        assert_eq!(gc.objects_removed, 0, "tagged artifact should be preserved");
2336
2337        let mut recovered = vec![];
2338        File::from(repo.open_object(&blob_object_id).unwrap())
2339            .read_to_end(&mut recovered)
2340            .unwrap();
2341        assert_eq!(recovered, sbom_data);
2342    }
2343
2344    /// Test storing and listing multiple container images.
2345    #[test]
2346    fn test_multiple_images() {
2347        let test_repo = TestRepo::<Sha256HashValue>::new();
2348        let repo = &test_repo.repo;
2349
2350        let (digest1, verity1, _) = create_test_image(repo, Some("app:v1"), "amd64");
2351        let (digest2, verity2, _) = create_test_image(repo, Some("app:v2"), "amd64");
2352        let (digest3, verity3, _) = create_test_image(repo, Some("other:latest"), "arm64");
2353
2354        let images = list_images(repo).unwrap();
2355        assert_eq!(images.len(), 3);
2356
2357        let names: Vec<_> = images.iter().map(|i| i.name.as_str()).collect();
2358        assert!(names.contains(&"app:v1"));
2359        assert!(names.contains(&"app:v2"));
2360        assert!(names.contains(&"other:latest"));
2361
2362        for img in &images {
2363            if img.name == "other:latest" {
2364                assert_eq!(img.architecture, "arm64");
2365            } else {
2366                assert_eq!(img.architecture, "amd64");
2367            }
2368            assert!(img.is_container);
2369        }
2370
2371        let img1 = OciImage::open_ref(repo, "app:v1").unwrap();
2372        assert_eq!(img1.manifest_digest(), &digest1);
2373        assert_eq!(img1.manifest_verity(), &verity1);
2374
2375        let img2 = OciImage::open_ref(repo, "app:v2").unwrap();
2376        assert_eq!(img2.manifest_digest(), &digest2);
2377        assert_eq!(img2.manifest_verity(), &verity2);
2378
2379        let img3 = OciImage::open_ref(repo, "other:latest").unwrap();
2380        assert_eq!(img3.manifest_digest(), &digest3);
2381        assert_eq!(img3.manifest_verity(), &verity3);
2382    }
2383
2384    /// Test that untagging removes the image from listing but preserves data.
2385    #[test]
2386    fn test_untag_image() {
2387        let test_repo = TestRepo::<Sha256HashValue>::new();
2388        let repo = &test_repo.repo;
2389
2390        let (digest1, verity1, _) = create_test_image(repo, Some("myapp:v1"), "amd64");
2391        let (digest2, _verity2, _) = create_test_image(repo, Some("myapp:v2"), "amd64");
2392
2393        let images = list_images(repo).unwrap();
2394        assert_eq!(images.len(), 2);
2395
2396        untag_image(repo, "myapp:v1").unwrap();
2397
2398        let images = list_images(repo).unwrap();
2399        assert_eq!(images.len(), 1);
2400        assert_eq!(images[0].name, "myapp:v2");
2401        assert_eq!(images[0].manifest_digest, digest2);
2402
2403        let img = OciImage::open(repo, &digest1, Some(&verity1)).unwrap();
2404        assert_eq!(img.manifest_digest(), &digest1);
2405
2406        let result = OciImage::open_ref(repo, "myapp:v1");
2407        assert!(result.is_err());
2408    }
2409
2410    /// Test resolving refs and listing refs.
2411    #[test]
2412    fn test_refs() {
2413        let test_repo = TestRepo::<Sha256HashValue>::new();
2414        let repo = &test_repo.repo;
2415
2416        let (digest, verity, _) = create_test_image(repo, Some("test:latest"), "amd64");
2417
2418        let refs = list_refs(repo).unwrap();
2419        assert_eq!(refs.len(), 1);
2420        assert_eq!(refs[0].0, "test:latest");
2421        assert_eq!(refs[0].1, digest);
2422
2423        let (resolved_digest, resolved_verity) = resolve_ref(repo, "test:latest").unwrap();
2424        assert_eq!(resolved_digest, digest);
2425        assert_eq!(resolved_verity, verity);
2426
2427        let result = resolve_ref::<Sha256HashValue>(repo, "nonexistent:tag");
2428        assert!(result.is_err());
2429    }
2430
2431    /// Test that tag_image rejects names containing `@`.
2432    #[test]
2433    fn test_tag_rejects_leading_at_sign() {
2434        let test_repo = TestRepo::<Sha256HashValue>::new();
2435        let repo = &test_repo.repo;
2436
2437        let (digest, _, _) = create_test_image(repo, Some("valid:v1"), "amd64");
2438
2439        // Leading @ is rejected
2440        let result = tag_image(repo, &digest, "@sha256:bad");
2441        assert!(result.is_err());
2442        let err = result.unwrap_err().to_string();
2443        assert!(err.contains("'@' is reserved"), "unexpected error: {err}");
2444
2445        // @ in the middle is fine
2446        let result = tag_image(repo, &digest, "name@digest");
2447        assert!(result.is_ok());
2448    }
2449
2450    /// Test that fsck catches refs starting with `@`.
2451    #[tokio::test]
2452    async fn test_oci_fsck_detects_invalid_ref_name() {
2453        let test_repo = TestRepo::<Sha256HashValue>::new();
2454        let repo = &test_repo.repo;
2455
2456        let (digest, _, _) = create_test_image(repo, Some("good:v1"), "amd64");
2457
2458        // Bypass validate_ref_name by creating the ref symlink directly
2459        let bad_name = "@badref";
2460        let ref_path = format!("streams/refs/{}", oci_ref_path(bad_name));
2461        let manifest_id = manifest_identifier(&digest);
2462        let target = format!("../../{manifest_id}");
2463        repo.symlink(&ref_path, &target)
2464            .expect("create bad ref symlink");
2465
2466        let result = oci_fsck(repo).await.unwrap();
2467        assert!(
2468            result.images_corrupted > 0,
2469            "fsck should report corruption for @ in ref name"
2470        );
2471        assert!(
2472            result
2473                .errors
2474                .iter()
2475                .any(|e| matches!(e, OciFsckError::InvalidRefName { name } if name == bad_name)),
2476            "fsck should report InvalidRefName error"
2477        );
2478        // The bad ref should be counted exactly once
2479        let invalid_count = result
2480            .errors
2481            .iter()
2482            .filter(|e| matches!(e, OciFsckError::InvalidRefName { .. }))
2483            .count();
2484        assert_eq!(invalid_count, 1, "should report exactly one InvalidRefName");
2485    }
2486
2487    /// Test that tagging an existing manifest with a new name works.
2488    #[test]
2489    fn test_tag_existing_manifest() {
2490        let test_repo = TestRepo::<Sha256HashValue>::new();
2491        let repo = &test_repo.repo;
2492
2493        let (digest, verity, _) = create_test_image(repo, Some("original:v1"), "amd64");
2494
2495        tag_image(repo, &digest, "alias:latest").unwrap();
2496
2497        let (d1, v1) = resolve_ref(repo, "original:v1").unwrap();
2498        let (d2, v2) = resolve_ref(repo, "alias:latest").unwrap();
2499        assert_eq!(d1, d2);
2500        assert_eq!(v1, v2);
2501        assert_eq!(d1, digest);
2502        assert_eq!(v1, verity);
2503
2504        let images = list_images(repo).unwrap();
2505        assert_eq!(images.len(), 2);
2506
2507        untag_image(repo, "original:v1").unwrap();
2508        let (d3, _) = resolve_ref(repo, "alias:latest").unwrap();
2509        assert_eq!(d3, digest);
2510
2511        let images = list_images(repo).unwrap();
2512        assert_eq!(images.len(), 1);
2513        assert_eq!(images[0].name, "alias:latest");
2514    }
2515
2516    /// Test opening image by manifest digest (no tag required).
2517    #[test]
2518    fn test_open_by_digest() {
2519        let test_repo = TestRepo::<Sha256HashValue>::new();
2520        let repo = &test_repo.repo;
2521
2522        let (digest, verity, config_digest) = create_test_image(repo, None, "amd64");
2523
2524        let images = list_images(repo).unwrap();
2525        assert!(images.is_empty());
2526
2527        let img = OciImage::open(repo, &digest, Some(&verity)).unwrap();
2528        assert_eq!(img.manifest_digest(), &digest);
2529        assert_eq!(img.config_digest(), &config_digest);
2530        assert!(img.is_container_image());
2531        assert_eq!(img.architecture(), "amd64");
2532
2533        let img2 = OciImage::open(repo, &digest, None).unwrap();
2534        assert_eq!(img2.manifest_digest(), &digest);
2535    }
2536
2537    /// Test fetching manifest and config from stored image.
2538    #[test]
2539    fn test_fetch_manifest_config() {
2540        let test_repo = TestRepo::<Sha256HashValue>::new();
2541        let repo = &test_repo.repo;
2542
2543        let (digest, verity, config_digest) =
2544            create_test_image(repo, Some("fetchtest:v1"), "amd64");
2545
2546        let img = OciImage::open_ref(repo, "fetchtest:v1").unwrap();
2547
2548        assert_eq!(img.manifest_digest(), &digest);
2549        assert_eq!(img.manifest_verity(), &verity);
2550        let manifest = img.manifest();
2551        assert_eq!(manifest.schema_version(), 2u32);
2552        assert_eq!(manifest.layers().len(), 1);
2553
2554        assert_eq!(img.config_digest(), &config_digest);
2555        let config = img.config().expect("should have config");
2556        assert_eq!(config.architecture().to_string(), "amd64");
2557        assert_eq!(config.os().to_string(), "linux");
2558        assert_eq!(config.rootfs().diff_ids().len(), 1);
2559
2560        let diff_ids = img.layer_diff_ids();
2561        assert_eq!(diff_ids.len(), 1);
2562        let layer_verity = img.layer_verity(diff_ids[0]);
2563        assert!(layer_verity.is_some());
2564    }
2565
2566    /// Test that has_manifest correctly detects existing manifests.
2567    #[test]
2568    fn test_has_manifest() {
2569        let test_repo = TestRepo::<Sha256HashValue>::new();
2570        let repo = &test_repo.repo;
2571
2572        let nonexistent: OciDigest =
2573            "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2574                .parse()
2575                .unwrap();
2576        assert!(has_manifest(repo, &nonexistent).unwrap().is_none());
2577
2578        let (digest, verity, _) = create_test_image(repo, None, "amd64");
2579
2580        let found = has_manifest(repo, &digest).unwrap();
2581        assert!(found.is_some());
2582        assert_eq!(found.unwrap(), verity);
2583
2584        assert!(has_manifest(repo, &nonexistent).unwrap().is_none());
2585    }
2586
2587    /// Test empty repository behavior.
2588    #[test]
2589    fn test_empty_repo() {
2590        let test_repo = TestRepo::<Sha256HashValue>::new();
2591        let repo = &test_repo.repo;
2592
2593        // List should return empty vec, not error
2594        let images = list_images(repo).unwrap();
2595        assert!(images.is_empty());
2596
2597        let refs = list_refs(repo).unwrap();
2598        assert!(refs.is_empty());
2599    }
2600
2601    /// Test untagging non-existent tag.
2602    #[test]
2603    fn test_untag_nonexistent() {
2604        let test_repo = TestRepo::<Sha256HashValue>::new();
2605        let repo = &test_repo.repo;
2606
2607        let result = untag_image(repo, "nonexistent:tag");
2608        assert!(result.is_err());
2609    }
2610
2611    // ==================== GC Integration Tests ====================
2612    //
2613    // These tests verify that garbage collection correctly handles OCI images:
2614    // - Tagged images are preserved (tags act as GC roots)
2615    // - Untagged images can be collected
2616    // - Shared layers between images are handled correctly
2617
2618    /// Test that GC preserves a tagged OCI image and all its components.
2619    #[test]
2620    fn test_gc_preserves_tagged_oci_image() {
2621        let test_repo = TestRepo::<Sha256HashValue>::new();
2622        let repo = &test_repo.repo;
2623
2624        let (manifest_digest, manifest_verity, config_digest) =
2625            create_test_image(repo, Some("myapp:v1"), "amd64");
2626
2627        let gc_result = repo.gc(&[]).unwrap();
2628
2629        assert_eq!(gc_result.objects_removed, 0);
2630        assert_eq!(gc_result.streams_pruned, 0);
2631
2632        let img = OciImage::open_ref(repo, "myapp:v1").unwrap();
2633        assert_eq!(img.manifest_digest(), &manifest_digest);
2634        assert_eq!(img.manifest_verity(), &manifest_verity);
2635        assert_eq!(img.config_digest(), &config_digest);
2636
2637        let diff_ids = img.layer_diff_ids();
2638        assert_eq!(diff_ids.len(), 1);
2639        assert!(img.layer_verity(diff_ids[0]).is_some());
2640    }
2641
2642    /// Test that GC removes an untagged OCI image.
2643    #[test]
2644    fn test_gc_removes_untagged_oci_image() {
2645        let test_repo = TestRepo::<Sha256HashValue>::new();
2646        let repo = &test_repo.repo;
2647
2648        let (manifest_digest, manifest_verity, _config_digest) =
2649            create_test_image(repo, None, "amd64");
2650
2651        let img = OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).unwrap();
2652        let diff_ids = img.layer_diff_ids();
2653        assert_eq!(diff_ids.len(), 1);
2654        drop(img);
2655
2656        let gc_result = repo.gc(&[]).unwrap();
2657
2658        assert!(gc_result.objects_removed > 0);
2659
2660        let result = has_manifest(repo, &manifest_digest);
2661        assert!(
2662            result.unwrap().is_none(),
2663            "manifest should be gone after GC"
2664        );
2665    }
2666
2667    /// Test that untagging an image makes it eligible for GC.
2668    #[test]
2669    fn test_gc_after_untag_removes_image() {
2670        let test_repo = TestRepo::<Sha256HashValue>::new();
2671        let repo = &test_repo.repo;
2672
2673        let (manifest_digest, manifest_verity, _) =
2674            create_test_image(repo, Some("temporary:v1"), "amd64");
2675
2676        let gc_result = repo.gc(&[]).unwrap();
2677        assert_eq!(gc_result.objects_removed, 0);
2678
2679        untag_image(repo, "temporary:v1").unwrap();
2680
2681        assert!(OciImage::open_ref(repo, "temporary:v1").is_err());
2682
2683        assert!(OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).is_ok());
2684
2685        let gc_result = repo.gc(&[]).unwrap();
2686        assert!(gc_result.objects_removed > 0);
2687
2688        assert!(has_manifest(repo, &manifest_digest).unwrap().is_none());
2689    }
2690
2691    /// Test GC with two images sharing layers - removing one preserves shared layers.
2692    #[test]
2693    fn test_gc_with_shared_layers() {
2694        let test_repo = TestRepo::<Sha256HashValue>::new();
2695        let repo = &test_repo.repo;
2696
2697        let shared_layer_data = b"shared-base-layer-content";
2698        let shared_layer_digest = hash_sha256(shared_layer_data);
2699
2700        let mut shared_layer_stream = repo
2701            .create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE)
2702            .unwrap();
2703        shared_layer_stream
2704            .write_external(shared_layer_data)
2705            .unwrap();
2706        let shared_layer_verity = repo
2707            .write_stream(
2708                shared_layer_stream,
2709                &crate::layer_identifier(&shared_layer_digest),
2710                None,
2711            )
2712            .unwrap();
2713
2714        // Helper to create an image using the shared layer
2715        let create_image_with_shared_layer = |repo: &Arc<Repository<Sha256HashValue>>,
2716                                              tag: Option<&str>,
2717                                              extra_data: &[u8]|
2718         -> (OciDigest, Sha256HashValue) {
2719            let rootfs = RootFsBuilder::default()
2720                .typ("layers")
2721                .diff_ids(vec![shared_layer_digest.to_string()])
2722                .build()
2723                .unwrap();
2724
2725            let cfg = ConfigBuilder::default().build().unwrap();
2726
2727            // Add unique data to make configs different
2728            let config = ImageConfigurationBuilder::default()
2729                .architecture("amd64")
2730                .os("linux")
2731                .rootfs(rootfs)
2732                .config(cfg)
2733                .created(String::from_utf8_lossy(extra_data).to_string())
2734                .build()
2735                .unwrap();
2736
2737            let config_json = config.to_string().unwrap();
2738            let config_digest = hash_sha256(config_json.as_bytes());
2739
2740            let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2741            config_stream.add_named_stream_ref(shared_layer_digest.as_ref(), &shared_layer_verity);
2742            config_stream
2743                .write_external(config_json.as_bytes())
2744                .unwrap();
2745            let config_verity = repo
2746                .write_stream(
2747                    config_stream,
2748                    &crate::config_identifier(&config_digest),
2749                    None,
2750                )
2751                .unwrap();
2752
2753            let config_descriptor = DescriptorBuilder::default()
2754                .media_type(MediaType::ImageConfig)
2755                .digest(config_digest.clone())
2756                .size(config_json.len() as u64)
2757                .build()
2758                .unwrap();
2759
2760            let layer_descriptor = DescriptorBuilder::default()
2761                .media_type(MediaType::ImageLayerGzip)
2762                .digest(shared_layer_digest.clone())
2763                .size(shared_layer_data.len() as u64)
2764                .build()
2765                .unwrap();
2766
2767            let manifest = ImageManifestBuilder::default()
2768                .schema_version(2u32)
2769                .media_type(MediaType::ImageManifest)
2770                .config(config_descriptor)
2771                .layers(vec![layer_descriptor])
2772                .build()
2773                .unwrap();
2774
2775            let layer_verities = [(shared_layer_digest.clone(), shared_layer_verity.clone())];
2776
2777            let manifest_json = manifest.to_string().unwrap();
2778            let manifest_digest = hash_sha256(manifest_json.as_bytes());
2779
2780            let (_stored_digest, manifest_verity) = write_manifest(
2781                repo,
2782                &manifest,
2783                &manifest_digest,
2784                &config_verity,
2785                &layer_verities,
2786                tag,
2787            )
2788            .unwrap();
2789
2790            (manifest_digest, manifest_verity)
2791        };
2792
2793        let (digest1, verity1) = create_image_with_shared_layer(repo, Some("tagged:v1"), b"image1");
2794        let (digest2, _verity2) = create_image_with_shared_layer(repo, None, b"image2");
2795
2796        assert!(has_manifest(repo, &digest1).unwrap().is_some());
2797        assert!(has_manifest(repo, &digest2).unwrap().is_some());
2798
2799        let gc_result = repo.gc(&[]).unwrap();
2800
2801        assert!(gc_result.objects_removed > 0);
2802
2803        let img1 = OciImage::open(repo, &digest1, Some(&verity1)).unwrap();
2804        assert_eq!(img1.layer_diff_ids().len(), 1);
2805        assert!(img1.layer_verity(shared_layer_digest.as_ref()).is_some());
2806
2807        assert!(has_manifest(repo, &digest2).unwrap().is_none());
2808
2809        // Shared layer still exists because the tagged image references it
2810        assert!(
2811            repo.has_stream(&crate::layer_identifier(&shared_layer_digest))
2812                .unwrap()
2813                .is_some()
2814        );
2815    }
2816
2817    /// Test that multiple tags on the same manifest are handled correctly.
2818    #[test]
2819    fn test_gc_with_multiple_tags_same_manifest() {
2820        let test_repo = TestRepo::<Sha256HashValue>::new();
2821        let repo = &test_repo.repo;
2822
2823        // Create an image with one tag
2824        let (manifest_digest, manifest_verity, _) =
2825            create_test_image(repo, Some("original:v1"), "amd64");
2826
2827        tag_image(repo, &manifest_digest, "alias:latest").unwrap();
2828
2829        assert_eq!(list_images(repo).unwrap().len(), 2);
2830
2831        untag_image(repo, "original:v1").unwrap();
2832
2833        let gc_result = repo.gc(&[]).unwrap();
2834
2835        assert_eq!(gc_result.objects_removed, 0);
2836
2837        let img = OciImage::open_ref(repo, "alias:latest").unwrap();
2838        assert_eq!(img.manifest_digest(), &manifest_digest);
2839        assert_eq!(img.manifest_verity(), &manifest_verity);
2840
2841        let diff_ids = img.layer_diff_ids();
2842        assert!(img.layer_verity(diff_ids[0]).is_some());
2843
2844        untag_image(repo, "alias:latest").unwrap();
2845
2846        let gc_result = repo.gc(&[]).unwrap();
2847
2848        assert!(gc_result.objects_removed > 0);
2849        assert!(has_manifest(repo, &manifest_digest).unwrap().is_none());
2850    }
2851
2852    /// Test gc_dry_run with OCI images.
2853    #[test]
2854    fn test_gc_dry_run_oci_image() {
2855        let test_repo = TestRepo::<Sha256HashValue>::new();
2856        let repo = &test_repo.repo;
2857
2858        // Create one tagged and one untagged image with DIFFERENT architectures
2859        // to ensure they have unique layer content (create_test_image uses arch in layer data)
2860        let (tagged_digest, tagged_verity, _) = create_test_image(repo, Some("keep:v1"), "amd64");
2861        let (untagged_digest, _untagged_verity, _) = create_test_image(repo, None, "arm64");
2862
2863        assert!(has_manifest(repo, &tagged_digest).unwrap().is_some());
2864        assert!(has_manifest(repo, &untagged_digest).unwrap().is_some());
2865
2866        let dry_run_result = repo.gc_dry_run(&[]).unwrap();
2867        assert!(
2868            dry_run_result.objects_removed > 0,
2869            "dry-run should report objects to remove, got {:?}",
2870            dry_run_result
2871        );
2872
2873        // But nothing should actually be removed
2874        assert!(has_manifest(repo, &tagged_digest).unwrap().is_some());
2875        assert!(has_manifest(repo, &untagged_digest).unwrap().is_some());
2876
2877        let img = OciImage::open(repo, &tagged_digest, Some(&tagged_verity)).unwrap();
2878        assert!(img.layer_verity(img.layer_diff_ids()[0]).is_some());
2879
2880        let real_result = repo.gc(&[]).unwrap();
2881
2882        assert_eq!(real_result.objects_removed, dry_run_result.objects_removed);
2883
2884        assert!(has_manifest(repo, &untagged_digest).unwrap().is_none());
2885        assert!(has_manifest(repo, &tagged_digest).unwrap().is_some());
2886    }
2887
2888    /// Test referrer index: store an artifact, add a referrer entry,
2889    /// then discover it via list_referrers.
2890    #[test]
2891    fn test_referrer_index_roundtrip() {
2892        let test_repo = TestRepo::<Sha256HashValue>::new();
2893        let repo = &test_repo.repo;
2894
2895        let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64");
2896
2897        let empty_config = b"{}";
2898        let config_digest = hash_sha256(empty_config);
2899        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2900        config_stream.write_external(empty_config).unwrap();
2901        let config_verity = repo
2902            .write_stream(
2903                config_stream,
2904                &crate::config_identifier(&config_digest),
2905                None,
2906            )
2907            .unwrap();
2908
2909        let mut artifact_digests = Vec::new();
2910        for i in 0..2u8 {
2911            let blob_data = format!("artifact-blob-{i}").into_bytes();
2912            let (blob_digest, blob_verity) = write_blob(repo, &blob_data).unwrap();
2913
2914            let config_descriptor = DescriptorBuilder::default()
2915                .media_type(MediaType::EmptyJSON)
2916                .digest(config_digest.clone())
2917                .size(empty_config.len() as u64)
2918                .build()
2919                .unwrap();
2920
2921            let layer_descriptor = DescriptorBuilder::default()
2922                .media_type(MediaType::Other("application/octet-stream".to_string()))
2923                .digest(blob_digest.clone())
2924                .size(blob_data.len() as u64)
2925                .build()
2926                .unwrap();
2927
2928            let manifest = ImageManifestBuilder::default()
2929                .schema_version(2u32)
2930                .media_type(MediaType::ImageManifest)
2931                .config(config_descriptor)
2932                .layers(vec![layer_descriptor])
2933                .build()
2934                .unwrap();
2935
2936            let layer_verities = [(blob_digest, blob_verity)];
2937
2938            let manifest_json = manifest.to_string().unwrap();
2939            let manifest_digest = hash_sha256(manifest_json.as_bytes());
2940
2941            write_manifest(
2942                repo,
2943                &manifest,
2944                &manifest_digest,
2945                &config_verity,
2946                &layer_verities,
2947                None,
2948            )
2949            .unwrap();
2950
2951            add_referrer(repo, &subject_digest, &manifest_digest).unwrap();
2952            artifact_digests.push(manifest_digest);
2953        }
2954
2955        let referrers = list_referrers(repo, &subject_digest).unwrap();
2956        assert_eq!(referrers.len(), 2);
2957
2958        let found_digests: Vec<&OciDigest> = referrers.iter().map(|(d, _)| d).collect();
2959        for expected in &artifact_digests {
2960            assert!(
2961                found_digests.contains(&expected),
2962                "Missing artifact {expected} in referrers"
2963            );
2964        }
2965    }
2966
2967    /// Helper to create a minimal OCI artifact manifest in the repository.
2968    ///
2969    /// Returns (manifest_digest, manifest_verity).
2970    fn create_test_artifact(
2971        repo: &Arc<Repository<Sha256HashValue>>,
2972        blob_data: &[u8],
2973    ) -> (OciDigest, Sha256HashValue) {
2974        let (blob_digest, blob_verity) = write_blob(repo, blob_data).unwrap();
2975
2976        let empty_config = b"{}";
2977        let config_digest = hash_sha256(empty_config);
2978
2979        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2980        config_stream.write_external(empty_config).unwrap();
2981        let config_verity = repo
2982            .write_stream(
2983                config_stream,
2984                &crate::config_identifier(&config_digest),
2985                None,
2986            )
2987            .unwrap();
2988
2989        let config_descriptor = DescriptorBuilder::default()
2990            .media_type(MediaType::EmptyJSON)
2991            .digest(config_digest.clone())
2992            .size(empty_config.len() as u64)
2993            .build()
2994            .unwrap();
2995
2996        let layer_descriptor = DescriptorBuilder::default()
2997            .media_type(MediaType::Other("application/octet-stream".to_string()))
2998            .digest(blob_digest.clone())
2999            .size(blob_data.len() as u64)
3000            .build()
3001            .unwrap();
3002
3003        let manifest = ImageManifestBuilder::default()
3004            .schema_version(2u32)
3005            .media_type(MediaType::ImageManifest)
3006            .config(config_descriptor)
3007            .layers(vec![layer_descriptor])
3008            .build()
3009            .unwrap();
3010
3011        let layer_verities = [(blob_digest, blob_verity)];
3012
3013        let manifest_json = manifest.to_string().unwrap();
3014        let manifest_digest = hash_sha256(manifest_json.as_bytes());
3015
3016        let (_stored_digest, manifest_verity) = write_manifest(
3017            repo,
3018            &manifest,
3019            &manifest_digest,
3020            &config_verity,
3021            &layer_verities,
3022            None,
3023        )
3024        .unwrap();
3025
3026        (manifest_digest, manifest_verity)
3027    }
3028
3029    /// Test that GC collects referrer artifacts when their subject is untagged.
3030    ///
3031    /// Referrer symlinks under `streams/refs/oci-referrers/` act as GC roots,
3032    /// so orphaned referrer entries must be cleaned up before GC to allow
3033    /// the artifact manifests and their objects to be collected.
3034    #[test]
3035    fn test_gc_cleans_referrer_artifacts() {
3036        let test_repo = TestRepo::<Sha256HashValue>::new();
3037        let repo = &test_repo.repo;
3038
3039        // 1. Create a subject image with a tag
3040        let (subject_digest, _subject_verity, _) =
3041            create_test_image(repo, Some("subject:v1"), "amd64");
3042
3043        // 2. Create an artifact referencing the subject
3044        let (artifact_digest, _artifact_verity) =
3045            create_test_artifact(repo, b"fake-signature-data");
3046
3047        // 3. Register the referrer relationship
3048        add_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3049
3050        // 4. Verify the referrer is discoverable
3051        let referrers = list_referrers(repo, &subject_digest).unwrap();
3052        assert_eq!(referrers.len(), 1);
3053        assert_eq!(referrers[0].0, artifact_digest);
3054
3055        // Verify GC preserves everything while subject is tagged
3056        let gc = repo.gc(&[]).unwrap();
3057        assert_eq!(gc.objects_removed, 0, "nothing should be collected yet");
3058
3059        // Artifact should still be accessible
3060        assert!(
3061            has_manifest(repo, &artifact_digest).unwrap().is_some(),
3062            "artifact manifest should exist"
3063        );
3064
3065        // 5. Untag the subject image
3066        untag_image(repo, "subject:v1").unwrap();
3067
3068        // 6. First GC pass: collects the subject's objects and cleans up
3069        //    its broken stream symlink. The artifact survives because the
3070        //    referrer symlink still acts as a GC root.
3071        let gc1 = repo.gc(&[]).unwrap();
3072        assert!(gc1.objects_removed > 0, "should collect subject objects");
3073        assert!(
3074            has_manifest(repo, &subject_digest).unwrap().is_none(),
3075            "subject manifest should be gone after first GC"
3076        );
3077        // Artifact is still alive — rooted by referrer symlink
3078        assert!(
3079            has_manifest(repo, &artifact_digest).unwrap().is_some(),
3080            "artifact should survive first GC (referrer symlink roots it)"
3081        );
3082
3083        // 7. Clean up dangling referrers (subject no longer exists)
3084        let cleaned = cleanup_dangling_referrers(repo).unwrap();
3085        assert_eq!(cleaned, 1, "should remove 1 dangling referrer entry");
3086
3087        // 8. Second GC pass: now collects the artifact (no longer rooted)
3088        let gc2 = repo.gc(&[]).unwrap();
3089        assert!(gc2.objects_removed > 0, "should collect artifact objects");
3090
3091        // 9. Verify the artifact manifest is gone
3092        assert!(
3093            has_manifest(repo, &artifact_digest).unwrap().is_none(),
3094            "artifact manifest should be collected"
3095        );
3096
3097        // 10. Verify list_referrers returns empty
3098        let referrers = list_referrers(repo, &subject_digest).unwrap();
3099        assert!(referrers.is_empty(), "no referrers should remain after GC");
3100
3101        // Also verify the subject manifest is gone
3102        assert!(
3103            has_manifest(repo, &subject_digest).unwrap().is_none(),
3104            "subject manifest should be collected"
3105        );
3106    }
3107
3108    /// Test that cleanup_dangling_referrers preserves referrers for tagged subjects.
3109    #[test]
3110    fn test_cleanup_referrers_preserves_tagged_subjects() {
3111        let test_repo = TestRepo::<Sha256HashValue>::new();
3112        let repo = &test_repo.repo;
3113
3114        // Create a tagged subject
3115        let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64");
3116
3117        // Create an artifact and register it as a referrer
3118        let (artifact_digest, _) = create_test_artifact(repo, b"sig-data");
3119        add_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3120
3121        // Cleanup should not remove anything — subject is still tagged
3122        let cleaned = cleanup_dangling_referrers(repo).unwrap();
3123        assert_eq!(cleaned, 0, "should not remove referrers for tagged subject");
3124
3125        // Referrer should still be discoverable
3126        let referrers = list_referrers(repo, &subject_digest).unwrap();
3127        assert_eq!(referrers.len(), 1);
3128    }
3129
3130    /// Test that cleanup handles multiple subjects, only removing dangling ones.
3131    #[test]
3132    fn test_cleanup_referrers_mixed_subjects() {
3133        let test_repo = TestRepo::<Sha256HashValue>::new();
3134        let repo = &test_repo.repo;
3135
3136        // Create two subjects
3137        let (subject1_digest, _, _) = create_test_image(repo, Some("subject1:v1"), "amd64");
3138        let (subject2_digest, _, _) = create_test_image(repo, Some("subject2:v1"), "arm64");
3139
3140        // Create artifacts for both
3141        let (artifact1_digest, _) = create_test_artifact(repo, b"sig-for-subject1");
3142        let (artifact2_digest, _) = create_test_artifact(repo, b"sig-for-subject2");
3143
3144        add_referrer(repo, &subject1_digest, &artifact1_digest).unwrap();
3145        add_referrer(repo, &subject2_digest, &artifact2_digest).unwrap();
3146
3147        // Untag only subject1
3148        untag_image(repo, "subject1:v1").unwrap();
3149
3150        // First GC pass to actually remove subject1's manifest stream
3151        // (cleanup_dangling_referrers checks has_manifest, which checks the
3152        // stream symlink; GC removes the broken symlink after object deletion)
3153        repo.gc(&[]).unwrap();
3154
3155        // Now cleanup should only remove referrers for subject1
3156        let cleaned = cleanup_dangling_referrers(repo).unwrap();
3157        assert_eq!(cleaned, 1, "should remove 1 referrer for untagged subject");
3158
3159        // Run GC again to collect the now-unrooted artifact1
3160        let gc = repo.gc(&[]).unwrap();
3161        assert!(gc.objects_removed > 0);
3162
3163        // subject2's referrer should still exist
3164        let referrers2 = list_referrers(repo, &subject2_digest).unwrap();
3165        assert_eq!(referrers2.len(), 1);
3166        assert_eq!(referrers2[0].0, artifact2_digest);
3167
3168        // subject1's artifact should be gone
3169        assert!(has_manifest(repo, &artifact1_digest).unwrap().is_none());
3170        // subject2's artifact should still exist
3171        assert!(has_manifest(repo, &artifact2_digest).unwrap().is_some());
3172    }
3173
3174    /// Test that cleanup_dangling_referrers is a no-op on an empty repository.
3175    #[test]
3176    fn test_cleanup_referrers_empty_repo() {
3177        let test_repo = TestRepo::<Sha256HashValue>::new();
3178        let repo = &test_repo.repo;
3179
3180        let cleaned = cleanup_dangling_referrers(repo).unwrap();
3181        assert_eq!(cleaned, 0);
3182    }
3183
3184    /// Test removing a single referrer: add, remove, verify gone, and
3185    /// confirm that a second remove is idempotent (no error).
3186    #[test]
3187    fn test_remove_referrer() {
3188        let test_repo = TestRepo::<Sha256HashValue>::new();
3189        let repo = &test_repo.repo;
3190
3191        let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64");
3192        let (artifact_digest, _) = create_test_artifact(repo, b"sig-remove-test");
3193
3194        add_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3195        assert_eq!(list_referrers(repo, &subject_digest).unwrap().len(), 1);
3196
3197        // Remove the referrer
3198        remove_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3199        assert!(list_referrers(repo, &subject_digest).unwrap().is_empty());
3200
3201        // Second remove is idempotent
3202        remove_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3203    }
3204
3205    // ==================== Property Tests ====================
3206
3207    mod proptests {
3208        use super::*;
3209        use proptest::prelude::*;
3210
3211        proptest! {
3212            #[test]
3213            fn encode_decode_tag_roundtrip(s in "\\PC*") {
3214                prop_assert_eq!(decode_tag(&encode_tag(&s)), s);
3215            }
3216
3217            #[test]
3218            fn encode_tag_no_slashes(s in "\\PC*") {
3219                prop_assert!(!encode_tag(&s).contains('/'));
3220            }
3221
3222            #[test]
3223            fn hash_deterministic_and_prefixed(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
3224                let h1 = hash_sha256(&data);
3225                let h2 = hash_sha256(&data);
3226                prop_assert_eq!(&h1, &h2);
3227                prop_assert!(AsRef::<str>::as_ref(&h1).starts_with("sha256:"));
3228            }
3229
3230            #[test]
3231            fn manifest_identifier_format(hex in "[0-9a-f]{64}") {
3232                let digest_str = format!("sha256:{hex}");
3233                let digest: OciDigest = digest_str.parse().unwrap();
3234                let id = manifest_identifier(&digest);
3235                prop_assert!(id.starts_with("oci-manifest-"));
3236                prop_assert!(id.ends_with(&digest_str));
3237            }
3238
3239            #[test]
3240            fn blob_identifier_format(hex in "[0-9a-f]{64}") {
3241                let digest_str = format!("sha256:{hex}");
3242                let digest: OciDigest = digest_str.parse().unwrap();
3243                let id = blob_identifier(&digest);
3244                prop_assert!(id.starts_with("oci-blob-"));
3245                prop_assert!(id.ends_with(&digest_str));
3246            }
3247
3248            #[test]
3249            fn write_read_blob_roundtrip(data in proptest::collection::vec(any::<u8>(), 1..4096)) {
3250                let test_repo = TestRepo::<Sha256HashValue>::new();
3251                let repo = &test_repo.repo;
3252
3253                let (digest, verity) = write_blob(repo, &data).unwrap();
3254                let read_back = open_blob(repo, &digest, Some(&verity)).unwrap();
3255                prop_assert_eq!(read_back, data);
3256            }
3257        }
3258    }
3259
3260    /// Test removing all referrers for a subject at once.
3261    #[test]
3262    fn test_remove_referrers_for_subject() {
3263        let test_repo = TestRepo::<Sha256HashValue>::new();
3264        let repo = &test_repo.repo;
3265
3266        let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64");
3267        let (artifact1_digest, _) = create_test_artifact(repo, b"sig-bulk-1");
3268        let (artifact2_digest, _) = create_test_artifact(repo, b"sig-bulk-2");
3269
3270        add_referrer(repo, &subject_digest, &artifact1_digest).unwrap();
3271        add_referrer(repo, &subject_digest, &artifact2_digest).unwrap();
3272        assert_eq!(list_referrers(repo, &subject_digest).unwrap().len(), 2);
3273
3274        // Remove all referrers for this subject
3275        remove_referrers_for_subject(repo, &subject_digest).unwrap();
3276        assert!(list_referrers(repo, &subject_digest).unwrap().is_empty());
3277
3278        // Idempotent: calling again on an already-empty subject is fine
3279        remove_referrers_for_subject(repo, &subject_digest).unwrap();
3280    }
3281
3282    // ==================== OCI Fsck Tests ====================
3283
3284    #[tokio::test]
3285    async fn test_oci_fsck_healthy_image() {
3286        let test_repo = TestRepo::<Sha256HashValue>::new();
3287        let repo = &test_repo.repo;
3288
3289        create_test_image(repo, Some("healthy:v1"), "amd64");
3290
3291        let result = oci_fsck(repo).await.unwrap();
3292
3293        assert!(
3294            result.is_ok(),
3295            "oci_fsck should pass on healthy repo: {result}"
3296        );
3297        assert_eq!(result.images_checked, 1);
3298        assert_eq!(result.images_corrupted, 0);
3299        assert!(result.repo_result.is_ok());
3300        assert!(result.errors.is_empty());
3301    }
3302
3303    #[tokio::test]
3304    async fn test_oci_fsck_detects_corrupt_manifest() {
3305        let test_repo = TestRepo::<Sha256HashValue>::new();
3306        let repo = &test_repo.repo;
3307
3308        let (manifest_digest, manifest_verity, _) =
3309            create_test_image(repo, Some("corrupt:v1"), "amd64");
3310
3311        // The manifest is stored as an external object in a splitstream.
3312        // Find the object file that holds the manifest JSON and corrupt it.
3313        let manifest_id = manifest_identifier(&manifest_digest);
3314        let mut stream = repo
3315            .open_stream(&manifest_id, Some(&manifest_verity), None)
3316            .unwrap();
3317
3318        let mut object_refs: Vec<Sha256HashValue> = Vec::new();
3319        stream
3320            .get_object_refs(|id| object_refs.push(id.clone()))
3321            .unwrap();
3322        assert!(
3323            !object_refs.is_empty(),
3324            "manifest should have an external object ref"
3325        );
3326
3327        // Corrupt the first (manifest JSON) object on disk.
3328        // Objects may be immutable due to fs-verity, so delete and recreate.
3329        let obj = &object_refs[0];
3330        let hex = obj.to_hex();
3331        let (dir, file) = hex.split_at(2);
3332        let obj_path = test_repo.path().join(format!("objects/{dir}/{file}"));
3333        std::fs::remove_file(&obj_path).unwrap();
3334        std::fs::write(&obj_path, b"not valid manifest json").unwrap();
3335
3336        let result = oci_fsck(repo).await.unwrap();
3337
3338        // The underlying repo fsck should detect the corrupted object
3339        assert!(
3340            !result.is_ok(),
3341            "oci_fsck should fail with corrupted manifest object: {result}"
3342        );
3343        assert!(
3344            result.repo_result().objects_corrupted() > 0,
3345            "repo fsck should detect corrupted object"
3346        );
3347    }
3348
3349    #[tokio::test]
3350    async fn test_oci_fsck_detects_missing_layer() {
3351        let test_repo = TestRepo::<Sha256HashValue>::new();
3352        let repo = &test_repo.repo;
3353
3354        let (manifest_digest, manifest_verity, _) =
3355            create_test_image(repo, Some("missing-layer:v1"), "amd64");
3356
3357        // Open the image to find the layer diff_id
3358        let img = OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).unwrap();
3359        let diff_ids = img.layer_diff_ids();
3360        assert_eq!(diff_ids.len(), 1);
3361
3362        // Find the layer stream and its backing splitstream object, then
3363        // delete the stream symlink so the layer appears missing.
3364        let diff_id_parsed: OciDigest = diff_ids[0].parse().unwrap();
3365        let layer_id = crate::layer_identifier(&diff_id_parsed);
3366        let stream_symlink = test_repo.path().join(format!("streams/{layer_id}"));
3367        std::fs::remove_file(&stream_symlink).unwrap();
3368
3369        let result = oci_fsck(repo).await.unwrap();
3370
3371        assert!(
3372            !result.is_ok(),
3373            "oci_fsck should detect missing layer: {result}"
3374        );
3375        assert!(
3376            result.images_corrupted > 0,
3377            "should report corrupted OCI image"
3378        );
3379        assert!(
3380            result
3381                .errors
3382                .iter()
3383                .any(|e| e.to_string().contains("layer-stream-missing")),
3384            "errors should mention missing layer stream: {:?}",
3385            result.errors
3386        );
3387    }
3388
3389    // ==================== Additional OCI Fsck Gap Tests ====================
3390
3391    #[tokio::test]
3392    async fn test_oci_fsck_detects_config_digest_mismatch() {
3393        // Exercises fsck_single_image config digest mismatch (line ~1109).
3394        // Corrupts the config JSON object so its sha256 hash no longer
3395        // matches the digest recorded in the manifest.
3396        let test_repo = TestRepo::<Sha256HashValue>::new();
3397        let repo = &test_repo.repo;
3398
3399        let (manifest_digest, manifest_verity, config_digest) =
3400            create_test_image(repo, Some("config-corrupt:v1"), "amd64");
3401
3402        // Open image to get config verity, then find and corrupt the config object
3403        let img = OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).unwrap();
3404        let config_verity = img.config_verity.clone();
3405        drop(img);
3406
3407        let config_id = crate::config_identifier(&config_digest);
3408        let mut stream = repo
3409            .open_stream(&config_id, Some(&config_verity), None)
3410            .unwrap();
3411        let mut config_obj_refs: Vec<Sha256HashValue> = Vec::new();
3412        stream
3413            .get_object_refs(|id| config_obj_refs.push(id.clone()))
3414            .unwrap();
3415        assert!(!config_obj_refs.is_empty());
3416
3417        // Corrupt the config object — replace with valid JSON that has
3418        // a different hash
3419        let obj = &config_obj_refs[0];
3420        let hex = obj.to_hex();
3421        let (prefix, rest) = hex.split_at(2);
3422        let dir =
3423            cap_std::fs::Dir::open_ambient_dir(test_repo.path(), cap_std::ambient_authority())
3424                .unwrap();
3425        let obj_rel = format!("objects/{prefix}/{rest}");
3426        dir.remove_file(&obj_rel).unwrap();
3427        // Write valid JSON config but with modified content
3428        dir.write(
3429            &obj_rel,
3430            br#"{"architecture":"arm64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}"#,
3431        )
3432        .unwrap();
3433
3434        let result = oci_fsck(repo).await.unwrap();
3435
3436        // The repo-level fsck will flag the object digest mismatch,
3437        // which makes the overall result not ok.
3438        assert!(
3439            !result.is_ok(),
3440            "oci_fsck should detect config corruption: {result}"
3441        );
3442    }
3443
3444    #[tokio::test]
3445    async fn test_oci_fsck_detects_missing_config_named_ref() {
3446        // Exercises the "manifest missing config reference" branch (line ~1079).
3447        // Deletes the config named ref from the manifest splitstream by
3448        // rewriting the manifest splitstream without the config named ref.
3449        //
3450        // Approach: create a manifest splitstream that stores the manifest
3451        // JSON externally but has NO named ref for the config, then point
3452        // the oci ref to it.
3453        let test_repo = TestRepo::<Sha256HashValue>::new();
3454        let repo = &test_repo.repo;
3455
3456        // Build a valid manifest JSON
3457        let layer_data = b"fake-layer-data";
3458        let layer_digest = hash_sha256(layer_data);
3459
3460        let mut layer_stream = repo
3461            .create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE)
3462            .unwrap();
3463        layer_stream.write_external(layer_data).unwrap();
3464        let layer_verity = repo
3465            .write_stream(layer_stream, &crate::layer_identifier(&layer_digest), None)
3466            .unwrap();
3467
3468        let rootfs = RootFsBuilder::default()
3469            .typ("layers")
3470            .diff_ids(vec![layer_digest.to_string()])
3471            .build()
3472            .unwrap();
3473        let cfg = ConfigBuilder::default().build().unwrap();
3474        let config = ImageConfigurationBuilder::default()
3475            .architecture("amd64")
3476            .os("linux")
3477            .rootfs(rootfs)
3478            .config(cfg)
3479            .build()
3480            .unwrap();
3481        let config_json = config.to_string().unwrap();
3482        let config_digest = hash_sha256(config_json.as_bytes());
3483
3484        // Store config normally
3485        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
3486        config_stream.add_named_stream_ref(layer_digest.as_ref(), &layer_verity);
3487        config_stream
3488            .write_external(config_json.as_bytes())
3489            .unwrap();
3490        let _config_verity = repo
3491            .write_stream(
3492                config_stream,
3493                &crate::config_identifier(&config_digest),
3494                None,
3495            )
3496            .unwrap();
3497
3498        let config_descriptor = DescriptorBuilder::default()
3499            .media_type(MediaType::ImageConfig)
3500            .digest(config_digest.clone())
3501            .size(config_json.len() as u64)
3502            .build()
3503            .unwrap();
3504        let layer_descriptor = DescriptorBuilder::default()
3505            .media_type(MediaType::ImageLayerGzip)
3506            .digest(layer_digest.clone())
3507            .size(layer_data.len() as u64)
3508            .build()
3509            .unwrap();
3510        let manifest = ImageManifestBuilder::default()
3511            .schema_version(2u32)
3512            .media_type(MediaType::ImageManifest)
3513            .config(config_descriptor)
3514            .layers(vec![layer_descriptor])
3515            .build()
3516            .unwrap();
3517
3518        let manifest_json = manifest.to_string().unwrap();
3519        let manifest_digest = hash_sha256(manifest_json.as_bytes());
3520
3521        // Store manifest WITHOUT config named ref — this is the bug we test
3522        let manifest_id = manifest_identifier(&manifest_digest);
3523        let mut manifest_stream = repo.create_stream(OCI_MANIFEST_CONTENT_TYPE).unwrap();
3524        // Deliberately omit: manifest_stream.add_named_stream_ref(...)
3525        manifest_stream
3526            .write_external(manifest_json.as_bytes())
3527            .unwrap();
3528        let _manifest_verity = repo
3529            .write_stream(manifest_stream, &manifest_id, None)
3530            .unwrap();
3531
3532        // Create the OCI ref pointing to this manifest
3533        let ref_path = oci_ref_path("no-config-ref:v1");
3534        let stream_path = format!("streams/{manifest_id}");
3535        repo.symlink(&format!("streams/refs/{ref_path}"), &stream_path)
3536            .unwrap();
3537
3538        let result = oci_fsck_image(repo, "no-config-ref:v1").await.unwrap();
3539
3540        assert!(
3541            !result.is_ok(),
3542            "oci_fsck should detect missing config ref: {result}"
3543        );
3544        assert!(
3545            result
3546                .errors
3547                .iter()
3548                .any(|e| e.to_string().contains("config-ref-missing")),
3549            "errors should mention missing config reference: {:?}",
3550            result.errors
3551        );
3552    }
3553
3554    #[tokio::test]
3555    async fn test_oci_fsck_healthy_artifact() {
3556        // Exercises the artifact validation path (line ~1183).
3557        // Creates a non-container artifact and verifies oci_fsck passes.
3558        let test_repo = TestRepo::<Sha256HashValue>::new();
3559        let repo = &test_repo.repo;
3560
3561        // Create an artifact with non-ImageConfig media type
3562        let blob_data = b"artifact-content-for-fsck-test";
3563        let (blob_digest, blob_verity) = write_blob(repo, blob_data).unwrap();
3564
3565        let empty_config = b"{}";
3566        let config_digest = hash_sha256(empty_config);
3567        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
3568        config_stream.write_external(empty_config).unwrap();
3569        let config_verity = repo
3570            .write_stream(
3571                config_stream,
3572                &crate::config_identifier(&config_digest),
3573                None,
3574            )
3575            .unwrap();
3576
3577        let config_descriptor = DescriptorBuilder::default()
3578            .media_type(MediaType::EmptyJSON) // NOT ImageConfig
3579            .digest(config_digest.clone())
3580            .size(empty_config.len() as u64)
3581            .build()
3582            .unwrap();
3583        let layer_descriptor = DescriptorBuilder::default()
3584            .media_type(MediaType::Other("application/octet-stream".to_string()))
3585            .digest(blob_digest.clone())
3586            .size(blob_data.len() as u64)
3587            .build()
3588            .unwrap();
3589        let manifest = ImageManifestBuilder::default()
3590            .schema_version(2u32)
3591            .media_type(MediaType::ImageManifest)
3592            .config(config_descriptor)
3593            .layers(vec![layer_descriptor])
3594            .build()
3595            .unwrap();
3596
3597        let layer_verities = [(blob_digest.to_string(), blob_verity)];
3598
3599        let manifest_json = manifest.to_string().unwrap();
3600        let manifest_digest = hash_sha256(manifest_json.as_bytes());
3601
3602        write_manifest(
3603            repo,
3604            &manifest,
3605            &manifest_digest,
3606            &config_verity,
3607            &layer_verities,
3608            Some("artifact-fsck:v1"),
3609        )
3610        .unwrap();
3611
3612        let result = oci_fsck(repo).await.unwrap();
3613        assert!(
3614            result.is_ok(),
3615            "oci_fsck should pass for healthy artifact: {result}"
3616        );
3617        assert_eq!(result.images_checked, 1);
3618        assert_eq!(result.images_corrupted, 0);
3619    }
3620
3621    #[tokio::test]
3622    async fn test_oci_fsck_detects_missing_artifact_layer_ref() {
3623        // Exercises the artifact "manifest missing layer reference" branch
3624        // (line ~1198). Creates an artifact where the manifest named refs
3625        // don't include the layer digest.
3626        let test_repo = TestRepo::<Sha256HashValue>::new();
3627        let repo = &test_repo.repo;
3628
3629        let blob_data = b"artifact-blob-missing-ref";
3630        let (blob_digest, _blob_verity) = write_blob(repo, blob_data).unwrap();
3631
3632        let empty_config = b"{}";
3633        let config_digest = hash_sha256(empty_config);
3634        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
3635        config_stream.write_external(empty_config).unwrap();
3636        let config_verity = repo
3637            .write_stream(
3638                config_stream,
3639                &crate::config_identifier(&config_digest),
3640                None,
3641            )
3642            .unwrap();
3643
3644        let config_descriptor = DescriptorBuilder::default()
3645            .media_type(MediaType::EmptyJSON)
3646            .digest(config_digest.clone())
3647            .size(empty_config.len() as u64)
3648            .build()
3649            .unwrap();
3650        let layer_descriptor = DescriptorBuilder::default()
3651            .media_type(MediaType::Other("application/wasm".to_string()))
3652            .digest(blob_digest.clone())
3653            .size(blob_data.len() as u64)
3654            .build()
3655            .unwrap();
3656        let manifest = ImageManifestBuilder::default()
3657            .schema_version(2u32)
3658            .media_type(MediaType::ImageManifest)
3659            .config(config_descriptor)
3660            .layers(vec![layer_descriptor])
3661            .build()
3662            .unwrap();
3663
3664        // Deliberately pass empty layer_verities — no layer refs in manifest
3665        let layer_verities: Vec<(String, Sha256HashValue)> = Vec::new();
3666
3667        let manifest_json = manifest.to_string().unwrap();
3668        let manifest_digest = hash_sha256(manifest_json.as_bytes());
3669
3670        write_manifest(
3671            repo,
3672            &manifest,
3673            &manifest_digest,
3674            &config_verity,
3675            &layer_verities,
3676            Some("artifact-no-layer-ref:v1"),
3677        )
3678        .unwrap();
3679
3680        let result = oci_fsck(repo).await.unwrap();
3681
3682        assert!(
3683            !result.is_ok(),
3684            "oci_fsck should detect missing artifact layer ref: {result}"
3685        );
3686        assert!(
3687            result
3688                .errors
3689                .iter()
3690                .any(|e| e.to_string().contains("artifact-layer-ref-missing")),
3691            "errors should mention missing layer reference: {:?}",
3692            result.errors
3693        );
3694    }
3695
3696    #[tokio::test]
3697    async fn test_oci_fsck_image_unresolvable_ref() {
3698        // Exercises oci_fsck_image with an unresolvable ref (line ~1011).
3699        let test_repo = TestRepo::<Sha256HashValue>::new();
3700        let repo = &test_repo.repo;
3701
3702        let result = oci_fsck_image(repo, "nonexistent:tag").await.unwrap();
3703
3704        assert!(!result.is_ok(), "should fail for nonexistent ref");
3705        assert_eq!(result.images_checked, 1);
3706        assert_eq!(result.images_corrupted, 1);
3707        assert!(
3708            result
3709                .errors
3710                .iter()
3711                .any(|e| e.to_string().contains("ref-resolve-failed")),
3712            "errors should mention cannot resolve ref: {:?}",
3713            result.errors
3714        );
3715    }
3716
3717    #[tokio::test]
3718    async fn test_oci_fsck_multiple_images_partial_corruption() {
3719        // Verifies that oci_fsck checks ALL images and correctly counts
3720        // corrupted vs healthy ones when there's a mix.
3721        let test_repo = TestRepo::<Sha256HashValue>::new();
3722        let repo = &test_repo.repo;
3723
3724        // Create two healthy images
3725        create_test_image(repo, Some("healthy1:v1"), "amd64");
3726        let (manifest_digest2, manifest_verity2, _) =
3727            create_test_image(repo, Some("corrupt1:v1"), "arm64");
3728
3729        // Corrupt the second image's layer
3730        let img = OciImage::open(repo, &manifest_digest2, Some(&manifest_verity2)).unwrap();
3731        let diff_ids = img.layer_diff_ids();
3732        let diff_id_parsed: OciDigest = diff_ids[0].parse().unwrap();
3733        let layer_id = crate::layer_identifier(&diff_id_parsed);
3734        let dir =
3735            cap_std::fs::Dir::open_ambient_dir(test_repo.path(), cap_std::ambient_authority())
3736                .unwrap();
3737        dir.remove_file(format!("streams/{layer_id}")).unwrap();
3738
3739        let result = oci_fsck(repo).await.unwrap();
3740
3741        assert!(!result.is_ok(), "should detect corruption: {result}");
3742        assert_eq!(result.images_checked, 2);
3743        assert_eq!(
3744            result.images_corrupted, 1,
3745            "only one image should be corrupt"
3746        );
3747    }
3748
3749    #[tokio::test]
3750    async fn test_oci_fsck_detects_missing_layer_named_ref_in_config() {
3751        // Exercises the "config missing layer reference" branch (line ~1134).
3752        // Creates a container image where the config splitstream is missing
3753        // the named ref for a layer diff_id.
3754        let test_repo = TestRepo::<Sha256HashValue>::new();
3755        let repo = &test_repo.repo;
3756
3757        let layer_data = b"layer-for-missing-ref-test";
3758        let layer_digest = hash_sha256(layer_data);
3759
3760        let mut layer_stream = repo
3761            .create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE)
3762            .unwrap();
3763        layer_stream.write_external(layer_data).unwrap();
3764        let layer_verity = repo
3765            .write_stream(layer_stream, &crate::layer_identifier(&layer_digest), None)
3766            .unwrap();
3767
3768        let rootfs = RootFsBuilder::default()
3769            .typ("layers")
3770            .diff_ids(vec![layer_digest.to_string()])
3771            .build()
3772            .unwrap();
3773        let cfg = ConfigBuilder::default().build().unwrap();
3774        let config = ImageConfigurationBuilder::default()
3775            .architecture("amd64")
3776            .os("linux")
3777            .rootfs(rootfs)
3778            .config(cfg)
3779            .build()
3780            .unwrap();
3781        let config_json = config.to_string().unwrap();
3782        let config_digest = hash_sha256(config_json.as_bytes());
3783
3784        // Store config WITHOUT the layer named ref — this is the bug
3785        let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
3786        // Deliberately omit: config_stream.add_named_stream_ref(&layer_digest, &layer_verity);
3787        config_stream
3788            .write_external(config_json.as_bytes())
3789            .unwrap();
3790        let config_verity = repo
3791            .write_stream(
3792                config_stream,
3793                &crate::config_identifier(&config_digest),
3794                None,
3795            )
3796            .unwrap();
3797
3798        let config_descriptor = DescriptorBuilder::default()
3799            .media_type(MediaType::ImageConfig)
3800            .digest(config_digest.clone())
3801            .size(config_json.len() as u64)
3802            .build()
3803            .unwrap();
3804        let layer_descriptor = DescriptorBuilder::default()
3805            .media_type(MediaType::ImageLayerGzip)
3806            .digest(layer_digest.clone())
3807            .size(layer_data.len() as u64)
3808            .build()
3809            .unwrap();
3810        let manifest = ImageManifestBuilder::default()
3811            .schema_version(2u32)
3812            .media_type(MediaType::ImageManifest)
3813            .config(config_descriptor)
3814            .layers(vec![layer_descriptor])
3815            .build()
3816            .unwrap();
3817
3818        let layer_verities = [(layer_digest.to_string(), layer_verity)];
3819        let manifest_json = manifest.to_string().unwrap();
3820        let manifest_digest = hash_sha256(manifest_json.as_bytes());
3821
3822        write_manifest(
3823            repo,
3824            &manifest,
3825            &manifest_digest,
3826            &config_verity,
3827            &layer_verities,
3828            Some("missing-layer-ref:v1"),
3829        )
3830        .unwrap();
3831
3832        let result = oci_fsck(repo).await.unwrap();
3833
3834        assert!(
3835            !result.is_ok(),
3836            "oci_fsck should detect missing layer ref in config: {result}"
3837        );
3838        assert!(
3839            result
3840                .errors
3841                .iter()
3842                .any(|e| e.to_string().contains("layer-ref-missing")),
3843            "errors should mention config missing layer reference: {:?}",
3844            result.errors
3845        );
3846    }
3847}