Skip to main content

ignition_core/module/
fetch.rs

1//! Release resolution, streamed download, and atomic cache-verify for
2//! third-party `.modl` module artifacts (Phase 15, RMOD-02/03).
3//!
4//! [`ModuleFeed`] talks to a module's GitHub release feed over a
5//! SEPARATE `reqwest::Client` from [`crate::client::ReqwestGatewayApi`]
6//! (D-03): the gateway client suppresses ALL redirects for the
7//! uncommissioned-gateway wizard 302 (see `crate::client::build_client`'s
8//! redirect policy), which would swallow the mandatory release-CDN
9//! redirect this fetch depends on. Redirects here are left at reqwest's
10//! untouched default (follows up to 10 hops).
11//!
12//! [`ModuleFeed::fetch_and_verify`] resolves ONLY through the tag-pinned
13//! releases endpoint (`/repos/{repo}/releases/tags/{tag}`) — the floating
14//! `releases/latest` endpoint is never called, for any reason, including
15//! as a fallback (SC-1). A cache hit ([`super::cached_entry`], keyed by
16//! version alone) short-circuits before any request is built, which is
17//! what makes SC-3 (no network on a repeat fetch) and SC-4
18//! (offline-with-cache succeeds) possible.
19
20use std::path::Path;
21use std::time::Duration;
22
23use futures_util::StreamExt;
24use sha2::{Digest, Sha256};
25use tokio::io::AsyncWriteExt;
26
27use crate::error::CoreError;
28use crate::module::{
29    ModuleSpec, cached_entry, module_cache_dir, validate_module_id, validate_version,
30};
31
32/// Hard byte budget read from a non-success feed response before it is
33/// used in an error message — see [`bounded_diagnostic_body`].
34const DIAGNOSTIC_BODY_CAP_BYTES: usize = 8 * 1024;
35/// Characters of that budget actually kept in the error message.
36const DIAGNOSTIC_BODY_CHARS: usize = 200;
37/// Appended when the diagnostic body was cut short.
38const DIAGNOSTIC_TRUNCATION_MARKER: &str = "… (truncated)";
39
40/// How a fetch treats an existing cache hit (D-02, D-09). All three
41/// behave IDENTICALLY on a cache MISS — the miss path (resolve,
42/// download, verify, persist) is implemented exactly once and is
43/// reached by every policy alike.
44///
45/// On a cache HIT, a re-released version upstream is refused BY
46/// DEFAULT: if the digest the feed publishes today differs from what's
47/// cached for this version, the fetch refuses naming both digests and
48/// leaves the cached artifact usable and untouched.
49/// `AcceptUpstreamChange` is the deliberate, explicit way to accept
50/// that change — it downloads the new bytes and verifies them against
51/// the NEWLY published digest before caching them ALONGSIDE (never
52/// over) the old entry. Phase 16 wires these to a CLI flag.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum FetchPolicy {
55    /// Trust an existing cache hit outright — never re-contact the feed
56    /// for a version already cached. The default.
57    #[default]
58    CacheFirst,
59    /// Re-verify a cache hit against the feed's current digest before
60    /// trusting it: an unchanged digest reuses the cache (no
61    /// re-download); a changed digest is REFUSED
62    /// (`module_digest_changed`, exit 6), naming both digests, with
63    /// the cached artifact left exactly as it was (15-02).
64    Refresh,
65    /// Accept a digest change the feed now reports for an already-cached
66    /// version: downloads the new bytes and verifies them against the
67    /// NEWLY published digest — never trusts them unverified — then
68    /// persists them alongside (not over) the existing cache entry
69    /// (15-02, D-02's explicit override).
70    AcceptUpstreamChange,
71}
72
73/// Where a [`FetchedModule`]'s bytes came from.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
75#[serde(rename_all = "snake_case")]
76pub enum ArtifactSource {
77    /// Served from the local cache — no HTTP request was made.
78    Cache,
79    /// Freshly resolved, downloaded, and verified this call.
80    Download,
81}
82
83/// The result of a successful [`ModuleFeed::fetch_and_verify`] call —
84/// core returns models, never prints (Phase 16's `--json` renders this).
85#[derive(Debug, Clone, serde::Serialize)]
86#[serde(rename_all = "snake_case")]
87pub struct FetchedModule {
88    /// The [`ModuleSpec::id`] this artifact belongs to.
89    pub module_id: String,
90    /// The requested version string.
91    pub version: String,
92    /// Full lowercase hex sha256 digest of the artifact bytes.
93    pub digest_sha256: String,
94    /// Path to the verified `.modl` file on disk.
95    pub path: std::path::PathBuf,
96    /// Artifact size in bytes.
97    pub bytes: u64,
98    /// Whether this call hit the cache or downloaded fresh.
99    pub source: ArtifactSource,
100}
101
102/// The GitHub releases-by-tag response, scoped to exactly the fields
103/// this module needs — no GitHub SDK (15-RESEARCH.md "Don't Hand-Roll").
104#[derive(Debug, serde::Deserialize)]
105struct Release {
106    assets: Vec<Asset>,
107}
108
109/// One release asset, scoped to exactly the fields this module needs.
110#[derive(Debug, serde::Deserialize)]
111struct Asset {
112    name: String,
113    browser_download_url: String,
114    /// `"sha256:<64-hex>"` on GitHub's modern API — `None`/malformed is
115    /// refused (`ign` never caches bytes it cannot verify).
116    digest: Option<String>,
117    /// The release's published size in bytes — caps the download
118    /// stream against an oversized/endless body (T-15-10, 15-02).
119    /// `Content-Length` is deliberately NOT trusted in its place: a
120    /// hostile or broken origin controls that header too, while this
121    /// value arrived over the separate release-metadata request.
122    size: u64,
123}
124
125/// A third-party module release feed — one `reqwest::Client`, kept
126/// SEPARATE from [`crate::client::ReqwestGatewayApi`] (D-03).
127pub struct ModuleFeed {
128    api_base: url::Url,
129    client: reqwest::Client,
130}
131
132impl ModuleFeed {
133    /// The production feed: `https://api.github.com`.
134    pub fn github() -> Result<Self, CoreError> {
135        Self::for_base(
136            url::Url::parse("https://api.github.com").expect("literal GitHub API URL parses"),
137        )
138    }
139
140    /// Build against an arbitrary base — the test seam (wiremock servers,
141    /// and the unroutable-address offline fixtures).
142    pub fn for_base(api_base: url::Url) -> Result<Self, CoreError> {
143        // Both this client and ReqwestGatewayApi's must install the
144        // process-wide TLS provider before their first request — the
145        // workspace builds reqwest with rustls-no-provider (D-03).
146        crate::client::install_crypto_provider();
147        let client = reqwest::Client::builder()
148            .connect_timeout(Duration::from_secs(10))
149            .timeout(Duration::from_secs(30))
150            .build()
151            .map_err(|err| {
152                CoreError::Internal(format!("cannot build module feed HTTP client: {err}"))
153            })?;
154        Ok(Self { api_base, client })
155    }
156
157    /// Resolve, download, verify, and cache `spec`'s `version` — or
158    /// return it straight from the cache with zero network requests
159    /// (see the module doc for the SC-3/SC-4 mechanism).
160    pub async fn fetch_and_verify(
161        &self,
162        spec: &ModuleSpec,
163        version: &str,
164        cache_root: &Path,
165        policy: FetchPolicy,
166    ) -> Result<FetchedModule, CoreError> {
167        // Refuse a hostile module id / version BEFORE it reaches a
168        // PathBuf::join or a URL (T-15-03) — before even the cache
169        // lookup, so a traversal attempt never touches the filesystem.
170        validate_module_id(spec.id)?;
171        validate_version(version)?;
172
173        // (a) Cache lookup — keyed by version alone. `CacheFirst` (the
174        // default) short-circuits HERE with ZERO requests of any kind
175        // — that's the whole SC-3/SC-4 mechanism, and adding drift
176        // detection below must never weaken it.
177        // `Refresh`/`AcceptUpstreamChange` fall through to resolve the
178        // release below and compare digests before deciding (D-02/D-09).
179        let cached = cached_entry(cache_root, spec.id, version);
180        if let Some(cached) = &cached
181            && policy == FetchPolicy::CacheFirst
182        {
183            return Ok(FetchedModule {
184                module_id: spec.id.to_string(),
185                version: version.to_string(),
186                digest_sha256: cached.digest_sha256.clone(),
187                path: cached.path.clone(),
188                bytes: cached.bytes,
189                source: ArtifactSource::Cache,
190            });
191        }
192
193        // (b) Resolve ONLY through the tag-pinned releases endpoint —
194        // the floating `releases/latest` endpoint is never called. Runs
195        // for a cache MISS (any policy) AND for a cache HIT under
196        // Refresh/AcceptUpstreamChange (the drift check needs the
197        // feed's CURRENT digest).
198        let tag = spec.tag(version);
199        let release_url = self
200            .api_base
201            .join(&format!("/repos/{}/releases/tags/{tag}", spec.repo))
202            .map_err(|err| CoreError::Internal(format!("cannot build release URL: {err}")))?;
203
204        let response = self
205            .client
206            .get(release_url.clone())
207            .header(reqwest::header::USER_AGENT, "ignition-cli")
208            .header(reqwest::header::ACCEPT, "application/vnd.github+json")
209            .send()
210            .await
211            .map_err(|err| CoreError::ModuleFeedUnreachable {
212                url: release_url.to_string(),
213                source: Some(err),
214            })?;
215
216        // (c) Classify the resolve response.
217        if response.status() == reqwest::StatusCode::NOT_FOUND {
218            return Err(CoreError::ModuleReleaseNotFound {
219                module: spec.id.to_string(),
220                version: version.to_string(),
221                url: release_url.to_string(),
222                detail: format!("no release exists for tag {tag:?}"),
223            });
224        }
225        if !response.status().is_success() {
226            let status = response.status();
227            // Rate-limit detail BEFORE anything else (15-RESEARCH
228            // Pitfall 5): GitHub's unauthenticated API answers an
229            // exhausted rate limit as a bare 403, which the gateway
230            // classifier's convention would misreport as a credential
231            // rejection. Distinguish by header, not status code alone —
232            // a 403 WITHOUT the header is a plain unusable answer, never
233            // presumed to be a rate limit.
234            let rate_limit_remaining = response
235                .headers()
236                .get("x-ratelimit-remaining")
237                .and_then(|value| value.to_str().ok())
238                .map(str::to_string);
239            let rate_limit_reset = response
240                .headers()
241                .get("x-ratelimit-reset")
242                .and_then(|value| value.to_str().ok())
243                .map(str::to_string);
244            let capped_body = bounded_diagnostic_body(response).await;
245            let detail = if rate_limit_remaining.as_deref() == Some("0") {
246                match rate_limit_reset {
247                    Some(reset) => format!(
248                        "feed rate limit exhausted (x-ratelimit-remaining: 0, resets at {reset})"
249                    ),
250                    None => "feed rate limit exhausted (x-ratelimit-remaining: 0)".to_string(),
251                }
252            } else {
253                format!("release lookup answered HTTP {status}: {capped_body}")
254            };
255            return Err(CoreError::ModuleFeedUnusable {
256                url: release_url.to_string(),
257                detail,
258            });
259        }
260        let release: Release =
261            response
262                .json()
263                .await
264                .map_err(|err| CoreError::ModuleFeedUnusable {
265                    url: release_url.to_string(),
266                    detail: format!("release body did not match the expected shape: {err}"),
267                })?;
268
269        // (d) Select the asset; verify it carries a usable digest.
270        let expected_asset_name = spec.asset_name(version);
271        let Some(asset) = release
272            .assets
273            .iter()
274            .find(|asset| asset.name == expected_asset_name)
275        else {
276            let present: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect();
277            return Err(CoreError::ModuleReleaseNotFound {
278                module: spec.id.to_string(),
279                version: version.to_string(),
280                url: release_url.to_string(),
281                detail: format!(
282                    "release has no asset named {expected_asset_name:?} (present: [{}])",
283                    present.join(", ")
284                ),
285            });
286        };
287
288        let expected_digest = match asset
289            .digest
290            .as_deref()
291            .and_then(|digest| digest.strip_prefix("sha256:"))
292        {
293            Some(hex) if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) => {
294                hex.to_ascii_lowercase()
295            }
296            _ => {
297                return Err(CoreError::ModuleFeedUnusable {
298                    url: release_url.to_string(),
299                    detail: format!(
300                        "asset {:?} has no usable sha256 digest (published: {:?})",
301                        asset.name, asset.digest
302                    ),
303                });
304            }
305        };
306
307        // (a-continued) A cache HIT under Refresh/AcceptUpstreamChange:
308        // decide from the digest comparison BEFORE touching the
309        // network again for bytes — D-02's two separate questions
310        // (see the module doc: this is the "did upstream change?"
311        // question, never the "do the bytes match?" one below, which
312        // has no override).
313        if let Some(cached) = cached {
314            if expected_digest == cached.digest_sha256 {
315                // Upstream still publishes the digest already cached —
316                // bytes already proven are not re-fetched.
317                return Ok(FetchedModule {
318                    module_id: spec.id.to_string(),
319                    version: version.to_string(),
320                    digest_sha256: cached.digest_sha256,
321                    path: cached.path,
322                    bytes: cached.bytes,
323                    source: ArtifactSource::Cache,
324                });
325            }
326            if policy == FetchPolicy::Refresh {
327                // D-02 default: refuse, name both digests, leave the
328                // cached artifact exactly as it was — no write, no
329                // delete, no overwrite.
330                return Err(CoreError::ModuleDigestChanged(Box::new(
331                    crate::error::ModuleDigestChangedDetails {
332                        module: spec.id.to_string(),
333                        version: version.to_string(),
334                        cached_digest: cached.digest_sha256,
335                        upstream_digest: expected_digest.clone(),
336                        cached_path: cached.path.display().to_string(),
337                    },
338                )));
339            }
340            // AcceptUpstreamChange: fall through to the SHARED
341            // download-verify-persist path below, which verifies the
342            // NEW bytes against `expected_digest` exactly as a cache
343            // miss would — the override never skips verification
344            // (D-02/D-09).
345        }
346
347        // (e) Download through the redirect (D-03: default policy).
348        let download_url = url::Url::parse(&asset.browser_download_url).map_err(|err| {
349            CoreError::ModuleFeedUnusable {
350                url: release_url.to_string(),
351                detail: format!(
352                    "asset download URL {:?} did not parse: {err}",
353                    asset.browser_download_url
354                ),
355            }
356        })?;
357
358        // Asset-URL scheme guard (T-15-05): an `https` feed may only
359        // hand back an `https` asset URL — refuse BEFORE attempting the
360        // download, never silently follow a downgrade. Keeps the
361        // mock-server (http base -> http asset) path working while
362        // making a production downgrade impossible.
363        if is_insecure_downgrade(self.api_base.scheme(), download_url.scheme()) {
364            return Err(CoreError::ModuleFeedUnusable {
365                url: download_url.to_string(),
366                detail: format!(
367                    "asset download URL uses {} while the feed itself is https — \
368                     refusing the insecure downgrade",
369                    download_url.scheme()
370                ),
371            });
372        }
373
374        let response = self
375            .client
376            .get(download_url.clone())
377            .send()
378            .await
379            .map_err(|err| CoreError::ModuleFeedUnreachable {
380                url: download_url.to_string(),
381                source: Some(err),
382            })?;
383        if !response.status().is_success() {
384            return Err(CoreError::ModuleFeedUnusable {
385                url: download_url.to_string(),
386                detail: format!("asset download answered HTTP {}", response.status()),
387            });
388        }
389
390        // (f) Stream to a temp file in the cache dir, hashing as we go
391        // (D-07): tempfile::NamedTempFile for RAII cleanup on any early
392        // return, tokio::fs::File::from_std(temp.reopen()?) for the
393        // async chunk writes.
394        let cache_dir = module_cache_dir(cache_root, spec.id);
395        tokio::fs::create_dir_all(&cache_dir).await.map_err(|err| {
396            CoreError::Internal(format!("cannot create {}: {err}", cache_dir.display()))
397        })?;
398
399        let temp = tempfile::NamedTempFile::new_in(&cache_dir).map_err(|err| {
400            CoreError::Internal(format!(
401                "cannot create temp file in {}: {err}",
402                cache_dir.display()
403            ))
404        })?;
405        let std_file = temp
406            .reopen()
407            .map_err(|err| CoreError::Internal(format!("cannot reopen temp file: {err}")))?;
408        let mut async_file = tokio::fs::File::from_std(std_file);
409
410        let mut hasher = Sha256::new();
411        let mut stream = response.bytes_stream();
412        let mut bytes: u64 = 0;
413        while let Some(chunk) = stream.next().await {
414            let chunk = chunk.map_err(|err| CoreError::ModuleFeedUnreachable {
415                url: download_url.to_string(),
416                source: Some(err),
417            })?;
418            bytes += chunk.len() as u64;
419            // Size cap (T-15-10): compare the running total against the
420            // release's PUBLISHED size — never Content-Length, which a
421            // hostile or broken origin controls just as easily.
422            // Returning here stops polling `stream` immediately (the
423            // remaining body is never drained) and drops `temp`, whose
424            // Drop discards whatever partial bytes were written so far.
425            if bytes > asset.size {
426                return Err(CoreError::ModuleFeedUnusable {
427                    url: download_url.to_string(),
428                    detail: format!(
429                        "asset body exceeded the published size ({} bytes) — aborted mid-stream",
430                        asset.size
431                    ),
432                });
433            }
434            hasher.update(&chunk);
435            async_file
436                .write_all(&chunk)
437                .await
438                .map_err(|err| CoreError::Internal(format!("cannot write temp file: {err}")))?;
439        }
440        async_file
441            .flush()
442            .await
443            .map_err(|err| CoreError::Internal(format!("cannot flush temp file: {err}")))?;
444
445        // (g) Verify. This comparison runs unconditionally, ahead of
446        // any policy branch, and takes no policy parameter — there is
447        // no FetchPolicy value that changes what happens here. A
448        // mismatch returns before `.persist()` — the NamedTempFile's
449        // Drop discards the bytes structurally.
450        let actual_digest = format!("{:x}", hasher.finalize());
451        if actual_digest != expected_digest {
452            return Err(CoreError::ModuleDigestMismatch(Box::new(
453                crate::error::ModuleDigestMismatchDetails {
454                    module: spec.id.to_string(),
455                    version: version.to_string(),
456                    url: download_url.to_string(),
457                    expected: expected_digest,
458                    actual: actual_digest,
459                },
460            )));
461        }
462
463        // (h) Persist — the ONLY way bytes reach the final cache path.
464        let final_path = cache_dir.join(format!("{version}-{actual_digest}.modl"));
465        temp.persist(&final_path).map_err(|err| {
466            CoreError::Internal(format!(
467                "cannot persist cache entry at {}: {err}",
468                final_path.display()
469            ))
470        })?;
471
472        Ok(FetchedModule {
473            module_id: spec.id.to_string(),
474            version: version.to_string(),
475            digest_sha256: actual_digest,
476            path: final_path,
477            bytes,
478            source: ArtifactSource::Download,
479        })
480    }
481}
482
483/// `true` when handing back `asset_scheme` would downgrade below the
484/// feed's own `feed_scheme` — an `https` feed serving a non-`https`
485/// asset URL (T-15-05). Pure and network-free by design: this workspace
486/// has no TLS-serving wiremock (the same limitation
487/// `tests/session_contract.rs`'s module doc already documents for the
488/// `ssl_verify` builder behavior — "the WIRE-level https-skip proof
489/// needs wiremock's optional `tls` feature, which this workspace does
490/// not enable"), so the guard's logic is pinned here directly rather
491/// than through a live round trip.
492fn is_insecure_downgrade(feed_scheme: &str, asset_scheme: &str) -> bool {
493    feed_scheme == "https" && asset_scheme != "https"
494}
495
496/// Read at most [`DIAGNOSTIC_BODY_CAP_BYTES`] from a non-success feed
497/// response for use in an error message.
498///
499/// `Response::text()` buffers the WHOLE remote body before any
500/// truncation, so a hostile or compromised feed could answer an error
501/// with an arbitrarily large payload and push `ign` into memory
502/// pressure — the request timeout bounds duration, not size. The
503/// artifact path already streams under a published-size cap; this is
504/// the same discipline applied to the diagnostic path, which is the
505/// one place a feed's bytes are read without one.
506///
507/// Whatever is read is decoded lossily (an error body is diagnostic
508/// text, never trusted structured data) and truncated to
509/// [`DIAGNOSTIC_BODY_CHARS`], with a marker when anything was dropped.
510async fn bounded_diagnostic_body(response: reqwest::Response) -> String {
511    let mut buf: Vec<u8> = Vec::new();
512    let mut truncated = false;
513    let mut stream = response.bytes_stream();
514    while let Some(chunk) = stream.next().await {
515        let Ok(chunk) = chunk else { break };
516        let remaining = DIAGNOSTIC_BODY_CAP_BYTES.saturating_sub(buf.len());
517        if chunk.len() >= remaining {
518            buf.extend_from_slice(&chunk[..remaining]);
519            truncated = true;
520            break;
521        }
522        buf.extend_from_slice(&chunk);
523    }
524    let text = String::from_utf8_lossy(&buf);
525    let mut capped: String = text.chars().take(DIAGNOSTIC_BODY_CHARS).collect();
526    if truncated || capped.chars().count() < text.chars().count() {
527        capped.push_str(DIAGNOSTIC_TRUNCATION_MARKER);
528    }
529    capped
530}
531
532#[cfg(test)]
533mod tests {
534    use super::is_insecure_downgrade;
535
536    #[test]
537    fn https_feed_refuses_non_https_asset() {
538        assert!(is_insecure_downgrade("https", "http"));
539    }
540
541    #[test]
542    fn https_feed_allows_https_asset() {
543        assert!(!is_insecure_downgrade("https", "https"));
544    }
545
546    #[test]
547    fn http_feed_allows_any_asset_scheme() {
548        assert!(!is_insecure_downgrade("http", "http"));
549        assert!(!is_insecure_downgrade("http", "https"));
550    }
551}