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#[derive(Debug, thiserror::Error)]
17pub enum FetchError {
18    #[error("unsupported input type: {0}")]
19    UnsupportedType(String),
20    #[error("missing required field: {0}")]
21    MissingField(&'static str),
22    #[error("download failed: {0}")]
23    Download(String),
24    #[error("I/O error: {0}")]
25    Io(#[from] std::io::Error),
26    #[error("archive extraction failed: {0}")]
27    Extract(String),
28}
29
30// ── InputFetcher ──────────────────────────────────────────────
31
32/// A content-addressed input fetcher that downloads and caches flake inputs.
33///
34/// Inputs are cached under `~/.cache/sui/inputs/` (or a custom directory)
35/// keyed by their `narHash` from the lock file. Cache hits skip network
36/// access entirely.
37pub struct InputFetcher {
38    cache_dir: PathBuf,
39}
40
41impl Default for InputFetcher {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl InputFetcher {
48    /// Create a fetcher using the default cache directory (`~/.cache/sui/inputs/`).
49    #[must_use]
50    pub fn new() -> Self {
51        let cache_dir = dirs_cache_dir().join("sui/inputs");
52        Self { cache_dir }
53    }
54
55    /// Create a fetcher with a custom cache directory.
56    #[must_use]
57    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
58        Self { cache_dir }
59    }
60
61    /// Return the cache directory path.
62    #[must_use]
63    pub fn cache_dir(&self) -> &Path {
64        &self.cache_dir
65    }
66
67    /// Fetch a locked input and return the local filesystem path.
68    ///
69    /// Uses content-addressed caching by `narHash` — if the hash is present
70    /// and a cached directory exists, returns immediately without network access.
71    pub fn fetch(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
72        // Check cache first (keyed by narHash).
73        if let Some(ref nar_hash) = locked.nar_hash {
74            let cache_key = sanitize_hash(nar_hash);
75            let cached = self.cache_dir.join(&cache_key);
76            if cached.exists() {
77                let resolved = find_single_subdir_or_self(&cached);
78                // Validate the cache entry is non-empty.  A previous fetch may
79                // have created the directory but failed before extracting any
80                // content (e.g. network timeout).  Treat empty dirs as cache
81                // misses so the fetch is retried.
82                if is_non_empty_dir(&resolved) {
83                    return Ok(resolved);
84                }
85                // Cache entry is empty/invalid — remove it and re-fetch.
86                let _ = std::fs::remove_dir_all(&cached);
87            }
88        }
89
90        match locked.source_type.as_str() {
91            "github" => self.fetch_github(locked),
92            "gitlab" => self.fetch_gitlab(locked),
93            "sourcehut" => self.fetch_sourcehut(locked),
94            "path" => Self::fetch_path(locked),
95            "git" => self.fetch_git(locked),
96            "tarball" | "file" => self.fetch_tarball(locked),
97            other => Err(FetchError::UnsupportedType(other.to_string())),
98        }
99    }
100
101    /// Construct the GitHub archive URL for a locked input.
102    #[must_use]
103    pub fn github_archive_url(owner: &str, repo: &str, rev: &str) -> String {
104        format!("https://github.com/{owner}/{repo}/archive/{rev}.tar.gz")
105    }
106
107    /// GitLab archive URL.  Shape differs from GitHub — the file
108    /// name embeds the repo + rev and lives under `/-/archive/{rev}/`.
109    /// Honors `host` so self-hosted gitlab instances (e.g.
110    /// `gitlab.gnome.org`, `git.example.com`) work; defaults to
111    /// `gitlab.com` when host is None.
112    #[must_use]
113    pub fn gitlab_archive_url(host: Option<&str>, owner: &str, repo: &str, rev: &str) -> String {
114        let host = host.unwrap_or("gitlab.com");
115        format!(
116            "https://{host}/{owner}/{repo}/-/archive/{rev}/{repo}-{rev}.tar.gz"
117        )
118    }
119
120    /// Sourcehut archive URL. Owners carry the `~` prefix on the
121    /// platform; the flake-ref parser stores them without the prefix,
122    /// so we prepend here.
123    #[must_use]
124    pub fn sourcehut_archive_url(owner: &str, repo: &str, rev: &str) -> String {
125        let owner_prefix = if owner.starts_with('~') {
126            owner.to_string()
127        } else {
128            format!("~{owner}")
129        };
130        format!("https://git.sr.ht/{owner_prefix}/{repo}/archive/{rev}.tar.gz")
131    }
132
133    // ── Private fetch methods ─────────────────────────────
134
135    fn fetch_github(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
136        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
137        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
138        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
139
140        let url = Self::github_archive_url(owner, repo, rev);
141        // Was a hand-inlined copy of `fetch_archive`'s body — the only copy of
142        // the three that lacked a cache guard, which is exactly how it came to
143        // re-download on every invocation. Sharing the body is the fix for the
144        // class; the guard below is the fix for the instance.
145        self.fetch_archive(locked, &url, &format!("github-{owner}-{repo}-{rev}"), rev)
146    }
147
148    /// GitHub, GitLab and Sourcehut share one archive-fetch shape — download a
149    /// tar.gz, extract, return the single top-level directory. Only the URL
150    /// construction differs.
151    ///
152    /// ── ★ STAGE THEN RENAME; NEVER EXTRACT INTO THE FINAL PATH ────────────
153    /// This used to `create_dir_all(dest)` and extract straight into it, which
154    /// produced three distinct defects from one decision:
155    ///
156    /// 1. **A partial tree is a valid cache hit.** The hit predicate is "the
157    ///    directory is non-empty", which goes true on the FIRST tar entry, so a
158    ///    concurrent process could adopt a half-extracted tree and evaluate it
159    ///    as if complete — a silently wrong eval, not an error.
160    /// 2. **A re-extraction UNIONS.** `tar` runs with `overwrite: true`, so
161    ///    extracting a second time over an existing tree leaves files that the
162    ///    newer tree deleted. Content at a "content-addressed" path then
163    ///    disagrees with the hash in its own name.
164    /// 3. **A failing process deleted another process's good cache entry.**
165    ///    Every error path called `remove_dir_all(&dest)` — on the FINAL path.
166    ///    A transient network error during a redundant re-fetch would wipe a
167    ///    complete tree that another eval was actively reading.
168    ///
169    /// Defect 2 is what poisoned `~/.cache/sui/nar-memo` and made `getFlake`
170    /// return a store path CppNix disagrees with (measured 2026-08-17; see
171    /// `sui-compat/src/source.rs`'s memo verifier, which is the read-side
172    /// defence this is the write-side cause of).
173    ///
174    /// Staging beside the target rather than in `/tmp` keeps the rename on one
175    /// filesystem, where it is atomic — the same reason `sui-castore`'s local
176    /// storage stages beside its target.
177    fn fetch_archive(
178        &self,
179        locked: &LockedInput,
180        url: &str,
181        cache_key: &str,
182        rev: &str,
183    ) -> Result<PathBuf, FetchError> {
184        let dest = self.dest_dir(locked, cache_key);
185
186        // ── The cache guard, and why it is conditional ────────────────────
187        // A rev that is a 40/64-hex commit names one immutable tree, so a
188        // complete directory at `dest` can be adopted with no network at all.
189        // A rev that is a BRANCH NAME does not: `github:owner/repo/main` is a
190        // legal ref (CppNix accepts it, so refusing it would be a parity
191        // divergence, not a safety win) and the tree behind it moves. Guarding
192        // unconditionally would freeze such an entry at whatever `main` was
193        // the first time it was fetched, forever.
194        //
195        // So: immutable revs are cached, mutable ones are always re-fetched.
196        // The old code re-fetched BOTH, which was wasteful for the first and
197        // accidentally correct for the second.
198        if is_immutable_rev(rev) && is_non_empty_dir(&dest) {
199            return Ok(find_single_subdir_or_self(&dest));
200        }
201
202        let staging = staging_path(&dest);
203        // A leftover staging dir means a previous process died mid-extract.
204        // It is ours to clear: the name carries our pid.
205        let _ = std::fs::remove_dir_all(&staging);
206        std::fs::create_dir_all(&staging)?;
207
208        let bytes = match download_bytes(url) {
209            Ok(b) => b,
210            Err(e) => {
211                let _ = std::fs::remove_dir_all(&staging);
212                return Err(e);
213            }
214        };
215        if let Err(e) = extract_tar_gz(&bytes, &staging) {
216            let _ = std::fs::remove_dir_all(&staging);
217            return Err(e);
218        }
219
220        publish(&staging, &dest, is_immutable_rev(rev))?;
221        Ok(find_single_subdir_or_self(&dest))
222    }
223
224    fn fetch_gitlab(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
225        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
226        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
227        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
228        let host = locked.host.as_deref();
229        let url = Self::gitlab_archive_url(host, owner, repo, rev);
230        let host_tag = host.unwrap_or("gitlab.com").replace('.', "_");
231        self.fetch_archive(locked, &url, &format!("gitlab-{host_tag}-{owner}-{repo}-{rev}"), rev)
232    }
233
234    fn fetch_sourcehut(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
235        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
236        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
237        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
238        let url = Self::sourcehut_archive_url(owner, repo, rev);
239        let sanitized_owner = owner.trim_start_matches('~');
240        self.fetch_archive(
241            locked,
242            &url,
243            &format!("sourcehut-{sanitized_owner}-{repo}-{rev}"),
244            rev,
245        )
246    }
247
248    fn fetch_path(locked: &LockedInput) -> Result<PathBuf, FetchError> {
249        let path = locked
250            .path
251            .as_deref()
252            .ok_or(FetchError::MissingField("path"))?;
253        Ok(PathBuf::from(path))
254    }
255
256    fn fetch_git(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
257        let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
258        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
259
260        let short_rev: String = rev.chars().take(12).collect();
261        let dest = self.dest_dir(locked, &format!("git-{short_rev}"));
262
263        // The cache key embeds the rev, so a full object id names one tree and
264        // a present one is adoptable. Same conditional as the archive path.
265        let immutable = is_immutable_rev(rev);
266        if immutable && is_non_empty_dir(&dest) {
267            return Ok(dest);
268        }
269
270        // Everything below builds the tree in a staging dir and publishes it
271        // with one rename. This path was left out of the first stage-then-
272        // rename pass, so until now a killed clone or a killed unpack left a
273        // partial tree at the FINAL path that the non-empty predicate above
274        // then accepted as a complete cache hit.
275        let staging = staging_path(&dest);
276        let _ = std::fs::remove_dir_all(&staging);
277
278        // Try GitHub tarball first (avoids git CLI dependency in containers).
279        // Most git-type inputs in flake.lock are GitHub repos that support
280        // archive downloads via /archive/{rev}.tar.gz.
281        if let Some(tarball_url) = github_tarball_from_git_url(url, rev) {
282            std::fs::create_dir_all(&staging)?;
283            match download_bytes(&tarball_url) {
284                Ok(bytes) => {
285                    if let Err(e) = extract_tar_gz(&bytes, &staging) {
286                        let _ = std::fs::remove_dir_all(&staging);
287                        return Err(e);
288                    }
289                    publish(&staging, &dest, immutable)?;
290                    return Ok(find_single_subdir_or_self(&dest));
291                }
292                Err(e) => {
293                    // Tarball fallback failed — try git CLI below.
294                    let _ = std::fs::remove_dir_all(&staging);
295                    tracing::debug!(url = %tarball_url, error = %e, "Tarball fallback failed, trying git CLI");
296                }
297            }
298        }
299
300        // Fall back to git CLI for non-GitHub repos or when tarball fails.
301        let status = std::process::Command::new("git")
302            .args(["clone", "--depth", "1", url])
303            .arg(&staging)
304            .stdout(std::process::Stdio::null())
305            .stderr(std::process::Stdio::null())
306            .status()
307            .map_err(|e| FetchError::Download(format!(
308                "git clone failed (git not in PATH?): {e}"
309            )))?;
310        if !status.success() {
311            let _ = std::fs::remove_dir_all(&staging);
312            return Err(FetchError::Download(format!(
313                "git clone failed for {url} (exit code: {})",
314                status.code().unwrap_or(-1)
315            )));
316        }
317
318        // Checkout the exact revision.
319        //
320        // NOTE, unverified and flagged rather than fixed here: the clone above
321        // is `--depth 1` of the DEFAULT BRANCH, so an arbitrary `rev` is very
322        // likely not among the objects it fetched, and this checkout would
323        // fail for any non-HEAD rev. That belongs to whoever owns `git.rs`.
324        if let Err(e) = crate::git::checkout_rev(&staging, rev) {
325            let _ = std::fs::remove_dir_all(&staging);
326            return Err(FetchError::Download(format!("git checkout {rev}: {e}")));
327        }
328
329        publish(&staging, &dest, immutable)?;
330        Ok(dest)
331    }
332
333    fn fetch_tarball(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
334        let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
335
336        let hash_suffix = locked
337            .nar_hash
338            .as_deref()
339            .map_or_else(|| url_to_safe_name(url), sanitize_hash);
340        let dest = self.dest_dir(locked, &format!("tarball-{hash_suffix}"));
341
342        // A tarball input is keyed on its narHash when it has one, which IS a
343        // content address, so a present tree is adoptable. When it has none
344        // the key is derived from the URL, which is mutable — same split as
345        // `is_immutable_rev` on the archive path.
346        let immutable = locked.nar_hash.is_some();
347        if immutable && is_non_empty_dir(&dest) {
348            return Ok(find_single_subdir_or_self(&dest));
349        }
350
351        // Stage then publish, exactly as `fetch_archive` does. This path was
352        // left behind by the first pass at that fix, so until now a killed
353        // `tarball:`/`file:` fetch could leave a partial tree that the
354        // non-empty predicate then accepted as a cache hit.
355        let staging = staging_path(&dest);
356        let _ = std::fs::remove_dir_all(&staging);
357        std::fs::create_dir_all(&staging)?;
358
359        let bytes = match download_bytes(url) {
360            Ok(b) => b,
361            Err(e) => {
362                let _ = std::fs::remove_dir_all(&staging);
363                return Err(e);
364            }
365        };
366        if let Err(e) = extract_tar_gz(&bytes, &staging) {
367            let _ = std::fs::remove_dir_all(&staging);
368            return Err(e);
369        }
370
371        publish(&staging, &dest, immutable)?;
372        Ok(find_single_subdir_or_self(&dest))
373    }
374
375    /// Compute the destination directory, preferring narHash-based names.
376    fn dest_dir(&self, locked: &LockedInput, fallback: &str) -> PathBuf {
377        if let Some(ref nar_hash) = locked.nar_hash {
378            self.cache_dir.join(sanitize_hash(nar_hash))
379        } else {
380            self.cache_dir.join(fallback)
381        }
382    }
383}
384
385// ── Helpers ───────────────────────────────────────────────────
386
387/// Try to convert a git URL to a GitHub tarball URL.
388///
389/// `https://github.com/NixOS/nixpkgs.git` + rev → `https://github.com/NixOS/nixpkgs/archive/{rev}.tar.gz`
390/// Returns `None` for non-GitHub URLs.
391fn github_tarball_from_git_url(url: &str, rev: &str) -> Option<String> {
392    let stripped = url
393        .strip_prefix("https://github.com/")
394        .or_else(|| url.strip_prefix("git+https://github.com/"))
395        .or_else(|| url.strip_prefix("http://github.com/"))?;
396    let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
397    // Validate it looks like owner/repo (no extra path segments)
398    let parts: Vec<&str> = stripped.split('/').collect();
399    if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
400        Some(format!(
401            "https://github.com/{}/{}/archive/{rev}.tar.gz",
402            parts[0], parts[1]
403        ))
404    } else {
405        None
406    }
407}
408
409/// Turn a narHash like `sha256-AAAA...=` into a filesystem-safe name.
410/// Turn a hash into a single safe path component.
411///
412/// ── ★ THE SUBSTITUTIONS ARE NOT A VALIDATION ──────────────────────────
413/// `:`→`-`, `/`→`_`, drop `=` makes a hash *look* like a filename; it does not
414/// make it *one*. `narHash` comes from a `flake.lock`, which is untrusted
415/// input for any flake you did not write yourself, and three values survive
416/// the transliteration as meaningful path components: `..`, `.` and `""`.
417///
418/// Because `/` is mapped away, a multi-level escape is impossible — the blast
419/// radius is exactly ONE level, and it should not be rounded up to arbitrary
420/// path deletion. One level is bad enough: `"narHash": ".."` makes the cache
421/// destination `<cache>/inputs/..` = `~/.cache/sui`, so (a) `fetch` returns
422/// `~/.cache/sui` AS the flake's source directory — a silently wrong eval with
423/// no error — and (b) on a miss, publishing `remove_dir_all`s it, taking
424/// `inputs/` and `nar-memo/` with it. That is the same memo whose poisoning
425/// `sui-compat/src/source.rs` was hardened against today.
426///
427/// So the component is validated, not merely transliterated: anything that is
428/// not a plain `[A-Za-z0-9._+-]` run, or that is `.`/`..`/empty, is replaced
429/// by a fixed-width digest of the input. Fixed-width by construction beats a
430/// denylist, which is what the transliteration was.
431fn sanitize_hash(hash: &str) -> String {
432    let mapped = hash.replace(':', "-").replace('/', "_").replace('=', "");
433    let shaped = !mapped.is_empty()
434        && mapped != "."
435        && mapped != ".."
436        && mapped
437            .bytes()
438            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-'));
439    if shaped {
440        mapped
441    } else {
442        // Deterministic, collision-resistant, and structurally incapable of
443        // being a traversal: hex has no `.` and no `/`.
444        use sha2::Digest as _;
445        let d = sha2::Sha256::digest(hash.as_bytes());
446        let mut out = String::with_capacity(2 + 64);
447        out.push_str("h-");
448        for b in d {
449            use std::fmt::Write as _;
450            let _ = write!(out, "{b:02x}");
451        }
452        out
453    }
454}
455
456/// Whether `rev` names one immutable tree — a full git object id.
457///
458/// 40 hex for sha1, 64 for the sha256 transition. Anything else (a branch, a
459/// tag, a short rev) can move, so it must never be served from cache without a
460/// network check. Lowercase only: git emits lowercase, and accepting mixed case
461/// would let `ABC…` and `abc…` occupy two cache entries for one tree.
462fn is_immutable_rev(rev: &str) -> bool {
463    matches!(rev.len(), 40 | 64) && rev.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
464}
465
466/// A scratch path beside `dest`, on the same filesystem so the publish rename
467/// is atomic.
468///
469/// ── ★ PID IS NOT ENOUGH; THE THREAD ID IS PART OF THE KEY ─────────────
470/// An earlier version scoped this on the pid alone and claimed that "two
471/// concurrent fetchers cannot share a staging dir". That is true across
472/// processes and FALSE within one: two threads of the same process fetching
473/// the same input compute the same staging path, and the second one's
474/// `remove_dir_all(&staging)` fires while the first is mid-unpack — so the
475/// first then publishes a TRUNCATED tree, reintroducing exactly the defect
476/// the staging dance exists to prevent.
477///
478/// Latent today (there is no `rayon`/`par_iter` in the eval path), and it
479/// detonates the moment anyone parallelizes input fetching, which is the
480/// obvious next optimization on a lock file with N inputs. A claim that is
481/// true only until someone does the obvious thing is not an invariant.
482fn staging_path(dest: &Path) -> PathBuf {
483    let name = dest
484        .file_name()
485        .map_or_else(|| "fetch".to_string(), |n| n.to_string_lossy().into_owned());
486    // `ThreadId`'s Debug is the only stable accessor on stable Rust; it
487    // renders as `ThreadId(N)`, so keep the digits and drop the rest.
488    let tid = format!("{:?}", std::thread::current().id());
489    let tid: String = tid.chars().filter(char::is_ascii_digit).collect();
490    let tmp = [
491        ".",
492        &name,
493        ".tmp-",
494        &std::process::id().to_string(),
495        "-",
496        &tid,
497    ]
498    .concat();
499    dest.parent()
500        .map_or_else(|| PathBuf::from(&tmp), |p| p.join(&tmp))
501}
502
503/// Move `staging` onto `dest` without ever leaving `dest` observably absent
504/// for longer than one rename syscall.
505///
506/// ── ★ WHY NOT `remove_dir_all(dest)` THEN RENAME ──────────────────────
507/// That was the first version, and it is a regression dressed as a fix. For a
508/// MUTABLE rev the guard above never short-circuits, so every invocation
509/// deleted the published tree and re-created it — meaning a concurrent eval
510/// reading that path got ENOENT for the whole duration of a recursive delete
511/// of (measured on `pleme-io/nix`) 654 files. The staging dance had narrowed
512/// the failure from "adopt a partial tree" to "have a complete tree yanked",
513/// which is better and is still a bug.
514///
515/// Two cases, and neither deletes in place:
516///
517/// - **Immutable rev, tree already present.** Another process published the
518///   same content-addressed tree. Theirs is by definition ours; adopt it and
519///   drop our staging. No delete of `dest` at all.
520/// - **Otherwise.** Rename the old tree ASIDE (one syscall), rename the new
521///   one in, then delete the aside at leisure. `dest` is unresolvable only
522///   between two renames rather than for the length of a tree walk.
523fn publish(staging: &Path, dest: &Path, immutable: bool) -> Result<(), FetchError> {
524    if immutable && is_non_empty_dir(dest) {
525        let _ = std::fs::remove_dir_all(staging);
526        return Ok(());
527    }
528
529    let aside = with_suffix(staging, ".old");
530    let _ = std::fs::remove_dir_all(&aside);
531    let moved_aside = dest.exists() && std::fs::rename(dest, &aside).is_ok();
532
533    match std::fs::rename(staging, dest) {
534        Ok(()) => {
535            if moved_aside {
536                let _ = std::fs::remove_dir_all(&aside);
537            }
538            Ok(())
539        }
540        Err(_) => {
541            // Put the old tree back rather than leaving the cache emptier
542            // than we found it.
543            if moved_aside && !dest.exists() {
544                let _ = std::fs::rename(&aside, dest);
545            }
546            let _ = std::fs::remove_dir_all(staging);
547            let _ = std::fs::remove_dir_all(&aside);
548            if is_non_empty_dir(dest) {
549                // Lost the race; the winner left a good tree.
550                Ok(())
551            } else {
552                Err(FetchError::Extract(
553                    "could not publish the fetched tree and no other process left one".into(),
554                ))
555            }
556        }
557    }
558}
559
560/// `path` with `suffix` appended to its file name (not `with_extension`,
561/// which truncates at the last dot and would mangle `repo-1.2.3`).
562fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
563    let name = path
564        .file_name()
565        .map_or_else(|| "x".to_string(), |n| n.to_string_lossy().into_owned());
566    path.parent().map_or_else(
567        || PathBuf::from([&name, suffix].concat()),
568        |p| p.join([&name, suffix].concat()),
569    )
570}
571
572/// Return `true` when `dir` exists and has at least one child entry.
573fn is_non_empty_dir(dir: &Path) -> bool {
574    std::fs::read_dir(dir)
575        .ok()
576        .is_some_and(|mut rd| rd.next().is_some())
577}
578
579/// If the directory contains exactly one child directory (common for GitHub
580/// tarballs which unpack as `repo-rev/`), return that child. Otherwise
581/// return the directory itself.
582fn find_single_subdir_or_self(dir: &Path) -> PathBuf {
583    let entries: Vec<_> = std::fs::read_dir(dir)
584        .ok()
585        .into_iter()
586        .flatten()
587        .filter_map(|e| e.ok())
588        .collect();
589    if entries.len() == 1 && entries[0].path().is_dir() {
590        entries[0].path()
591    } else {
592        dir.to_path_buf()
593    }
594}
595
596/// Download a URL and return the raw bytes.
597///
598/// Uses `ureq` (synchronous, no tokio runtime) so this function is safe to
599/// call from inside a running tokio context — no nested-runtime panic.
600///
601/// Body limit raised to 512 MiB to accommodate large inputs like nixpkgs tarballs.
602fn download_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
603    let mut req = ureq::get(url);
604
605    // Attach a host-appropriate auth token when one is available.
606    // CppNix consults `~/.config/nix/nix.conf` `access-tokens =
607    // github.com=<TOKEN>` etc.; we keep parity by reading the same
608    // sources plus the common `GITHUB_TOKEN` env (gh CLI, nix-darwin
609    // shell init).  Without this the operator's private flake
610    // inputs (e.g. `arnes`) 404 unauthenticated.
611    if let Some(token) = github_token_for_url(url) {
612        req = req.header("Authorization", &format!("token {token}"));
613    }
614
615    let mut response = req
616        .call()
617        .map_err(|e| FetchError::Download(format!("{url}: {e}")))?;
618
619    if !response.status().is_success() {
620        return Err(FetchError::Download(format!(
621            "{url}: HTTP {}",
622            response.status().as_u16()
623        )));
624    }
625
626    response
627        .body_mut()
628        .with_config()
629        .limit(512 * 1024 * 1024)
630        .read_to_vec()
631        .map_err(|e| FetchError::Download(format!("{url}: {e}")))
632}
633
634/// Resolve a host-appropriate auth token for outgoing requests.
635///
636/// Sources, in order:
637///   1. `GITHUB_TOKEN` env var (covers gh CLI exports + CI tokens).
638///   2. `NIX_CONFIG` env var, parsed for `access-tokens` line.
639///   3. `~/.config/nix/nix.conf` parsed for `access-tokens` line.
640///   4. `~/.config/gh/hosts.yml` (`oauth_token:` field for github.com).
641///
642/// Returns `Some(token)` only for github.com URLs in this iteration —
643/// gitlab / sr.ht / private git hosts can be added when needed.
644fn github_token_for_url(url: &str) -> Option<String> {
645    if !url.starts_with("https://github.com/")
646        && !url.starts_with("https://api.github.com/")
647    {
648        return None;
649    }
650    if let Ok(t) = std::env::var("GITHUB_TOKEN") {
651        if !t.is_empty() {
652            return Some(t);
653        }
654    }
655    if let Ok(cfg) = std::env::var("NIX_CONFIG") {
656        if let Some(t) = parse_access_tokens(&cfg, "github.com") {
657            return Some(t);
658        }
659    }
660    if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
661        let nix_conf = home.join(".config/nix/nix.conf");
662        if let Ok(cfg) = std::fs::read_to_string(&nix_conf) {
663            if let Some(t) = parse_access_tokens(&cfg, "github.com") {
664                return Some(t);
665            }
666        }
667        let gh_hosts = home.join(".config/gh/hosts.yml");
668        if let Ok(yml) = std::fs::read_to_string(&gh_hosts) {
669            if let Some(t) = parse_gh_hosts_token(&yml, "github.com") {
670                return Some(t);
671            }
672        }
673    }
674    None
675}
676
677/// Parse a `~/.config/nix/nix.conf`-style `access-tokens = host=TOKEN ...`
678/// line and return the token for `host` if present.
679fn parse_access_tokens(cfg: &str, host: &str) -> Option<String> {
680    for line in cfg.lines() {
681        let trimmed = line.trim();
682        if let Some(rest) = trimmed.strip_prefix("access-tokens") {
683            let rest = rest.trim_start().trim_start_matches('=').trim();
684            for pair in rest.split_whitespace() {
685                if let Some((h, t)) = pair.split_once('=') {
686                    if h == host {
687                        return Some(t.to_string());
688                    }
689                }
690            }
691        }
692    }
693    None
694}
695
696/// Parse `~/.config/gh/hosts.yml` and return the `oauth_token:` value
697/// nested under the given host key.  We do this without a full YAML
698/// parser to keep sui-eval's dep footprint small — the file is a
699/// stable 5-line shape gh maintains.
700fn parse_gh_hosts_token(yml: &str, host: &str) -> Option<String> {
701    let mut in_host = false;
702    for line in yml.lines() {
703        let raw = line;
704        let trimmed = raw.trim();
705        if trimmed.starts_with(host) && trimmed.ends_with(':') {
706            in_host = true;
707            continue;
708        }
709        if !raw.starts_with(' ') && !raw.starts_with('\t') && !trimmed.is_empty() {
710            in_host = false;
711        }
712        if in_host {
713            if let Some(rest) = trimmed.strip_prefix("oauth_token:") {
714                return Some(rest.trim().to_string());
715            }
716        }
717    }
718    None
719}
720
721/// Extract a `.tar.gz` archive into a destination directory.
722fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<(), FetchError> {
723    let gz = flate2::read::GzDecoder::new(bytes);
724
725    // Check if the gzip header is valid before attempting extraction.
726    // An empty or non-gzip payload would fail inside tar::Archive.
727    let mut buffered = std::io::BufReader::new(gz);
728    let mut peek = [0u8; 1];
729    // Try reading one byte to detect decompression errors early.
730    match buffered.read(&mut peek) {
731        Ok(0) => {
732            return Err(FetchError::Extract("empty archive".into()));
733        }
734        Err(e) => {
735            return Err(FetchError::Extract(format!("gzip decompression: {e}")));
736        }
737        Ok(_) => {
738            // Put the byte back by chaining it in front of the reader.
739            let cursor = std::io::Cursor::new(peek);
740            let chain = cursor.chain(buffered);
741            let mut archive = tar::Archive::new(chain);
742            archive
743                .unpack(dest)
744                .map_err(|e| FetchError::Extract(format!("tar unpack: {e}")))?;
745        }
746    }
747
748    Ok(())
749}
750
751/// Convert a URL into a filesystem-safe name (for fallback cache keys).
752fn url_to_safe_name(url: &str) -> String {
753    url.chars()
754        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
755        .collect()
756}
757
758/// Platform-aware cache directory discovery.
759fn dirs_cache_dir() -> PathBuf {
760    // Try XDG_CACHE_HOME first, then platform default, then /tmp.
761    // Absolute, not merely non-empty — see eval_cache.rs for the class.
762    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
763        .map(PathBuf::from)
764        .filter(|p| p.is_absolute())
765    {
766        return xdg;
767    }
768    if let Some(home) = std::env::var_os("HOME")
769        .map(PathBuf::from)
770        .filter(|p| p.is_absolute())
771    {
772        let default = home.join(".cache");
773        if default.exists() || std::fs::create_dir_all(&default).is_ok() {
774            return default;
775        }
776    }
777    PathBuf::from("/tmp")
778}
779
780// ── Tests ─────────────────────────────────────────────────────
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use std::collections::BTreeMap;
786
787    /// Helper: build a `LockedInput` with the given fields.
788    fn make_locked(source_type: &str) -> LockedInput {
789        LockedInput {
790            source_type: source_type.to_string(),
791            owner: None,
792            repo: None,
793            rev: None,
794            nar_hash: None,
795            last_modified: None,
796            path: None,
797            url: None,
798            git_ref: None,
799            dir: None,
800            host: None,
801            extra: BTreeMap::new(),
802        }
803    }
804
805    // ── sanitize_hash ─────────────────────────────────────
806
807    #[test]
808    fn sanitize_hash_replaces_special_chars() {
809        assert_eq!(
810            sanitize_hash("sha256-AAAAAAAAAAAAAAAAAAAAAA="),
811            "sha256-AAAAAAAAAAAAAAAAAAAAAA"
812        );
813        assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
814    }
815
816    // ── sanitize_hash — a component, not a transliteration ──
817
818    #[test]
819    fn a_traversal_hash_cannot_become_a_path_component() {
820        // `narHash` is lock-file input. `..` survives the substitutions and
821        // would make the cache dest `<cache>/inputs/..` = `~/.cache/sui`,
822        // which then gets returned AS the flake source and, on a miss,
823        // remove_dir_all'd — taking `inputs/` and `nar-memo/` with it.
824        for hostile in ["..", ".", "", "../..", "..\u{0}"] {
825            let s = sanitize_hash(hostile);
826            assert!(
827                s != ".." && s != "." && !s.is_empty(),
828                "{hostile:?} sanitized to {s:?}, still a meaningful component"
829            );
830            assert!(
831                !s.contains('/') && !s.contains('\\'),
832                "{hostile:?} sanitized to {s:?}, still a separator"
833            );
834        }
835        // Deterministic — the same input must key the same directory.
836        assert_eq!(sanitize_hash(".."), sanitize_hash(".."));
837        // …and distinct inputs must not collide onto one entry.
838        assert_ne!(sanitize_hash(".."), sanitize_hash("."));
839    }
840
841    #[test]
842    fn a_well_formed_hash_is_untouched_by_the_guard() {
843        // The guard must not change the key for ordinary input, or every
844        // existing cache entry is orphaned on upgrade.
845        assert_eq!(
846            sanitize_hash("sha256-avzRM+ffKgikqMRcOhhYp3ifgwXMGbH0rEGEZPEGMYE="),
847            "sha256-avzRM+ffKgikqMRcOhhYp3ifgwXMGbH0rEGEZPEGMYE"
848        );
849        assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
850    }
851
852    // ── publish — never leave `dest` absent during a tree walk ──
853
854    #[test]
855    fn publishing_an_immutable_tree_adopts_the_winner_and_deletes_nothing() {
856        let tmp = tempfile::tempdir().unwrap();
857        let dest = tmp.path().join("github-o-r-deadbeef");
858        let staging = staging_path(&dest);
859        // A concurrent process already published.
860        std::fs::create_dir_all(&dest).unwrap();
861        std::fs::write(dest.join("theirs"), b"x").unwrap();
862        std::fs::create_dir_all(&staging).unwrap();
863        std::fs::write(staging.join("ours"), b"y").unwrap();
864
865        publish(&staging, &dest, true).unwrap();
866
867        assert!(
868            dest.join("theirs").exists(),
869            "an immutable tree is content-addressed: the winner's tree IS ours, \
870             and deleting it to install an identical one is pure risk"
871        );
872        assert!(!staging.exists(), "our staging must be cleaned up");
873    }
874
875    #[test]
876    fn publishing_a_mutable_tree_replaces_it_without_a_delete_in_place() {
877        let tmp = tempfile::tempdir().unwrap();
878        let dest = tmp.path().join("github-o-r-main");
879        let staging = staging_path(&dest);
880        std::fs::create_dir_all(&dest).unwrap();
881        std::fs::write(dest.join("old"), b"x").unwrap();
882        std::fs::create_dir_all(&staging).unwrap();
883        std::fs::write(staging.join("new"), b"y").unwrap();
884
885        publish(&staging, &dest, false).unwrap();
886
887        assert!(dest.join("new").exists(), "the new tree must be published");
888        assert!(!dest.join("old").exists(), "and must REPLACE, not union");
889        assert!(!staging.exists());
890        // The aside must not be left behind as cache litter.
891        let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
892            .unwrap()
893            .filter_map(Result::ok)
894            .map(|e| e.file_name().to_string_lossy().into_owned())
895            .filter(|n| n.contains(".old"))
896            .collect();
897        assert!(leftovers.is_empty(), "aside dirs left behind: {leftovers:?}");
898    }
899
900    #[test]
901    fn staging_is_scoped_by_thread_not_only_by_pid() {
902        // An earlier version keyed on pid alone and CLAIMED two concurrent
903        // fetchers could not collide. Two threads of one process share a pid,
904        // so the second one's cleanup would delete the first one's half-built
905        // tree and the first would then publish a truncated one.
906        let dest = std::path::Path::new("/c/inputs/github-o-r-deadbeef");
907        let here = staging_path(dest);
908        let there = std::thread::spawn(move || staging_path(dest))
909            .join()
910            .unwrap();
911        assert_ne!(
912            here, there,
913            "two threads must not share a staging directory"
914        );
915    }
916
917    // ── is_immutable_rev — what may be served from cache ──
918
919    #[test]
920    fn only_a_full_object_id_is_treated_as_immutable() {
921        // sha1 and the sha256 transition: one rev, one tree, forever.
922        assert!(is_immutable_rev("7fd33221240a3ab97781a066c5efe0124979527f"));
923        assert!(is_immutable_rev(&"a".repeat(64)));
924
925        // ── ★ THE ONE THAT MATTERS ───────────────────────────────────────
926        // `github:owner/repo/main` is a legal ref and CppNix accepts it, so
927        // we must too — but the tree behind it MOVES. Caching it as if
928        // immutable would freeze the entry at whatever `main` was the first
929        // time it was fetched. There is a `github-pleme-io-nix-main`
930        // directory in the live cache today, so this is not hypothetical.
931        assert!(!is_immutable_rev("main"), "a branch name is not a commit");
932        assert!(!is_immutable_rev("v1.2.3"), "a tag can be moved");
933        assert!(!is_immutable_rev("7fd3322"), "a short rev is ambiguous");
934        assert!(!is_immutable_rev(""), "an empty rev names nothing");
935
936        // Length alone is not enough — 40 non-hex chars is not an object id.
937        assert!(!is_immutable_rev(&"z".repeat(40)));
938        // Uppercase is refused deliberately: git emits lowercase, and
939        // accepting both would give one tree two cache entries.
940        assert!(!is_immutable_rev(&"A".repeat(40)));
941    }
942
943    // ── staging_path — atomicity depends on it being a SIBLING ──
944
945    #[test]
946    fn staging_is_a_sibling_so_the_publish_rename_is_atomic() {
947        let dest = std::path::Path::new("/cache/sui/inputs/sha256-abc/github-o-r-deadbeef");
948        let staging = staging_path(dest);
949        assert_eq!(
950            staging.parent(),
951            dest.parent(),
952            "staging in /tmp would put the rename across filesystems, where it \
953             is a copy — and a copy is not atomic, which is the whole point"
954        );
955        assert_ne!(staging, dest.to_path_buf());
956        let name = staging.file_name().unwrap().to_string_lossy().into_owned();
957        assert!(name.starts_with('.'), "hidden, so it is not mistaken for a tree");
958        assert!(
959            name.contains(&std::process::id().to_string()),
960            "pid-scoped, so two concurrent fetchers cannot share a staging dir"
961        );
962        // A dotted directory name must not be truncated the way
963        // `Path::with_extension` would truncate it.
964        let dotted = std::path::Path::new("/c/github-o-r-1.2.3");
965        assert!(
966            staging_path(dotted)
967                .file_name()
968                .unwrap()
969                .to_string_lossy()
970                .contains("github-o-r-1.2.3"),
971            "the full directory name must survive into the staging name"
972        );
973    }
974
975    // ── find_single_subdir_or_self ────────────────────────
976
977    #[test]
978    fn find_single_subdir_returns_child_when_one_dir() {
979        let tmp = tempfile::tempdir().unwrap();
980        let child = tmp.path().join("repo-abc123");
981        std::fs::create_dir(&child).unwrap();
982        std::fs::write(child.join("file.txt"), "hello").unwrap();
983
984        let result = find_single_subdir_or_self(tmp.path());
985        assert_eq!(result, child);
986    }
987
988    #[test]
989    fn find_single_subdir_returns_self_when_multiple() {
990        let tmp = tempfile::tempdir().unwrap();
991        std::fs::create_dir(tmp.path().join("a")).unwrap();
992        std::fs::create_dir(tmp.path().join("b")).unwrap();
993
994        let result = find_single_subdir_or_self(tmp.path());
995        assert_eq!(result, tmp.path());
996    }
997
998    #[test]
999    fn find_single_subdir_returns_self_when_empty() {
1000        let tmp = tempfile::tempdir().unwrap();
1001        let result = find_single_subdir_or_self(tmp.path());
1002        assert_eq!(result, tmp.path());
1003    }
1004
1005    #[test]
1006    fn find_single_subdir_returns_self_when_child_is_file() {
1007        let tmp = tempfile::tempdir().unwrap();
1008        std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1009        let result = find_single_subdir_or_self(tmp.path());
1010        assert_eq!(result, tmp.path());
1011    }
1012
1013    // ── url_to_safe_name ──────────────────────────────────
1014
1015    #[test]
1016    fn url_to_safe_name_replaces_slashes_and_colons() {
1017        let name = url_to_safe_name("https://example.com/foo/bar.tar.gz");
1018        assert!(!name.contains('/'));
1019        assert!(!name.contains(':'));
1020        assert!(name.contains("example"));
1021    }
1022
1023    // ── InputFetcher construction ─────────────────────────
1024
1025    #[test]
1026    fn fetcher_with_custom_cache_dir() {
1027        let tmp = tempfile::tempdir().unwrap();
1028        let fetcher = InputFetcher::with_cache_dir(tmp.path().to_path_buf());
1029        assert_eq!(fetcher.cache_dir(), tmp.path());
1030    }
1031
1032    #[test]
1033    fn fetcher_default_cache_dir_exists() {
1034        let fetcher = InputFetcher::new();
1035        // The path should end with "sui/inputs".
1036        let path_str = fetcher.cache_dir().to_string_lossy();
1037        assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
1038    }
1039
1040    // ── path-type fetch ───────────────────────────────────
1041
1042    #[test]
1043    fn fetch_path_returns_filesystem_path() {
1044        let tmp = tempfile::tempdir().unwrap();
1045        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1046
1047        let mut locked = make_locked("path");
1048        locked.path = Some("/var/empty/dep".to_string());
1049
1050        let result = fetcher.fetch(&locked).unwrap();
1051        assert_eq!(result, PathBuf::from("/var/empty/dep"));
1052    }
1053
1054    #[test]
1055    fn fetch_path_missing_field_errors() {
1056        let tmp = tempfile::tempdir().unwrap();
1057        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1058        let locked = make_locked("path");
1059        let result = fetcher.fetch(&locked);
1060        assert!(result.is_err());
1061        assert!(result.unwrap_err().to_string().contains("path"));
1062    }
1063
1064    // ── unsupported type ──────────────────────────────────
1065
1066    #[test]
1067    fn fetch_unsupported_type_returns_error() {
1068        // `mercurial` — parser doesn't produce this and fetcher
1069        // doesn't handle it. Remains unsupported for now. If a
1070        // future commit adds mercurial support, swap this to the
1071        // next truly-unsupported source_type to keep the test
1072        // meaningful.
1073        let tmp = tempfile::tempdir().unwrap();
1074        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1075        let locked = make_locked("mercurial");
1076        let result = fetcher.fetch(&locked);
1077        assert!(matches!(result, Err(FetchError::UnsupportedType(_))));
1078    }
1079
1080    #[test]
1081    fn gitlab_archive_url_is_well_formed() {
1082        assert_eq!(
1083            InputFetcher::gitlab_archive_url(None, "group", "proj", "abc123"),
1084            "https://gitlab.com/group/proj/-/archive/abc123/proj-abc123.tar.gz"
1085        );
1086    }
1087
1088    #[test]
1089    fn gitlab_archive_url_honors_custom_host() {
1090        assert_eq!(
1091            InputFetcher::gitlab_archive_url(Some("gitlab.gnome.org"), "GNOME", "gnome-shell", "abc"),
1092            "https://gitlab.gnome.org/GNOME/gnome-shell/-/archive/abc/gnome-shell-abc.tar.gz"
1093        );
1094    }
1095
1096    #[test]
1097    fn sourcehut_archive_url_prepends_tilde() {
1098        // Sourcehut owner names on the platform carry a `~` prefix
1099        // (`~emersion`) but the flake-ref parser drops it. Fetcher
1100        // must reinstate so the URL is canonical.
1101        assert_eq!(
1102            InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
1103            "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
1104        );
1105        // If the caller already included `~`, don't double it.
1106        assert_eq!(
1107            InputFetcher::sourcehut_archive_url("~emersion", "page", "HEAD"),
1108            "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
1109        );
1110    }
1111
1112    // ── cache hit ─────────────────────────────────────────
1113
1114    #[test]
1115    fn cache_hit_returns_cached_path() {
1116        let tmp = tempfile::tempdir().unwrap();
1117        let cache_dir = tmp.path().join("cache");
1118        std::fs::create_dir_all(&cache_dir).unwrap();
1119
1120        // Pre-populate cache.
1121        let hash = "sha256-TESTCACHEHIT";
1122        let cached_dir = cache_dir.join(sanitize_hash(hash));
1123        std::fs::create_dir_all(&cached_dir).unwrap();
1124        std::fs::write(cached_dir.join("flake.nix"), "{}").unwrap();
1125
1126        let fetcher = InputFetcher::with_cache_dir(cache_dir);
1127        let mut locked = make_locked("github");
1128        locked.nar_hash = Some(hash.to_string());
1129        // Intentionally leave owner/repo/rev empty — cache hit should skip fetch.
1130
1131        let result = fetcher.fetch(&locked).unwrap();
1132        // The cached directory has one file (not a subdir), so it returns itself.
1133        assert_eq!(result, cached_dir);
1134    }
1135
1136    // ── github URL construction ───────────────────────────
1137
1138    #[test]
1139    fn github_archive_url_format() {
1140        let url = InputFetcher::github_archive_url("nixos", "nixpkgs", "abc123");
1141        assert_eq!(
1142            url,
1143            "https://github.com/nixos/nixpkgs/archive/abc123.tar.gz"
1144        );
1145    }
1146
1147    // ── github fetch missing fields ───────────────────────
1148
1149    #[test]
1150    fn fetch_github_missing_owner_errors() {
1151        let tmp = tempfile::tempdir().unwrap();
1152        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1153        let mut locked = make_locked("github");
1154        locked.repo = Some("nixpkgs".into());
1155        locked.rev = Some("abc123".into());
1156        let result = fetcher.fetch(&locked);
1157        assert!(result.is_err());
1158        assert!(result.unwrap_err().to_string().contains("owner"));
1159    }
1160
1161    #[test]
1162    fn fetch_github_missing_rev_errors() {
1163        let tmp = tempfile::tempdir().unwrap();
1164        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1165        let mut locked = make_locked("github");
1166        locked.owner = Some("nixos".into());
1167        locked.repo = Some("nixpkgs".into());
1168        let result = fetcher.fetch(&locked);
1169        assert!(result.is_err());
1170        assert!(result.unwrap_err().to_string().contains("rev"));
1171    }
1172
1173    // ── git fetch missing fields ──────────────────────────
1174
1175    #[test]
1176    fn fetch_git_missing_url_errors() {
1177        let tmp = tempfile::tempdir().unwrap();
1178        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1179        let mut locked = make_locked("git");
1180        locked.rev = Some("abc123".into());
1181        let result = fetcher.fetch(&locked);
1182        assert!(result.is_err());
1183        assert!(result.unwrap_err().to_string().contains("url"));
1184    }
1185
1186    #[test]
1187    fn fetch_git_missing_rev_errors() {
1188        let tmp = tempfile::tempdir().unwrap();
1189        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1190        let mut locked = make_locked("git");
1191        locked.url = Some("https://example.com/repo.git".into());
1192        let result = fetcher.fetch(&locked);
1193        assert!(result.is_err());
1194        assert!(result.unwrap_err().to_string().contains("rev"));
1195    }
1196
1197    // ── tarball fetch missing URL ─────────────────────────
1198
1199    #[test]
1200    fn fetch_tarball_missing_url_errors() {
1201        let tmp = tempfile::tempdir().unwrap();
1202        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1203        let locked = make_locked("tarball");
1204        let result = fetcher.fetch(&locked);
1205        assert!(result.is_err());
1206        assert!(result.unwrap_err().to_string().contains("url"));
1207    }
1208
1209    // ── extract_tar_gz ────────────────────────────────────
1210
1211    #[test]
1212    fn extract_tar_gz_empty_archive_errors() {
1213        let tmp = tempfile::tempdir().unwrap();
1214        let result = extract_tar_gz(&[], tmp.path());
1215        assert!(result.is_err());
1216    }
1217
1218    #[test]
1219    fn extract_tar_gz_invalid_data_errors() {
1220        let tmp = tempfile::tempdir().unwrap();
1221        let result = extract_tar_gz(b"not a gzip stream at all", tmp.path());
1222        assert!(result.is_err());
1223    }
1224
1225    // ── dest_dir logic ────────────────────────────────────
1226
1227    #[test]
1228    fn dest_dir_uses_nar_hash_when_present() {
1229        let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
1230        let mut locked = make_locked("github");
1231        locked.nar_hash = Some("sha256-ABC123=".to_string());
1232        let dest = fetcher.dest_dir(&locked, "fallback");
1233        assert!(dest.to_string_lossy().contains("sha256-ABC123"));
1234        assert!(!dest.to_string_lossy().contains("fallback"));
1235    }
1236
1237    #[test]
1238    fn dest_dir_uses_fallback_when_no_hash() {
1239        let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
1240        let locked = make_locked("github");
1241        let dest = fetcher.dest_dir(&locked, "fallback-name");
1242        assert!(dest.to_string_lossy().contains("fallback-name"));
1243    }
1244
1245    // ── is_non_empty_dir ─────────────────────────────────
1246
1247    #[test]
1248    fn is_non_empty_dir_returns_true_for_non_empty() {
1249        let tmp = tempfile::tempdir().unwrap();
1250        std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1251        assert!(is_non_empty_dir(tmp.path()));
1252    }
1253
1254    #[test]
1255    fn is_non_empty_dir_returns_false_for_empty() {
1256        let tmp = tempfile::tempdir().unwrap();
1257        assert!(!is_non_empty_dir(tmp.path()));
1258    }
1259
1260    #[test]
1261    fn is_non_empty_dir_returns_false_for_missing() {
1262        assert!(!is_non_empty_dir(Path::new("/nonexistent/path/12345")));
1263    }
1264
1265    // ── empty cache invalidation ─────────────────────────
1266
1267    #[test]
1268    fn empty_cache_dir_is_treated_as_miss() {
1269        let tmp = tempfile::tempdir().unwrap();
1270        let cache_dir = tmp.path().join("cache");
1271        std::fs::create_dir_all(&cache_dir).unwrap();
1272
1273        // Pre-create an *empty* cache directory (simulates a failed fetch).
1274        let hash = "sha256-EMPTYTEST";
1275        let cached_dir = cache_dir.join(sanitize_hash(hash));
1276        std::fs::create_dir_all(&cached_dir).unwrap();
1277        // Verify the directory is empty.
1278        assert!(std::fs::read_dir(&cached_dir).unwrap().next().is_none());
1279
1280        let fetcher = InputFetcher::with_cache_dir(cache_dir);
1281        let mut locked = make_locked("github");
1282        locked.nar_hash = Some(hash.to_string());
1283        // owner/repo/rev are missing, so the re-fetch will fail — but
1284        // the important thing is that the cache miss was detected (the
1285        // stale directory was removed) and the code attempted a fresh fetch.
1286        let result = fetcher.fetch(&locked);
1287        assert!(result.is_err(), "should not return stale empty cache");
1288        // The empty directory should have been cleaned up.
1289        assert!(!cached_dir.exists(), "stale cache dir should be removed");
1290    }
1291
1292    // ── github_tarball_from_git_url ──────────────────────
1293
1294    #[test]
1295    fn tarball_from_https_github() {
1296        let url = github_tarball_from_git_url(
1297            "https://github.com/NixOS/nixpkgs.git",
1298            "abc123",
1299        );
1300        assert_eq!(
1301            url.as_deref(),
1302            Some("https://github.com/NixOS/nixpkgs/archive/abc123.tar.gz")
1303        );
1304    }
1305
1306    #[test]
1307    fn tarball_from_git_plus_https() {
1308        let url = github_tarball_from_git_url(
1309            "git+https://github.com/NixOS/nixpkgs",
1310            "def456",
1311        );
1312        assert_eq!(
1313            url.as_deref(),
1314            Some("https://github.com/NixOS/nixpkgs/archive/def456.tar.gz")
1315        );
1316    }
1317
1318    #[test]
1319    fn tarball_from_non_github_returns_none() {
1320        assert!(github_tarball_from_git_url("https://gitlab.com/foo/bar.git", "abc").is_none());
1321        assert!(github_tarball_from_git_url("ssh://git@github.com/foo/bar", "abc").is_none());
1322    }
1323
1324    #[test]
1325    fn tarball_from_malformed_path_returns_none() {
1326        assert!(github_tarball_from_git_url("https://github.com/", "abc").is_none());
1327        assert!(github_tarball_from_git_url("https://github.com/only-owner", "abc").is_none());
1328    }
1329}
1330
1331
1332/// Turn a parsed flake reference into a directory on disk, fetching it first
1333/// if it is remote.
1334///
1335/// ── ★ ONE PLACE, BECAUSE THERE ARE THREE CALLERS ────────────────────────
1336/// `evaluate_flake` takes a `&Path`, so every entry point that accepts a
1337/// `--flake` argument has to answer "where is it?" — `sui-orchestrate`'s
1338/// `build_toplevel` and two sites in the `sui` CLI. Written per-caller, the
1339/// remote case would be right in whichever one was being fixed and missing in
1340/// the others, which is precisely how `github:` refs came to work in some
1341/// paths and not the one the fleet reconciler uses.
1342///
1343/// A local ref costs nothing here. A remote one is content-addressed and
1344/// cached by the same fetcher that pulls locked flake inputs, so re-resolving
1345/// the same rev does no network.
1346///
1347/// # Errors
1348///
1349/// Returns [`FetchError`] when a remote source cannot be fetched or
1350/// extracted.
1351pub fn resolve_flake_dir(flake_ref: &FlakeRef) -> Result<std::path::PathBuf, FetchError> {
1352    match flake_ref.local_dir() {
1353        Some(p) => Ok(p.to_path_buf()),
1354        None => {
1355            let locked = flake_ref
1356                .source
1357                .locked_input()
1358                .ok_or_else(|| FetchError::UnsupportedType("non-fetchable flake source".into()))?;
1359            InputFetcher::new().fetch(&locked)
1360        }
1361    }
1362}