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