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
246                .artifact_type()
247                .as_ref()
248                .map(std::string::ToString::to_string);
249            let kind = ann
250                .get(K_KIND)
251                .cloned()
252                .unwrap_or_else(|| referrer_kind(artifact_type.as_deref()).to_string());
253            out.push(ReferrerInfo {
254                digest: hex.clone(),
255                artifact_type,
256                kind,
257                manifest_path: layout::blob_path(&self.root, &hex),
258            });
259        }
260        Ok(out)
261    }
262
263    /// List referrers attached to the component identified by `component_ref`
264    /// (source ref as typed). Empty if the component is not stored.
265    pub fn list_referrers(
266        &self,
267        component_ref: &str,
268        kind_filter: Option<&str>,
269    ) -> Result<Vec<crate::referrer::ReferrerInfo>, StoreError> {
270        let subject_hex = {
271            let _lock = StoreLock::shared(&self.root)?;
272            let idx = index::load(&self.root)?;
273            let canonical = crate::fetch::lookup_ref(component_ref);
274            match index::find_by_ref(idx.manifests(), &canonical) {
275                Some(d) => index::digest_hex(d),
276                None => return Ok(Vec::new()),
277            }
278        };
279        let mut refs = self.list_referrers_by_digest(&subject_hex)?;
280        if let Some(k) = kind_filter {
281            refs.retain(|r| r.kind == k);
282        }
283        Ok(refs)
284    }
285
286    /// Resolve a stored component by its source ref to the path of its wasm
287    /// layer blob. Returns `Ok(None)` if not present. Holds a shared lock.
288    pub fn resolve(&self, reference: &str) -> Result<Option<PathBuf>, StoreError> {
289        let _lock = StoreLock::shared(&self.root)?;
290        let idx = index::load(&self.root)?;
291        let manifests = idx.manifests();
292        let Some(desc) = index::find_by_ref(manifests, reference) else {
293            return Ok(None);
294        };
295        let manifest_hex = index::digest_hex(desc);
296        let manifest_bytes = layout::read_blob(&self.root, &manifest_hex)?;
297        let manifest: ImageManifest = serde_json::from_slice(&manifest_bytes)
298            .map_err(|e| StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
299        let layer = manifest.layers().first().ok_or_else(|| {
300            StoreError::Io(std::io::Error::new(
301                std::io::ErrorKind::InvalidData,
302                "manifest has no layers",
303            ))
304        })?;
305        let wasm_hex = index::digest_hex(layer);
306        Ok(Some(layout::blob_path(&self.root, &wasm_hex)))
307    }
308
309    /// List every stored component with its provenance. Holds a shared lock.
310    /// Descriptors with unparseable annotations are skipped (defensive).
311    pub fn list(&self) -> Result<Vec<Stored>, StoreError> {
312        let _lock = StoreLock::shared(&self.root)?;
313        let idx = index::load(&self.root)?;
314        let mut out = Vec::new();
315        for desc in idx.manifests() {
316            let Some(ann) = desc.annotations() else {
317                continue;
318            };
319            let Ok(provenance) = Provenance::from_annotations(ann) else {
320                continue;
321            };
322            let manifest_hex = index::digest_hex(desc);
323            let wasm_digest = self.wasm_digest_of(&manifest_hex).unwrap_or_default();
324            out.push(Stored {
325                manifest_digest: manifest_hex,
326                wasm_digest,
327                provenance,
328            });
329        }
330        Ok(out)
331    }
332
333    /// Hex digest of the first layer of the manifest blob `manifest_hex`.
334    fn wasm_digest_of(&self, manifest_hex: &str) -> Result<String, StoreError> {
335        let bytes = layout::read_blob(&self.root, manifest_hex)?;
336        let manifest: ImageManifest = serde_json::from_slice(&bytes)
337            .map_err(|e| StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
338        Ok(manifest
339            .layers()
340            .first()
341            .map(index::digest_hex)
342            .unwrap_or_default())
343    }
344
345    /// Delete blobs not reachable from `index.json`. Returns the count
346    /// removed. Holds the exclusive lock.
347    pub fn gc(&self) -> Result<usize, StoreError> {
348        let _lock = StoreLock::exclusive(&self.root)?;
349        let idx = index::load(&self.root)?;
350        let root = self.root.clone();
351        let reachable = index::reachable_digests(&idx, move |hex| {
352            layout::read_blob(&root, hex).map_err(IndexError::Io)
353        })?;
354
355        let mut removed = 0;
356        let blobs = layout::blobs_dir(&self.root);
357        if blobs.is_dir() {
358            for entry in std::fs::read_dir(&blobs)? {
359                let entry = entry?;
360                let name = entry.file_name();
361                let Some(hex) = name.to_str() else {
362                    continue;
363                };
364                if hex.starts_with('.') {
365                    continue;
366                }
367                if !reachable.contains(hex) {
368                    std::fs::remove_file(entry.path())?;
369                    removed += 1;
370                }
371            }
372        }
373        Ok(removed)
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::provenance::{Provenance, Source};
381    use tempfile::TempDir;
382
383    fn prov(reference: &str, wasm: &[u8]) -> Provenance {
384        Provenance {
385            source: Source::Oci {
386                reference: reference.into(),
387            },
388            digest: format!("sha256:{}", crate::layout::sha256_hex(wasm)),
389            fetched_at: "2026-05-26T00:00:00Z".into(),
390            name: Some("demo".into()),
391            version: Some("0.1.0".into()),
392        }
393    }
394
395    #[test]
396    fn put_component_writes_blobs_and_indexes_it() {
397        let dir = TempDir::new().unwrap();
398        let store = Store::open(dir.path()).unwrap();
399        let wasm = b"\0asm\x01\0\0\0fake-component";
400        let stored = store
401            .put_component(wasm, None, &prov("oci://ghcr.io/x/demo:0.1.0", wasm))
402            .unwrap();
403        assert!(crate::layout::has_blob(
404            dir.path(),
405            &crate::layout::sha256_hex(wasm)
406        ));
407        assert_eq!(crate::index::load(dir.path()).unwrap().manifests().len(), 1);
408        assert_eq!(stored.provenance.name.as_deref(), Some("demo"));
409    }
410
411    #[test]
412    fn put_same_ref_twice_replaces_not_duplicates() {
413        let dir = TempDir::new().unwrap();
414        let store = Store::open(dir.path()).unwrap();
415        let v1 = b"component-v1";
416        let v2 = b"component-v2";
417        store
418            .put_component(v1, None, &prov("oci://ghcr.io/x/demo:latest", v1))
419            .unwrap();
420        store
421            .put_component(v2, None, &prov("oci://ghcr.io/x/demo:latest", v2))
422            .unwrap();
423        assert_eq!(
424            crate::index::load(dir.path()).unwrap().manifests().len(),
425            1,
426            "same ref repointed, not duplicated"
427        );
428    }
429
430    #[test]
431    fn resolve_returns_wasm_blob_path_for_known_ref() {
432        let dir = TempDir::new().unwrap();
433        let store = Store::open(dir.path()).unwrap();
434        let wasm = b"resolvable-component";
435        store
436            .put_component(wasm, None, &prov("oci://ghcr.io/x/demo:0.1.0", wasm))
437            .unwrap();
438        let path = store
439            .resolve("oci://ghcr.io/x/demo:0.1.0")
440            .unwrap()
441            .expect("hit");
442        assert_eq!(std::fs::read(path).unwrap(), wasm);
443        assert!(
444            store
445                .resolve("oci://ghcr.io/x/missing:0.1.0")
446                .unwrap()
447                .is_none()
448        );
449    }
450
451    #[test]
452    fn list_returns_provenance_for_each_stored_component() {
453        let dir = TempDir::new().unwrap();
454        let store = Store::open(dir.path()).unwrap();
455        let a = b"comp-a";
456        let b = b"comp-b";
457        store
458            .put_component(a, None, &prov("oci://ghcr.io/x/a:1", a))
459            .unwrap();
460        store
461            .put_component(b, None, &prov("oci://ghcr.io/x/b:1", b))
462            .unwrap();
463        let mut refs: Vec<String> = store
464            .list()
465            .unwrap()
466            .into_iter()
467            .map(|s| match s.provenance.source {
468                crate::provenance::Source::Oci { reference } => reference,
469                _ => unreachable!(),
470            })
471            .collect();
472        refs.sort();
473        assert_eq!(refs, vec!["oci://ghcr.io/x/a:1", "oci://ghcr.io/x/b:1"]);
474    }
475
476    #[test]
477    fn put_oci_artifact_stores_manifest_verbatim_and_resolves() {
478        let dir = TempDir::new().unwrap();
479        let store = Store::open(dir.path()).unwrap();
480
481        let wasm = b"\0asm\x01\0\0\0verbatim";
482        let wasm_hex = crate::layout::sha256_hex(wasm);
483        let cfg = b"\xA0"; // CBOR empty map
484        let cfg_hex = crate::layout::sha256_hex(cfg);
485        let manifest_json = format!(
486            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}}}]}}"#,
487            cfg_len = cfg.len(),
488            wasm_len = wasm.len(),
489        );
490        let manifest_bytes = manifest_json.into_bytes();
491        let upstream_digest = crate::layout::sha256_hex(&manifest_bytes);
492
493        let prov = Provenance {
494            source: Source::Oci {
495                reference: "oci://ghcr.io/x/verb:1".into(),
496            },
497            digest: format!("sha256:{upstream_digest}"),
498            fetched_at: "2026-05-26T00:00:00Z".into(),
499            name: Some("verb".into()),
500            version: Some("1".into()),
501        };
502
503        let stored = store
504            .put_oci_artifact(
505                &manifest_bytes,
506                &[(wasm_hex, wasm.to_vec()), (cfg_hex, cfg.to_vec())],
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}