Skip to main content

act_store/
fetch.rs

1//! Fetching components into the store: OCI / HTTP / local. Network I/O is
2//! isolated in thin wrappers; store-assembly logic is offline-testable.
3
4use 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
12/// RFC 3339 timestamp for "now".
13fn now_rfc3339() -> String {
14    chrono::Utc::now().to_rfc3339()
15}
16
17/// Manifest annotation publishers use to state the artifact's real version,
18/// which is the only version a moving tag like `:latest` can be resolved to.
19const K_OCI_VERSION: &str = "org.opencontainers.image.version";
20
21/// A reqwest client that requests + transparently decompresses gzip/br/zstd and
22/// reuses one HTTP/2 connection (ALPN-negotiated over TLS).
23pub(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
33/// GET a single blob with the right `Accept`, transparent decompression, and a
34/// digest check over the decompressed bytes.
35pub(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
70/// Install a component from a local file path as a pinned `local` snapshot
71/// (synthesized manifest). Records the source ref as `file://<absolute path>`.
72pub 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
88/// The canonical `file://<absolute path>` ref string for a local file.
89pub(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
94/// Assemble HTTP provenance from already-downloaded bytes + optional caching
95/// headers and store via a synthesized manifest. Offline — no network.
96pub 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
119/// Download a `.wasm` from `url` and store it. Network wrapper.
120pub 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
150/// Offline OCI assembly: parse `manifest_bytes`, collect config + every layer
151/// blob via `get_blob` (keyed by hex digest), store verbatim. `manifest_digest`
152/// is the upstream digest (`sha256:...`).
153pub 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    // A moving tag (`:latest`) says nothing about the version, so the
179    // publisher's annotation outranks it when present.
180    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
196/// Pull an OCI component (manifest + blobs) and store it verbatim.
197pub 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    // pull_manifest_raw returns (bytes::Bytes, String) in oci-client 0.17
219    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    // Acquire the registry token once; reuse it for every blob GET.
233    // NOTE: oci-client 0.17 `auth` returns `Result<Option<String>>` directly.
234    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    // Fetch config + every layer concurrently over one HTTP/2 connection.
248    let jobs = descriptors.iter().map(|desc| {
249        let http = http.clone(); // cheap Arc clone; clones share the connection pool (h2 multiplexing preserved)
250        let url = blob_url(&registry, &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    // Concurrency is intentionally unbounded — components are ~1 config + 1 layer over a single
260    // h2 connection (server-capped MAX_CONCURRENT_STREAMS); revisit with buffer_unordered(N) if many-layer artifacts appear.
261    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
288/// The blob download URL for a digest in `repo`'s registry/repository.
289/// Assumes `https` and a verbatim registry host (e.g. ghcr.io, actpkg.dev) — does NOT
290/// handle docker.io's `registry-1` normalization or plaintext `http` registries.
291fn blob_url(registry: &str, repository: &str, digest: &str) -> String {
292    format!("https://{registry}/v2/{repository}/blobs/{digest}")
293}
294
295/// Max depth for transitive referrer collection (referrer-of-a-referrer).
296const REFERRER_DEPTH: u8 = 4;
297
298/// Offline: store one referrer's manifest + blobs against `subject_digest`.
299pub 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
309/// Build a by-digest `Reference` in the same repo as `repo`.
310fn 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
324/// Pull a referrer manifest's config + layer blobs into `(hex, bytes)` pairs.
325async 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
346/// Pull every connected artifact (referrer) of component manifest
347/// `subject_digest` (`sha256:...`) in `repo`, store it, and recurse to the
348/// transitive closure (depth-capped). Best-effort: a registry without the
349/// referrers API yields nothing; per-referrer errors are logged and skipped so
350/// referrer collection never fails the component pull.
351async 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
420/// Fetch `reference` into the store regardless of kind. Local files are
421/// installed as pinned snapshots.
422pub 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
436/// The canonical store-lookup ref for a user-supplied reference. Must match the
437/// ref that `pull` records when storing: local -> `file://<canonical>`,
438/// oci -> `oci://<ref>`, http -> the URL string.
439pub(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
448/// Read-through resolve: return the wasm blob path for `reference`, pulling it
449/// into the store first if absent.
450pub 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/// Result of an [`update`].
464#[derive(Debug, Clone, PartialEq, Eq)]
465pub enum UpdateOutcome {
466    /// The re-resolved digest matched the stored one; nothing changed.
467    Unchanged,
468    /// A newer artifact was pulled. Digests are `sha256:...`.
469    Updated { from: String, to: String },
470    /// The ref is not in the store.
471    NotStored,
472}
473
474/// Re-resolve `reference` and re-pull if the digest moved.
475pub 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
497/// The `source.ref` (as stored) of a provenance, for matching against a key.
498fn 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); // decompressed back to the original bytes
546    }
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    /// Build a single-layer OCI manifest, optionally annotated, plus the blob
679    /// table `assemble_oci` reads through. Returns `(manifest, digest, blobs)`.
680    #[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    /// The catalog's NAME/VERSION columns render this provenance, so a pull
715    /// that leaves both unset makes them permanently blank.
716    #[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    /// A moving tag says nothing about the version, but publishers annotate
724    /// the manifest with the real one — prefer it.
725    #[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    /// A digest-pinned ref carries no tag; the name is still recoverable.
736    #[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(); // pulls
899        let b = super::ensure(&store, &bare).await.unwrap(); // store hit
900        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        // Resolve the random component's layer digest via the manifest, then fetch it.
930        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        // Succeeds only if the digest verifies over decompressed bytes.
961        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}