//! HTTP fetches and verified downloads. //! //! Everything this module pulls comes from a public Gitea repo over anonymous HTTPS — the shard //! host has no Gitea credentials and needs none (PLAN.md §1). The one rule that matters: //! **nothing downloaded is used before its SHA256 has been checked against the bundle.** The //! artifacts are deliberately unsigned (§3), so the checksum is the entire trust anchor, and a //! download that "mostly worked" is exactly the case that must not proceed. use std::fs::File; use std::io::{self, BufWriter}; use std::path::Path; use std::time::Duration; use anyhow::{anyhow, bail, Context, Result}; use crate::util::HashingWriter; /// Identifies the tool and its version in Gitea's logs — worth having when an operator reports that /// a fetch failed and nobody can tell which build made the request. fn user_agent() -> String { format!("runicgateway-installer/{}", env!("CARGO_PKG_VERSION")) } /// The default global timeout. Generous because the overlay tarball travels over whatever link the /// shard host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the /// run with a message instead of hanging an operator's terminal indefinitely. const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); /// One agent per call is fine at this volume, and it keeps the timeouts in one place. fn agent(timeout: Duration) -> ureq::Agent { ureq::Agent::config_builder() .user_agent(user_agent()) .timeout_global(Some(timeout)) .build() .into() } /// Fetches a small text document (the bundle manifest). pub fn get_text(url: &str) -> Result { get_text_within(url, DEFAULT_TIMEOUT) } /// Fetches a small text document, giving up after `timeout`. /// /// `doctor` uses this for both of its network calls, and the short timeout is the point: every one /// of its rows is optional context around local state, so a host with no route out must produce a /// report a few seconds later rather than a terminal that appears to have hung. The install path /// keeps [`DEFAULT_TIMEOUT`], where a slow answer is still worth waiting for. pub fn get_text_within(url: &str, timeout: Duration) -> Result { let mut response = agent(timeout) .get(url) .call() .with_context(|| format!("cannot reach {url}"))?; let status = response.status(); if !status.is_success() { bail!("{url} returned HTTP {}", status.as_u16()); } response .body_mut() .read_to_string() .with_context(|| format!("cannot read the response from {url}")) } /// Downloads `url` to `dest`, verifying SHA256 **while writing**. /// /// On mismatch the partial file is removed before returning: leaving a wrong-hash artifact on disk /// invites a later step — or a puzzled operator — to use it anyway. pub fn download_verified(url: &str, dest: &Path, expected_sha256: &str) -> Result<()> { let expected = expected_sha256.trim().to_ascii_lowercase(); if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) { bail!("refusing to download {url}: the bundle records an unusable SHA256 ({expected_sha256:?})"); } let mut response = agent(DEFAULT_TIMEOUT) .get(url) .call() .with_context(|| format!("cannot reach {url}"))?; let status = response.status(); if !status.is_success() { bail!("{url} returned HTTP {}", status.as_u16()); } let file = File::create(dest).with_context(|| format!("cannot create {}", dest.display()))?; let mut writer = HashingWriter::new(BufWriter::new(file)); io::copy(&mut response.body_mut().as_reader(), &mut writer) .with_context(|| format!("download of {url} failed"))?; let actual = writer.finish(); if actual != expected { let _ = std::fs::remove_file(dest); return Err(anyhow!( "checksum mismatch for {url}\n expected {expected}\n got {actual}\n\ These artifacts are unsigned, so the checksum is the only thing vouching for them. \ Refusing to use this download." )); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::util::TempDir; #[test] fn the_user_agent_names_the_build() { let ua = user_agent(); assert!(ua.starts_with("runicgateway-installer/"), "{ua}"); assert!(ua.len() > "runicgateway-installer/".len(), "{ua}"); } #[test] fn a_malformed_expected_hash_is_refused_before_any_request() { // A bundle whose sha256 field is truncated, uppercase-garbled or empty must stop the run // rather than download something that can then only be compared against nonsense. The URL // is unroutable on purpose: reaching the network at all would be the bug. let dir = TempDir::new("rg-test-net").unwrap(); let dest = dir.path().join("artifact"); for bad in ["", "abc", &"z".repeat(64)] { let err = download_verified("http://127.0.0.1:1/artifact", &dest, bad).unwrap_err(); assert!( err.to_string().contains("unusable SHA256"), "expected a pre-flight refusal, got: {err}" ); } assert!(!dest.exists()); } }