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. A concurrent fetcher may have won the race and already
221        // renamed a tree into place; on a mutable rev we also need to replace
222        // whatever is there. Remove-then-rename is not atomic, but the window
223        // now contains only COMPLETE trees, never a partial one.
224        if dest.exists() {
225            let _ = std::fs::remove_dir_all(&dest);
226        }
227        if std::fs::rename(&staging, &dest).is_err() {
228            let _ = std::fs::remove_dir_all(&staging);
229            // Losing the race is not a failure if the winner left a good tree.
230            if !is_non_empty_dir(&dest) {
231                return Err(FetchError::Extract(
232                    "could not publish the fetched tree and no other process left one".into(),
233                ));
234            }
235        }
236        Ok(find_single_subdir_or_self(&dest))
237    }
238
239    fn fetch_gitlab(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
240        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
241        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
242        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
243        let host = locked.host.as_deref();
244        let url = Self::gitlab_archive_url(host, owner, repo, rev);
245        let host_tag = host.unwrap_or("gitlab.com").replace('.', "_");
246        self.fetch_archive(locked, &url, &format!("gitlab-{host_tag}-{owner}-{repo}-{rev}"), rev)
247    }
248
249    fn fetch_sourcehut(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
250        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
251        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
252        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
253        let url = Self::sourcehut_archive_url(owner, repo, rev);
254        let sanitized_owner = owner.trim_start_matches('~');
255        self.fetch_archive(
256            locked,
257            &url,
258            &format!("sourcehut-{sanitized_owner}-{repo}-{rev}"),
259            rev,
260        )
261    }
262
263    fn fetch_path(locked: &LockedInput) -> Result<PathBuf, FetchError> {
264        let path = locked
265            .path
266            .as_deref()
267            .ok_or(FetchError::MissingField("path"))?;
268        Ok(PathBuf::from(path))
269    }
270
271    fn fetch_git(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
272        let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
273        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
274
275        let short_rev: String = rev.chars().take(12).collect();
276        let dest = self.dest_dir(locked, &format!("git-{short_rev}"));
277
278        if dest.exists() {
279            if is_non_empty_dir(&dest) {
280                return Ok(dest);
281            }
282            let _ = std::fs::remove_dir_all(&dest);
283        }
284
285        // Try GitHub tarball first (avoids git CLI dependency in containers).
286        // Most git-type inputs in flake.lock are GitHub repos that support
287        // archive downloads via /archive/{rev}.tar.gz.
288        if let Some(tarball_url) = github_tarball_from_git_url(url, rev) {
289            std::fs::create_dir_all(&dest)?;
290            match download_bytes(&tarball_url) {
291                Ok(bytes) => {
292                    if let Err(e) = extract_tar_gz(&bytes, &dest) {
293                        let _ = std::fs::remove_dir_all(&dest);
294                        return Err(e);
295                    }
296                    return Ok(find_single_subdir_or_self(&dest));
297                }
298                Err(e) => {
299                    // Tarball fallback failed — try git CLI below.
300                    let _ = std::fs::remove_dir_all(&dest);
301                    tracing::debug!(url = %tarball_url, error = %e, "Tarball fallback failed, trying git CLI");
302                }
303            }
304        }
305
306        // Fall back to git CLI for non-GitHub repos or when tarball fails.
307        let status = std::process::Command::new("git")
308            .args(["clone", "--depth", "1", url])
309            .arg(&dest)
310            .stdout(std::process::Stdio::null())
311            .stderr(std::process::Stdio::null())
312            .status()
313            .map_err(|e| FetchError::Download(format!(
314                "git clone failed (git not in PATH?): {e}"
315            )))?;
316        if !status.success() {
317            let _ = std::fs::remove_dir_all(&dest);
318            return Err(FetchError::Download(format!(
319                "git clone failed for {url} (exit code: {})",
320                status.code().unwrap_or(-1)
321            )));
322        }
323
324        // Checkout the exact revision.
325        crate::git::checkout_rev(&dest, rev)
326            .map_err(|e| FetchError::Download(format!("git checkout {rev}: {e}")))?;
327
328        Ok(dest)
329    }
330
331    fn fetch_tarball(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
332        let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
333
334        let hash_suffix = locked
335            .nar_hash
336            .as_deref()
337            .map_or_else(|| url_to_safe_name(url), sanitize_hash);
338        let dest = self.dest_dir(locked, &format!("tarball-{hash_suffix}"));
339
340        if dest.exists() {
341            let resolved = find_single_subdir_or_self(&dest);
342            if is_non_empty_dir(&resolved) {
343                return Ok(resolved);
344            }
345            let _ = std::fs::remove_dir_all(&dest);
346        }
347
348        std::fs::create_dir_all(&dest)?;
349        let bytes = match download_bytes(url) {
350            Ok(b) => b,
351            Err(e) => {
352                let _ = std::fs::remove_dir_all(&dest);
353                return Err(e);
354            }
355        };
356        if let Err(e) = extract_tar_gz(&bytes, &dest) {
357            let _ = std::fs::remove_dir_all(&dest);
358            return Err(e);
359        }
360
361        Ok(find_single_subdir_or_self(&dest))
362    }
363
364    /// Compute the destination directory, preferring narHash-based names.
365    fn dest_dir(&self, locked: &LockedInput, fallback: &str) -> PathBuf {
366        if let Some(ref nar_hash) = locked.nar_hash {
367            self.cache_dir.join(sanitize_hash(nar_hash))
368        } else {
369            self.cache_dir.join(fallback)
370        }
371    }
372}
373
374// ── Helpers ───────────────────────────────────────────────────
375
376/// Try to convert a git URL to a GitHub tarball URL.
377///
378/// `https://github.com/NixOS/nixpkgs.git` + rev → `https://github.com/NixOS/nixpkgs/archive/{rev}.tar.gz`
379/// Returns `None` for non-GitHub URLs.
380fn github_tarball_from_git_url(url: &str, rev: &str) -> Option<String> {
381    let stripped = url
382        .strip_prefix("https://github.com/")
383        .or_else(|| url.strip_prefix("git+https://github.com/"))
384        .or_else(|| url.strip_prefix("http://github.com/"))?;
385    let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
386    // Validate it looks like owner/repo (no extra path segments)
387    let parts: Vec<&str> = stripped.split('/').collect();
388    if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
389        Some(format!(
390            "https://github.com/{}/{}/archive/{rev}.tar.gz",
391            parts[0], parts[1]
392        ))
393    } else {
394        None
395    }
396}
397
398/// Turn a narHash like `sha256-AAAA...=` into a filesystem-safe name.
399fn sanitize_hash(hash: &str) -> String {
400    hash.replace(':', "-").replace('/', "_").replace('=', "")
401}
402
403/// Whether `rev` names one immutable tree — a full git object id.
404///
405/// 40 hex for sha1, 64 for the sha256 transition. Anything else (a branch, a
406/// tag, a short rev) can move, so it must never be served from cache without a
407/// network check. Lowercase only: git emits lowercase, and accepting mixed case
408/// would let `ABC…` and `abc…` occupy two cache entries for one tree.
409fn is_immutable_rev(rev: &str) -> bool {
410    matches!(rev.len(), 40 | 64) && rev.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
411}
412
413/// A scratch path beside `dest`, on the same filesystem so the publish rename
414/// is atomic. The pid keeps two concurrent fetchers from sharing a staging dir.
415fn staging_path(dest: &Path) -> PathBuf {
416    let name = dest
417        .file_name()
418        .map_or_else(|| "fetch".to_string(), |n| n.to_string_lossy().into_owned());
419    let tmp = [".", &name, ".tmp-", &std::process::id().to_string()].concat();
420    dest.parent()
421        .map_or_else(|| PathBuf::from(&tmp), |p| p.join(&tmp))
422}
423
424/// Return `true` when `dir` exists and has at least one child entry.
425fn is_non_empty_dir(dir: &Path) -> bool {
426    std::fs::read_dir(dir)
427        .ok()
428        .is_some_and(|mut rd| rd.next().is_some())
429}
430
431/// If the directory contains exactly one child directory (common for GitHub
432/// tarballs which unpack as `repo-rev/`), return that child. Otherwise
433/// return the directory itself.
434fn find_single_subdir_or_self(dir: &Path) -> PathBuf {
435    let entries: Vec<_> = std::fs::read_dir(dir)
436        .ok()
437        .into_iter()
438        .flatten()
439        .filter_map(|e| e.ok())
440        .collect();
441    if entries.len() == 1 && entries[0].path().is_dir() {
442        entries[0].path()
443    } else {
444        dir.to_path_buf()
445    }
446}
447
448/// Download a URL and return the raw bytes.
449///
450/// Uses `ureq` (synchronous, no tokio runtime) so this function is safe to
451/// call from inside a running tokio context — no nested-runtime panic.
452///
453/// Body limit raised to 512 MiB to accommodate large inputs like nixpkgs tarballs.
454fn download_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
455    let mut req = ureq::get(url);
456
457    // Attach a host-appropriate auth token when one is available.
458    // CppNix consults `~/.config/nix/nix.conf` `access-tokens =
459    // github.com=<TOKEN>` etc.; we keep parity by reading the same
460    // sources plus the common `GITHUB_TOKEN` env (gh CLI, nix-darwin
461    // shell init).  Without this the operator's private flake
462    // inputs (e.g. `arnes`) 404 unauthenticated.
463    if let Some(token) = github_token_for_url(url) {
464        req = req.header("Authorization", &format!("token {token}"));
465    }
466
467    let mut response = req
468        .call()
469        .map_err(|e| FetchError::Download(format!("{url}: {e}")))?;
470
471    if !response.status().is_success() {
472        return Err(FetchError::Download(format!(
473            "{url}: HTTP {}",
474            response.status().as_u16()
475        )));
476    }
477
478    response
479        .body_mut()
480        .with_config()
481        .limit(512 * 1024 * 1024)
482        .read_to_vec()
483        .map_err(|e| FetchError::Download(format!("{url}: {e}")))
484}
485
486/// Resolve a host-appropriate auth token for outgoing requests.
487///
488/// Sources, in order:
489///   1. `GITHUB_TOKEN` env var (covers gh CLI exports + CI tokens).
490///   2. `NIX_CONFIG` env var, parsed for `access-tokens` line.
491///   3. `~/.config/nix/nix.conf` parsed for `access-tokens` line.
492///   4. `~/.config/gh/hosts.yml` (`oauth_token:` field for github.com).
493///
494/// Returns `Some(token)` only for github.com URLs in this iteration —
495/// gitlab / sr.ht / private git hosts can be added when needed.
496fn github_token_for_url(url: &str) -> Option<String> {
497    if !url.starts_with("https://github.com/")
498        && !url.starts_with("https://api.github.com/")
499    {
500        return None;
501    }
502    if let Ok(t) = std::env::var("GITHUB_TOKEN") {
503        if !t.is_empty() {
504            return Some(t);
505        }
506    }
507    if let Ok(cfg) = std::env::var("NIX_CONFIG") {
508        if let Some(t) = parse_access_tokens(&cfg, "github.com") {
509            return Some(t);
510        }
511    }
512    if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
513        let nix_conf = home.join(".config/nix/nix.conf");
514        if let Ok(cfg) = std::fs::read_to_string(&nix_conf) {
515            if let Some(t) = parse_access_tokens(&cfg, "github.com") {
516                return Some(t);
517            }
518        }
519        let gh_hosts = home.join(".config/gh/hosts.yml");
520        if let Ok(yml) = std::fs::read_to_string(&gh_hosts) {
521            if let Some(t) = parse_gh_hosts_token(&yml, "github.com") {
522                return Some(t);
523            }
524        }
525    }
526    None
527}
528
529/// Parse a `~/.config/nix/nix.conf`-style `access-tokens = host=TOKEN ...`
530/// line and return the token for `host` if present.
531fn parse_access_tokens(cfg: &str, host: &str) -> Option<String> {
532    for line in cfg.lines() {
533        let trimmed = line.trim();
534        if let Some(rest) = trimmed.strip_prefix("access-tokens") {
535            let rest = rest.trim_start().trim_start_matches('=').trim();
536            for pair in rest.split_whitespace() {
537                if let Some((h, t)) = pair.split_once('=') {
538                    if h == host {
539                        return Some(t.to_string());
540                    }
541                }
542            }
543        }
544    }
545    None
546}
547
548/// Parse `~/.config/gh/hosts.yml` and return the `oauth_token:` value
549/// nested under the given host key.  We do this without a full YAML
550/// parser to keep sui-eval's dep footprint small — the file is a
551/// stable 5-line shape gh maintains.
552fn parse_gh_hosts_token(yml: &str, host: &str) -> Option<String> {
553    let mut in_host = false;
554    for line in yml.lines() {
555        let raw = line;
556        let trimmed = raw.trim();
557        if trimmed.starts_with(host) && trimmed.ends_with(':') {
558            in_host = true;
559            continue;
560        }
561        if !raw.starts_with(' ') && !raw.starts_with('\t') && !trimmed.is_empty() {
562            in_host = false;
563        }
564        if in_host {
565            if let Some(rest) = trimmed.strip_prefix("oauth_token:") {
566                return Some(rest.trim().to_string());
567            }
568        }
569    }
570    None
571}
572
573/// Extract a `.tar.gz` archive into a destination directory.
574fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<(), FetchError> {
575    let gz = flate2::read::GzDecoder::new(bytes);
576
577    // Check if the gzip header is valid before attempting extraction.
578    // An empty or non-gzip payload would fail inside tar::Archive.
579    let mut buffered = std::io::BufReader::new(gz);
580    let mut peek = [0u8; 1];
581    // Try reading one byte to detect decompression errors early.
582    match buffered.read(&mut peek) {
583        Ok(0) => {
584            return Err(FetchError::Extract("empty archive".into()));
585        }
586        Err(e) => {
587            return Err(FetchError::Extract(format!("gzip decompression: {e}")));
588        }
589        Ok(_) => {
590            // Put the byte back by chaining it in front of the reader.
591            let cursor = std::io::Cursor::new(peek);
592            let chain = cursor.chain(buffered);
593            let mut archive = tar::Archive::new(chain);
594            archive
595                .unpack(dest)
596                .map_err(|e| FetchError::Extract(format!("tar unpack: {e}")))?;
597        }
598    }
599
600    Ok(())
601}
602
603/// Convert a URL into a filesystem-safe name (for fallback cache keys).
604fn url_to_safe_name(url: &str) -> String {
605    url.chars()
606        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
607        .collect()
608}
609
610/// Platform-aware cache directory discovery.
611fn dirs_cache_dir() -> PathBuf {
612    // Try XDG_CACHE_HOME first, then platform default, then /tmp.
613    // Absolute, not merely non-empty — see eval_cache.rs for the class.
614    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
615        .map(PathBuf::from)
616        .filter(|p| p.is_absolute())
617    {
618        return xdg;
619    }
620    if let Some(home) = std::env::var_os("HOME")
621        .map(PathBuf::from)
622        .filter(|p| p.is_absolute())
623    {
624        let default = home.join(".cache");
625        if default.exists() || std::fs::create_dir_all(&default).is_ok() {
626            return default;
627        }
628    }
629    PathBuf::from("/tmp")
630}
631
632// ── Tests ─────────────────────────────────────────────────────
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use std::collections::BTreeMap;
638
639    /// Helper: build a `LockedInput` with the given fields.
640    fn make_locked(source_type: &str) -> LockedInput {
641        LockedInput {
642            source_type: source_type.to_string(),
643            owner: None,
644            repo: None,
645            rev: None,
646            nar_hash: None,
647            last_modified: None,
648            path: None,
649            url: None,
650            git_ref: None,
651            dir: None,
652            host: None,
653            extra: BTreeMap::new(),
654        }
655    }
656
657    // ── sanitize_hash ─────────────────────────────────────
658
659    #[test]
660    fn sanitize_hash_replaces_special_chars() {
661        assert_eq!(
662            sanitize_hash("sha256-AAAAAAAAAAAAAAAAAAAAAA="),
663            "sha256-AAAAAAAAAAAAAAAAAAAAAA"
664        );
665        assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
666    }
667
668    // ── is_immutable_rev — what may be served from cache ──
669
670    #[test]
671    fn only_a_full_object_id_is_treated_as_immutable() {
672        // sha1 and the sha256 transition: one rev, one tree, forever.
673        assert!(is_immutable_rev("7fd33221240a3ab97781a066c5efe0124979527f"));
674        assert!(is_immutable_rev(&"a".repeat(64)));
675
676        // ── ★ THE ONE THAT MATTERS ───────────────────────────────────────
677        // `github:owner/repo/main` is a legal ref and CppNix accepts it, so
678        // we must too — but the tree behind it MOVES. Caching it as if
679        // immutable would freeze the entry at whatever `main` was the first
680        // time it was fetched. There is a `github-pleme-io-nix-main`
681        // directory in the live cache today, so this is not hypothetical.
682        assert!(!is_immutable_rev("main"), "a branch name is not a commit");
683        assert!(!is_immutable_rev("v1.2.3"), "a tag can be moved");
684        assert!(!is_immutable_rev("7fd3322"), "a short rev is ambiguous");
685        assert!(!is_immutable_rev(""), "an empty rev names nothing");
686
687        // Length alone is not enough — 40 non-hex chars is not an object id.
688        assert!(!is_immutable_rev(&"z".repeat(40)));
689        // Uppercase is refused deliberately: git emits lowercase, and
690        // accepting both would give one tree two cache entries.
691        assert!(!is_immutable_rev(&"A".repeat(40)));
692    }
693
694    // ── staging_path — atomicity depends on it being a SIBLING ──
695
696    #[test]
697    fn staging_is_a_sibling_so_the_publish_rename_is_atomic() {
698        let dest = std::path::Path::new("/cache/sui/inputs/sha256-abc/github-o-r-deadbeef");
699        let staging = staging_path(dest);
700        assert_eq!(
701            staging.parent(),
702            dest.parent(),
703            "staging in /tmp would put the rename across filesystems, where it \
704             is a copy — and a copy is not atomic, which is the whole point"
705        );
706        assert_ne!(staging, dest.to_path_buf());
707        let name = staging.file_name().unwrap().to_string_lossy().into_owned();
708        assert!(name.starts_with('.'), "hidden, so it is not mistaken for a tree");
709        assert!(
710            name.contains(&std::process::id().to_string()),
711            "pid-scoped, so two concurrent fetchers cannot share a staging dir"
712        );
713        // A dotted directory name must not be truncated the way
714        // `Path::with_extension` would truncate it.
715        let dotted = std::path::Path::new("/c/github-o-r-1.2.3");
716        assert!(
717            staging_path(dotted)
718                .file_name()
719                .unwrap()
720                .to_string_lossy()
721                .contains("github-o-r-1.2.3"),
722            "the full directory name must survive into the staging name"
723        );
724    }
725
726    // ── find_single_subdir_or_self ────────────────────────
727
728    #[test]
729    fn find_single_subdir_returns_child_when_one_dir() {
730        let tmp = tempfile::tempdir().unwrap();
731        let child = tmp.path().join("repo-abc123");
732        std::fs::create_dir(&child).unwrap();
733        std::fs::write(child.join("file.txt"), "hello").unwrap();
734
735        let result = find_single_subdir_or_self(tmp.path());
736        assert_eq!(result, child);
737    }
738
739    #[test]
740    fn find_single_subdir_returns_self_when_multiple() {
741        let tmp = tempfile::tempdir().unwrap();
742        std::fs::create_dir(tmp.path().join("a")).unwrap();
743        std::fs::create_dir(tmp.path().join("b")).unwrap();
744
745        let result = find_single_subdir_or_self(tmp.path());
746        assert_eq!(result, tmp.path());
747    }
748
749    #[test]
750    fn find_single_subdir_returns_self_when_empty() {
751        let tmp = tempfile::tempdir().unwrap();
752        let result = find_single_subdir_or_self(tmp.path());
753        assert_eq!(result, tmp.path());
754    }
755
756    #[test]
757    fn find_single_subdir_returns_self_when_child_is_file() {
758        let tmp = tempfile::tempdir().unwrap();
759        std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
760        let result = find_single_subdir_or_self(tmp.path());
761        assert_eq!(result, tmp.path());
762    }
763
764    // ── url_to_safe_name ──────────────────────────────────
765
766    #[test]
767    fn url_to_safe_name_replaces_slashes_and_colons() {
768        let name = url_to_safe_name("https://example.com/foo/bar.tar.gz");
769        assert!(!name.contains('/'));
770        assert!(!name.contains(':'));
771        assert!(name.contains("example"));
772    }
773
774    // ── InputFetcher construction ─────────────────────────
775
776    #[test]
777    fn fetcher_with_custom_cache_dir() {
778        let tmp = tempfile::tempdir().unwrap();
779        let fetcher = InputFetcher::with_cache_dir(tmp.path().to_path_buf());
780        assert_eq!(fetcher.cache_dir(), tmp.path());
781    }
782
783    #[test]
784    fn fetcher_default_cache_dir_exists() {
785        let fetcher = InputFetcher::new();
786        // The path should end with "sui/inputs".
787        let path_str = fetcher.cache_dir().to_string_lossy();
788        assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
789    }
790
791    // ── path-type fetch ───────────────────────────────────
792
793    #[test]
794    fn fetch_path_returns_filesystem_path() {
795        let tmp = tempfile::tempdir().unwrap();
796        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
797
798        let mut locked = make_locked("path");
799        locked.path = Some("/var/empty/dep".to_string());
800
801        let result = fetcher.fetch(&locked).unwrap();
802        assert_eq!(result, PathBuf::from("/var/empty/dep"));
803    }
804
805    #[test]
806    fn fetch_path_missing_field_errors() {
807        let tmp = tempfile::tempdir().unwrap();
808        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
809        let locked = make_locked("path");
810        let result = fetcher.fetch(&locked);
811        assert!(result.is_err());
812        assert!(result.unwrap_err().to_string().contains("path"));
813    }
814
815    // ── unsupported type ──────────────────────────────────
816
817    #[test]
818    fn fetch_unsupported_type_returns_error() {
819        // `mercurial` — parser doesn't produce this and fetcher
820        // doesn't handle it. Remains unsupported for now. If a
821        // future commit adds mercurial support, swap this to the
822        // next truly-unsupported source_type to keep the test
823        // meaningful.
824        let tmp = tempfile::tempdir().unwrap();
825        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
826        let locked = make_locked("mercurial");
827        let result = fetcher.fetch(&locked);
828        assert!(matches!(result, Err(FetchError::UnsupportedType(_))));
829    }
830
831    #[test]
832    fn gitlab_archive_url_is_well_formed() {
833        assert_eq!(
834            InputFetcher::gitlab_archive_url(None, "group", "proj", "abc123"),
835            "https://gitlab.com/group/proj/-/archive/abc123/proj-abc123.tar.gz"
836        );
837    }
838
839    #[test]
840    fn gitlab_archive_url_honors_custom_host() {
841        assert_eq!(
842            InputFetcher::gitlab_archive_url(Some("gitlab.gnome.org"), "GNOME", "gnome-shell", "abc"),
843            "https://gitlab.gnome.org/GNOME/gnome-shell/-/archive/abc/gnome-shell-abc.tar.gz"
844        );
845    }
846
847    #[test]
848    fn sourcehut_archive_url_prepends_tilde() {
849        // Sourcehut owner names on the platform carry a `~` prefix
850        // (`~emersion`) but the flake-ref parser drops it. Fetcher
851        // must reinstate so the URL is canonical.
852        assert_eq!(
853            InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
854            "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
855        );
856        // If the caller already included `~`, don't double it.
857        assert_eq!(
858            InputFetcher::sourcehut_archive_url("~emersion", "page", "HEAD"),
859            "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
860        );
861    }
862
863    // ── cache hit ─────────────────────────────────────────
864
865    #[test]
866    fn cache_hit_returns_cached_path() {
867        let tmp = tempfile::tempdir().unwrap();
868        let cache_dir = tmp.path().join("cache");
869        std::fs::create_dir_all(&cache_dir).unwrap();
870
871        // Pre-populate cache.
872        let hash = "sha256-TESTCACHEHIT";
873        let cached_dir = cache_dir.join(sanitize_hash(hash));
874        std::fs::create_dir_all(&cached_dir).unwrap();
875        std::fs::write(cached_dir.join("flake.nix"), "{}").unwrap();
876
877        let fetcher = InputFetcher::with_cache_dir(cache_dir);
878        let mut locked = make_locked("github");
879        locked.nar_hash = Some(hash.to_string());
880        // Intentionally leave owner/repo/rev empty — cache hit should skip fetch.
881
882        let result = fetcher.fetch(&locked).unwrap();
883        // The cached directory has one file (not a subdir), so it returns itself.
884        assert_eq!(result, cached_dir);
885    }
886
887    // ── github URL construction ───────────────────────────
888
889    #[test]
890    fn github_archive_url_format() {
891        let url = InputFetcher::github_archive_url("nixos", "nixpkgs", "abc123");
892        assert_eq!(
893            url,
894            "https://github.com/nixos/nixpkgs/archive/abc123.tar.gz"
895        );
896    }
897
898    // ── github fetch missing fields ───────────────────────
899
900    #[test]
901    fn fetch_github_missing_owner_errors() {
902        let tmp = tempfile::tempdir().unwrap();
903        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
904        let mut locked = make_locked("github");
905        locked.repo = Some("nixpkgs".into());
906        locked.rev = Some("abc123".into());
907        let result = fetcher.fetch(&locked);
908        assert!(result.is_err());
909        assert!(result.unwrap_err().to_string().contains("owner"));
910    }
911
912    #[test]
913    fn fetch_github_missing_rev_errors() {
914        let tmp = tempfile::tempdir().unwrap();
915        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
916        let mut locked = make_locked("github");
917        locked.owner = Some("nixos".into());
918        locked.repo = Some("nixpkgs".into());
919        let result = fetcher.fetch(&locked);
920        assert!(result.is_err());
921        assert!(result.unwrap_err().to_string().contains("rev"));
922    }
923
924    // ── git fetch missing fields ──────────────────────────
925
926    #[test]
927    fn fetch_git_missing_url_errors() {
928        let tmp = tempfile::tempdir().unwrap();
929        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
930        let mut locked = make_locked("git");
931        locked.rev = Some("abc123".into());
932        let result = fetcher.fetch(&locked);
933        assert!(result.is_err());
934        assert!(result.unwrap_err().to_string().contains("url"));
935    }
936
937    #[test]
938    fn fetch_git_missing_rev_errors() {
939        let tmp = tempfile::tempdir().unwrap();
940        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
941        let mut locked = make_locked("git");
942        locked.url = Some("https://example.com/repo.git".into());
943        let result = fetcher.fetch(&locked);
944        assert!(result.is_err());
945        assert!(result.unwrap_err().to_string().contains("rev"));
946    }
947
948    // ── tarball fetch missing URL ─────────────────────────
949
950    #[test]
951    fn fetch_tarball_missing_url_errors() {
952        let tmp = tempfile::tempdir().unwrap();
953        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
954        let locked = make_locked("tarball");
955        let result = fetcher.fetch(&locked);
956        assert!(result.is_err());
957        assert!(result.unwrap_err().to_string().contains("url"));
958    }
959
960    // ── extract_tar_gz ────────────────────────────────────
961
962    #[test]
963    fn extract_tar_gz_empty_archive_errors() {
964        let tmp = tempfile::tempdir().unwrap();
965        let result = extract_tar_gz(&[], tmp.path());
966        assert!(result.is_err());
967    }
968
969    #[test]
970    fn extract_tar_gz_invalid_data_errors() {
971        let tmp = tempfile::tempdir().unwrap();
972        let result = extract_tar_gz(b"not a gzip stream at all", tmp.path());
973        assert!(result.is_err());
974    }
975
976    // ── dest_dir logic ────────────────────────────────────
977
978    #[test]
979    fn dest_dir_uses_nar_hash_when_present() {
980        let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
981        let mut locked = make_locked("github");
982        locked.nar_hash = Some("sha256-ABC123=".to_string());
983        let dest = fetcher.dest_dir(&locked, "fallback");
984        assert!(dest.to_string_lossy().contains("sha256-ABC123"));
985        assert!(!dest.to_string_lossy().contains("fallback"));
986    }
987
988    #[test]
989    fn dest_dir_uses_fallback_when_no_hash() {
990        let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
991        let locked = make_locked("github");
992        let dest = fetcher.dest_dir(&locked, "fallback-name");
993        assert!(dest.to_string_lossy().contains("fallback-name"));
994    }
995
996    // ── is_non_empty_dir ─────────────────────────────────
997
998    #[test]
999    fn is_non_empty_dir_returns_true_for_non_empty() {
1000        let tmp = tempfile::tempdir().unwrap();
1001        std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1002        assert!(is_non_empty_dir(tmp.path()));
1003    }
1004
1005    #[test]
1006    fn is_non_empty_dir_returns_false_for_empty() {
1007        let tmp = tempfile::tempdir().unwrap();
1008        assert!(!is_non_empty_dir(tmp.path()));
1009    }
1010
1011    #[test]
1012    fn is_non_empty_dir_returns_false_for_missing() {
1013        assert!(!is_non_empty_dir(Path::new("/nonexistent/path/12345")));
1014    }
1015
1016    // ── empty cache invalidation ─────────────────────────
1017
1018    #[test]
1019    fn empty_cache_dir_is_treated_as_miss() {
1020        let tmp = tempfile::tempdir().unwrap();
1021        let cache_dir = tmp.path().join("cache");
1022        std::fs::create_dir_all(&cache_dir).unwrap();
1023
1024        // Pre-create an *empty* cache directory (simulates a failed fetch).
1025        let hash = "sha256-EMPTYTEST";
1026        let cached_dir = cache_dir.join(sanitize_hash(hash));
1027        std::fs::create_dir_all(&cached_dir).unwrap();
1028        // Verify the directory is empty.
1029        assert!(std::fs::read_dir(&cached_dir).unwrap().next().is_none());
1030
1031        let fetcher = InputFetcher::with_cache_dir(cache_dir);
1032        let mut locked = make_locked("github");
1033        locked.nar_hash = Some(hash.to_string());
1034        // owner/repo/rev are missing, so the re-fetch will fail — but
1035        // the important thing is that the cache miss was detected (the
1036        // stale directory was removed) and the code attempted a fresh fetch.
1037        let result = fetcher.fetch(&locked);
1038        assert!(result.is_err(), "should not return stale empty cache");
1039        // The empty directory should have been cleaned up.
1040        assert!(!cached_dir.exists(), "stale cache dir should be removed");
1041    }
1042
1043    // ── github_tarball_from_git_url ──────────────────────
1044
1045    #[test]
1046    fn tarball_from_https_github() {
1047        let url = github_tarball_from_git_url(
1048            "https://github.com/NixOS/nixpkgs.git",
1049            "abc123",
1050        );
1051        assert_eq!(
1052            url.as_deref(),
1053            Some("https://github.com/NixOS/nixpkgs/archive/abc123.tar.gz")
1054        );
1055    }
1056
1057    #[test]
1058    fn tarball_from_git_plus_https() {
1059        let url = github_tarball_from_git_url(
1060            "git+https://github.com/NixOS/nixpkgs",
1061            "def456",
1062        );
1063        assert_eq!(
1064            url.as_deref(),
1065            Some("https://github.com/NixOS/nixpkgs/archive/def456.tar.gz")
1066        );
1067    }
1068
1069    #[test]
1070    fn tarball_from_non_github_returns_none() {
1071        assert!(github_tarball_from_git_url("https://gitlab.com/foo/bar.git", "abc").is_none());
1072        assert!(github_tarball_from_git_url("ssh://git@github.com/foo/bar", "abc").is_none());
1073    }
1074
1075    #[test]
1076    fn tarball_from_malformed_path_returns_none() {
1077        assert!(github_tarball_from_git_url("https://github.com/", "abc").is_none());
1078        assert!(github_tarball_from_git_url("https://github.com/only-owner", "abc").is_none());
1079    }
1080}
1081
1082
1083/// Turn a parsed flake reference into a directory on disk, fetching it first
1084/// if it is remote.
1085///
1086/// ── ★ ONE PLACE, BECAUSE THERE ARE THREE CALLERS ────────────────────────
1087/// `evaluate_flake` takes a `&Path`, so every entry point that accepts a
1088/// `--flake` argument has to answer "where is it?" — `sui-orchestrate`'s
1089/// `build_toplevel` and two sites in the `sui` CLI. Written per-caller, the
1090/// remote case would be right in whichever one was being fixed and missing in
1091/// the others, which is precisely how `github:` refs came to work in some
1092/// paths and not the one the fleet reconciler uses.
1093///
1094/// A local ref costs nothing here. A remote one is content-addressed and
1095/// cached by the same fetcher that pulls locked flake inputs, so re-resolving
1096/// the same rev does no network.
1097///
1098/// # Errors
1099///
1100/// Returns [`FetchError`] when a remote source cannot be fetched or
1101/// extracted.
1102pub fn resolve_flake_dir(flake_ref: &FlakeRef) -> Result<std::path::PathBuf, FetchError> {
1103    match flake_ref.local_dir() {
1104        Some(p) => Ok(p.to_path_buf()),
1105        None => {
1106            let locked = flake_ref
1107                .source
1108                .locked_input()
1109                .ok_or_else(|| FetchError::UnsupportedType("non-fetchable flake source".into()))?;
1110            InputFetcher::new().fetch(&locked)
1111        }
1112    }
1113}