Skip to main content

sui_eval/
fetcher.rs

1//! Content-addressed input fetcher for flake.lock resolved inputs.
2//!
3//! Fetches locked flake inputs (github tarballs, git repos, local paths,
4//! remote tarballs) and caches them by `narHash` so repeated evaluations
5//! hit the local filesystem instead of the network.
6
7use std::io::Read as _;
8use std::path::{Path, PathBuf};
9
10use sui_compat::flake::LockedInput;
11use sui_compat::flake_ref::FlakeRef;
12
13// ── Error type ────────────────────────────────────────────────
14
15/// Errors that can occur during input fetching.
16///
17/// # Why the HTTP failures are separate variants
18///
19/// Every network failure used to collapse into [`FetchError::Download`], a
20/// single `String`. The status code was *known* — it was read as a `u16` and
21/// immediately formatted into prose — so a rate-limit and a missing repository
22/// arrived at the caller as the same shape, distinguishable only by matching
23/// English text.
24///
25/// That cost real work, measured 2026-08-17: GitHub throttled a flake input's
26/// archive on one host **while the API quota showed 4653/5000 remaining** (a
27/// per-egress-IP limit on archive generation, unaffected by holding a valid
28/// token), and two full rebuilds died before the cause was understood. The
29/// remedy for a throttle is unlike the remedy for anything else here — another
30/// egress can fetch the identical bytes, and `flake.lock`'s pinned `narHash`
31/// makes "identical" *checkable* rather than merely hoped-for — so a consumer
32/// has to be able to branch on it. A downstream tool
33/// (`pleme-io/fleet`'s `warm-inputs`) was reduced to `contains("HTTP error
34/// 429")` on this crate's own error text for exactly that reason.
35///
36/// So: no status is discarded, and [`FetchError::UnexpectedStatus`] exists so
37/// that adding a *new* HTTP behaviour cannot silently fall back into a prose
38/// bucket — an unclassified code still arrives as a number.
39#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum FetchError {
42    #[error("unsupported input type: {0}")]
43    UnsupportedType(String),
44    #[error("missing required field: {0}")]
45    MissingField(&'static str),
46    /// A genuine transport failure — DNS, TLS, connect timeout, body read.
47    /// **Not** a status: an HTTP response that arrived and said "no" is one of
48    /// the four typed variants below.
49    #[error("download failed: {0}")]
50    Download(String),
51    /// The upstream refused to serve content it has: HTTP 429, or a 403 whose
52    /// body names a secondary rate limit. `retry_after` carries the server's
53    /// own `Retry-After` in seconds when it sent one — the only authority on
54    /// how long to wait, and previously thrown away unread.
55    #[error("throttled by {url} (HTTP {status}){}", match retry_after {
56        Some(s) => format!(", retry after {s}s"),
57        None => String::new(),
58    })]
59    Throttled {
60        url: String,
61        status: u16,
62        retry_after: Option<u64>,
63    },
64    /// 401 or 403 — our credential, not the content. Another egress does not
65    /// help; the token does.
66    #[error("not authorized for {url} (HTTP {status}) — check the access token")]
67    Unauthorized { url: String, status: u16 },
68    /// 404. For a private input this is frequently an *auth* failure wearing a
69    /// not-found mask, which is why the message says so rather than asserting
70    /// the content is absent.
71    #[error("{url} not found (HTTP 404) — or present but invisible to this credential")]
72    NotFound { url: String },
73    /// Any other non-2xx. Carries the code so an unhandled status is still a
74    /// number a caller can act on, never prose.
75    #[error("{url} returned HTTP {status}")]
76    UnexpectedStatus { url: String, status: u16 },
77    #[error("I/O error: {0}")]
78    Io(#[from] std::io::Error),
79    #[error("archive extraction failed: {0}")]
80    Extract(String),
81}
82
83impl FetchError {
84    /// The HTTP status, when this failure carried one.
85    ///
86    /// Exists so a caller branches on a number rather than re-deriving one from
87    /// the `Display` text — which is the habit this enum was widened to end.
88    #[must_use]
89    pub fn status(&self) -> Option<u16> {
90        match self {
91            Self::Throttled { status, .. }
92            | Self::Unauthorized { status, .. }
93            | Self::UnexpectedStatus { status, .. } => Some(*status),
94            Self::NotFound { .. } => Some(404),
95            _ => None,
96        }
97    }
98
99    /// Whether fetching the identical bytes from a different network egress
100    /// could succeed.
101    ///
102    /// True **only** for a throttle. A 401/403/404 is about our credential or
103    /// the content, so another host is refused identically — and answering
104    /// `true` there would send an operator to build a second fetch path that
105    /// cannot work.
106    #[must_use]
107    pub fn is_throttled(&self) -> bool {
108        matches!(self, Self::Throttled { .. })
109    }
110}
111
112// ── Typed archive report ──────────────────────────────────────
113
114/// Which category a per-input failure fell into, as a stable machine-readable
115/// tag.
116///
117/// This is the field a downstream tool branches on instead of matching prose.
118/// The tags are wire-facing, so they are kebab-case and **must not be renamed**
119/// once a consumer reads them — a renamed tag silently stops matching, which is
120/// the same class of failure as the prose-matching it replaces.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
122#[serde(rename_all = "kebab-case")]
123pub enum FailureKind {
124    /// The upstream refused to serve content it has. **The one recoverable
125    /// kind**: a different network egress can fetch identical bytes.
126    Throttled,
127    /// Our credential is insufficient (401/403). Another egress is refused
128    /// identically.
129    Unauthorized,
130    /// 404 — absent, or present but invisible to this credential.
131    NotFound,
132    /// A non-2xx nobody wrote an arm for; `status` carries the code.
133    UnexpectedStatus,
134    /// DNS / TLS / timeout — no response arrived at all.
135    Transport,
136    /// Not an HTTP failure: unsupported input type, missing field, IO,
137    /// extraction.
138    Local,
139}
140
141/// One input that could not be fetched, described well enough that a caller can
142/// decide what to do without reading a sentence.
143#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
144pub struct InputFailure {
145    /// The `flake.lock` node name, so the operator knows *which* input.
146    pub input: String,
147    pub kind: FailureKind,
148    /// The HTTP status when there was one.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub status: Option<u16>,
151    /// The server's own `Retry-After` in seconds, when it sent one.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub retry_after: Option<u64>,
154    /// Whether a different network egress could plausibly succeed. Derived, not
155    /// stored twice — a consumer should not have to re-derive policy from a tag.
156    pub recoverable_elsewhere: bool,
157    /// The human sentence, kept for a log. **Not** the machine surface: a
158    /// consumer that parses this field has re-created the defect.
159    pub message: String,
160}
161
162impl InputFailure {
163    /// Classify a fetch failure for a named input.
164    #[must_use]
165    pub fn from_error(input: &str, err: &FetchError) -> Self {
166        let kind = match err {
167            FetchError::Throttled { .. } => FailureKind::Throttled,
168            FetchError::Unauthorized { .. } => FailureKind::Unauthorized,
169            FetchError::NotFound { .. } => FailureKind::NotFound,
170            FetchError::UnexpectedStatus { .. } => FailureKind::UnexpectedStatus,
171            FetchError::Download(_) => FailureKind::Transport,
172            _ => FailureKind::Local,
173        };
174        Self {
175            input: input.to_string(),
176            kind,
177            status: err.status(),
178            retry_after: match err {
179                FetchError::Throttled { retry_after, .. } => *retry_after,
180                _ => None,
181            },
182            recoverable_elsewhere: err.is_throttled(),
183            message: err.to_string(),
184        }
185    }
186}
187
188/// The outcome of walking every locked input.
189///
190/// `scanned` is carried deliberately: it is the **denominator**. A report of
191/// zero failures means nothing without it — a walk that discovered no inputs
192/// would otherwise be indistinguishable from a fleet that is fully warm, which
193/// is the vacuous-success shape this codebase keeps paying for.
194#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
195pub struct ArchiveReport {
196    pub scanned: usize,
197    pub already_present: usize,
198    pub fetched: usize,
199    pub failures: Vec<InputFailure>,
200}
201
202impl ArchiveReport {
203    /// Whether every scanned input is now available locally.
204    ///
205    /// **False when nothing was scanned**, by construction: "warm" is a claim
206    /// about a non-empty set, and an empty walk has not earned it.
207    #[must_use]
208    pub fn is_complete(&self) -> bool {
209        self.scanned > 0 && self.failures.is_empty()
210    }
211
212    /// The failures a different egress could fix — what a recovery tool acts on.
213    #[must_use]
214    pub fn recoverable(&self) -> impl Iterator<Item = &InputFailure> {
215        self.failures.iter().filter(|f| f.recoverable_elsewhere)
216    }
217}
218
219// ── InputFetcher ──────────────────────────────────────────────
220
221/// A content-addressed input fetcher that downloads and caches flake inputs.
222///
223/// Inputs are cached under `~/.cache/sui/inputs/` (or a custom directory)
224/// keyed by their `narHash` from the lock file. Cache hits skip network
225/// access entirely.
226pub struct InputFetcher {
227    cache_dir: PathBuf,
228}
229
230impl Default for InputFetcher {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236impl InputFetcher {
237    /// Create a fetcher using the default cache directory (`~/.cache/sui/inputs/`).
238    #[must_use]
239    pub fn new() -> Self {
240        let cache_dir = dirs_cache_dir().join("sui/inputs");
241        Self { cache_dir }
242    }
243
244    /// Create a fetcher with a custom cache directory.
245    #[must_use]
246    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
247        Self { cache_dir }
248    }
249
250    /// Return the cache directory path.
251    #[must_use]
252    pub fn cache_dir(&self) -> &Path {
253        &self.cache_dir
254    }
255
256    /// Whether this input would be served from cache without any network access.
257    ///
258    /// Deliberately shares [`Self::cache_probe`] with [`Self::fetch`] rather
259    /// than re-deriving the cache path: two independent notions of "cached"
260    /// drift, and the drift is invisible — a reporter would announce "already
261    /// present" for an entry the fetcher then re-downloads. Note it applies the
262    /// same **non-empty** requirement, so a directory left behind by a fetch
263    /// that died mid-extract counts as a miss here exactly as it does there.
264    #[must_use]
265    pub fn is_cached(&self, locked: &LockedInput) -> bool {
266        self.cache_probe(locked).is_some()
267    }
268
269    /// Resolve a usable cache entry for `locked`, if one exists.
270    ///
271    /// `None` means "fetch is required", covering both no-entry and
272    /// entry-exists-but-is-empty.
273    fn cache_probe(&self, locked: &LockedInput) -> Option<PathBuf> {
274        let nar_hash = locked.nar_hash.as_ref()?;
275        let cached = self.cache_dir.join(sanitize_hash(nar_hash));
276        if !cached.exists() {
277            return None;
278        }
279        let resolved = find_single_subdir_or_self(&cached);
280        is_non_empty_dir(&resolved).then_some(resolved)
281    }
282
283    /// Fetch a locked input and return the local filesystem path.
284    ///
285    /// Uses content-addressed caching by `narHash` — if the hash is present
286    /// and a cached directory exists, returns immediately without network access.
287    pub fn fetch(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
288        // Check cache first (keyed by narHash), through the SAME probe
289        // `is_cached` uses so a reporter and the fetcher can never disagree
290        // about whether network access is about to happen.
291        if let Some(resolved) = self.cache_probe(locked) {
292            return Ok(resolved);
293        }
294        // A present-but-empty entry is a miss (a previous fetch created the
295        // directory then died before extracting). Clear it so the retry below
296        // is not blocked by its own debris.
297        if let Some(ref nar_hash) = locked.nar_hash {
298            let cached = self.cache_dir.join(sanitize_hash(nar_hash));
299            if cached.exists() {
300                let _ = std::fs::remove_dir_all(&cached);
301            }
302        }
303
304        match locked.source_type.as_str() {
305            "github" => self.fetch_github(locked),
306            "gitlab" => self.fetch_gitlab(locked),
307            "sourcehut" => self.fetch_sourcehut(locked),
308            "path" => Self::fetch_path(locked),
309            "git" => self.fetch_git(locked),
310            "tarball" | "file" => self.fetch_tarball(locked),
311            other => Err(FetchError::UnsupportedType(other.to_string())),
312        }
313    }
314
315    /// Construct the GitHub archive URL for a locked input.
316    #[must_use]
317    pub fn github_archive_url(owner: &str, repo: &str, rev: &str) -> String {
318        format!("https://github.com/{owner}/{repo}/archive/{rev}.tar.gz")
319    }
320
321    /// GitLab archive URL.  Shape differs from GitHub — the file
322    /// name embeds the repo + rev and lives under `/-/archive/{rev}/`.
323    /// Honors `host` so self-hosted gitlab instances (e.g.
324    /// `gitlab.gnome.org`, `git.example.com`) work; defaults to
325    /// `gitlab.com` when host is None.
326    #[must_use]
327    pub fn gitlab_archive_url(host: Option<&str>, owner: &str, repo: &str, rev: &str) -> String {
328        let host = host.unwrap_or("gitlab.com");
329        format!(
330            "https://{host}/{owner}/{repo}/-/archive/{rev}/{repo}-{rev}.tar.gz"
331        )
332    }
333
334    /// Sourcehut archive URL. Owners carry the `~` prefix on the
335    /// platform; the flake-ref parser stores them without the prefix,
336    /// so we prepend here.
337    #[must_use]
338    pub fn sourcehut_archive_url(owner: &str, repo: &str, rev: &str) -> String {
339        let owner_prefix = if owner.starts_with('~') {
340            owner.to_string()
341        } else {
342            format!("~{owner}")
343        };
344        format!("https://git.sr.ht/{owner_prefix}/{repo}/archive/{rev}.tar.gz")
345    }
346
347    // ── Private fetch methods ─────────────────────────────
348
349    fn fetch_github(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
350        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
351        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
352        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
353
354        let url = Self::github_archive_url(owner, repo, rev);
355        // Was a hand-inlined copy of `fetch_archive`'s body — the only copy of
356        // the three that lacked a cache guard, which is exactly how it came to
357        // re-download on every invocation. Sharing the body is the fix for the
358        // class; the guard below is the fix for the instance.
359        self.fetch_archive(locked, &url, &format!("github-{owner}-{repo}-{rev}"), rev)
360    }
361
362    /// GitHub, GitLab and Sourcehut share one archive-fetch shape — download a
363    /// tar.gz, extract, return the single top-level directory. Only the URL
364    /// construction differs.
365    ///
366    /// ── ★ STAGE THEN RENAME; NEVER EXTRACT INTO THE FINAL PATH ────────────
367    /// This used to `create_dir_all(dest)` and extract straight into it, which
368    /// produced three distinct defects from one decision:
369    ///
370    /// 1. **A partial tree is a valid cache hit.** The hit predicate is "the
371    ///    directory is non-empty", which goes true on the FIRST tar entry, so a
372    ///    concurrent process could adopt a half-extracted tree and evaluate it
373    ///    as if complete — a silently wrong eval, not an error.
374    /// 2. **A re-extraction UNIONS.** `tar` runs with `overwrite: true`, so
375    ///    extracting a second time over an existing tree leaves files that the
376    ///    newer tree deleted. Content at a "content-addressed" path then
377    ///    disagrees with the hash in its own name.
378    /// 3. **A failing process deleted another process's good cache entry.**
379    ///    Every error path called `remove_dir_all(&dest)` — on the FINAL path.
380    ///    A transient network error during a redundant re-fetch would wipe a
381    ///    complete tree that another eval was actively reading.
382    ///
383    /// Defect 2 is what poisoned `~/.cache/sui/nar-memo` and made `getFlake`
384    /// return a store path CppNix disagrees with (measured 2026-08-17; see
385    /// `sui-compat/src/source.rs`'s memo verifier, which is the read-side
386    /// defence this is the write-side cause of).
387    ///
388    /// Staging beside the target rather than in `/tmp` keeps the rename on one
389    /// filesystem, where it is atomic — the same reason `sui-castore`'s local
390    /// storage stages beside its target.
391    fn fetch_archive(
392        &self,
393        locked: &LockedInput,
394        url: &str,
395        cache_key: &str,
396        rev: &str,
397    ) -> Result<PathBuf, FetchError> {
398        let dest = self.dest_dir(locked, cache_key);
399
400        // ── The cache guard, and why it is conditional ────────────────────
401        // A rev that is a 40/64-hex commit names one immutable tree, so a
402        // complete directory at `dest` can be adopted with no network at all.
403        // A rev that is a BRANCH NAME does not: `github:owner/repo/main` is a
404        // legal ref (CppNix accepts it, so refusing it would be a parity
405        // divergence, not a safety win) and the tree behind it moves. Guarding
406        // unconditionally would freeze such an entry at whatever `main` was
407        // the first time it was fetched, forever.
408        //
409        // So: immutable revs are cached, mutable ones are always re-fetched.
410        // The old code re-fetched BOTH, which was wasteful for the first and
411        // accidentally correct for the second.
412        if is_immutable_rev(rev) && is_non_empty_dir(&dest) {
413            return Ok(find_single_subdir_or_self(&dest));
414        }
415
416        let staging = staging_path(&dest);
417        // A leftover staging dir means a previous process died mid-extract.
418        // It is ours to clear: the name carries our pid.
419        let _ = std::fs::remove_dir_all(&staging);
420        std::fs::create_dir_all(&staging)?;
421
422        let bytes = match download_bytes(url) {
423            Ok(b) => b,
424            Err(e) => {
425                let _ = std::fs::remove_dir_all(&staging);
426                return Err(e);
427            }
428        };
429        if let Err(e) = extract_tar_gz(&bytes, &staging) {
430            let _ = std::fs::remove_dir_all(&staging);
431            return Err(e);
432        }
433
434        publish(&staging, &dest, is_immutable_rev(rev))?;
435        Ok(find_single_subdir_or_self(&dest))
436    }
437
438    fn fetch_gitlab(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
439        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
440        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
441        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
442        let host = locked.host.as_deref();
443        let url = Self::gitlab_archive_url(host, owner, repo, rev);
444        let host_tag = host.unwrap_or("gitlab.com").replace('.', "_");
445        self.fetch_archive(locked, &url, &format!("gitlab-{host_tag}-{owner}-{repo}-{rev}"), rev)
446    }
447
448    fn fetch_sourcehut(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
449        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
450        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
451        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
452        let url = Self::sourcehut_archive_url(owner, repo, rev);
453        let sanitized_owner = owner.trim_start_matches('~');
454        self.fetch_archive(
455            locked,
456            &url,
457            &format!("sourcehut-{sanitized_owner}-{repo}-{rev}"),
458            rev,
459        )
460    }
461
462    fn fetch_path(locked: &LockedInput) -> Result<PathBuf, FetchError> {
463        let path = locked
464            .path
465            .as_deref()
466            .ok_or(FetchError::MissingField("path"))?;
467        Ok(PathBuf::from(path))
468    }
469
470    fn fetch_git(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
471        let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
472        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
473
474        let short_rev: String = rev.chars().take(12).collect();
475        let dest = self.dest_dir(locked, &format!("git-{short_rev}"));
476
477        // The cache key embeds the rev, so a full object id names one tree and
478        // a present one is adoptable. Same conditional as the archive path.
479        let immutable = is_immutable_rev(rev);
480        if immutable && is_non_empty_dir(&dest) {
481            return Ok(dest);
482        }
483
484        // Everything below builds the tree in a staging dir and publishes it
485        // with one rename. This path was left out of the first stage-then-
486        // rename pass, so until now a killed clone or a killed unpack left a
487        // partial tree at the FINAL path that the non-empty predicate above
488        // then accepted as a complete cache hit.
489        let staging = staging_path(&dest);
490        let _ = std::fs::remove_dir_all(&staging);
491
492        // Try GitHub tarball first (avoids git CLI dependency in containers).
493        // Most git-type inputs in flake.lock are GitHub repos that support
494        // archive downloads via /archive/{rev}.tar.gz.
495        if let Some(tarball_url) = github_tarball_from_git_url(url, rev) {
496            std::fs::create_dir_all(&staging)?;
497            match download_bytes(&tarball_url) {
498                Ok(bytes) => {
499                    if let Err(e) = extract_tar_gz(&bytes, &staging) {
500                        let _ = std::fs::remove_dir_all(&staging);
501                        return Err(e);
502                    }
503                    publish(&staging, &dest, immutable)?;
504                    return Ok(find_single_subdir_or_self(&dest));
505                }
506                Err(e) => {
507                    // Tarball fallback failed — try git CLI below.
508                    let _ = std::fs::remove_dir_all(&staging);
509                    tracing::debug!(url = %tarball_url, error = %e, "Tarball fallback failed, trying git CLI");
510                }
511            }
512        }
513
514        // Fall back to git CLI for non-GitHub repos or when tarball fails.
515        let status = std::process::Command::new("git")
516            .args(["clone", "--depth", "1", url])
517            .arg(&staging)
518            .stdout(std::process::Stdio::null())
519            .stderr(std::process::Stdio::null())
520            .status()
521            .map_err(|e| FetchError::Download(format!(
522                "git clone failed (git not in PATH?): {e}"
523            )))?;
524        if !status.success() {
525            let _ = std::fs::remove_dir_all(&staging);
526            return Err(FetchError::Download(format!(
527                "git clone failed for {url} (exit code: {})",
528                status.code().unwrap_or(-1)
529            )));
530        }
531
532        // Checkout the exact revision.
533        //
534        // NOTE, unverified and flagged rather than fixed here: the clone above
535        // is `--depth 1` of the DEFAULT BRANCH, so an arbitrary `rev` is very
536        // likely not among the objects it fetched, and this checkout would
537        // fail for any non-HEAD rev. That belongs to whoever owns `git.rs`.
538        if let Err(e) = crate::git::checkout_rev(&staging, rev) {
539            let _ = std::fs::remove_dir_all(&staging);
540            return Err(FetchError::Download(format!("git checkout {rev}: {e}")));
541        }
542
543        publish(&staging, &dest, immutable)?;
544        Ok(dest)
545    }
546
547    fn fetch_tarball(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
548        let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
549
550        let hash_suffix = locked
551            .nar_hash
552            .as_deref()
553            .map_or_else(|| url_to_safe_name(url), sanitize_hash);
554        let dest = self.dest_dir(locked, &format!("tarball-{hash_suffix}"));
555
556        // A tarball input is keyed on its narHash when it has one, which IS a
557        // content address, so a present tree is adoptable. When it has none
558        // the key is derived from the URL, which is mutable — same split as
559        // `is_immutable_rev` on the archive path.
560        let immutable = locked.nar_hash.is_some();
561        if immutable && is_non_empty_dir(&dest) {
562            return Ok(find_single_subdir_or_self(&dest));
563        }
564
565        // Stage then publish, exactly as `fetch_archive` does. This path was
566        // left behind by the first pass at that fix, so until now a killed
567        // `tarball:`/`file:` fetch could leave a partial tree that the
568        // non-empty predicate then accepted as a cache hit.
569        let staging = staging_path(&dest);
570        let _ = std::fs::remove_dir_all(&staging);
571        std::fs::create_dir_all(&staging)?;
572
573        let bytes = match download_bytes(url) {
574            Ok(b) => b,
575            Err(e) => {
576                let _ = std::fs::remove_dir_all(&staging);
577                return Err(e);
578            }
579        };
580        if let Err(e) = extract_tar_gz(&bytes, &staging) {
581            let _ = std::fs::remove_dir_all(&staging);
582            return Err(e);
583        }
584
585        publish(&staging, &dest, immutable)?;
586        Ok(find_single_subdir_or_self(&dest))
587    }
588
589    /// Compute the destination directory, preferring narHash-based names.
590    fn dest_dir(&self, locked: &LockedInput, fallback: &str) -> PathBuf {
591        if let Some(ref nar_hash) = locked.nar_hash {
592            self.cache_dir.join(sanitize_hash(nar_hash))
593        } else {
594            self.cache_dir.join(fallback)
595        }
596    }
597}
598
599// ── Helpers ───────────────────────────────────────────────────
600
601/// Try to convert a git URL to a GitHub tarball URL.
602///
603/// `https://github.com/NixOS/nixpkgs.git` + rev → `https://github.com/NixOS/nixpkgs/archive/{rev}.tar.gz`
604/// Returns `None` for non-GitHub URLs.
605fn github_tarball_from_git_url(url: &str, rev: &str) -> Option<String> {
606    let stripped = url
607        .strip_prefix("https://github.com/")
608        .or_else(|| url.strip_prefix("git+https://github.com/"))
609        .or_else(|| url.strip_prefix("http://github.com/"))?;
610    let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
611    // Validate it looks like owner/repo (no extra path segments)
612    let parts: Vec<&str> = stripped.split('/').collect();
613    if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
614        Some(format!(
615            "https://github.com/{}/{}/archive/{rev}.tar.gz",
616            parts[0], parts[1]
617        ))
618    } else {
619        None
620    }
621}
622
623/// Turn a narHash like `sha256-AAAA...=` into a filesystem-safe name.
624/// Turn a hash into a single safe path component.
625///
626/// ── ★ THE SUBSTITUTIONS ARE NOT A VALIDATION ──────────────────────────
627/// `:`→`-`, `/`→`_`, drop `=` makes a hash *look* like a filename; it does not
628/// make it *one*. `narHash` comes from a `flake.lock`, which is untrusted
629/// input for any flake you did not write yourself, and three values survive
630/// the transliteration as meaningful path components: `..`, `.` and `""`.
631///
632/// Because `/` is mapped away, a multi-level escape is impossible — the blast
633/// radius is exactly ONE level, and it should not be rounded up to arbitrary
634/// path deletion. One level is bad enough: `"narHash": ".."` makes the cache
635/// destination `<cache>/inputs/..` = `~/.cache/sui`, so (a) `fetch` returns
636/// `~/.cache/sui` AS the flake's source directory — a silently wrong eval with
637/// no error — and (b) on a miss, publishing `remove_dir_all`s it, taking
638/// `inputs/` and `nar-memo/` with it. That is the same memo whose poisoning
639/// `sui-compat/src/source.rs` was hardened against today.
640///
641/// So the component is validated, not merely transliterated: anything that is
642/// not a plain `[A-Za-z0-9._+-]` run, or that is `.`/`..`/empty, is replaced
643/// by a fixed-width digest of the input. Fixed-width by construction beats a
644/// denylist, which is what the transliteration was.
645fn sanitize_hash(hash: &str) -> String {
646    let mapped = hash.replace(':', "-").replace('/', "_").replace('=', "");
647    let shaped = !mapped.is_empty()
648        && mapped != "."
649        && mapped != ".."
650        && mapped
651            .bytes()
652            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-'));
653    if shaped {
654        mapped
655    } else {
656        // Deterministic, collision-resistant, and structurally incapable of
657        // being a traversal: hex has no `.` and no `/`.
658        use sha2::Digest as _;
659        let d = sha2::Sha256::digest(hash.as_bytes());
660        let mut out = String::with_capacity(2 + 64);
661        out.push_str("h-");
662        for b in d {
663            use std::fmt::Write as _;
664            let _ = write!(out, "{b:02x}");
665        }
666        out
667    }
668}
669
670/// Whether `rev` names one immutable tree — a full git object id.
671///
672/// 40 hex for sha1, 64 for the sha256 transition. Anything else (a branch, a
673/// tag, a short rev) can move, so it must never be served from cache without a
674/// network check. Lowercase only: git emits lowercase, and accepting mixed case
675/// would let `ABC…` and `abc…` occupy two cache entries for one tree.
676fn is_immutable_rev(rev: &str) -> bool {
677    matches!(rev.len(), 40 | 64) && rev.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
678}
679
680/// A scratch path beside `dest`, on the same filesystem so the publish rename
681/// is atomic.
682///
683/// ── ★ PID IS NOT ENOUGH; THE THREAD ID IS PART OF THE KEY ─────────────
684/// An earlier version scoped this on the pid alone and claimed that "two
685/// concurrent fetchers cannot share a staging dir". That is true across
686/// processes and FALSE within one: two threads of the same process fetching
687/// the same input compute the same staging path, and the second one's
688/// `remove_dir_all(&staging)` fires while the first is mid-unpack — so the
689/// first then publishes a TRUNCATED tree, reintroducing exactly the defect
690/// the staging dance exists to prevent.
691///
692/// Latent today (there is no `rayon`/`par_iter` in the eval path), and it
693/// detonates the moment anyone parallelizes input fetching, which is the
694/// obvious next optimization on a lock file with N inputs. A claim that is
695/// true only until someone does the obvious thing is not an invariant.
696fn staging_path(dest: &Path) -> PathBuf {
697    let name = dest
698        .file_name()
699        .map_or_else(|| "fetch".to_string(), |n| n.to_string_lossy().into_owned());
700    // `ThreadId`'s Debug is the only stable accessor on stable Rust; it
701    // renders as `ThreadId(N)`, so keep the digits and drop the rest.
702    let tid = format!("{:?}", std::thread::current().id());
703    let tid: String = tid.chars().filter(char::is_ascii_digit).collect();
704    let tmp = [
705        ".",
706        &name,
707        ".tmp-",
708        &std::process::id().to_string(),
709        "-",
710        &tid,
711    ]
712    .concat();
713    dest.parent()
714        .map_or_else(|| PathBuf::from(&tmp), |p| p.join(&tmp))
715}
716
717/// Move `staging` onto `dest` without ever leaving `dest` observably absent
718/// for longer than one rename syscall.
719///
720/// ── ★ WHY NOT `remove_dir_all(dest)` THEN RENAME ──────────────────────
721/// That was the first version, and it is a regression dressed as a fix. For a
722/// MUTABLE rev the guard above never short-circuits, so every invocation
723/// deleted the published tree and re-created it — meaning a concurrent eval
724/// reading that path got ENOENT for the whole duration of a recursive delete
725/// of (measured on `pleme-io/nix`) 654 files. The staging dance had narrowed
726/// the failure from "adopt a partial tree" to "have a complete tree yanked",
727/// which is better and is still a bug.
728///
729/// Two cases, and neither deletes in place:
730///
731/// - **Immutable rev, tree already present.** Another process published the
732///   same content-addressed tree. Theirs is by definition ours; adopt it and
733///   drop our staging. No delete of `dest` at all.
734/// - **Otherwise.** Rename the old tree ASIDE (one syscall), rename the new
735///   one in, then delete the aside at leisure. `dest` is unresolvable only
736///   between two renames rather than for the length of a tree walk.
737fn publish(staging: &Path, dest: &Path, immutable: bool) -> Result<(), FetchError> {
738    if immutable && is_non_empty_dir(dest) {
739        let _ = std::fs::remove_dir_all(staging);
740        return Ok(());
741    }
742
743    let aside = with_suffix(staging, ".old");
744    let _ = std::fs::remove_dir_all(&aside);
745    let moved_aside = dest.exists() && std::fs::rename(dest, &aside).is_ok();
746
747    match std::fs::rename(staging, dest) {
748        Ok(()) => {
749            if moved_aside {
750                let _ = std::fs::remove_dir_all(&aside);
751            }
752            Ok(())
753        }
754        Err(_) => {
755            // Put the old tree back rather than leaving the cache emptier
756            // than we found it.
757            if moved_aside && !dest.exists() {
758                let _ = std::fs::rename(&aside, dest);
759            }
760            let _ = std::fs::remove_dir_all(staging);
761            let _ = std::fs::remove_dir_all(&aside);
762            if is_non_empty_dir(dest) {
763                // Lost the race; the winner left a good tree.
764                Ok(())
765            } else {
766                Err(FetchError::Extract(
767                    "could not publish the fetched tree and no other process left one".into(),
768                ))
769            }
770        }
771    }
772}
773
774/// `path` with `suffix` appended to its file name (not `with_extension`,
775/// which truncates at the last dot and would mangle `repo-1.2.3`).
776fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
777    let name = path
778        .file_name()
779        .map_or_else(|| "x".to_string(), |n| n.to_string_lossy().into_owned());
780    path.parent().map_or_else(
781        || PathBuf::from([&name, suffix].concat()),
782        |p| p.join([&name, suffix].concat()),
783    )
784}
785
786/// Return `true` when `dir` exists and has at least one child entry.
787fn is_non_empty_dir(dir: &Path) -> bool {
788    std::fs::read_dir(dir)
789        .ok()
790        .is_some_and(|mut rd| rd.next().is_some())
791}
792
793/// If the directory contains exactly one child directory (common for GitHub
794/// tarballs which unpack as `repo-rev/`), return that child. Otherwise
795/// return the directory itself.
796fn find_single_subdir_or_self(dir: &Path) -> PathBuf {
797    let entries: Vec<_> = std::fs::read_dir(dir)
798        .ok()
799        .into_iter()
800        .flatten()
801        .filter_map(|e| e.ok())
802        .collect();
803    if entries.len() == 1 && entries[0].path().is_dir() {
804        entries[0].path()
805    } else {
806        dir.to_path_buf()
807    }
808}
809
810/// Download a URL and return the raw bytes.
811///
812/// Uses `ureq` (synchronous, no tokio runtime) so this function is safe to
813/// call from inside a running tokio context — no nested-runtime panic.
814///
815/// Body limit raised to 512 MiB to accommodate large inputs like nixpkgs tarballs.
816fn download_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
817    // `http_status_as_error(false)` is load-bearing, not a preference.
818    //
819    // ureq 3 defaults it to TRUE, which turns every non-2xx into
820    // `Err(Error::StatusCode(_))` *before* the response object exists. Two
821    // consequences that both bit this function: the `!status().is_success()`
822    // branch below was **unreachable for real HTTP failures** — dead code that
823    // read as the status check — and `Retry-After` was unreachable too, because
824    // the headers live on a response we never received.
825    //
826    // Turning it off means a 429 arrives as a response we can classify AND
827    // read headers from, and the branch below becomes the live classifier it
828    // always looked like. `call()` then errors only on genuine transport
829    // failure, which is exactly what `FetchError::Download` should mean.
830    let agent: ureq::Agent = ureq::Agent::config_builder()
831        .http_status_as_error(false)
832        .build()
833        .into();
834    let mut req = agent.get(url);
835
836    // Attach a host-appropriate auth token when one is available.
837    // CppNix consults `~/.config/nix/nix.conf` `access-tokens =
838    // github.com=<TOKEN>` etc.; we keep parity by reading the same
839    // sources plus the common `GITHUB_TOKEN` env (gh CLI, nix-darwin
840    // shell init).  Without this the operator's private flake
841    // inputs (e.g. `arnes`) 404 unauthenticated.
842    if let Some(token) = github_token_for_url(url) {
843        req = req.header("Authorization", &format!("token {token}"));
844    }
845
846    let mut response = req
847        .call()
848        .map_err(|e| FetchError::Download(format!("{url}: {e}")))?;
849
850    if !response.status().is_success() {
851        return Err(classify_status(url, &response));
852    }
853
854    response
855        .body_mut()
856        .with_config()
857        .limit(512 * 1024 * 1024)
858        .read_to_vec()
859        .map_err(|e| FetchError::Download(format!("{url}: {e}")))
860}
861
862/// Turn a non-2xx response into the typed variant that names what happened.
863///
864/// Pure apart from reading the response's status and headers, so it is unit
865/// testable without a network — which matters because the interesting cases
866/// (429 with and without `Retry-After`, a 403 that is really a secondary rate
867/// limit) are precisely the ones nobody can reproduce on demand.
868fn classify_status<B>(url: &str, response: &ureq::http::Response<B>) -> FetchError {
869    let status = response.status().as_u16();
870    let retry_after = retry_after_seconds(response.headers());
871
872    // A secondary rate limit is spelled 403 by GitHub, and 403 otherwise means
873    // "your credential is not enough". The header is what separates them: a
874    // plain authorization failure carries no Retry-After. Reading a throttle as
875    // a credential fault sends an operator to re-provision a token that is
876    // fine — so when the server says "come back later", believe it over the code.
877    let throttled = status == 429 || (status == 403 && retry_after.is_some());
878
879    if throttled {
880        FetchError::Throttled {
881            url: url.to_string(),
882            status,
883            retry_after,
884        }
885    } else if status == 404 {
886        FetchError::NotFound {
887            url: url.to_string(),
888        }
889    } else if status == 401 || status == 403 {
890        FetchError::Unauthorized {
891            url: url.to_string(),
892            status,
893        }
894    } else {
895        FetchError::UnexpectedStatus {
896            url: url.to_string(),
897            status,
898        }
899    }
900}
901
902/// Read `Retry-After` as whole seconds.
903///
904/// RFC 9110 permits either a delta-seconds integer or an HTTP-date. Only the
905/// integer form is honoured here, and an HTTP-date yields `None` rather than a
906/// guess: a wrong wait derived from a misparsed date is worse than admitting we
907/// were not told, because a caller that receives `None` falls back to its own
908/// bounded policy while one that receives a wrong number obeys it.
909fn retry_after_seconds(headers: &ureq::http::HeaderMap) -> Option<u64> {
910    headers
911        .get("retry-after")?
912        .to_str()
913        .ok()?
914        .trim()
915        .parse::<u64>()
916        .ok()
917}
918
919/// Resolve a host-appropriate auth token for outgoing requests.
920///
921/// Sources, in order:
922///   1. `GITHUB_TOKEN` env var (covers gh CLI exports + CI tokens).
923///   2. `NIX_CONFIG` env var, parsed for `access-tokens` line.
924///   3. `~/.config/nix/nix.conf` parsed for `access-tokens` line.
925///   4. `~/.config/gh/hosts.yml` (`oauth_token:` field for github.com).
926///
927/// Returns `Some(token)` only for github.com URLs in this iteration —
928/// gitlab / sr.ht / private git hosts can be added when needed.
929fn github_token_for_url(url: &str) -> Option<String> {
930    if !url.starts_with("https://github.com/")
931        && !url.starts_with("https://api.github.com/")
932    {
933        return None;
934    }
935    if let Ok(t) = std::env::var("GITHUB_TOKEN") {
936        if !t.is_empty() {
937            return Some(t);
938        }
939    }
940    if let Ok(cfg) = std::env::var("NIX_CONFIG") {
941        if let Some(t) = parse_access_tokens(&cfg, "github.com") {
942            return Some(t);
943        }
944    }
945    if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
946        let nix_conf = home.join(".config/nix/nix.conf");
947        if let Ok(cfg) = std::fs::read_to_string(&nix_conf) {
948            if let Some(t) = parse_access_tokens(&cfg, "github.com") {
949                return Some(t);
950            }
951        }
952        let gh_hosts = home.join(".config/gh/hosts.yml");
953        if let Ok(yml) = std::fs::read_to_string(&gh_hosts) {
954            if let Some(t) = parse_gh_hosts_token(&yml, "github.com") {
955                return Some(t);
956            }
957        }
958    }
959    None
960}
961
962/// Parse a `~/.config/nix/nix.conf`-style `access-tokens = host=TOKEN ...`
963/// line and return the token for `host` if present.
964fn parse_access_tokens(cfg: &str, host: &str) -> Option<String> {
965    for line in cfg.lines() {
966        let trimmed = line.trim();
967        if let Some(rest) = trimmed.strip_prefix("access-tokens") {
968            let rest = rest.trim_start().trim_start_matches('=').trim();
969            for pair in rest.split_whitespace() {
970                if let Some((h, t)) = pair.split_once('=') {
971                    if h == host {
972                        return Some(t.to_string());
973                    }
974                }
975            }
976        }
977    }
978    None
979}
980
981/// Parse `~/.config/gh/hosts.yml` and return the `oauth_token:` value
982/// nested under the given host key.  We do this without a full YAML
983/// parser to keep sui-eval's dep footprint small — the file is a
984/// stable 5-line shape gh maintains.
985fn parse_gh_hosts_token(yml: &str, host: &str) -> Option<String> {
986    let mut in_host = false;
987    for line in yml.lines() {
988        let raw = line;
989        let trimmed = raw.trim();
990        if trimmed.starts_with(host) && trimmed.ends_with(':') {
991            in_host = true;
992            continue;
993        }
994        if !raw.starts_with(' ') && !raw.starts_with('\t') && !trimmed.is_empty() {
995            in_host = false;
996        }
997        if in_host {
998            if let Some(rest) = trimmed.strip_prefix("oauth_token:") {
999                return Some(rest.trim().to_string());
1000            }
1001        }
1002    }
1003    None
1004}
1005
1006/// Extract a `.tar.gz` archive into a destination directory.
1007fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<(), FetchError> {
1008    let gz = flate2::read::GzDecoder::new(bytes);
1009
1010    // Check if the gzip header is valid before attempting extraction.
1011    // An empty or non-gzip payload would fail inside tar::Archive.
1012    let mut buffered = std::io::BufReader::new(gz);
1013    let mut peek = [0u8; 1];
1014    // Try reading one byte to detect decompression errors early.
1015    match buffered.read(&mut peek) {
1016        Ok(0) => {
1017            return Err(FetchError::Extract("empty archive".into()));
1018        }
1019        Err(e) => {
1020            return Err(FetchError::Extract(format!("gzip decompression: {e}")));
1021        }
1022        Ok(_) => {
1023            // Put the byte back by chaining it in front of the reader.
1024            let cursor = std::io::Cursor::new(peek);
1025            let chain = cursor.chain(buffered);
1026            let mut archive = tar::Archive::new(chain);
1027            archive
1028                .unpack(dest)
1029                .map_err(|e| FetchError::Extract(format!("tar unpack: {e}")))?;
1030        }
1031    }
1032
1033    Ok(())
1034}
1035
1036/// Convert a URL into a filesystem-safe name (for fallback cache keys).
1037fn url_to_safe_name(url: &str) -> String {
1038    url.chars()
1039        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
1040        .collect()
1041}
1042
1043/// Platform-aware cache directory discovery.
1044fn dirs_cache_dir() -> PathBuf {
1045    // Try XDG_CACHE_HOME first, then platform default, then /tmp.
1046    // Absolute, not merely non-empty — see eval_cache.rs for the class.
1047    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
1048        .map(PathBuf::from)
1049        .filter(|p| p.is_absolute())
1050    {
1051        return xdg;
1052    }
1053    if let Some(home) = std::env::var_os("HOME")
1054        .map(PathBuf::from)
1055        .filter(|p| p.is_absolute())
1056    {
1057        let default = home.join(".cache");
1058        if default.exists() || std::fs::create_dir_all(&default).is_ok() {
1059            return default;
1060        }
1061    }
1062    PathBuf::from("/tmp")
1063}
1064
1065// ── Tests ─────────────────────────────────────────────────────
1066
1067#[cfg(test)]
1068mod archive_report_tests {
1069    use super::*;
1070
1071    fn throttled(retry: Option<u64>) -> FetchError {
1072        FetchError::Throttled { url: "u".into(), status: 429, retry_after: retry }
1073    }
1074
1075    #[test]
1076    fn a_throttle_becomes_a_machine_readable_tag_with_the_servers_advice() {
1077        // The whole deliverable in one assertion: the fact a consumer used to
1078        // recover by matching "HTTP error 429" is now a tag plus a number.
1079        let f = InputFailure::from_error("nixpkgs", &throttled(Some(120)));
1080        assert_eq!(f.kind, FailureKind::Throttled);
1081        assert_eq!(f.status, Some(429));
1082        assert_eq!(f.retry_after, Some(120));
1083        assert!(f.recoverable_elsewhere);
1084        assert_eq!(f.input, "nixpkgs", "the report must name WHICH input");
1085    }
1086
1087    #[test]
1088    fn each_error_maps_to_its_own_kind() {
1089        let cases: Vec<(FetchError, FailureKind)> = vec![
1090            (throttled(None), FailureKind::Throttled),
1091            (FetchError::Unauthorized { url: "u".into(), status: 403 }, FailureKind::Unauthorized),
1092            (FetchError::NotFound { url: "u".into() }, FailureKind::NotFound),
1093            (FetchError::UnexpectedStatus { url: "u".into(), status: 503 }, FailureKind::UnexpectedStatus),
1094            (FetchError::Download("dns".into()), FailureKind::Transport),
1095            (FetchError::UnsupportedType("hg".into()), FailureKind::Local),
1096            (FetchError::Extract("bad tar".into()), FailureKind::Local),
1097        ];
1098        for (err, want) in cases {
1099            let got = InputFailure::from_error("i", &err).kind;
1100            assert_eq!(got, want, "{err:?} classified as {got:?}");
1101        }
1102    }
1103
1104    #[test]
1105    fn only_a_throttle_is_marked_recoverable_elsewhere() {
1106        for e in [
1107            FetchError::Unauthorized { url: "u".into(), status: 401 },
1108            FetchError::NotFound { url: "u".into() },
1109            FetchError::UnexpectedStatus { url: "u".into(), status: 500 },
1110            FetchError::Download("tls".into()),
1111        ] {
1112            assert!(
1113                !InputFailure::from_error("i", &e).recoverable_elsewhere,
1114                "{e:?} must not claim another egress would help"
1115            );
1116        }
1117    }
1118
1119    #[test]
1120    fn an_empty_walk_is_NOT_complete() {
1121        // The vacuity guard, and the reason `scanned` is in the wire shape at
1122        // all: zero failures over zero inputs must never read as "warm". A
1123        // discovery bug that finds no inputs would otherwise report success.
1124        let empty = ArchiveReport { scanned: 0, already_present: 0, fetched: 0, failures: vec![] };
1125        assert!(!empty.is_complete(), "an empty walk has not earned 'complete'");
1126
1127        let real = ArchiveReport { scanned: 3, already_present: 3, fetched: 0, failures: vec![] };
1128        assert!(real.is_complete());
1129    }
1130
1131    #[test]
1132    fn recoverable_filters_to_exactly_the_throttles() {
1133        let r = ArchiveReport {
1134            scanned: 4,
1135            already_present: 1,
1136            fetched: 0,
1137            failures: vec![
1138                InputFailure::from_error("a", &throttled(Some(5))),
1139                InputFailure::from_error("b", &FetchError::NotFound { url: "u".into() }),
1140                InputFailure::from_error("c", &throttled(None)),
1141            ],
1142        };
1143        let names: Vec<&str> = r.recoverable().map(|f| f.input.as_str()).collect();
1144        assert_eq!(names, vec!["a", "c"]);
1145        assert!(!r.is_complete());
1146    }
1147
1148    #[test]
1149    fn the_json_shape_is_the_contract_a_consumer_reads() {
1150        // Field names and tag spellings are wire-facing. Pinning them here means
1151        // a rename is a failing test rather than a consumer that silently stops
1152        // matching — the same failure mode as the prose-matching this replaces.
1153        let r = ArchiveReport {
1154            scanned: 2,
1155            already_present: 1,
1156            fetched: 0,
1157            failures: vec![InputFailure::from_error("nixpkgs", &throttled(Some(90)))],
1158        };
1159        let v: serde_json::Value = serde_json::to_value(&r).expect("serializes");
1160        assert_eq!(v["scanned"], 2);
1161        assert_eq!(v["already_present"], 1);
1162        assert_eq!(v["failures"][0]["kind"], "throttled", "kebab-case tag");
1163        assert_eq!(v["failures"][0]["status"], 429);
1164        assert_eq!(v["failures"][0]["retry_after"], 90);
1165        assert_eq!(v["failures"][0]["recoverable_elsewhere"], true);
1166        assert_eq!(v["failures"][0]["input"], "nixpkgs");
1167
1168        // Absent optionals are OMITTED, not null — a consumer checking
1169        // presence must not have to also check for null.
1170        let r2 = ArchiveReport {
1171            scanned: 1,
1172            already_present: 0,
1173            fetched: 0,
1174            failures: vec![InputFailure::from_error("x", &FetchError::Download("dns".into()))],
1175        };
1176        let v2: serde_json::Value = serde_json::to_value(&r2).unwrap();
1177        assert!(v2["failures"][0].get("status").is_none());
1178        assert!(v2["failures"][0].get("retry_after").is_none());
1179        assert_eq!(v2["failures"][0]["kind"], "transport");
1180    }
1181}
1182
1183#[cfg(test)]
1184mod status_classification_tests {
1185    use super::*;
1186
1187    /// Build a response carrying only what the classifier reads.
1188    fn resp(status: u16, headers: &[(&str, &str)]) -> ureq::http::Response<()> {
1189        let mut b = ureq::http::Response::builder().status(status);
1190        for (k, v) in headers {
1191            b = b.header(*k, *v);
1192        }
1193        b.body(()).expect("a status + headers response always builds")
1194    }
1195
1196    const URL: &str = "https://api.github.com/repos/o/r/tarball/deadbeef";
1197
1198    #[test]
1199    fn a_429_is_throttled_and_keeps_the_servers_own_retry_after() {
1200        // The measured case. `Retry-After` is the only authority on how long to
1201        // wait, and it used to be discarded unread.
1202        let e = classify_status(URL, &resp(429, &[("retry-after", "120")]));
1203        assert!(matches!(
1204            e,
1205            FetchError::Throttled {
1206                status: 429,
1207                retry_after: Some(120),
1208                ..
1209            }
1210        ));
1211        assert!(e.is_throttled());
1212        assert_eq!(e.status(), Some(429));
1213    }
1214
1215    #[test]
1216    fn a_429_without_a_header_is_still_throttled() {
1217        // GitHub's archive throttle frequently sends no Retry-After. Absence of
1218        // advice must not downgrade the classification.
1219        let e = classify_status(URL, &resp(429, &[]));
1220        assert!(matches!(e, FetchError::Throttled { retry_after: None, .. }));
1221        assert!(e.is_throttled());
1222    }
1223
1224    #[test]
1225    fn a_403_with_retry_after_is_a_throttle_not_a_credential_fault() {
1226        // GitHub spells its secondary rate limit 403. Reading it as an auth
1227        // failure sends an operator to re-provision a token that is fine.
1228        let e = classify_status(URL, &resp(403, &[("retry-after", "60")]));
1229        assert!(
1230            e.is_throttled(),
1231            "a 403 that says 'come back later' is a throttle, got {e:?}"
1232        );
1233    }
1234
1235    #[test]
1236    fn a_bare_403_is_a_credential_fault_and_NOT_recoverable_elsewhere() {
1237        // The other half of the pair: without the header, 403 means our
1238        // credential. Answering `is_throttled` here would send a caller to
1239        // build a second fetch path that is refused identically.
1240        let e = classify_status(URL, &resp(403, &[]));
1241        assert!(matches!(e, FetchError::Unauthorized { status: 403, .. }));
1242        assert!(!e.is_throttled());
1243    }
1244
1245    #[test]
1246    fn a_404_says_it_may_be_an_invisible_private_input() {
1247        let e = classify_status(URL, &resp(404, &[]));
1248        assert!(matches!(e, FetchError::NotFound { .. }));
1249        assert_eq!(e.status(), Some(404));
1250        // For a private flake input a 404 is routinely an auth failure wearing
1251        // a not-found mask, so the message must not assert absence.
1252        let msg = e.to_string();
1253        assert!(msg.contains("invisible to this credential"), "got {msg}");
1254    }
1255
1256    #[test]
1257    fn an_unhandled_status_arrives_as_a_NUMBER_never_as_prose() {
1258        // The anti-regression variant: a status nobody wrote an arm for must
1259        // still reach the caller as a u16, so widening HTTP behaviour cannot
1260        // silently re-create the single-String bucket this enum replaced.
1261        let e = classify_status(URL, &resp(503, &[]));
1262        assert!(matches!(e, FetchError::UnexpectedStatus { status: 503, .. }));
1263        assert_eq!(e.status(), Some(503));
1264        assert!(!e.is_throttled());
1265    }
1266
1267    #[test]
1268    fn no_two_http_failures_render_the_same_bytes() {
1269        // ★★ kotae: a caller must be able to tell these apart. If any two
1270        // rendered identically, a consumer would be back to guessing — the
1271        // defect that made a downstream tool grep this crate's error text.
1272        //
1273        // The variants are constructed DIRECTLY at a deliberately CONSTANT
1274        // status, and that shape is the whole point. An earlier version of this
1275        // test classified four different statuses and compared the results — it
1276        // passes trivially, because the status number is interpolated into every
1277        // message, so the strings differ no matter how badly the *variants*
1278        // collide. Red-running proved it: NotFound's message was rewritten to be
1279        // byte-identical to UnexpectedStatus's and this test still went GREEN
1280        // while an unrelated test caught the break. A test that cannot fail for
1281        // the reason it names is worse than no test, because its green reads as
1282        // coverage of a property nobody is checking.
1283        let u = URL.to_string();
1284        let cases: Vec<(&str, FetchError)> = vec![
1285            ("Throttled(no advice)",  FetchError::Throttled { url: u.clone(), status: 404, retry_after: None }),
1286            ("Throttled(advice)",     FetchError::Throttled { url: u.clone(), status: 404, retry_after: Some(30) }),
1287            ("Unauthorized",          FetchError::Unauthorized { url: u.clone(), status: 404 }),
1288            ("NotFound",              FetchError::NotFound { url: u.clone() }),
1289            ("UnexpectedStatus",      FetchError::UnexpectedStatus { url: u.clone(), status: 404 }),
1290            ("Download",              FetchError::Download(format!("{u}: connection reset"))),
1291        ];
1292
1293        for (i, (name_a, a)) in cases.iter().enumerate() {
1294            for (name_b, b) in cases.iter().skip(i + 1) {
1295                assert_ne!(
1296                    a.to_string(),
1297                    b.to_string(),
1298                    "{name_a} and {name_b} render identically at the same status \
1299                     — a caller cannot distinguish them"
1300                );
1301            }
1302        }
1303    }
1304
1305    #[test]
1306    fn an_http_date_retry_after_yields_none_rather_than_a_guess() {
1307        // RFC 9110 allows an HTTP-date. We do not parse it, and `None` is the
1308        // honest answer: a caller given None uses its own bounded policy, while
1309        // a caller given a wrong number obeys it.
1310        let h = resp(429, &[("retry-after", "Wed, 21 Oct 2026 07:28:00 GMT")]);
1311        assert_eq!(retry_after_seconds(h.headers()), None);
1312        // ...and the classification is unaffected.
1313        assert!(classify_status(URL, &h).is_throttled());
1314    }
1315
1316    #[test]
1317    fn a_junk_retry_after_does_not_panic_or_lie() {
1318        for v in ["", "  ", "abc", "-5", "12.5", "9999999999999999999999"] {
1319            let h = resp(429, &[("retry-after", v)]);
1320            assert_eq!(
1321                retry_after_seconds(h.headers()),
1322                None,
1323                "{v:?} must not parse"
1324            );
1325        }
1326        assert_eq!(retry_after_seconds(resp(429, &[("retry-after", " 30 ")]).headers()), Some(30));
1327    }
1328
1329    #[test]
1330    fn a_transport_failure_is_not_given_a_status() {
1331        // `Download` is reserved for DNS/TLS/timeout — things with no response.
1332        // If it ever reported a status, the two categories would have merged
1333        // again.
1334        let e = FetchError::Download("dns failure".into());
1335        assert_eq!(e.status(), None);
1336        assert!(!e.is_throttled());
1337    }
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342    use super::*;
1343    use std::collections::BTreeMap;
1344
1345    /// Helper: build a `LockedInput` with the given fields.
1346    fn make_locked(source_type: &str) -> LockedInput {
1347        LockedInput {
1348            source_type: source_type.to_string(),
1349            owner: None,
1350            repo: None,
1351            rev: None,
1352            nar_hash: None,
1353            last_modified: None,
1354            path: None,
1355            url: None,
1356            git_ref: None,
1357            dir: None,
1358            host: None,
1359            extra: BTreeMap::new(),
1360        }
1361    }
1362
1363    // ── sanitize_hash ─────────────────────────────────────
1364
1365    #[test]
1366    fn sanitize_hash_replaces_special_chars() {
1367        assert_eq!(
1368            sanitize_hash("sha256-AAAAAAAAAAAAAAAAAAAAAA="),
1369            "sha256-AAAAAAAAAAAAAAAAAAAAAA"
1370        );
1371        assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
1372    }
1373
1374    // ── sanitize_hash — a component, not a transliteration ──
1375
1376    #[test]
1377    fn a_traversal_hash_cannot_become_a_path_component() {
1378        // `narHash` is lock-file input. `..` survives the substitutions and
1379        // would make the cache dest `<cache>/inputs/..` = `~/.cache/sui`,
1380        // which then gets returned AS the flake source and, on a miss,
1381        // remove_dir_all'd — taking `inputs/` and `nar-memo/` with it.
1382        for hostile in ["..", ".", "", "../..", "..\u{0}"] {
1383            let s = sanitize_hash(hostile);
1384            assert!(
1385                s != ".." && s != "." && !s.is_empty(),
1386                "{hostile:?} sanitized to {s:?}, still a meaningful component"
1387            );
1388            assert!(
1389                !s.contains('/') && !s.contains('\\'),
1390                "{hostile:?} sanitized to {s:?}, still a separator"
1391            );
1392        }
1393        // Deterministic — the same input must key the same directory.
1394        assert_eq!(sanitize_hash(".."), sanitize_hash(".."));
1395        // …and distinct inputs must not collide onto one entry.
1396        assert_ne!(sanitize_hash(".."), sanitize_hash("."));
1397    }
1398
1399    #[test]
1400    fn a_well_formed_hash_is_untouched_by_the_guard() {
1401        // The guard must not change the key for ordinary input, or every
1402        // existing cache entry is orphaned on upgrade.
1403        assert_eq!(
1404            sanitize_hash("sha256-avzRM+ffKgikqMRcOhhYp3ifgwXMGbH0rEGEZPEGMYE="),
1405            "sha256-avzRM+ffKgikqMRcOhhYp3ifgwXMGbH0rEGEZPEGMYE"
1406        );
1407        assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
1408    }
1409
1410    // ── publish — never leave `dest` absent during a tree walk ──
1411
1412    #[test]
1413    fn publishing_an_immutable_tree_adopts_the_winner_and_deletes_nothing() {
1414        let tmp = tempfile::tempdir().unwrap();
1415        let dest = tmp.path().join("github-o-r-deadbeef");
1416        let staging = staging_path(&dest);
1417        // A concurrent process already published.
1418        std::fs::create_dir_all(&dest).unwrap();
1419        std::fs::write(dest.join("theirs"), b"x").unwrap();
1420        std::fs::create_dir_all(&staging).unwrap();
1421        std::fs::write(staging.join("ours"), b"y").unwrap();
1422
1423        publish(&staging, &dest, true).unwrap();
1424
1425        assert!(
1426            dest.join("theirs").exists(),
1427            "an immutable tree is content-addressed: the winner's tree IS ours, \
1428             and deleting it to install an identical one is pure risk"
1429        );
1430        assert!(!staging.exists(), "our staging must be cleaned up");
1431    }
1432
1433    #[test]
1434    fn publishing_a_mutable_tree_replaces_it_without_a_delete_in_place() {
1435        let tmp = tempfile::tempdir().unwrap();
1436        let dest = tmp.path().join("github-o-r-main");
1437        let staging = staging_path(&dest);
1438        std::fs::create_dir_all(&dest).unwrap();
1439        std::fs::write(dest.join("old"), b"x").unwrap();
1440        std::fs::create_dir_all(&staging).unwrap();
1441        std::fs::write(staging.join("new"), b"y").unwrap();
1442
1443        publish(&staging, &dest, false).unwrap();
1444
1445        assert!(dest.join("new").exists(), "the new tree must be published");
1446        assert!(!dest.join("old").exists(), "and must REPLACE, not union");
1447        assert!(!staging.exists());
1448        // The aside must not be left behind as cache litter.
1449        let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
1450            .unwrap()
1451            .filter_map(Result::ok)
1452            .map(|e| e.file_name().to_string_lossy().into_owned())
1453            .filter(|n| n.contains(".old"))
1454            .collect();
1455        assert!(leftovers.is_empty(), "aside dirs left behind: {leftovers:?}");
1456    }
1457
1458    #[test]
1459    fn staging_is_scoped_by_thread_not_only_by_pid() {
1460        // An earlier version keyed on pid alone and CLAIMED two concurrent
1461        // fetchers could not collide. Two threads of one process share a pid,
1462        // so the second one's cleanup would delete the first one's half-built
1463        // tree and the first would then publish a truncated one.
1464        let dest = std::path::Path::new("/c/inputs/github-o-r-deadbeef");
1465        let here = staging_path(dest);
1466        let there = std::thread::spawn(move || staging_path(dest))
1467            .join()
1468            .unwrap();
1469        assert_ne!(
1470            here, there,
1471            "two threads must not share a staging directory"
1472        );
1473    }
1474
1475    // ── is_immutable_rev — what may be served from cache ──
1476
1477    #[test]
1478    fn only_a_full_object_id_is_treated_as_immutable() {
1479        // sha1 and the sha256 transition: one rev, one tree, forever.
1480        assert!(is_immutable_rev("7fd33221240a3ab97781a066c5efe0124979527f"));
1481        assert!(is_immutable_rev(&"a".repeat(64)));
1482
1483        // ── ★ THE ONE THAT MATTERS ───────────────────────────────────────
1484        // `github:owner/repo/main` is a legal ref and CppNix accepts it, so
1485        // we must too — but the tree behind it MOVES. Caching it as if
1486        // immutable would freeze the entry at whatever `main` was the first
1487        // time it was fetched. There is a `github-pleme-io-nix-main`
1488        // directory in the live cache today, so this is not hypothetical.
1489        assert!(!is_immutable_rev("main"), "a branch name is not a commit");
1490        assert!(!is_immutable_rev("v1.2.3"), "a tag can be moved");
1491        assert!(!is_immutable_rev("7fd3322"), "a short rev is ambiguous");
1492        assert!(!is_immutable_rev(""), "an empty rev names nothing");
1493
1494        // Length alone is not enough — 40 non-hex chars is not an object id.
1495        assert!(!is_immutable_rev(&"z".repeat(40)));
1496        // Uppercase is refused deliberately: git emits lowercase, and
1497        // accepting both would give one tree two cache entries.
1498        assert!(!is_immutable_rev(&"A".repeat(40)));
1499    }
1500
1501    // ── staging_path — atomicity depends on it being a SIBLING ──
1502
1503    #[test]
1504    fn staging_is_a_sibling_so_the_publish_rename_is_atomic() {
1505        let dest = std::path::Path::new("/cache/sui/inputs/sha256-abc/github-o-r-deadbeef");
1506        let staging = staging_path(dest);
1507        assert_eq!(
1508            staging.parent(),
1509            dest.parent(),
1510            "staging in /tmp would put the rename across filesystems, where it \
1511             is a copy — and a copy is not atomic, which is the whole point"
1512        );
1513        assert_ne!(staging, dest.to_path_buf());
1514        let name = staging.file_name().unwrap().to_string_lossy().into_owned();
1515        assert!(name.starts_with('.'), "hidden, so it is not mistaken for a tree");
1516        assert!(
1517            name.contains(&std::process::id().to_string()),
1518            "pid-scoped, so two concurrent fetchers cannot share a staging dir"
1519        );
1520        // A dotted directory name must not be truncated the way
1521        // `Path::with_extension` would truncate it.
1522        let dotted = std::path::Path::new("/c/github-o-r-1.2.3");
1523        assert!(
1524            staging_path(dotted)
1525                .file_name()
1526                .unwrap()
1527                .to_string_lossy()
1528                .contains("github-o-r-1.2.3"),
1529            "the full directory name must survive into the staging name"
1530        );
1531    }
1532
1533    // ── find_single_subdir_or_self ────────────────────────
1534
1535    #[test]
1536    fn find_single_subdir_returns_child_when_one_dir() {
1537        let tmp = tempfile::tempdir().unwrap();
1538        let child = tmp.path().join("repo-abc123");
1539        std::fs::create_dir(&child).unwrap();
1540        std::fs::write(child.join("file.txt"), "hello").unwrap();
1541
1542        let result = find_single_subdir_or_self(tmp.path());
1543        assert_eq!(result, child);
1544    }
1545
1546    #[test]
1547    fn find_single_subdir_returns_self_when_multiple() {
1548        let tmp = tempfile::tempdir().unwrap();
1549        std::fs::create_dir(tmp.path().join("a")).unwrap();
1550        std::fs::create_dir(tmp.path().join("b")).unwrap();
1551
1552        let result = find_single_subdir_or_self(tmp.path());
1553        assert_eq!(result, tmp.path());
1554    }
1555
1556    #[test]
1557    fn find_single_subdir_returns_self_when_empty() {
1558        let tmp = tempfile::tempdir().unwrap();
1559        let result = find_single_subdir_or_self(tmp.path());
1560        assert_eq!(result, tmp.path());
1561    }
1562
1563    #[test]
1564    fn find_single_subdir_returns_self_when_child_is_file() {
1565        let tmp = tempfile::tempdir().unwrap();
1566        std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1567        let result = find_single_subdir_or_self(tmp.path());
1568        assert_eq!(result, tmp.path());
1569    }
1570
1571    // ── url_to_safe_name ──────────────────────────────────
1572
1573    #[test]
1574    fn url_to_safe_name_replaces_slashes_and_colons() {
1575        let name = url_to_safe_name("https://example.com/foo/bar.tar.gz");
1576        assert!(!name.contains('/'));
1577        assert!(!name.contains(':'));
1578        assert!(name.contains("example"));
1579    }
1580
1581    // ── InputFetcher construction ─────────────────────────
1582
1583    #[test]
1584    fn fetcher_with_custom_cache_dir() {
1585        let tmp = tempfile::tempdir().unwrap();
1586        let fetcher = InputFetcher::with_cache_dir(tmp.path().to_path_buf());
1587        assert_eq!(fetcher.cache_dir(), tmp.path());
1588    }
1589
1590    #[test]
1591    fn fetcher_default_cache_dir_exists() {
1592        let fetcher = InputFetcher::new();
1593        // The path should end with "sui/inputs".
1594        let path_str = fetcher.cache_dir().to_string_lossy();
1595        assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
1596    }
1597
1598    // ── path-type fetch ───────────────────────────────────
1599
1600    #[test]
1601    fn fetch_path_returns_filesystem_path() {
1602        let tmp = tempfile::tempdir().unwrap();
1603        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1604
1605        let mut locked = make_locked("path");
1606        locked.path = Some("/var/empty/dep".to_string());
1607
1608        let result = fetcher.fetch(&locked).unwrap();
1609        assert_eq!(result, PathBuf::from("/var/empty/dep"));
1610    }
1611
1612    #[test]
1613    fn fetch_path_missing_field_errors() {
1614        let tmp = tempfile::tempdir().unwrap();
1615        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1616        let locked = make_locked("path");
1617        let result = fetcher.fetch(&locked);
1618        assert!(result.is_err());
1619        assert!(result.unwrap_err().to_string().contains("path"));
1620    }
1621
1622    // ── unsupported type ──────────────────────────────────
1623
1624    #[test]
1625    fn fetch_unsupported_type_returns_error() {
1626        // `mercurial` — parser doesn't produce this and fetcher
1627        // doesn't handle it. Remains unsupported for now. If a
1628        // future commit adds mercurial support, swap this to the
1629        // next truly-unsupported source_type to keep the test
1630        // meaningful.
1631        let tmp = tempfile::tempdir().unwrap();
1632        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1633        let locked = make_locked("mercurial");
1634        let result = fetcher.fetch(&locked);
1635        assert!(matches!(result, Err(FetchError::UnsupportedType(_))));
1636    }
1637
1638    #[test]
1639    fn gitlab_archive_url_is_well_formed() {
1640        assert_eq!(
1641            InputFetcher::gitlab_archive_url(None, "group", "proj", "abc123"),
1642            "https://gitlab.com/group/proj/-/archive/abc123/proj-abc123.tar.gz"
1643        );
1644    }
1645
1646    #[test]
1647    fn gitlab_archive_url_honors_custom_host() {
1648        assert_eq!(
1649            InputFetcher::gitlab_archive_url(Some("gitlab.gnome.org"), "GNOME", "gnome-shell", "abc"),
1650            "https://gitlab.gnome.org/GNOME/gnome-shell/-/archive/abc/gnome-shell-abc.tar.gz"
1651        );
1652    }
1653
1654    #[test]
1655    fn sourcehut_archive_url_prepends_tilde() {
1656        // Sourcehut owner names on the platform carry a `~` prefix
1657        // (`~emersion`) but the flake-ref parser drops it. Fetcher
1658        // must reinstate so the URL is canonical.
1659        assert_eq!(
1660            InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
1661            "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
1662        );
1663        // If the caller already included `~`, don't double it.
1664        assert_eq!(
1665            InputFetcher::sourcehut_archive_url("~emersion", "page", "HEAD"),
1666            "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
1667        );
1668    }
1669
1670    // ── cache hit ─────────────────────────────────────────
1671
1672    #[test]
1673    fn cache_hit_returns_cached_path() {
1674        let tmp = tempfile::tempdir().unwrap();
1675        let cache_dir = tmp.path().join("cache");
1676        std::fs::create_dir_all(&cache_dir).unwrap();
1677
1678        // Pre-populate cache.
1679        let hash = "sha256-TESTCACHEHIT";
1680        let cached_dir = cache_dir.join(sanitize_hash(hash));
1681        std::fs::create_dir_all(&cached_dir).unwrap();
1682        std::fs::write(cached_dir.join("flake.nix"), "{}").unwrap();
1683
1684        let fetcher = InputFetcher::with_cache_dir(cache_dir);
1685        let mut locked = make_locked("github");
1686        locked.nar_hash = Some(hash.to_string());
1687        // Intentionally leave owner/repo/rev empty — cache hit should skip fetch.
1688
1689        let result = fetcher.fetch(&locked).unwrap();
1690        // The cached directory has one file (not a subdir), so it returns itself.
1691        assert_eq!(result, cached_dir);
1692    }
1693
1694    // ── github URL construction ───────────────────────────
1695
1696    #[test]
1697    fn github_archive_url_format() {
1698        let url = InputFetcher::github_archive_url("nixos", "nixpkgs", "abc123");
1699        assert_eq!(
1700            url,
1701            "https://github.com/nixos/nixpkgs/archive/abc123.tar.gz"
1702        );
1703    }
1704
1705    // ── github fetch missing fields ───────────────────────
1706
1707    #[test]
1708    fn fetch_github_missing_owner_errors() {
1709        let tmp = tempfile::tempdir().unwrap();
1710        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1711        let mut locked = make_locked("github");
1712        locked.repo = Some("nixpkgs".into());
1713        locked.rev = Some("abc123".into());
1714        let result = fetcher.fetch(&locked);
1715        assert!(result.is_err());
1716        assert!(result.unwrap_err().to_string().contains("owner"));
1717    }
1718
1719    #[test]
1720    fn fetch_github_missing_rev_errors() {
1721        let tmp = tempfile::tempdir().unwrap();
1722        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1723        let mut locked = make_locked("github");
1724        locked.owner = Some("nixos".into());
1725        locked.repo = Some("nixpkgs".into());
1726        let result = fetcher.fetch(&locked);
1727        assert!(result.is_err());
1728        assert!(result.unwrap_err().to_string().contains("rev"));
1729    }
1730
1731    // ── git fetch missing fields ──────────────────────────
1732
1733    #[test]
1734    fn fetch_git_missing_url_errors() {
1735        let tmp = tempfile::tempdir().unwrap();
1736        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1737        let mut locked = make_locked("git");
1738        locked.rev = Some("abc123".into());
1739        let result = fetcher.fetch(&locked);
1740        assert!(result.is_err());
1741        assert!(result.unwrap_err().to_string().contains("url"));
1742    }
1743
1744    #[test]
1745    fn fetch_git_missing_rev_errors() {
1746        let tmp = tempfile::tempdir().unwrap();
1747        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1748        let mut locked = make_locked("git");
1749        locked.url = Some("https://example.com/repo.git".into());
1750        let result = fetcher.fetch(&locked);
1751        assert!(result.is_err());
1752        assert!(result.unwrap_err().to_string().contains("rev"));
1753    }
1754
1755    // ── tarball fetch missing URL ─────────────────────────
1756
1757    #[test]
1758    fn fetch_tarball_missing_url_errors() {
1759        let tmp = tempfile::tempdir().unwrap();
1760        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1761        let locked = make_locked("tarball");
1762        let result = fetcher.fetch(&locked);
1763        assert!(result.is_err());
1764        assert!(result.unwrap_err().to_string().contains("url"));
1765    }
1766
1767    // ── extract_tar_gz ────────────────────────────────────
1768
1769    #[test]
1770    fn extract_tar_gz_empty_archive_errors() {
1771        let tmp = tempfile::tempdir().unwrap();
1772        let result = extract_tar_gz(&[], tmp.path());
1773        assert!(result.is_err());
1774    }
1775
1776    #[test]
1777    fn extract_tar_gz_invalid_data_errors() {
1778        let tmp = tempfile::tempdir().unwrap();
1779        let result = extract_tar_gz(b"not a gzip stream at all", tmp.path());
1780        assert!(result.is_err());
1781    }
1782
1783    // ── dest_dir logic ────────────────────────────────────
1784
1785    #[test]
1786    fn dest_dir_uses_nar_hash_when_present() {
1787        let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
1788        let mut locked = make_locked("github");
1789        locked.nar_hash = Some("sha256-ABC123=".to_string());
1790        let dest = fetcher.dest_dir(&locked, "fallback");
1791        assert!(dest.to_string_lossy().contains("sha256-ABC123"));
1792        assert!(!dest.to_string_lossy().contains("fallback"));
1793    }
1794
1795    #[test]
1796    fn dest_dir_uses_fallback_when_no_hash() {
1797        let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
1798        let locked = make_locked("github");
1799        let dest = fetcher.dest_dir(&locked, "fallback-name");
1800        assert!(dest.to_string_lossy().contains("fallback-name"));
1801    }
1802
1803    // ── is_non_empty_dir ─────────────────────────────────
1804
1805    #[test]
1806    fn is_non_empty_dir_returns_true_for_non_empty() {
1807        let tmp = tempfile::tempdir().unwrap();
1808        std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1809        assert!(is_non_empty_dir(tmp.path()));
1810    }
1811
1812    #[test]
1813    fn is_non_empty_dir_returns_false_for_empty() {
1814        let tmp = tempfile::tempdir().unwrap();
1815        assert!(!is_non_empty_dir(tmp.path()));
1816    }
1817
1818    #[test]
1819    fn is_non_empty_dir_returns_false_for_missing() {
1820        assert!(!is_non_empty_dir(Path::new("/nonexistent/path/12345")));
1821    }
1822
1823    // ── empty cache invalidation ─────────────────────────
1824
1825    #[test]
1826    fn empty_cache_dir_is_treated_as_miss() {
1827        let tmp = tempfile::tempdir().unwrap();
1828        let cache_dir = tmp.path().join("cache");
1829        std::fs::create_dir_all(&cache_dir).unwrap();
1830
1831        // Pre-create an *empty* cache directory (simulates a failed fetch).
1832        let hash = "sha256-EMPTYTEST";
1833        let cached_dir = cache_dir.join(sanitize_hash(hash));
1834        std::fs::create_dir_all(&cached_dir).unwrap();
1835        // Verify the directory is empty.
1836        assert!(std::fs::read_dir(&cached_dir).unwrap().next().is_none());
1837
1838        let fetcher = InputFetcher::with_cache_dir(cache_dir);
1839        let mut locked = make_locked("github");
1840        locked.nar_hash = Some(hash.to_string());
1841        // owner/repo/rev are missing, so the re-fetch will fail — but
1842        // the important thing is that the cache miss was detected (the
1843        // stale directory was removed) and the code attempted a fresh fetch.
1844        let result = fetcher.fetch(&locked);
1845        assert!(result.is_err(), "should not return stale empty cache");
1846        // The empty directory should have been cleaned up.
1847        assert!(!cached_dir.exists(), "stale cache dir should be removed");
1848    }
1849
1850    // ── github_tarball_from_git_url ──────────────────────
1851
1852    #[test]
1853    fn tarball_from_https_github() {
1854        let url = github_tarball_from_git_url(
1855            "https://github.com/NixOS/nixpkgs.git",
1856            "abc123",
1857        );
1858        assert_eq!(
1859            url.as_deref(),
1860            Some("https://github.com/NixOS/nixpkgs/archive/abc123.tar.gz")
1861        );
1862    }
1863
1864    #[test]
1865    fn tarball_from_git_plus_https() {
1866        let url = github_tarball_from_git_url(
1867            "git+https://github.com/NixOS/nixpkgs",
1868            "def456",
1869        );
1870        assert_eq!(
1871            url.as_deref(),
1872            Some("https://github.com/NixOS/nixpkgs/archive/def456.tar.gz")
1873        );
1874    }
1875
1876    #[test]
1877    fn tarball_from_non_github_returns_none() {
1878        assert!(github_tarball_from_git_url("https://gitlab.com/foo/bar.git", "abc").is_none());
1879        assert!(github_tarball_from_git_url("ssh://git@github.com/foo/bar", "abc").is_none());
1880    }
1881
1882    #[test]
1883    fn tarball_from_malformed_path_returns_none() {
1884        assert!(github_tarball_from_git_url("https://github.com/", "abc").is_none());
1885        assert!(github_tarball_from_git_url("https://github.com/only-owner", "abc").is_none());
1886    }
1887}
1888
1889
1890/// Turn a parsed flake reference into a directory on disk, fetching it first
1891/// if it is remote.
1892///
1893/// ── ★ ONE PLACE, BECAUSE THERE ARE THREE CALLERS ────────────────────────
1894/// `evaluate_flake` takes a `&Path`, so every entry point that accepts a
1895/// `--flake` argument has to answer "where is it?" — `sui-orchestrate`'s
1896/// `build_toplevel` and two sites in the `sui` CLI. Written per-caller, the
1897/// remote case would be right in whichever one was being fixed and missing in
1898/// the others, which is precisely how `github:` refs came to work in some
1899/// paths and not the one the fleet reconciler uses.
1900///
1901/// A local ref costs nothing here. A remote one is content-addressed and
1902/// cached by the same fetcher that pulls locked flake inputs, so re-resolving
1903/// the same rev does no network.
1904///
1905/// # Errors
1906///
1907/// Returns [`FetchError`] when a remote source cannot be fetched or
1908/// extracted.
1909pub fn resolve_flake_dir(flake_ref: &FlakeRef) -> Result<std::path::PathBuf, FetchError> {
1910    match flake_ref.local_dir() {
1911        Some(p) => Ok(p.to_path_buf()),
1912        None => {
1913            let locked = flake_ref
1914                .source
1915                .locked_input()
1916                .ok_or_else(|| FetchError::UnsupportedType("non-fetchable flake source".into()))?;
1917            InputFetcher::new().fetch(&locked)
1918        }
1919    }
1920}