1use 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";
19const EMPTY_CONFIG: &[u8] = &[0xA0];
22
23#[derive(Debug, Clone)]
25pub struct Stored {
26 pub manifest_digest: String,
28 pub wasm_digest: String,
30 pub provenance: Provenance,
31}
32
33#[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 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 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 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 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 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 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 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 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 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 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"; 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 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}