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}