1use std::path::{Path, PathBuf};
5
6use oci_client::manifest::OciImageManifest;
7
8use crate::provenance::{Provenance, Source};
9use crate::reference::Ref;
10use crate::store::{Store, StoreError, Stored};
11
12fn now_rfc3339() -> String {
14 chrono::Utc::now().to_rfc3339()
15}
16
17const K_OCI_VERSION: &str = "org.opencontainers.image.version";
20
21pub(crate) fn compression_client() -> Result<hclient::Client, StoreError> {
34 install_crypto_provider();
35 let transport = hclient_native::Native::new(
36 hclient_rt_tokio::Tokio,
37 hclient_tls_rustls::Rustls::with_webpki_roots(),
38 hclient_dns_system::SystemDns::new(hclient_rt_tokio::Tokio),
39 );
40 hclient::Client::builder(transport)
41 .build()
42 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))
43}
44
45pub fn install_crypto_provider() {
63 static ONCE: std::sync::Once = std::sync::Once::new();
64 ONCE.call_once(|| {
65 let _ = rustls::crypto::ring::default_provider().install_default();
66 });
67}
68
69pub(crate) async fn fetch_blob(
72 http: &hclient::Client,
73 blob_url: &str,
74 accept: &str,
75 digest: &str,
76 token: Option<&str>,
77) -> Result<Vec<u8>, StoreError> {
78 let mut req = http.get(blob_url).header("accept", accept);
79 if let Some(t) = token {
80 req = req.header("authorization", &format!("Bearer {t}"));
81 }
82 let resp = req
83 .send()
84 .await
85 .map_err(|e| StoreError::Io(std::io::Error::other(e)))?;
86 if !resp.status().is_success() {
87 return Err(StoreError::Io(std::io::Error::other(format!(
88 "HTTP {} fetching {blob_url}",
89 resp.status()
90 ))));
91 }
92 let bytes = resp
93 .collect()
94 .await
95 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
96 .bytes()
97 .to_vec();
98 let got = crate::layout::sha256_hex(&bytes);
99 if got != strip(digest) {
100 return Err(StoreError::Digest(format!(
101 "{digest} != sha256:{got} (from {blob_url})"
102 )));
103 }
104 Ok(bytes)
105}
106
107pub fn install_local(store: &Store, path: &Path) -> Result<Stored, StoreError> {
110 let bytes = std::fs::read(path)?;
111 let source = Source::Local {
112 path: local_ref(path),
113 };
114 let (name, version) = crate::provenance::implied_name_version(&source);
115 let provenance = Provenance {
116 source,
117 digest: format!("sha256:{}", crate::layout::sha256_hex(&bytes)),
118 fetched_at: now_rfc3339(),
119 name,
120 version,
121 };
122 store.put_component(&bytes, None, &provenance)
123}
124
125pub(crate) fn local_ref(path: &Path) -> String {
127 let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
128 format!("file://{}", abs.display())
129}
130
131pub fn store_http_bytes(
134 store: &Store,
135 url: &str,
136 bytes: &[u8],
137 etag: Option<String>,
138 last_modified: Option<String>,
139) -> Result<Stored, StoreError> {
140 let source = Source::Http {
141 url: url.to_string(),
142 etag,
143 last_modified,
144 };
145 let (name, version) = crate::provenance::implied_name_version(&source);
146 let provenance = Provenance {
147 source,
148 digest: format!("sha256:{}", crate::layout::sha256_hex(bytes)),
149 fetched_at: now_rfc3339(),
150 name,
151 version,
152 };
153 store.put_component(bytes, None, &provenance)
154}
155
156pub async fn fetch_http(store: &Store, url: &str) -> Result<Stored, StoreError> {
158 let http = compression_client()?;
159 let resp = http
160 .get(url)
161 .header("accept", "application/wasm")
162 .send()
163 .await
164 .map_err(|e| StoreError::Io(std::io::Error::other(e)))?;
165 if !resp.status().is_success() {
166 return Err(StoreError::Io(std::io::Error::other(format!(
167 "HTTP {} fetching {url}",
168 resp.status()
169 ))));
170 }
171 let etag = header(&resp, "etag");
172 let last_modified = header(&resp, "last-modified");
173 let body = resp
174 .collect()
175 .await
176 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
177 store_http_bytes(store, url, body.bytes(), etag, last_modified)
178}
179
180fn header<B>(resp: &hclient::Response<B>, name: &str) -> Option<String> {
181 resp.headers()
182 .get(name)
183 .and_then(|v| v.to_str().ok())
184 .map(str::to_string)
185}
186
187pub fn assemble_oci(
191 store: &Store,
192 reference: &str,
193 manifest_bytes: &[u8],
194 manifest_digest: &str,
195 get_blob: impl Fn(&str) -> Result<Vec<u8>, StoreError>,
196) -> Result<Stored, StoreError> {
197 let manifest: OciImageManifest = serde_json::from_slice(manifest_bytes)
198 .map_err(|e| StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
199
200 let mut blobs: Vec<(String, Vec<u8>)> = Vec::new();
201 let mut want = |digest: &str| -> Result<(), StoreError> {
202 let hex = strip(digest);
203 blobs.push((hex.clone(), get_blob(&hex)?));
204 Ok(())
205 };
206 want(&manifest.config.digest)?;
207 for layer in &manifest.layers {
208 want(&layer.digest)?;
209 }
210
211 let source = Source::Oci {
212 reference: reference.to_string(),
213 };
214 let (name, tag_version) = crate::provenance::implied_name_version(&source);
215 let annotated_version = manifest
218 .annotations
219 .as_ref()
220 .and_then(|a| a.get(K_OCI_VERSION))
221 .cloned();
222
223 let provenance = Provenance {
224 source,
225 digest: manifest_digest.to_string(),
226 fetched_at: now_rfc3339(),
227 name,
228 version: annotated_version.or(tag_version),
229 };
230 store.put_oci_artifact(manifest_bytes, &blobs, &provenance)
231}
232
233pub async fn fetch_oci(store: &Store, reference: &str) -> Result<Stored, StoreError> {
235 use oci_client::client::{ClientConfig, ClientProtocol};
236 use oci_client::manifest::{IMAGE_MANIFEST_MEDIA_TYPE, OCI_IMAGE_MEDIA_TYPE};
237 use oci_client::secrets::RegistryAuth;
238 use oci_client::{Client, Reference, RegistryOperation};
239
240 let oci_ref: Reference = reference
241 .strip_prefix("oci://")
242 .unwrap_or(reference)
243 .parse()
244 .map_err(|e| {
245 StoreError::Io(std::io::Error::other(format!(
246 "bad OCI ref {reference}: {e}"
247 )))
248 })?;
249 let client = Client::new(ClientConfig {
250 protocol: ClientProtocol::Https,
251 ..Default::default()
252 });
253 let auth = RegistryAuth::Anonymous;
254
255 let (manifest_raw, manifest_digest) = client
257 .pull_manifest_raw(
258 &oci_ref,
259 &auth,
260 &[OCI_IMAGE_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE],
261 )
262 .await
263 .map_err(|e| StoreError::Io(std::io::Error::other(e)))?;
264 let manifest_bytes: Vec<u8> = manifest_raw.to_vec();
265
266 let manifest: OciImageManifest = serde_json::from_slice(&manifest_bytes)
267 .map_err(|e| StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
268
269 let token: Option<String> = client
272 .auth(&oci_ref, &auth, RegistryOperation::Pull)
273 .await
274 .map_err(|e| StoreError::Io(std::io::Error::other(e)))?;
275
276 let http = compression_client()?;
277 let registry = oci_ref.registry().to_string();
278 let repository = oci_ref.repository().to_string();
279
280 let mut descriptors = vec![manifest.config.clone()];
281 descriptors.extend(manifest.layers.iter().cloned());
282
283 let jobs = descriptors.iter().map(|desc| {
285 let http = http.clone(); let url = blob_url(®istry, &repository, &desc.digest);
287 let accept = desc.media_type.clone();
288 let digest = desc.digest.clone();
289 let token = token.clone();
290 async move {
291 let bytes = fetch_blob(&http, &url, &accept, &digest, token.as_deref()).await?;
292 Ok::<(String, Vec<u8>), StoreError>((strip(&digest), bytes))
293 }
294 });
295 let fetched: std::collections::HashMap<String, Vec<u8>> = futures::future::try_join_all(jobs)
298 .await?
299 .into_iter()
300 .collect();
301
302 let stored = assemble_oci(store, reference, &manifest_bytes, &manifest_digest, |hex| {
303 fetched
304 .get(hex)
305 .cloned()
306 .ok_or_else(|| StoreError::Digest(hex.into()))
307 })?;
308 collect_referrers(
309 &client,
310 &auth,
311 &oci_ref,
312 &manifest_digest,
313 store,
314 REFERRER_DEPTH,
315 )
316 .await;
317 Ok(stored)
318}
319
320fn strip(digest: &str) -> String {
321 digest.rsplit(':').next().unwrap_or(digest).to_string()
322}
323
324fn blob_url(registry: &str, repository: &str, digest: &str) -> String {
328 format!("https://{registry}/v2/{repository}/blobs/{digest}")
329}
330
331const REFERRER_DEPTH: u8 = 4;
333
334pub fn store_referrer(
336 store: &Store,
337 manifest_bytes: &[u8],
338 blobs: &[(String, Vec<u8>)],
339 subject_digest: &str,
340 artifact_type: Option<&str>,
341) -> Result<String, StoreError> {
342 store.put_referrer(manifest_bytes, blobs, subject_digest, artifact_type)
343}
344
345fn digest_ref(
347 repo: &oci_client::Reference,
348 digest: &str,
349) -> Result<oci_client::Reference, StoreError> {
350 let d = if digest.contains(':') {
351 digest.to_string()
352 } else {
353 format!("sha256:{digest}")
354 };
355 format!("{}/{}@{}", repo.registry(), repo.repository(), d)
356 .parse()
357 .map_err(|e| StoreError::Io(std::io::Error::other(format!("bad digest ref: {e}"))))
358}
359
360async fn referrer_blobs(
362 client: &oci_client::Client,
363 referrer_ref: &oci_client::Reference,
364 manifest_bytes: &[u8],
365) -> Result<Vec<(String, Vec<u8>)>, StoreError> {
366 let manifest: OciImageManifest = serde_json::from_slice(manifest_bytes)
367 .map_err(|e| StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
368 let mut descriptors = vec![manifest.config.clone()];
369 descriptors.extend(manifest.layers.iter().cloned());
370 let mut out = Vec::new();
371 for d in &descriptors {
372 let mut buf: Vec<u8> = Vec::new();
373 client
374 .pull_blob(referrer_ref, d, &mut buf)
375 .await
376 .map_err(|e| StoreError::Io(std::io::Error::other(e)))?;
377 out.push((strip(&d.digest), buf));
378 }
379 Ok(out)
380}
381
382async fn collect_referrers(
388 client: &oci_client::Client,
389 auth: &oci_client::secrets::RegistryAuth,
390 repo: &oci_client::Reference,
391 subject_digest: &str,
392 store: &Store,
393 depth: u8,
394) {
395 use oci_client::manifest::{IMAGE_MANIFEST_MEDIA_TYPE, OCI_IMAGE_MEDIA_TYPE};
396 if depth == 0 {
397 return;
398 }
399 let subject_ref = match digest_ref(repo, subject_digest) {
400 Ok(r) => r,
401 Err(_) => return,
402 };
403 let index = match client.pull_referrers(&subject_ref, None).await {
404 Ok(idx) => idx,
405 Err(e) => {
406 tracing::debug!(%subject_digest, error = %e, "no referrers / referrers API unavailable");
407 return;
408 }
409 };
410 for desc in index.manifests {
411 let ref_digest = desc.digest.clone();
412 let referrer_ref = match digest_ref(repo, &ref_digest) {
413 Ok(r) => r,
414 Err(_) => continue,
415 };
416 let pulled = client
417 .pull_manifest_raw(
418 &referrer_ref,
419 auth,
420 &[OCI_IMAGE_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE],
421 )
422 .await;
423 let (m_bytes, m_digest) = match pulled {
424 Ok((b, d)) => (b.to_vec(), d),
425 Err(e) => {
426 tracing::warn!(%ref_digest, error = %e, "failed to pull referrer manifest");
427 continue;
428 }
429 };
430 let blobs = match referrer_blobs(client, &referrer_ref, &m_bytes).await {
431 Ok(b) => b,
432 Err(e) => {
433 tracing::warn!(%ref_digest, error = %e, "failed to pull referrer blobs");
434 continue;
435 }
436 };
437 let artifact_type = desc.artifact_type.clone();
438 if let Err(e) =
439 store.put_referrer(&m_bytes, &blobs, subject_digest, artifact_type.as_deref())
440 {
441 tracing::warn!(%ref_digest, error = %e, "failed to store referrer");
442 continue;
443 }
444 Box::pin(collect_referrers(
445 client,
446 auth,
447 repo,
448 &m_digest,
449 store,
450 depth - 1,
451 ))
452 .await;
453 }
454}
455
456pub async fn pull(store: &Store, reference: &str) -> Result<Stored, StoreError> {
459 let parsed: Ref = reference
460 .parse()
461 .map_err(|e| StoreError::Io(std::io::Error::other(format!("{e}"))))?;
462 match parsed {
463 Ref::Local(path) => install_local(store, &path),
464 Ref::Http(url) => fetch_http(store, url.as_str()).await,
465 Ref::Oci(r) => fetch_oci(store, &format!("oci://{r}")).await,
466 Ref::Name(n) => Err(StoreError::Io(std::io::Error::other(format!(
467 "registry name resolution not implemented: {n}"
468 )))),
469 }
470}
471
472pub(crate) fn lookup_ref(reference: &str) -> String {
476 match reference.parse::<Ref>() {
477 Ok(Ref::Local(path)) => local_ref(&path),
478 Ok(Ref::Oci(r)) => format!("oci://{r}"),
479 Ok(Ref::Http(url)) => url.to_string(),
480 _ => reference.to_string(),
481 }
482}
483
484pub async fn ensure(store: &Store, reference: &str) -> Result<PathBuf, StoreError> {
487 let key = lookup_ref(reference);
488 if let Some(path) = store.resolve(&key)? {
489 return Ok(path);
490 }
491 pull(store, reference).await?;
492 store.resolve(&key)?.ok_or_else(|| {
493 StoreError::Io(std::io::Error::other(format!(
494 "resolve failed after pull: {reference}"
495 )))
496 })
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
501pub enum UpdateOutcome {
502 Unchanged,
504 Updated { from: String, to: String },
506 NotStored,
508}
509
510pub async fn update(store: &Store, reference: &str) -> Result<UpdateOutcome, StoreError> {
512 let key = lookup_ref(reference);
513 let before = store
514 .list()?
515 .into_iter()
516 .find(|s| source_ref(&s.provenance) == key)
517 .map(|s| s.provenance.digest);
518 let Some(before) = before else {
519 return Ok(UpdateOutcome::NotStored);
520 };
521 let restored = pull(store, reference).await?;
522 let after = restored.provenance.digest;
523 if after == before {
524 Ok(UpdateOutcome::Unchanged)
525 } else {
526 Ok(UpdateOutcome::Updated {
527 from: before,
528 to: after,
529 })
530 }
531}
532
533fn source_ref(p: &Provenance) -> &str {
535 match &p.source {
536 Source::Oci { reference } => reference,
537 Source::Http { url, .. } => url,
538 Source::Local { path } => path,
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use crate::store::StoreError;
546 use tempfile::TempDir;
547
548 #[tokio::test]
549 async fn fetch_blob_decompresses_gzip_and_verifies_digest() {
550 use flate2::{Compression, write::GzEncoder};
551 use std::io::Write;
552 use wiremock::matchers::{header, header_exists, method, path};
553 use wiremock::{Mock, MockServer, ResponseTemplate};
554
555 let original = b"\0asm\x01\0\0\0hello-wasm-body".to_vec();
556 let hex = crate::layout::sha256_hex(&original);
557 let digest = format!("sha256:{hex}");
558
559 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
560 enc.write_all(&original).unwrap();
561 let gz = enc.finish().unwrap();
562
563 let server = MockServer::start().await;
564 Mock::given(method("GET"))
565 .and(path(format!("/v2/lib/x/blobs/{digest}")))
566 .and(header("accept", "application/wasm"))
567 .and(header_exists("accept-encoding"))
568 .respond_with(
569 ResponseTemplate::new(200)
570 .insert_header("content-encoding", "gzip")
571 .set_body_bytes(gz),
572 )
573 .mount(&server)
574 .await;
575
576 let url = format!("{}/v2/lib/x/blobs/{digest}", server.uri());
577 let http = super::compression_client().unwrap();
578 let got = super::fetch_blob(&http, &url, "application/wasm", &digest, None)
579 .await
580 .unwrap();
581 assert_eq!(got, original); }
583
584 #[tokio::test]
585 async fn fetch_blob_rejects_digest_mismatch() {
586 use wiremock::matchers::{method, path};
587 use wiremock::{Mock, MockServer, ResponseTemplate};
588
589 let server = MockServer::start().await;
590 Mock::given(method("GET"))
591 .and(path("/v2/lib/x/blobs/sha256:deadbeef"))
592 .respond_with(
593 ResponseTemplate::new(200).set_body_bytes(b"not-the-expected-bytes".to_vec()),
594 )
595 .mount(&server)
596 .await;
597
598 let url = format!("{}/v2/lib/x/blobs/sha256:deadbeef", server.uri());
599 let http = super::compression_client().unwrap();
600 let err = super::fetch_blob(&http, &url, "application/wasm", "sha256:deadbeef", None)
601 .await
602 .unwrap_err();
603 assert!(matches!(err, StoreError::Digest(_)));
604 }
605
606 #[tokio::test]
607 async fn fetch_blob_sends_bearer_when_token_present() {
608 use wiremock::matchers::{header, method, path};
609 use wiremock::{Mock, MockServer, ResponseTemplate};
610
611 let body = b"abc".to_vec();
612 let digest = format!("sha256:{}", crate::layout::sha256_hex(&body));
613 let server = MockServer::start().await;
614 Mock::given(method("GET"))
615 .and(path(format!("/v2/lib/x/blobs/{digest}")))
616 .and(header("authorization", "Bearer tok123"))
617 .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
618 .mount(&server)
619 .await;
620
621 let url = format!("{}/v2/lib/x/blobs/{digest}", server.uri());
622 let http = super::compression_client().unwrap();
623 let got = super::fetch_blob(&http, &url, "application/wasm", &digest, Some("tok123"))
624 .await
625 .unwrap();
626 assert_eq!(got, body);
627 }
628
629 #[test]
630 fn install_local_then_resolve() {
631 let dir = TempDir::new().unwrap();
632 let store = Store::open(dir.path()).unwrap();
633 let wasm_path = dir.path().join("c.wasm");
634 std::fs::write(&wasm_path, b"local-bytes").unwrap();
635 let stored = install_local(&store, &wasm_path).unwrap();
636 assert!(matches!(stored.provenance.source, Source::Local { .. }));
637 let file_ref = match &stored.provenance.source {
638 Source::Local { path } => path.clone(),
639 _ => unreachable!(),
640 };
641 let resolved = store.resolve(&file_ref).unwrap().expect("hit");
642 assert_eq!(std::fs::read(resolved).unwrap(), b"local-bytes");
643 }
644
645 #[test]
646 fn store_http_bytes_records_http_provenance_with_headers() {
647 let dir = TempDir::new().unwrap();
648 let store = Store::open(dir.path()).unwrap();
649 let stored = store_http_bytes(
650 &store,
651 "https://cdn.example.com/x.wasm",
652 b"http-bytes",
653 Some("\"etag123\"".into()),
654 Some("Wed, 21 May 2026 00:00:00 GMT".into()),
655 )
656 .unwrap();
657 match stored.provenance.source {
658 Source::Http {
659 url,
660 etag,
661 last_modified,
662 } => {
663 assert_eq!(url, "https://cdn.example.com/x.wasm");
664 assert_eq!(etag.as_deref(), Some("\"etag123\""));
665 assert!(last_modified.is_some());
666 }
667 _ => panic!("expected Http source"),
668 }
669 assert!(
670 store
671 .resolve("https://cdn.example.com/x.wasm")
672 .unwrap()
673 .is_some()
674 );
675 }
676
677 #[test]
678 fn assemble_oci_stores_verbatim_and_resolves() {
679 let dir = TempDir::new().unwrap();
680 let store = Store::open(dir.path()).unwrap();
681 let wasm = b"\0asm\x01\0\0\0oci";
682 let wasm_hex = crate::layout::sha256_hex(wasm);
683 let cfg = b"\xA0";
684 let cfg_hex = crate::layout::sha256_hex(cfg);
685 let manifest = format!(
686 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":{c}}},"layers":[{{"mediaType":"application/wasm","digest":"sha256:{wasm_hex}","size":{w}}}]}}"#,
687 c = cfg.len(), w = wasm.len(),
688 ).into_bytes();
689 let upstream = crate::layout::sha256_hex(&manifest);
690 let mut blobs = std::collections::HashMap::new();
691 blobs.insert(wasm_hex, wasm.to_vec());
692 blobs.insert(cfg_hex, cfg.to_vec());
693 let stored = assemble_oci(
694 &store,
695 "oci://ghcr.io/x/oci:1",
696 &manifest,
697 &format!("sha256:{upstream}"),
698 |hex| {
699 blobs
700 .get(hex)
701 .cloned()
702 .ok_or_else(|| StoreError::Digest(hex.into()))
703 },
704 )
705 .unwrap();
706 assert_eq!(stored.manifest_digest, upstream);
707 assert_eq!(stored.provenance.digest, format!("sha256:{upstream}"));
708 assert_eq!(
709 std::fs::read(store.resolve("oci://ghcr.io/x/oci:1").unwrap().unwrap()).unwrap(),
710 wasm
711 );
712 }
713
714 #[allow(clippy::type_complexity)]
717 fn oci_fixture(
718 annotations: &str,
719 ) -> (Vec<u8>, String, std::collections::HashMap<String, Vec<u8>>) {
720 let wasm = b"\0asm\x01\0\0\0named";
721 let wasm_hex = crate::layout::sha256_hex(wasm);
722 let cfg = b"\xA0";
723 let cfg_hex = crate::layout::sha256_hex(cfg);
724 let manifest = format!(
725 r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",{annotations}"config":{{"mediaType":"application/vnd.wasm.config.v0+json","digest":"sha256:{cfg_hex}","size":{c}}},"layers":[{{"mediaType":"application/wasm","digest":"sha256:{wasm_hex}","size":{w}}}]}}"#,
726 c = cfg.len(),
727 w = wasm.len(),
728 )
729 .into_bytes();
730 let digest = format!("sha256:{}", crate::layout::sha256_hex(&manifest));
731 let mut blobs = std::collections::HashMap::new();
732 blobs.insert(wasm_hex, wasm.to_vec());
733 blobs.insert(cfg_hex, cfg.to_vec());
734 (manifest, digest, blobs)
735 }
736
737 fn assemble(reference: &str, annotations: &str) -> Stored {
738 let dir = TempDir::new().unwrap();
739 let store = Store::open(dir.path()).unwrap();
740 let (manifest, digest, blobs) = oci_fixture(annotations);
741 assemble_oci(&store, reference, &manifest, &digest, |hex| {
742 blobs
743 .get(hex)
744 .cloned()
745 .ok_or_else(|| StoreError::Digest(hex.into()))
746 })
747 .unwrap()
748 }
749
750 #[test]
753 fn assemble_oci_names_the_component_after_the_repository_and_tag() {
754 let stored = assemble("oci://actpkg.dev/library/crypto:0.4.1", "");
755 assert_eq!(stored.provenance.name.as_deref(), Some("crypto"));
756 assert_eq!(stored.provenance.version.as_deref(), Some("0.4.1"));
757 }
758
759 #[test]
762 fn assemble_oci_prefers_the_manifest_version_annotation_over_the_tag() {
763 let stored = assemble(
764 "oci://actpkg.dev/library/time:latest",
765 r#""annotations":{"org.opencontainers.image.version":"0.2.4"},"#,
766 );
767 assert_eq!(stored.provenance.name.as_deref(), Some("time"));
768 assert_eq!(stored.provenance.version.as_deref(), Some("0.2.4"));
769 }
770
771 #[test]
773 fn assemble_oci_names_a_digest_pinned_ref_without_a_version() {
774 let stored = assemble(
775 "oci://ghcr.io/actpkg/sqlite@sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
776 "",
777 );
778 assert_eq!(stored.provenance.name.as_deref(), Some("sqlite"));
779 assert_eq!(stored.provenance.version, None);
780 }
781
782 #[test]
783 fn install_local_names_the_component_after_the_file_stem() {
784 let dir = TempDir::new().unwrap();
785 let store = Store::open(dir.path()).unwrap();
786 let wasm_path = dir.path().join("filesystem.wasm");
787 std::fs::write(&wasm_path, b"local-bytes").unwrap();
788 let stored = install_local(&store, &wasm_path).unwrap();
789 assert_eq!(stored.provenance.name.as_deref(), Some("filesystem"));
790 }
791
792 #[test]
793 fn store_http_bytes_names_the_component_after_the_url_filename() {
794 let dir = TempDir::new().unwrap();
795 let store = Store::open(dir.path()).unwrap();
796 let stored = store_http_bytes(
797 &store,
798 "https://cdn.example.com/random.wasm",
799 b"http-bytes",
800 None,
801 None,
802 )
803 .unwrap();
804 assert_eq!(stored.provenance.name.as_deref(), Some("random"));
805 }
806
807 #[tokio::test]
808 #[ignore = "network: fetches a real .wasm over HTTP"]
809 async fn fetch_http_live() {
810 let url = "https://github.com/actcore/act-cli/raw/main/README.md";
811 let dir = TempDir::new().unwrap();
812 let store = Store::open(dir.path()).unwrap();
813 let stored = fetch_http(&store, url).await.unwrap();
814 assert!(stored.provenance.digest.starts_with("sha256:"));
815 assert!(store.resolve(url).unwrap().is_some());
816 }
817
818 #[test]
819 fn store_referrer_offline() {
820 let dir = TempDir::new().unwrap();
821 let store = Store::open(dir.path()).unwrap();
822 let subject = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
823 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();
824 let cfg = b"{}".to_vec();
825 let cfg_hex = crate::layout::sha256_hex(&cfg);
826 super::store_referrer(
827 &store,
828 &m,
829 &[(cfg_hex, cfg)],
830 subject,
831 Some("application/spdx+json"),
832 )
833 .unwrap();
834 assert_eq!(
835 store
836 .list_referrers_by_digest(
837 "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
838 )
839 .unwrap()
840 .len(),
841 1
842 );
843 }
844
845 #[tokio::test]
846 #[ignore = "network: pulls a component AND its referrers from ghcr.io"]
847 async fn fetch_oci_with_referrers_live() {
848 let dir = TempDir::new().unwrap();
849 let store = Store::open(dir.path()).unwrap();
850 let r = "oci://ghcr.io/actpkg/time:0.2.0";
851 let stored = super::fetch_oci(&store, r).await.unwrap();
852 assert!(store.resolve(r).unwrap().is_some());
853 let refs = store
854 .list_referrers_by_digest(&stored.manifest_digest)
855 .unwrap();
856 eprintln!("referrers collected for time:0.2.0: {}", refs.len());
857 }
858
859 #[tokio::test]
860 async fn fetch_http_sends_accept_and_decompresses() {
861 use flate2::{Compression, write::GzEncoder};
862 use std::io::Write;
863 use wiremock::matchers::{header, header_exists, method, path};
864 use wiremock::{Mock, MockServer, ResponseTemplate};
865
866 let original = b"\0asm\x01\0\0\0http-path".to_vec();
867 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
868 enc.write_all(&original).unwrap();
869 let gz = enc.finish().unwrap();
870
871 let server = MockServer::start().await;
872 Mock::given(method("GET"))
873 .and(path("/x.wasm"))
874 .and(header("accept", "application/wasm"))
875 .and(header_exists("accept-encoding"))
876 .respond_with(
877 ResponseTemplate::new(200)
878 .insert_header("content-encoding", "gzip")
879 .set_body_bytes(gz),
880 )
881 .mount(&server)
882 .await;
883
884 let dir = TempDir::new().unwrap();
885 let store = Store::open(dir.path()).unwrap();
886 let url = format!("{}/x.wasm", server.uri());
887 let stored = super::fetch_http(&store, &url).await.unwrap();
888 assert_eq!(
889 stored.provenance.digest,
890 format!("sha256:{}", crate::layout::sha256_hex(&original))
891 );
892 assert_eq!(
893 std::fs::read(store.resolve(&url).unwrap().unwrap()).unwrap(),
894 original
895 );
896 }
897
898 #[test]
899 fn blob_url_builds_distribution_url() {
900 assert_eq!(
901 super::blob_url("actpkg.dev", "library/random", "sha256:abc123"),
902 "https://actpkg.dev/v2/library/random/blobs/sha256:abc123"
903 );
904 }
905
906 #[tokio::test]
907 #[ignore = "network: pulls a real component from ghcr.io"]
908 async fn fetch_oci_live() {
909 let dir = TempDir::new().unwrap();
910 let store = Store::open(dir.path()).unwrap();
911 let r = "oci://ghcr.io/actpkg/time:0.2.0";
912 let stored = fetch_oci(&store, r).await.unwrap();
913 assert!(stored.provenance.digest.starts_with("sha256:"));
914 assert!(store.resolve(r).unwrap().is_some());
915 }
916
917 #[tokio::test]
918 async fn pull_dispatches_local_by_ref_kind() {
919 let dir = TempDir::new().unwrap();
920 let store = Store::open(dir.path()).unwrap();
921 let p = dir.path().join("d.wasm");
922 std::fs::write(&p, b"dispatch").unwrap();
923 let stored = super::pull(&store, &p.display().to_string()).await.unwrap();
924 assert!(matches!(stored.provenance.source, Source::Local { .. }));
925 }
926
927 #[tokio::test]
928 async fn ensure_local_by_bare_path_is_read_through() {
929 let dir = TempDir::new().unwrap();
930 let store = Store::open(dir.path()).unwrap();
931 let p = dir.path().join("f.wasm");
932 std::fs::write(&p, b"bare").unwrap();
933 let bare = p.display().to_string();
934 let a = super::ensure(&store, &bare).await.unwrap(); let b = super::ensure(&store, &bare).await.unwrap(); assert_eq!(a, b);
937 assert_eq!(std::fs::read(&a).unwrap(), b"bare");
938 }
939
940 #[tokio::test]
941 async fn update_local_noop_then_changed() {
942 let dir = TempDir::new().unwrap();
943 let store = Store::open(dir.path()).unwrap();
944 let p = dir.path().join("u.wasm");
945 std::fs::write(&p, b"v1").unwrap();
946 let stored = super::pull(&store, &p.display().to_string()).await.unwrap();
947 let r = match &stored.provenance.source {
948 Source::Local { path } => path.clone(),
949 _ => unreachable!(),
950 };
951 assert!(matches!(
952 super::update(&store, &r).await.unwrap(),
953 super::UpdateOutcome::Unchanged
954 ));
955 std::fs::write(&p, b"v2-bigger").unwrap();
956 match super::update(&store, &r).await.unwrap() {
957 super::UpdateOutcome::Updated { from, to } => assert_ne!(from, to),
958 other => panic!("expected Updated, got {other:?}"),
959 }
960 }
961
962 #[tokio::test]
963 #[ignore = "network: pulls a real blob from actpkg.dev and checks compression"]
964 async fn fetch_blob_live_actpkg_compresses() {
965 use oci_client::client::{Client, ClientConfig, ClientProtocol};
967 use oci_client::manifest::{
968 IMAGE_MANIFEST_MEDIA_TYPE, OCI_IMAGE_MEDIA_TYPE, OciImageManifest,
969 };
970 use oci_client::secrets::RegistryAuth;
971 use oci_client::{Reference, RegistryOperation};
972
973 let oci_ref: Reference = "actpkg.dev/library/random:latest".parse().unwrap();
974 let client = Client::new(ClientConfig {
975 protocol: ClientProtocol::Https,
976 ..Default::default()
977 });
978 let (raw, _d) = client
979 .pull_manifest_raw(
980 &oci_ref,
981 &RegistryAuth::Anonymous,
982 &[OCI_IMAGE_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE],
983 )
984 .await
985 .unwrap();
986 let manifest: OciImageManifest = serde_json::from_slice(&raw).unwrap();
987 let layer = &manifest.layers[0];
988 let token = client
989 .auth(&oci_ref, &RegistryAuth::Anonymous, RegistryOperation::Pull)
990 .await
991 .unwrap();
992
993 let http = super::compression_client().unwrap();
994 let url = super::blob_url("actpkg.dev", "library/random", &layer.digest);
995 let bytes = super::fetch_blob(
997 &http,
998 &url,
999 &layer.media_type,
1000 &layer.digest,
1001 token.as_deref(),
1002 )
1003 .await
1004 .unwrap();
1005 eprintln!(
1006 "pulled+verified {} bytes (Accept={})",
1007 bytes.len(),
1008 layer.media_type
1009 );
1010 assert_eq!(bytes.len() as i64, layer.size);
1011 }
1012}