Skip to main content

warble_cli/
hub_fetch.rs

1//! Fetches the Hub component library over the network into a per-user cache, when there is no
2//! in-repo `hub/components/` to prefer instead (see the crate's `in_repo_hub_dir` helper and
3//! [`crate::default_component_sources`]).
4//!
5//! Resolution order: a profile's own `components/` (Local, highest precedence) beats the user
6//! cache, which beats fetching the central Hub fresh. This module only
7//! implements the last two steps — filling and trusting the cache.
8//!
9//! Everything here is in-process: `ureq` + `rustls` for HTTPS (no shelling out to `curl`/`wget`,
10//! absent from `debian:bookworm-slim`), `flate2` + `tar` for archive handling (no shelling out to
11//! system `tar`). The Hub ships as one `hub-<version>.tar.gz` archive per release, unpacking to
12//! component directories at its top level (`answer_query/component.yml`, not
13//! `components/answer_query/component.yml`).
14//!
15//! ## Cache trust
16//!
17//! A prior, abandoned design cached to the world-writable, fully-derivable-path shared temp
18//! directory and returned early on a bare `.complete` sentinel — never verifying content or
19//! ownership. A local actor could pre-plant a poisoned cache at that guessable path and every
20//! later compile would silently use it (reproduced end to end: a canary in a cached step file
21//! appeared twice in the emitted IR). This module closes that vector two ways:
22//!
23//! - The cache root is a **per-user** directory (the platform user cache dir, never the shared
24//!   temp dir), created owner-only (`0o700` on unix).
25//! - A cache hit is decided by **recomputing the SHA-256 of the on-disk archive** and comparing it
26//!   against the on-disk checksum sidecar, every time — not by checking whether a marker file
27//!   exists. Any mismatch, or a missing/malformed file on either side, is treated as a cache miss
28//!   and triggers a fresh fetch. The `extracted/` directory is always deleted and re-derived from
29//!   the now-verified archive, never trusted as pre-existing content, so a directly-planted file
30//!   under `extracted/` (bypassing the archive and its checksum entirely) cannot survive either.
31//!
32//! Concurrent `warble compile` invocations that land on the same version's cache directory can
33//! race on `extracted/` (remove → mkdir tmp → unpack → rename). This is benign, not silently
34//! unsafe: every racing process is extracting the same checksum-verified archive bytes, so the
35//! worst case is a transient, loudly-reported rename/ENOENT error that a retry clears — never
36//! mixed versions or silently corrupted content.
37
38use std::fs;
39use std::io::Read as _;
40use std::path::{Path, PathBuf};
41
42/// Where the CLI fetches Hub release archives from by default: `Canner/Warble`'s GitHub Releases.
43/// Overridable via `WARBLE_HUB_BASE_URL`, which is the seam tests use to point at a local fixture
44/// server instead of the network.
45const DEFAULT_BASE_URL: &str = "https://github.com/Canner/Warble/releases/download";
46
47/// Environment variable that overrides [`DEFAULT_BASE_URL`]. Test-only in practice, but not
48/// `#[cfg(test)]`-gated: a host air-gapped from GitHub but mirroring releases internally can use it
49/// too.
50const BASE_URL_ENV: &str = "WARBLE_HUB_BASE_URL";
51
52/// Environment variable that overrides the per-user cache root discovered by [`user_cache_root`].
53/// Test-only in practice (lets a poisoning test plant a corrupt cache in a controlled tempdir
54/// without touching the real developer cache), but not `#[cfg(test)]`-gated for the same reason as
55/// [`BASE_URL_ENV`].
56const CACHE_ROOT_ENV: &str = "WARBLE_HUB_CACHE_ROOT";
57
58/// Rejects anything that is not a plain `MAJOR.MINOR.PATCH` numeric triplet, optionally followed
59/// by a `-prerelease`/`+build` suffix drawn from a safe character set. This exists for two
60/// reasons: to reject mutable refs like `"main"` or `"latest"` as a Hub version (a Hub version
61/// must be fixed and checksum-verifiable, never a moving target with no hash to verify
62/// against), and — just as importantly — to keep the *whole* string, suffix included, safe to use
63/// as a path component and a URL segment. `ensure_cached_hub` joins this value directly into a
64/// cache path (`cache_root.join("warble").join("hub").join(version)`) and interpolates it into a
65/// release URL; a suffix left unvalidated (as an earlier version of this function did, checking
66/// only the pre-`-`/`+` core) lets a value like `"1.0.0-../../../ESCAPED"` pass as a "valid
67/// triplet" while smuggling `..` path segments through to `Path::join`, writing (and
68/// owner-only-`chmod`-ing) a directory outside the intended cache subtree — independent of
69/// whether the subsequent fetch ever succeeds. So every character of `version`, not just the
70/// numeric core, is checked here before any path or URL is built from it.
71pub fn validate_hub_version(version: &str) -> Result<(), String> {
72    let core = version
73        .split(['-', '+'])
74        .next()
75        .expect("split always yields at least one segment");
76    let parts: Vec<&str> = core.split('.').collect();
77    let is_numeric_triplet = parts.len() == 3
78        && parts
79            .iter()
80            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
81    let is_safe_charset = version
82        .chars()
83        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-'));
84    let has_traversal = version.contains("..") || version.contains('/') || version.contains('\\');
85    if is_numeric_triplet && is_safe_charset && !has_traversal {
86        Ok(())
87    } else {
88        Err(format!(
89            "'{version}' is not a valid Hub version (expected a fixed MAJOR.MINOR.PATCH, e.g. \
90             '0.7.0', with an optional '-prerelease' or '+build' suffix restricted to \
91             [0-9A-Za-z.+-] and containing no '..', '/', or '\\\\') — a mutable ref such as 'main' \
92             or 'latest' cannot be checksum-verified and is rejected, and a crafted suffix cannot \
93             be used to escape the cache directory; pass a released Warble version, or use \
94             --hub-dir to point at a local Hub checkout instead"
95        ))
96    }
97}
98
99/// The base URL Hub release archives are fetched from — `WARBLE_HUB_BASE_URL` if set (the test
100/// seam), else [`DEFAULT_BASE_URL`].
101fn base_url() -> String {
102    std::env::var(BASE_URL_ENV).unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
103}
104
105/// The archive and checksum-sidecar URLs for `version`, matching the release layout published
106/// alongside this decision: tag `v<version>` carries `hub-<version>.tar.gz` and
107/// `hub-<version>.tar.gz.sha256` assets.
108fn archive_urls(version: &str) -> (String, String) {
109    let base = base_url();
110    let archive = format!("{base}/v{version}/hub-{version}.tar.gz");
111    let checksum = format!("{archive}.sha256");
112    (archive, checksum)
113}
114
115/// The platform per-user cache directory, before the `warble/hub` suffix. Never the shared temp
116/// directory — see the module-level cache-trust discussion. `WARBLE_HUB_CACHE_ROOT` overrides this
117/// outright (the test seam); otherwise this is the ordinary platform convention, hand-rolled rather
118/// than pulling in a directories crate for one lookup.
119fn user_cache_root() -> Result<PathBuf, String> {
120    if let Ok(root) = std::env::var(CACHE_ROOT_ENV) {
121        return Ok(PathBuf::from(root));
122    }
123    #[cfg(target_os = "macos")]
124    {
125        let home = std::env::var("HOME").map_err(|_| {
126            "cannot determine a cache directory: $HOME is unset — set WARBLE_HUB_CACHE_ROOT to a \
127             writable directory, or use --hub-dir to point at a local Hub checkout instead"
128                .to_string()
129        })?;
130        Ok(PathBuf::from(home).join("Library").join("Caches"))
131    }
132    #[cfg(all(unix, not(target_os = "macos")))]
133    {
134        if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
135            if !xdg.is_empty() {
136                return Ok(PathBuf::from(xdg));
137            }
138        }
139        let home = std::env::var("HOME").map_err(|_| {
140            "cannot determine a cache directory: neither $XDG_CACHE_HOME nor $HOME is set — set \
141             WARBLE_HUB_CACHE_ROOT to a writable directory, or use --hub-dir to point at a local \
142             Hub checkout instead"
143                .to_string()
144        })?;
145        Ok(PathBuf::from(home).join(".cache"))
146    }
147    #[cfg(windows)]
148    {
149        let local_app_data = std::env::var("LOCALAPPDATA").map_err(|_| {
150            "cannot determine a cache directory: %LOCALAPPDATA% is unset — set \
151             WARBLE_HUB_CACHE_ROOT to a writable directory, or use --hub-dir to point at a local \
152             Hub checkout instead"
153                .to_string()
154        })?;
155        Ok(PathBuf::from(local_app_data))
156    }
157    #[cfg(not(any(unix, windows)))]
158    {
159        Err(
160            "cannot determine a cache directory on this platform — set WARBLE_HUB_CACHE_ROOT to a \
161             writable directory, or use --hub-dir to point at a local Hub checkout instead"
162                .to_string(),
163        )
164    }
165}
166
167/// Create `dir` (and its parents) owner-only. On unix this sets `0o700` after creation — the
168/// per-user, not-world-writable requirement for the cache — and re-asserts the mode even if the
169/// directory already existed, so an inherited looser mode does not silently persist. Windows has no
170/// POSIX mode bits; per-user separation there comes from `%LOCALAPPDATA%` already being
171/// user-scoped by the platform.
172fn create_private_dir_all(dir: &Path) -> Result<(), String> {
173    fs::create_dir_all(dir).map_err(|e| {
174        format!(
175            "could not create the Hub cache directory {} ({e}) — check that the location is \
176             writable, set {CACHE_ROOT_ENV} to a writable directory, or use --hub-dir to point at \
177             a local Hub checkout instead",
178            dir.display()
179        )
180    })?;
181    #[cfg(unix)]
182    {
183        use std::os::unix::fs::PermissionsExt;
184        fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).map_err(|e| {
185            format!(
186                "could not set owner-only permissions on the Hub cache directory {} ({e}) — check \
187                 that you own the directory, set {CACHE_ROOT_ENV} to a writable directory, or use \
188                 --hub-dir to point at a local Hub checkout instead",
189                dir.display()
190            )
191        })?;
192    }
193    Ok(())
194}
195
196/// Reads `path` and returns its lowercase-hex SHA-256, or `None` if it cannot be read at all
197/// (missing, permissions, etc.) — treated as a cache miss by the caller, not a hard error.
198fn sha256_of_file(path: &Path) -> Option<String> {
199    use sha2::{Digest, Sha256};
200    let mut file = fs::File::open(path).ok()?;
201    let mut hasher = Sha256::new();
202    std::io::copy(&mut file, &mut hasher).ok()?;
203    Some(format!("{:x}", hasher.finalize()))
204}
205
206/// Parses a `<hex-digest>  hub-<version>.tar.gz`-shaped checksum sidecar (the conventional
207/// `sha256sum` output format) down to just the hex digest, tolerating a bare hex digest with no
208/// filename too. Returns `None` if the content is not recognizably a hex digest.
209fn parse_checksum_sidecar(content: &str) -> Option<String> {
210    let digest = content.split_whitespace().next()?;
211    let is_hex =
212        !digest.is_empty() && digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit());
213    is_hex.then(|| digest.to_ascii_lowercase())
214}
215
216/// Fetches `url`'s full response body as bytes, mapping ureq's error shape onto the two
217/// user-facing failure modes worth distinguishing: an HTTP status (most importantly 404, "no
218/// asset for this version") versus every other transport/IO failure ("unreachable network").
219fn fetch_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
220    match ureq::get(url).call() {
221        Ok(mut response) => {
222            let mut bytes = Vec::new();
223            response
224                .body_mut()
225                .as_reader()
226                .read_to_end(&mut bytes)
227                .map_err(|e| FetchError::Unreachable(e.to_string()))?;
228            Ok(bytes)
229        }
230        Err(ureq::Error::StatusCode(code)) => Err(FetchError::Status(code)),
231        Err(e) => Err(FetchError::Unreachable(e.to_string())),
232    }
233}
234
235enum FetchError {
236    Status(u16),
237    Unreachable(String),
238}
239
240/// Writes `bytes` to `dest` via a temp-file-then-atomic-rename in the same directory, so a failed
241/// or partial write never contaminates `dest` (and a concurrent reader either sees the old content
242/// or the new content in full, never a torn write).
243fn write_atomically(dest: &Path, bytes: &[u8]) -> Result<(), String> {
244    let dir = dest
245        .parent()
246        .ok_or_else(|| format!("{} has no parent directory to write into", dest.display()))?;
247    let tmp = dir.join(format!(
248        ".{}.tmp-{}",
249        dest.file_name()
250            .and_then(|n| n.to_str())
251            .unwrap_or("download"),
252        std::process::id()
253    ));
254    fs::write(&tmp, bytes).map_err(|e| format!("failed to write {} ({e})", tmp.display()))?;
255    fs::rename(&tmp, dest).map_err(|e| {
256        let _ = fs::remove_file(&tmp);
257        format!("failed to finalize {} ({e})", dest.display())
258    })
259}
260
261/// Ensures the Hub for `version` is present, verified, and extracted under the per-user cache,
262/// fetching it over the network if needed. Returns the path to the extracted component directory
263/// (containing `<id>/component.yml` entries at its top level, ready to hand to
264/// [`crate::ComponentSource::hub`]).
265///
266/// Cache layout per version: `<cache_root>/warble/hub/<version>/{hub-<version>.tar.gz,
267/// hub-<version>.tar.gz.sha256, extracted/}`. A hit is decided by recomputing the archive's SHA-256
268/// against the sidecar every call — see the module-level cache-trust discussion for why. `extracted/`
269/// is always rebuilt fresh from the verified archive, regardless of hit or miss.
270pub fn ensure_cached_hub(version: &str) -> Result<PathBuf, String> {
271    validate_hub_version(version)?;
272
273    let cache_root = user_cache_root()?;
274    let version_dir = cache_root.join("warble").join("hub").join(version);
275    create_private_dir_all(&version_dir)?;
276
277    let archive_path = version_dir.join(format!("hub-{version}.tar.gz"));
278    let checksum_path = version_dir.join(format!("hub-{version}.tar.gz.sha256"));
279    let (archive_url, checksum_url) = archive_urls(version);
280
281    let cached_digest = fs::read_to_string(&checksum_path)
282        .ok()
283        .and_then(|s| parse_checksum_sidecar(&s));
284    let on_disk_digest = sha256_of_file(&archive_path);
285    let cache_hit = matches!((&cached_digest, &on_disk_digest), (Some(a), Some(b)) if a == b);
286
287    if !cache_hit {
288        let archive_bytes = fetch_bytes(&archive_url).map_err(|e| match e {
289            FetchError::Status(404) => format!(
290                "no Hub archive is published for version {version} at {archive_url} (HTTP 404) — \
291                 this Warble release may predate the Hub-fetch archive, or --hub-version points at \
292                 a version that never shipped one; use --hub-dir to point at a local Hub checkout \
293                 instead"
294            ),
295            FetchError::Status(code) => format!(
296                "fetching the Hub archive for v{version} from {archive_url} failed with HTTP \
297                 {code} — use --hub-dir to point at a local Hub checkout instead"
298            ),
299            FetchError::Unreachable(detail) => format!(
300                "could not reach {archive_url} to fetch Hub v{version} ({detail}) — check your \
301                 network connection, or use --hub-dir to point at a local Hub checkout instead"
302            ),
303        })?;
304        let checksum_text = fetch_bytes(&checksum_url)
305            .map_err(|e| match e {
306                FetchError::Status(404) => format!(
307                    "no checksum is published for Hub version {version} at {checksum_url} (HTTP \
308                     404) — this Warble release may predate the Hub-fetch archive, or \
309                     --hub-version points at a version that never shipped one; use --hub-dir to \
310                     point at a local Hub checkout instead"
311                ),
312                FetchError::Status(code) => format!(
313                    "fetching the Hub checksum for v{version} from {checksum_url} failed with \
314                     HTTP {code} — use --hub-dir to point at a local Hub checkout instead"
315                ),
316                FetchError::Unreachable(detail) => format!(
317                    "could not reach {checksum_url} to fetch the Hub v{version} checksum ({detail}) \
318                     — check your network connection, or use --hub-dir to point at a local Hub \
319                     checkout instead"
320                ),
321            })
322            .and_then(|bytes| {
323                String::from_utf8(bytes)
324                    .map_err(|e| format!("the published checksum for Hub v{version} is not valid text ({e})"))
325            })?;
326        let expected_digest = parse_checksum_sidecar(&checksum_text).ok_or_else(|| {
327            format!(
328                "the published checksum for Hub v{version} at {checksum_url} is not a recognizable \
329                 SHA-256 digest — this looks like a publishing problem upstream; use --hub-dir to \
330                 point at a local Hub checkout instead"
331            )
332        })?;
333
334        use sha2::{Digest, Sha256};
335        let actual_digest = format!("{:x}", Sha256::digest(&archive_bytes));
336        if actual_digest != expected_digest {
337            return Err(format!(
338                "downloaded Hub archive for v{version} does not match its published checksum \
339                 (expected {expected_digest}, got {actual_digest}) — the download may have been \
340                 corrupted or tampered with; retrying the fetch, or using --hub-dir to point at a \
341                 local Hub checkout instead, are both safe next steps"
342            ));
343        }
344
345        write_atomically(&archive_path, &archive_bytes)?;
346        write_atomically(&checksum_path, expected_digest.as_bytes())?;
347    }
348
349    // Always re-derive `extracted/` fresh from the now-verified archive, regardless of whether this
350    // was a cache hit or a fresh fetch — never trust pre-existing extracted content, which is
351    // exactly the historical poisoning vector (a file planted directly under the extraction
352    // directory bypasses the archive's checksum entirely).
353    let extracted_dir = version_dir.join("extracted");
354    if extracted_dir.is_dir() {
355        fs::remove_dir_all(&extracted_dir).map_err(|e| {
356            format!(
357                "could not clear the stale extracted Hub directory {} before re-extracting ({e}) \
358                 — check that the location is writable, or use --hub-dir to point at a local Hub \
359                 checkout instead",
360                extracted_dir.display()
361            )
362        })?;
363    }
364    let extracting_dir = version_dir.join(format!(".extracted.tmp-{}", std::process::id()));
365    if extracting_dir.exists() {
366        let _ = fs::remove_dir_all(&extracting_dir);
367    }
368    fs::create_dir_all(&extracting_dir).map_err(|e| {
369        format!(
370            "could not create {} to extract the Hub archive into ({e}) — check that the location \
371             is writable, or use --hub-dir to point at a local Hub checkout instead",
372            extracting_dir.display()
373        )
374    })?;
375    let archive_bytes = fs::read(&archive_path).map_err(|e| {
376        format!(
377            "could not read the cached Hub archive {} ({e}) — try removing {} and re-running, or \
378             use --hub-dir to point at a local Hub checkout instead",
379            archive_path.display(),
380            version_dir.display()
381        )
382    })?;
383    let decoder = flate2::read::GzDecoder::new(archive_bytes.as_slice());
384    let mut archive = tar::Archive::new(decoder);
385    archive.unpack(&extracting_dir).map_err(|e| {
386        format!(
387            "the cached Hub archive for v{version} could not be extracted ({e}) — it may be \
388             corrupted; try removing {} and re-running, or use --hub-dir to point at a local Hub \
389             checkout instead",
390            version_dir.display()
391        )
392    })?;
393    fs::rename(&extracting_dir, &extracted_dir).map_err(|e| {
394        format!(
395            "could not finalize the extracted Hub directory {} ({e}) — check that the location is \
396             writable, or use --hub-dir to point at a local Hub checkout instead",
397            extracted_dir.display()
398        )
399    })?;
400
401    Ok(extracted_dir)
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn validate_hub_version_accepts_a_plain_triplet() {
410        assert!(validate_hub_version("0.7.0").is_ok());
411        assert!(validate_hub_version("12.34.56").is_ok());
412    }
413
414    #[test]
415    fn validate_hub_version_accepts_prerelease_and_build_suffixes() {
416        assert!(validate_hub_version("0.7.0-rc.1").is_ok());
417        assert!(validate_hub_version("0.7.0+build.5").is_ok());
418    }
419
420    #[test]
421    fn validate_hub_version_rejects_mutable_refs() {
422        for bad in ["main", "latest", "HEAD", ""] {
423            let err = validate_hub_version(bad).expect_err(bad);
424            assert!(err.contains("not a valid Hub version"), "{bad}: {err}");
425            assert!(err.contains("--hub-dir"), "{bad}: {err}");
426        }
427    }
428
429    #[test]
430    fn validate_hub_version_rejects_partial_versions() {
431        assert!(validate_hub_version("0.7").is_err());
432        assert!(validate_hub_version("0.7.0.1").is_err());
433        assert!(validate_hub_version("v0.7.0").is_err());
434    }
435
436    #[test]
437    fn validate_hub_version_rejects_path_traversal_in_suffix() {
438        // A pre-`-` core of "1.0.0" is a valid numeric triplet on its own; the whole point of
439        // this test is that a partial (core-only) check would wrongly accept the string below
440        // even though the suffix is exactly the kind of value `ensure_cached_hub` joins straight
441        // into a filesystem path and a URL. It must be rejected before either of those happens.
442        for bad in [
443            "1.0.0-../../evil",
444            "1.0.0-../../../ESCAPED",
445            "1.0.0/../../evil",
446            "1.0.0-evil/../../..",
447            "1.0.0-a\\..\\evil",
448        ] {
449            let err = validate_hub_version(bad).expect_err(bad);
450            assert!(err.contains("not a valid Hub version"), "{bad}: {err}");
451        }
452
453        // The same crafted value must also be rejected by the actual choke point that builds a
454        // path from it — `ensure_cached_hub` calls `validate_hub_version` as its very first
455        // statement, before `version_dir` (a `Path::join`) is constructed, so no directory should
456        // ever be created for a rejected version.
457        let err = ensure_cached_hub("1.0.0-../../evil").expect_err("must be rejected");
458        assert!(err.contains("not a valid Hub version"), "{err}");
459    }
460
461    #[test]
462    fn parse_checksum_sidecar_accepts_sha256sum_format() {
463        let digest = "a".repeat(64);
464        let sidecar = format!("{digest}  hub-0.7.0.tar.gz\n");
465        assert_eq!(parse_checksum_sidecar(&sidecar), Some(digest));
466    }
467
468    #[test]
469    fn parse_checksum_sidecar_accepts_bare_digest() {
470        let digest = "b".repeat(64);
471        assert_eq!(parse_checksum_sidecar(&digest), Some(digest));
472    }
473
474    #[test]
475    fn parse_checksum_sidecar_rejects_garbage() {
476        assert_eq!(parse_checksum_sidecar("not a checksum"), None);
477        assert_eq!(parse_checksum_sidecar(""), None);
478    }
479
480    #[test]
481    fn archive_urls_match_the_release_asset_contract() {
482        // SAFETY: single-threaded test body; no other test in this process reads this var
483        // concurrently with a mismatched expectation (each caller sets and immediately reads it).
484        unsafe {
485            std::env::set_var(BASE_URL_ENV, "https://example.test/dl");
486        }
487        let (archive, checksum) = archive_urls("0.7.0");
488        unsafe {
489            std::env::remove_var(BASE_URL_ENV);
490        }
491        assert_eq!(archive, "https://example.test/dl/v0.7.0/hub-0.7.0.tar.gz");
492        assert_eq!(
493            checksum,
494            "https://example.test/dl/v0.7.0/hub-0.7.0.tar.gz.sha256"
495        );
496    }
497}