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}