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.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 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 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 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 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 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"; 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 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}