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