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 client that requests and transparently decompresses gzip/br/zstd, over
22/// HTTP/2 where the server negotiates it.
23///
24/// The compression is not a nicety: a registry serving `application/wasm` with
25/// zstd is the difference between a pull measured in tens of megabytes and one
26/// measured in hundreds, so all three encodings stay enabled together.
27///
28/// `hclient` keeps the runtime, TLS and resolver as separate seams rather than
29/// picking for you; this is the store's pick — tokio, rustls over the webpki
30/// roots, the system resolver. Webpki rather than the platform store so a
31/// machine with an empty system trust store cannot make a pull fail here and
32/// nowhere else.
33pub(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
45/// Choose the process-level rustls crypto provider, once.
46///
47/// rustls picks one by looking at which is compiled in and **refuses to guess
48/// when several are** — correctly. Two are, and not because anything here
49/// asks for both: `oci-client` brings `reqwest`, which brings `hyper-rustls`
50/// with `aws-lc-rs`, while `hclient-tls-rustls` uses `ring`. Nothing in this
51/// workspace calls `reqwest` any more; it arrives underneath the OCI client.
52///
53/// Choosing here rather than leaving it to whichever registers first is the
54/// difference between a deliberate crypto stack and one decided by link order.
55/// It can go when `oci-client` stops bringing its own TLS stack, which is a
56/// bigger question than this function.
57///
58/// Public because `act-cli`'s OAuth flow and `act-runtime`'s `wasi:http` client
59/// need the same choice; one home beats the same `Once` written three times.
60/// `install_default` returning `Err` means one is already installed, which is
61/// the ordinary case after the first call.
62pub 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
69/// GET a single blob with the right `Accept`, transparent decompression, and a
70/// digest check over the decompressed bytes.
71pub(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
107/// Install a component from a local file path as a pinned `local` snapshot
108/// (synthesized manifest). Records the source ref as `file://<absolute path>`.
109pub 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
125/// The canonical `file://<absolute path>` ref string for a local file.
126pub(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
131/// Assemble HTTP provenance from already-downloaded bytes + optional caching
132/// headers and store via a synthesized manifest. Offline — no network.
133pub 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
156/// Download a `.wasm` from `url` and store it. Network wrapper.
157pub 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
187/// Offline OCI assembly: parse `manifest_bytes`, collect config + every layer
188/// blob via `get_blob` (keyed by hex digest), store verbatim. `manifest_digest`
189/// is the upstream digest (`sha256:...`).
190pub 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    // A moving tag (`:latest`) says nothing about the version, so the
216    // publisher's annotation outranks it when present.
217    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
233/// Pull an OCI component (manifest + blobs) and store it verbatim.
234pub 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    // pull_manifest_raw returns (bytes::Bytes, String) in oci-client 0.17
256    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    // Acquire the registry token once; reuse it for every blob GET.
270    // NOTE: oci-client 0.17 `auth` returns `Result<Option<String>>` directly.
271    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    // Fetch config + every layer concurrently over one HTTP/2 connection.
284    let jobs = descriptors.iter().map(|desc| {
285        let http = http.clone(); // cheap Arc clone; clones share the connection pool (h2 multiplexing preserved)
286        let url = blob_url(&registry, &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    // Concurrency is intentionally unbounded — components are ~1 config + 1 layer over a single
296    // h2 connection (server-capped MAX_CONCURRENT_STREAMS); revisit with buffer_unordered(N) if many-layer artifacts appear.
297    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
324/// The blob download URL for a digest in `repo`'s registry/repository.
325/// Assumes `https` and a verbatim registry host (e.g. ghcr.io, actpkg.dev) — does NOT
326/// handle docker.io's `registry-1` normalization or plaintext `http` registries.
327fn blob_url(registry: &str, repository: &str, digest: &str) -> String {
328    format!("https://{registry}/v2/{repository}/blobs/{digest}")
329}
330
331/// Max depth for transitive referrer collection (referrer-of-a-referrer).
332const REFERRER_DEPTH: u8 = 4;
333
334/// Offline: store one referrer's manifest + blobs against `subject_digest`.
335pub 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
345/// Build a by-digest `Reference` in the same repo as `repo`.
346fn 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
360/// Pull a referrer manifest's config + layer blobs into `(hex, bytes)` pairs.
361async 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
382/// Pull every connected artifact (referrer) of component manifest
383/// `subject_digest` (`sha256:...`) in `repo`, store it, and recurse to the
384/// transitive closure (depth-capped). Best-effort: a registry without the
385/// referrers API yields nothing; per-referrer errors are logged and skipped so
386/// referrer collection never fails the component pull.
387async 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
456/// Fetch `reference` into the store regardless of kind. Local files are
457/// installed as pinned snapshots.
458pub 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
472/// The canonical store-lookup ref for a user-supplied reference. Must match the
473/// ref that `pull` records when storing: local -> `file://<canonical>`,
474/// oci -> `oci://<ref>`, http -> the URL string.
475pub(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
484/// Read-through resolve: return the wasm blob path for `reference`, pulling it
485/// into the store first if absent.
486pub 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/// Result of an [`update`].
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub enum UpdateOutcome {
502    /// The re-resolved digest matched the stored one; nothing changed.
503    Unchanged,
504    /// A newer artifact was pulled. Digests are `sha256:...`.
505    Updated { from: String, to: String },
506    /// The ref is not in the store.
507    NotStored,
508}
509
510/// Re-resolve `reference` and re-pull if the digest moved.
511pub 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
533/// The `source.ref` (as stored) of a provenance, for matching against a key.
534fn 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); // decompressed back to the original bytes
582    }
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    /// Build a single-layer OCI manifest, optionally annotated, plus the blob
715    /// table `assemble_oci` reads through. Returns `(manifest, digest, blobs)`.
716    #[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    /// The catalog's NAME/VERSION columns render this provenance, so a pull
751    /// that leaves both unset makes them permanently blank.
752    #[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    /// A moving tag says nothing about the version, but publishers annotate
760    /// the manifest with the real one — prefer it.
761    #[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    /// A digest-pinned ref carries no tag; the name is still recoverable.
772    #[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(); // pulls
935        let b = super::ensure(&store, &bare).await.unwrap(); // store hit
936        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        // Resolve the random component's layer digest via the manifest, then fetch it.
966        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        // Succeeds only if the digest verifies over decompressed bytes.
996        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}