Skip to main content

act_store/
store.rs

1//! `Store` orchestrator: composes layout + index + provenance under a lock.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6
7use oci_spec::image::{
8    Descriptor, DescriptorBuilder, ImageManifest, ImageManifestBuilder, MediaType, SCHEMA_VERSION,
9    Sha256Digest,
10};
11
12use crate::index::{self, IndexError};
13use crate::layout;
14use crate::lock::StoreLock;
15use crate::provenance::{Provenance, ProvenanceError};
16
17const WASM_MEDIA_TYPE: &str = "application/wasm";
18const CONFIG_MEDIA_TYPE: &str = "application/vnd.actcore.component.config.v1+cbor";
19// CBOR empty map (0xA0) — valid CBOR matching CONFIG_MEDIA_TYPE, used when a
20// component carries no `act:component` metadata.
21const EMPTY_CONFIG: &[u8] = &[0xA0];
22
23/// A component as recorded in the store.
24#[derive(Debug, Clone)]
25pub struct Stored {
26    /// Hex digest of the OCI manifest in `index.json`.
27    pub manifest_digest: String,
28    /// Hex digest of the wasm layer blob.
29    pub wasm_digest: String,
30    pub provenance: Provenance,
31}
32
33/// Handle to an on-disk OCI-layout store.
34#[derive(Debug, Clone)]
35pub struct Store {
36    root: PathBuf,
37}
38
39#[derive(Debug, thiserror::Error)]
40pub enum StoreError {
41    #[error(transparent)]
42    Index(#[from] IndexError),
43    #[error(transparent)]
44    Provenance(#[from] ProvenanceError),
45    #[error("io error: {0}")]
46    Io(#[from] std::io::Error),
47    #[error("oci-spec error: {0}")]
48    Oci(#[from] oci_spec::OciSpecError),
49    #[error("invalid digest `{0}`")]
50    Digest(String),
51}
52
53impl Store {
54    /// Open (and initialize) a store at `root`.
55    pub fn open(root: &Path) -> Result<Self, StoreError> {
56        layout::init(root)?;
57        Ok(Self {
58            root: root.to_path_buf(),
59        })
60    }
61
62    pub fn root(&self) -> &Path {
63        &self.root
64    }
65
66    fn descriptor_for(
67        &self,
68        hex: &str,
69        size: u64,
70        media_type: &str,
71    ) -> Result<Descriptor, StoreError> {
72        let digest = Sha256Digest::from_str(hex).map_err(|_| StoreError::Digest(hex.into()))?;
73        Ok(DescriptorBuilder::default()
74            .media_type(MediaType::Other(media_type.to_string()))
75            .digest(digest)
76            .size(size)
77            .build()?)
78    }
79
80    /// Store a component: write the wasm layer + config blobs, synthesize the
81    /// OCI manifest, and upsert its descriptor (with provenance annotations)
82    /// into `index.json`. `config_bytes` is the `act:component` CBOR metadata
83    /// (or `None` for an empty config). Holds the exclusive lock.
84    pub fn put_component(
85        &self,
86        wasm: &[u8],
87        config_bytes: Option<&[u8]>,
88        provenance: &Provenance,
89    ) -> Result<Stored, StoreError> {
90        let _lock = StoreLock::exclusive(&self.root)?;
91
92        let wasm_hex = layout::write_blob(&self.root, wasm)?;
93        let wasm_desc = self.descriptor_for(&wasm_hex, wasm.len() as u64, WASM_MEDIA_TYPE)?;
94
95        let cfg = config_bytes.unwrap_or(EMPTY_CONFIG);
96        let cfg_hex = layout::write_blob(&self.root, cfg)?;
97        let cfg_desc = self.descriptor_for(&cfg_hex, cfg.len() as u64, CONFIG_MEDIA_TYPE)?;
98
99        let manifest: ImageManifest = ImageManifestBuilder::default()
100            .schema_version(SCHEMA_VERSION)
101            .media_type(MediaType::ImageManifest)
102            .config(cfg_desc)
103            .layers(vec![wasm_desc])
104            .build()?;
105        let manifest_json = serde_json::to_vec(&manifest)
106            .map_err(|e| StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
107        let manifest_hex = layout::write_blob(&self.root, &manifest_json)?;
108
109        let annotations: HashMap<String, String> = provenance.to_annotations();
110        let desc =
111            index::manifest_descriptor(&manifest_hex, manifest_json.len() as u64, annotations)?;
112
113        let idx = index::load(&self.root)?;
114        let mut manifests = idx.manifests().clone();
115        index::upsert(&mut manifests, desc);
116        let idx = index::build_index(manifests);
117        index::save(&self.root, &idx)?;
118
119        Ok(Stored {
120            manifest_digest: manifest_hex,
121            wasm_digest: wasm_hex,
122            provenance: provenance.clone(),
123        })
124    }
125
126    /// Store an OCI artifact whose manifest already exists upstream, **verbatim**:
127    /// write `manifest_bytes` and every blob in `blobs` content-addressed, then
128    /// upsert an index descriptor pointing at the manifest's own digest (so the
129    /// upstream digest — which signatures are computed over — is preserved).
130    /// `blobs` are `(expected_hex, bytes)` for the config and every layer.
131    /// Holds the exclusive lock.
132    pub fn put_oci_artifact(
133        &self,
134        manifest_bytes: &[u8],
135        blobs: &[(String, Vec<u8>)],
136        provenance: &Provenance,
137    ) -> Result<Stored, StoreError> {
138        let _lock = StoreLock::exclusive(&self.root)?;
139
140        for (expected_hex, bytes) in blobs {
141            let got = layout::write_blob(&self.root, bytes)?;
142            if &got != expected_hex {
143                return Err(StoreError::Digest(format!(
144                    "blob digest mismatch: expected {expected_hex}, got {got}"
145                )));
146            }
147        }
148        let manifest_hex = layout::write_blob(&self.root, manifest_bytes)?;
149
150        let manifest: ImageManifest = serde_json::from_slice(manifest_bytes)
151            .map_err(|e| StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
152        let wasm_digest = manifest
153            .layers()
154            .first()
155            .map(index::digest_hex)
156            .unwrap_or_default();
157
158        let annotations: HashMap<String, String> = provenance.to_annotations();
159        let desc =
160            index::manifest_descriptor(&manifest_hex, manifest_bytes.len() as u64, annotations)?;
161
162        let idx = index::load(&self.root)?;
163        let mut manifests = idx.manifests().clone();
164        index::upsert(&mut manifests, desc);
165        let idx = index::build_index(manifests);
166        index::save(&self.root, &idx)?;
167
168        Ok(Stored {
169            manifest_digest: manifest_hex,
170            wasm_digest,
171            provenance: provenance.clone(),
172        })
173    }
174
175    /// Store a connected artifact (referrer) verbatim: write its manifest +
176    /// blobs content-addressed and index it with `dev.actcore.referrer.subject`
177    /// (= `subject_digest`, a `sha256:...` string) and `dev.actcore.referrer.kind`
178    /// annotations + `artifactType`. Deduped by referrer manifest digest. Returns
179    /// the referrer manifest hex. Holds the exclusive lock.
180    pub fn put_referrer(
181        &self,
182        manifest_bytes: &[u8],
183        blobs: &[(String, Vec<u8>)],
184        subject_digest: &str,
185        artifact_type: Option<&str>,
186    ) -> Result<String, StoreError> {
187        use crate::referrer::{K_KIND, K_SUBJECT, referrer_kind};
188        let _lock = StoreLock::exclusive(&self.root)?;
189
190        for (expected_hex, bytes) in blobs {
191            let got = layout::write_blob(&self.root, bytes)?;
192            if &got != expected_hex {
193                return Err(StoreError::Digest(format!(
194                    "referrer blob digest mismatch: expected {expected_hex}, got {got}"
195                )));
196            }
197        }
198        let ref_hex = layout::write_blob(&self.root, manifest_bytes)?;
199
200        let mut annotations: HashMap<String, String> = HashMap::new();
201        annotations.insert(K_SUBJECT.to_string(), subject_digest.to_string());
202        annotations.insert(K_KIND.to_string(), referrer_kind(artifact_type).to_string());
203
204        let digest =
205            Sha256Digest::from_str(&ref_hex).map_err(|_| StoreError::Digest(ref_hex.clone()))?;
206        let mut builder = DescriptorBuilder::default()
207            .media_type(MediaType::ImageManifest)
208            .digest(digest)
209            .size(manifest_bytes.len() as u64)
210            .annotations(annotations);
211        if let Some(at) = artifact_type {
212            builder = builder.artifact_type(MediaType::Other(at.to_string()));
213        }
214        let desc = builder.build()?;
215
216        let idx = index::load(&self.root)?;
217        let mut manifests = idx.manifests().clone();
218        index::upsert_by_digest(&mut manifests, desc);
219        let idx = index::build_index(manifests);
220        index::save(&self.root, &idx)?;
221        Ok(ref_hex)
222    }
223
224    /// List referrers attached to the component manifest with hex digest
225    /// `subject_hex` (no `sha256:` prefix).
226    pub fn list_referrers_by_digest(
227        &self,
228        subject_hex: &str,
229    ) -> Result<Vec<crate::referrer::ReferrerInfo>, StoreError> {
230        use crate::referrer::{K_KIND, K_SUBJECT, ReferrerInfo, referrer_kind};
231        let _lock = StoreLock::shared(&self.root)?;
232        let idx = index::load(&self.root)?;
233        let mut out = Vec::new();
234        for d in idx.manifests() {
235            let Some(ann) = d.annotations() else {
236                continue;
237            };
238            let Some(subj) = ann.get(K_SUBJECT) else {
239                continue;
240            };
241            if subj.rsplit(':').next().unwrap_or(subj) != subject_hex {
242                continue;
243            }
244            let hex = index::digest_hex(d);
245            let artifact_type = d.artifact_type().as_ref().map(|m| m.to_string());
246            let kind = ann
247                .get(K_KIND)
248                .cloned()
249                .unwrap_or_else(|| referrer_kind(artifact_type.as_deref()).to_string());
250            out.push(ReferrerInfo {
251                digest: hex.clone(),
252                artifact_type,
253                kind,
254                manifest_path: layout::blob_path(&self.root, &hex),
255            });
256        }
257        Ok(out)
258    }
259
260    /// List referrers attached to the component identified by `component_ref`
261    /// (source ref as typed). Empty if the component is not stored.
262    pub fn list_referrers(
263        &self,
264        component_ref: &str,
265        kind_filter: Option<&str>,
266    ) -> Result<Vec<crate::referrer::ReferrerInfo>, StoreError> {
267        let subject_hex = {
268            let _lock = StoreLock::shared(&self.root)?;
269            let idx = index::load(&self.root)?;
270            let canonical = crate::fetch::lookup_ref(component_ref);
271            match index::find_by_ref(idx.manifests(), &canonical) {
272                Some(d) => index::digest_hex(d),
273                None => return Ok(Vec::new()),
274            }
275        };
276        let mut refs = self.list_referrers_by_digest(&subject_hex)?;
277        if let Some(k) = kind_filter {
278            refs.retain(|r| r.kind == k);
279        }
280        Ok(refs)
281    }
282
283    /// Resolve a stored component by its source ref to the path of its wasm
284    /// layer blob. Returns `Ok(None)` if not present. Holds a shared lock.
285    pub fn resolve(&self, reference: &str) -> Result<Option<PathBuf>, StoreError> {
286        let _lock = StoreLock::shared(&self.root)?;
287        let idx = index::load(&self.root)?;
288        let manifests = idx.manifests();
289        let Some(desc) = index::find_by_ref(manifests, reference) else {
290            return Ok(None);
291        };
292        let manifest_hex = index::digest_hex(desc);
293        let manifest_bytes = layout::read_blob(&self.root, &manifest_hex)?;
294        let manifest: ImageManifest = serde_json::from_slice(&manifest_bytes)
295            .map_err(|e| StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
296        let layer = manifest.layers().first().ok_or_else(|| {
297            StoreError::Io(std::io::Error::new(
298                std::io::ErrorKind::InvalidData,
299                "manifest has no layers",
300            ))
301        })?;
302        let wasm_hex = index::digest_hex(layer);
303        Ok(Some(layout::blob_path(&self.root, &wasm_hex)))
304    }
305
306    /// List every stored component with its provenance. Holds a shared lock.
307    /// Descriptors with unparseable annotations are skipped (defensive).
308    pub fn list(&self) -> Result<Vec<Stored>, StoreError> {
309        let _lock = StoreLock::shared(&self.root)?;
310        let idx = index::load(&self.root)?;
311        let mut out = Vec::new();
312        for desc in idx.manifests() {
313            let Some(ann) = desc.annotations() else {
314                continue;
315            };
316            let Ok(provenance) = Provenance::from_annotations(ann) else {
317                continue;
318            };
319            let manifest_hex = index::digest_hex(desc);
320            let wasm_digest = self.wasm_digest_of(&manifest_hex).unwrap_or_default();
321            out.push(Stored {
322                manifest_digest: manifest_hex,
323                wasm_digest,
324                provenance,
325            });
326        }
327        Ok(out)
328    }
329
330    /// Hex digest of the first layer of the manifest blob `manifest_hex`.
331    fn wasm_digest_of(&self, manifest_hex: &str) -> Result<String, StoreError> {
332        let bytes = layout::read_blob(&self.root, manifest_hex)?;
333        let manifest: ImageManifest = serde_json::from_slice(&bytes)
334            .map_err(|e| StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
335        Ok(manifest
336            .layers()
337            .first()
338            .map(index::digest_hex)
339            .unwrap_or_default())
340    }
341
342    /// Delete blobs not reachable from `index.json`. Returns the count
343    /// removed. Holds the exclusive lock.
344    pub fn gc(&self) -> Result<usize, StoreError> {
345        let _lock = StoreLock::exclusive(&self.root)?;
346        let idx = index::load(&self.root)?;
347        let root = self.root.clone();
348        let reachable = index::reachable_digests(&idx, move |hex| {
349            layout::read_blob(&root, hex).map_err(IndexError::Io)
350        })?;
351
352        let mut removed = 0;
353        let blobs = layout::blobs_dir(&self.root);
354        if blobs.is_dir() {
355            for entry in std::fs::read_dir(&blobs)? {
356                let entry = entry?;
357                let name = entry.file_name();
358                let Some(hex) = name.to_str() else {
359                    continue;
360                };
361                if hex.starts_with('.') {
362                    continue;
363                }
364                if !reachable.contains(hex) {
365                    std::fs::remove_file(entry.path())?;
366                    removed += 1;
367                }
368            }
369        }
370        Ok(removed)
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::provenance::{Provenance, Source};
378    use tempfile::TempDir;
379
380    fn prov(reference: &str, wasm: &[u8]) -> Provenance {
381        Provenance {
382            source: Source::Oci {
383                reference: reference.into(),
384            },
385            digest: format!("sha256:{}", crate::layout::sha256_hex(wasm)),
386            fetched_at: "2026-05-26T00:00:00Z".into(),
387            name: Some("demo".into()),
388            version: Some("0.1.0".into()),
389        }
390    }
391
392    #[test]
393    fn put_component_writes_blobs_and_indexes_it() {
394        let dir = TempDir::new().unwrap();
395        let store = Store::open(dir.path()).unwrap();
396        let wasm = b"\0asm\x01\0\0\0fake-component";
397        let stored = store
398            .put_component(wasm, None, &prov("oci://ghcr.io/x/demo:0.1.0", wasm))
399            .unwrap();
400        assert!(crate::layout::has_blob(
401            dir.path(),
402            &crate::layout::sha256_hex(wasm)
403        ));
404        assert_eq!(crate::index::load(dir.path()).unwrap().manifests().len(), 1);
405        assert_eq!(stored.provenance.name.as_deref(), Some("demo"));
406    }
407
408    #[test]
409    fn put_same_ref_twice_replaces_not_duplicates() {
410        let dir = TempDir::new().unwrap();
411        let store = Store::open(dir.path()).unwrap();
412        let v1 = b"component-v1";
413        let v2 = b"component-v2";
414        store
415            .put_component(v1, None, &prov("oci://ghcr.io/x/demo:latest", v1))
416            .unwrap();
417        store
418            .put_component(v2, None, &prov("oci://ghcr.io/x/demo:latest", v2))
419            .unwrap();
420        assert_eq!(
421            crate::index::load(dir.path()).unwrap().manifests().len(),
422            1,
423            "same ref repointed, not duplicated"
424        );
425    }
426
427    #[test]
428    fn resolve_returns_wasm_blob_path_for_known_ref() {
429        let dir = TempDir::new().unwrap();
430        let store = Store::open(dir.path()).unwrap();
431        let wasm = b"resolvable-component";
432        store
433            .put_component(wasm, None, &prov("oci://ghcr.io/x/demo:0.1.0", wasm))
434            .unwrap();
435        let path = store
436            .resolve("oci://ghcr.io/x/demo:0.1.0")
437            .unwrap()
438            .expect("hit");
439        assert_eq!(std::fs::read(path).unwrap(), wasm);
440        assert!(
441            store
442                .resolve("oci://ghcr.io/x/missing:0.1.0")
443                .unwrap()
444                .is_none()
445        );
446    }
447
448    #[test]
449    fn list_returns_provenance_for_each_stored_component() {
450        let dir = TempDir::new().unwrap();
451        let store = Store::open(dir.path()).unwrap();
452        let a = b"comp-a";
453        let b = b"comp-b";
454        store
455            .put_component(a, None, &prov("oci://ghcr.io/x/a:1", a))
456            .unwrap();
457        store
458            .put_component(b, None, &prov("oci://ghcr.io/x/b:1", b))
459            .unwrap();
460        let mut refs: Vec<String> = store
461            .list()
462            .unwrap()
463            .into_iter()
464            .map(|s| match s.provenance.source {
465                crate::provenance::Source::Oci { reference } => reference,
466                _ => unreachable!(),
467            })
468            .collect();
469        refs.sort();
470        assert_eq!(refs, vec!["oci://ghcr.io/x/a:1", "oci://ghcr.io/x/b:1"]);
471    }
472
473    #[test]
474    fn put_oci_artifact_stores_manifest_verbatim_and_resolves() {
475        let dir = TempDir::new().unwrap();
476        let store = Store::open(dir.path()).unwrap();
477
478        let wasm = b"\0asm\x01\0\0\0verbatim";
479        let wasm_hex = crate::layout::sha256_hex(wasm);
480        let cfg = b"\xA0"; // CBOR empty map
481        let cfg_hex = crate::layout::sha256_hex(cfg);
482        let manifest_json = format!(
483            r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{{"mediaType":"application/vnd.actcore.component.config.v1+cbor","digest":"sha256:{cfg_hex}","size":{cfg_len}}},"layers":[{{"mediaType":"application/wasm","digest":"sha256:{wasm_hex}","size":{wasm_len}}}]}}"#,
484            cfg_len = cfg.len(),
485            wasm_len = wasm.len(),
486        );
487        let manifest_bytes = manifest_json.into_bytes();
488        let upstream_digest = crate::layout::sha256_hex(&manifest_bytes);
489
490        let prov = Provenance {
491            source: Source::Oci {
492                reference: "oci://ghcr.io/x/verb:1".into(),
493            },
494            digest: format!("sha256:{upstream_digest}"),
495            fetched_at: "2026-05-26T00:00:00Z".into(),
496            name: Some("verb".into()),
497            version: Some("1".into()),
498        };
499
500        let stored = store
501            .put_oci_artifact(
502                &manifest_bytes,
503                &[
504                    (wasm_hex.clone(), wasm.to_vec()),
505                    (cfg_hex.clone(), cfg.to_vec()),
506                ],
507                &prov,
508            )
509            .unwrap();
510
511        assert_eq!(stored.manifest_digest, upstream_digest);
512        let path = store
513            .resolve("oci://ghcr.io/x/verb:1")
514            .unwrap()
515            .expect("hit");
516        assert_eq!(std::fs::read(path).unwrap(), wasm);
517    }
518
519    #[test]
520    fn gc_deletes_orphan_blobs_keeps_referenced() {
521        let dir = TempDir::new().unwrap();
522        let store = Store::open(dir.path()).unwrap();
523        let wasm = b"kept-component";
524        store
525            .put_component(wasm, None, &prov("oci://ghcr.io/x/keep:1", wasm))
526            .unwrap();
527        let orphan = crate::layout::write_blob(dir.path(), b"orphan-bytes").unwrap();
528        assert!(crate::layout::has_blob(dir.path(), &orphan));
529        let reclaimed = store.gc().unwrap();
530        assert_eq!(reclaimed, 1, "exactly one orphan blob removed");
531        assert!(!crate::layout::has_blob(dir.path(), &orphan));
532        assert!(store.resolve("oci://ghcr.io/x/keep:1").unwrap().is_some());
533    }
534
535    #[test]
536    fn put_referrer_indexes_with_subject_and_kind() {
537        let dir = TempDir::new().unwrap();
538        let store = Store::open(dir.path()).unwrap();
539        let subject = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
540        let referrer_manifest = br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[]}"#.to_vec();
541        let empty_cfg = b"{}".to_vec();
542        let cfg_hex = crate::layout::sha256_hex(&empty_cfg);
543        let ref_hex = store
544            .put_referrer(
545                &referrer_manifest,
546                &[(cfg_hex, empty_cfg)],
547                subject,
548                Some("application/vnd.dev.sigstore.bundle.v0.3+json"),
549            )
550            .unwrap();
551        let refs = store
552            .list_referrers_by_digest(
553                "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
554            )
555            .unwrap();
556        assert_eq!(refs.len(), 1);
557        assert_eq!(refs[0].digest, ref_hex);
558        assert_eq!(refs[0].kind, "sigstore-bundle");
559        assert!(refs[0].manifest_path.is_file());
560    }
561
562    #[test]
563    fn list_referrers_by_ref_resolves_component_digest() {
564        let dir = TempDir::new().unwrap();
565        let store = Store::open(dir.path()).unwrap();
566        let wasm = b"\0asm\x01\0\0\0comp";
567        let stored = store
568            .put_component(wasm, None, &prov("oci://ghcr.io/x/c:1", wasm))
569            .unwrap();
570        let subject = format!("sha256:{}", stored.manifest_digest);
571        let m = br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[]}"#.to_vec();
572        let cfg = b"{}".to_vec();
573        let cfg_hex = crate::layout::sha256_hex(&cfg);
574        store
575            .put_referrer(
576                &m,
577                &[(cfg_hex, cfg)],
578                &subject,
579                Some("application/vnd.dev.cosign.simplesigning.v1+json"),
580            )
581            .unwrap();
582        let found = store.list_referrers("oci://ghcr.io/x/c:1", None).unwrap();
583        assert_eq!(found.len(), 1);
584        assert_eq!(found[0].kind, "cosign-signature");
585        // kind filter
586        assert!(
587            store
588                .list_referrers("oci://ghcr.io/x/c:1", Some("sbom"))
589                .unwrap()
590                .is_empty()
591        );
592    }
593
594    #[test]
595    fn gc_collects_referrer_when_component_repointed_away() {
596        let dir = TempDir::new().unwrap();
597        let store = Store::open(dir.path()).unwrap();
598        let v1 = b"component-v1";
599        let s1 = store
600            .put_component(v1, None, &prov("oci://ghcr.io/x/c:latest", v1))
601            .unwrap();
602        let subject_v1 = format!("sha256:{}", s1.manifest_digest);
603        let m = br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[]}"#.to_vec();
604        let cfg = b"{}".to_vec();
605        let cfg_hex = crate::layout::sha256_hex(&cfg);
606        let ref_hex = store
607            .put_referrer(
608                &m,
609                &[(cfg_hex, cfg)],
610                &subject_v1,
611                Some("application/spdx+json"),
612            )
613            .unwrap();
614        assert!(crate::layout::has_blob(dir.path(), &ref_hex));
615        let v2 = b"component-v2";
616        store
617            .put_component(v2, None, &prov("oci://ghcr.io/x/c:latest", v2))
618            .unwrap();
619        store.gc().unwrap();
620        assert!(
621            !crate::layout::has_blob(dir.path(), &ref_hex),
622            "referrer of removed v1 collected"
623        );
624        assert!(store.resolve("oci://ghcr.io/x/c:latest").unwrap().is_some());
625    }
626}