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            // An entry cached before readability was enforced is still
184            // 0600 on disk; a hit must repair it, not hand back an
185            // artifact the gateway cannot read.
186            ensure_readable(&cached.path)?;
187            return Ok(FetchedModule {
188                module_id: spec.id.to_string(),
189                version: version.to_string(),
190                digest_sha256: cached.digest_sha256.clone(),
191                path: cached.path.clone(),
192                bytes: cached.bytes,
193                source: ArtifactSource::Cache,
194            });
195        }
196
197        // (b) Resolve ONLY through the tag-pinned releases endpoint —
198        // the floating `releases/latest` endpoint is never called. Runs
199        // for a cache MISS (any policy) AND for a cache HIT under
200        // Refresh/AcceptUpstreamChange (the drift check needs the
201        // feed's CURRENT digest).
202        let tag = spec.tag(version);
203        let release_url = self
204            .api_base
205            .join(&format!("/repos/{}/releases/tags/{tag}", spec.repo))
206            .map_err(|err| CoreError::Internal(format!("cannot build release URL: {err}")))?;
207
208        let response = self
209            .client
210            .get(release_url.clone())
211            .header(reqwest::header::USER_AGENT, "ignition-cli")
212            .header(reqwest::header::ACCEPT, "application/vnd.github+json")
213            .send()
214            .await
215            .map_err(|err| CoreError::ModuleFeedUnreachable {
216                url: release_url.to_string(),
217                source: Some(err),
218            })?;
219
220        // (c) Classify the resolve response.
221        if response.status() == reqwest::StatusCode::NOT_FOUND {
222            return Err(CoreError::ModuleReleaseNotFound {
223                module: spec.id.to_string(),
224                version: version.to_string(),
225                url: release_url.to_string(),
226                detail: format!("no release exists for tag {tag:?}"),
227            });
228        }
229        if !response.status().is_success() {
230            let status = response.status();
231            // Rate-limit detail BEFORE anything else (15-RESEARCH
232            // Pitfall 5): GitHub's unauthenticated API answers an
233            // exhausted rate limit as a bare 403, which the gateway
234            // classifier's convention would misreport as a credential
235            // rejection. Distinguish by header, not status code alone —
236            // a 403 WITHOUT the header is a plain unusable answer, never
237            // presumed to be a rate limit.
238            let rate_limit_remaining = response
239                .headers()
240                .get("x-ratelimit-remaining")
241                .and_then(|value| value.to_str().ok())
242                .map(str::to_string);
243            let rate_limit_reset = response
244                .headers()
245                .get("x-ratelimit-reset")
246                .and_then(|value| value.to_str().ok())
247                .map(str::to_string);
248            let capped_body = bounded_diagnostic_body(response).await;
249            let detail = if rate_limit_remaining.as_deref() == Some("0") {
250                match rate_limit_reset {
251                    Some(reset) => format!(
252                        "feed rate limit exhausted (x-ratelimit-remaining: 0, resets at {reset})"
253                    ),
254                    None => "feed rate limit exhausted (x-ratelimit-remaining: 0)".to_string(),
255                }
256            } else {
257                format!("release lookup answered HTTP {status}: {capped_body}")
258            };
259            return Err(CoreError::ModuleFeedUnusable {
260                url: release_url.to_string(),
261                detail,
262            });
263        }
264        let release: Release =
265            response
266                .json()
267                .await
268                .map_err(|err| CoreError::ModuleFeedUnusable {
269                    url: release_url.to_string(),
270                    detail: format!("release body did not match the expected shape: {err}"),
271                })?;
272
273        // (d) Select the asset; verify it carries a usable digest.
274        let expected_asset_name = spec.asset_name(version);
275        let Some(asset) = release
276            .assets
277            .iter()
278            .find(|asset| asset.name == expected_asset_name)
279        else {
280            let present: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect();
281            return Err(CoreError::ModuleReleaseNotFound {
282                module: spec.id.to_string(),
283                version: version.to_string(),
284                url: release_url.to_string(),
285                detail: format!(
286                    "release has no asset named {expected_asset_name:?} (present: [{}])",
287                    present.join(", ")
288                ),
289            });
290        };
291
292        let expected_digest = match asset
293            .digest
294            .as_deref()
295            .and_then(|digest| digest.strip_prefix("sha256:"))
296        {
297            Some(hex) if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) => {
298                hex.to_ascii_lowercase()
299            }
300            _ => {
301                return Err(CoreError::ModuleFeedUnusable {
302                    url: release_url.to_string(),
303                    detail: format!(
304                        "asset {:?} has no usable sha256 digest (published: {:?})",
305                        asset.name, asset.digest
306                    ),
307                });
308            }
309        };
310
311        // (a-continued) A cache HIT under Refresh/AcceptUpstreamChange:
312        // decide from the digest comparison BEFORE touching the
313        // network again for bytes — D-02's two separate questions
314        // (see the module doc: this is the "did upstream change?"
315        // question, never the "do the bytes match?" one below, which
316        // has no override).
317        if let Some(cached) = cached {
318            if expected_digest == cached.digest_sha256 {
319                // Upstream still publishes the digest already cached —
320                // bytes already proven are not re-fetched.
321                ensure_readable(&cached.path)?;
322                return Ok(FetchedModule {
323                    module_id: spec.id.to_string(),
324                    version: version.to_string(),
325                    digest_sha256: cached.digest_sha256,
326                    path: cached.path,
327                    bytes: cached.bytes,
328                    source: ArtifactSource::Cache,
329                });
330            }
331            if policy == FetchPolicy::Refresh {
332                // D-02 default: refuse, name both digests, leave the
333                // cached artifact exactly as it was — no write, no
334                // delete, no overwrite.
335                return Err(CoreError::ModuleDigestChanged(Box::new(
336                    crate::error::ModuleDigestChangedDetails {
337                        module: spec.id.to_string(),
338                        version: version.to_string(),
339                        cached_digest: cached.digest_sha256,
340                        upstream_digest: expected_digest.clone(),
341                        cached_path: cached.path.display().to_string(),
342                    },
343                )));
344            }
345            // AcceptUpstreamChange: fall through to the SHARED
346            // download-verify-persist path below, which verifies the
347            // NEW bytes against `expected_digest` exactly as a cache
348            // miss would — the override never skips verification
349            // (D-02/D-09).
350        }
351
352        // (e) Download through the redirect (D-03: default policy).
353        let download_url = url::Url::parse(&asset.browser_download_url).map_err(|err| {
354            CoreError::ModuleFeedUnusable {
355                url: release_url.to_string(),
356                detail: format!(
357                    "asset download URL {:?} did not parse: {err}",
358                    asset.browser_download_url
359                ),
360            }
361        })?;
362
363        // Asset-URL scheme guard (T-15-05): an `https` feed may only
364        // hand back an `https` asset URL — refuse BEFORE attempting the
365        // download, never silently follow a downgrade. Keeps the
366        // mock-server (http base -> http asset) path working while
367        // making a production downgrade impossible.
368        if is_insecure_downgrade(self.api_base.scheme(), download_url.scheme()) {
369            return Err(CoreError::ModuleFeedUnusable {
370                url: download_url.to_string(),
371                detail: format!(
372                    "asset download URL uses {} while the feed itself is https — \
373                     refusing the insecure downgrade",
374                    download_url.scheme()
375                ),
376            });
377        }
378
379        let response = self
380            .client
381            .get(download_url.clone())
382            .send()
383            .await
384            .map_err(|err| CoreError::ModuleFeedUnreachable {
385                url: download_url.to_string(),
386                source: Some(err),
387            })?;
388        if !response.status().is_success() {
389            return Err(CoreError::ModuleFeedUnusable {
390                url: download_url.to_string(),
391                detail: format!("asset download answered HTTP {}", response.status()),
392            });
393        }
394
395        // (f) Stream to a temp file in the cache dir, hashing as we go
396        // (D-07): tempfile::NamedTempFile for RAII cleanup on any early
397        // return, tokio::fs::File::from_std(temp.reopen()?) for the
398        // async chunk writes.
399        let cache_dir = module_cache_dir(cache_root, spec.id);
400        tokio::fs::create_dir_all(&cache_dir).await.map_err(|err| {
401            CoreError::Internal(format!("cannot create {}: {err}", cache_dir.display()))
402        })?;
403
404        let temp = tempfile::NamedTempFile::new_in(&cache_dir).map_err(|err| {
405            CoreError::Internal(format!(
406                "cannot create temp file in {}: {err}",
407                cache_dir.display()
408            ))
409        })?;
410        let std_file = temp
411            .reopen()
412            .map_err(|err| CoreError::Internal(format!("cannot reopen temp file: {err}")))?;
413        let mut async_file = tokio::fs::File::from_std(std_file);
414
415        let mut hasher = Sha256::new();
416        let mut stream = response.bytes_stream();
417        let mut bytes: u64 = 0;
418        while let Some(chunk) = stream.next().await {
419            let chunk = chunk.map_err(|err| CoreError::ModuleFeedUnreachable {
420                url: download_url.to_string(),
421                source: Some(err),
422            })?;
423            bytes += chunk.len() as u64;
424            // Size cap (T-15-10): compare the running total against the
425            // release's PUBLISHED size — never Content-Length, which a
426            // hostile or broken origin controls just as easily.
427            // Returning here stops polling `stream` immediately (the
428            // remaining body is never drained) and drops `temp`, whose
429            // Drop discards whatever partial bytes were written so far.
430            if bytes > asset.size {
431                return Err(CoreError::ModuleFeedUnusable {
432                    url: download_url.to_string(),
433                    detail: format!(
434                        "asset body exceeded the published size ({} bytes) — aborted mid-stream",
435                        asset.size
436                    ),
437                });
438            }
439            hasher.update(&chunk);
440            async_file
441                .write_all(&chunk)
442                .await
443                .map_err(|err| CoreError::Internal(format!("cannot write temp file: {err}")))?;
444        }
445        async_file
446            .flush()
447            .await
448            .map_err(|err| CoreError::Internal(format!("cannot flush temp file: {err}")))?;
449
450        // (g) Verify. This comparison runs unconditionally, ahead of
451        // any policy branch, and takes no policy parameter — there is
452        // no FetchPolicy value that changes what happens here. A
453        // mismatch returns before `.persist()` — the NamedTempFile's
454        // Drop discards the bytes structurally.
455        let actual_digest = format!("{:x}", hasher.finalize());
456        if actual_digest != expected_digest {
457            return Err(CoreError::ModuleDigestMismatch(Box::new(
458                crate::error::ModuleDigestMismatchDetails {
459                    module: spec.id.to_string(),
460                    version: version.to_string(),
461                    url: download_url.to_string(),
462                    expected: expected_digest,
463                    actual: actual_digest,
464                },
465            )));
466        }
467
468        // (h) Persist — the ONLY way bytes reach the final cache path.
469        //
470        // Readability is set on the TEMP file, before publication. Doing it
471        // after `persist` would expose the final path at the temp mode for a
472        // window, and a failure there would leave an unreadable artifact
473        // already published; failing here instead lets the NamedTempFile's
474        // Drop discard it, exactly as a digest mismatch does.
475        ensure_readable(temp.path())?;
476        let final_path = cache_dir.join(format!("{version}-{actual_digest}.modl"));
477        temp.persist(&final_path).map_err(|err| {
478            CoreError::Internal(format!(
479                "cannot persist cache entry at {}: {err}",
480                final_path.display()
481            ))
482        })?;
483
484        Ok(FetchedModule {
485            module_id: spec.id.to_string(),
486            version: version.to_string(),
487            digest_sha256: actual_digest,
488            path: final_path,
489            bytes,
490            source: ArtifactSource::Download,
491        })
492    }
493}
494
495/// `true` when handing back `asset_scheme` would downgrade below the
496/// feed's own `feed_scheme` — an `https` feed serving a non-`https`
497/// asset URL (T-15-05). Pure and network-free by design: this workspace
498/// has no TLS-serving wiremock (the same limitation
499/// `tests/session_contract.rs`'s module doc already documents for the
500/// `ssl_verify` builder behavior — "the WIRE-level https-skip proof
501/// needs wiremock's optional `tls` feature, which this workspace does
502/// not enable"), so the guard's logic is pinned here directly rather
503/// than through a live round trip.
504fn is_insecure_downgrade(feed_scheme: &str, asset_scheme: &str) -> bool {
505    feed_scheme == "https" && asset_scheme != "https"
506}
507
508/// Read at most [`DIAGNOSTIC_BODY_CAP_BYTES`] from a non-success feed
509/// response for use in an error message.
510///
511/// `Response::text()` buffers the WHOLE remote body before any
512/// truncation, so a hostile or compromised feed could answer an error
513/// with an arbitrarily large payload and push `ign` into memory
514/// pressure — the request timeout bounds duration, not size. The
515/// artifact path already streams under a published-size cap; this is
516/// the same discipline applied to the diagnostic path, which is the
517/// one place a feed's bytes are read without one.
518///
519/// Whatever is read is decoded lossily (an error body is diagnostic
520/// text, never trusted structured data) and truncated to
521/// [`DIAGNOSTIC_BODY_CHARS`], with a marker when anything was dropped.
522async fn bounded_diagnostic_body(response: reqwest::Response) -> String {
523    let mut buf: Vec<u8> = Vec::new();
524    let mut truncated = false;
525    let mut stream = response.bytes_stream();
526    while let Some(chunk) = stream.next().await {
527        let Ok(chunk) = chunk else { break };
528        let remaining = DIAGNOSTIC_BODY_CAP_BYTES.saturating_sub(buf.len());
529        if chunk.len() >= remaining {
530            buf.extend_from_slice(&chunk[..remaining]);
531            truncated = true;
532            break;
533        }
534        buf.extend_from_slice(&chunk);
535    }
536    let text = String::from_utf8_lossy(&buf);
537    let mut capped: String = text.chars().take(DIAGNOSTIC_BODY_CHARS).collect();
538    if truncated || capped.chars().count() < text.chars().count() {
539        capped.push_str(DIAGNOSTIC_TRUNCATION_MARKER);
540    }
541    capped
542}
543
544/// Make a cache entry readable by any uid — the gateway runs as its own
545/// user, not ours.
546///
547/// A cache entry is an artifact to be MOUNTED, not a secret.
548/// `NamedTempFile` creates at 0600 and `persist` preserves that mode, so
549/// without this the stock `inductiveautomation/ignition` image (uid
550/// 2003:2003) cannot read a bind-mounted `.modl` and the module is
551/// silently absent — no error, nothing in the gateway log.
552///
553/// Idempotent, and applied on cache HITS as well as fresh downloads:
554/// entries written before this existed are still 0600 on disk, and a hit
555/// must repair one rather than hand back an unreadable artifact.
556///
557/// Unix-only. Windows has no equivalent mode bit and its default ACL on a
558/// user-owned file is already readable.
559fn ensure_readable(path: &Path) -> Result<(), CoreError> {
560    #[cfg(unix)]
561    {
562        use std::os::unix::fs::PermissionsExt;
563        let current = std::fs::metadata(path)
564            .map_err(|err| {
565                CoreError::Internal(format!(
566                    "cannot read permissions of cache entry at {}: {err}",
567                    path.display()
568                ))
569            })?
570            .permissions()
571            .mode();
572        if current & 0o044 == 0o044 {
573            return Ok(());
574        }
575        std::fs::set_permissions(path, std::fs::Permissions::from_mode(current | 0o044)).map_err(
576            |err| {
577                CoreError::Internal(format!(
578                    "cannot make cache entry readable at {}: {err}",
579                    path.display()
580                ))
581            },
582        )?;
583    }
584    #[cfg(not(unix))]
585    let _ = path;
586    Ok(())
587}
588
589#[cfg(test)]
590mod tests {
591    use super::is_insecure_downgrade;
592
593    #[test]
594    fn https_feed_refuses_non_https_asset() {
595        assert!(is_insecure_downgrade("https", "http"));
596    }
597
598    #[test]
599    fn https_feed_allows_https_asset() {
600        assert!(!is_insecure_downgrade("https", "https"));
601    }
602
603    #[test]
604    fn http_feed_allows_any_asset_scheme() {
605        assert!(!is_insecure_downgrade("http", "http"));
606        assert!(!is_insecure_downgrade("http", "https"));
607    }
608}