1use std::io::Read as _;
8use std::path::{Path, PathBuf};
9
10use sui_compat::flake::LockedInput;
11
12#[derive(Debug, thiserror::Error)]
16pub enum FetchError {
17 #[error("unsupported input type: {0}")]
18 UnsupportedType(String),
19 #[error("missing required field: {0}")]
20 MissingField(&'static str),
21 #[error("download failed: {0}")]
22 Download(String),
23 #[error("I/O error: {0}")]
24 Io(#[from] std::io::Error),
25 #[error("archive extraction failed: {0}")]
26 Extract(String),
27}
28
29pub struct InputFetcher {
37 cache_dir: PathBuf,
38}
39
40impl Default for InputFetcher {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46impl InputFetcher {
47 #[must_use]
49 pub fn new() -> Self {
50 let cache_dir = dirs_cache_dir().join("sui/inputs");
51 Self { cache_dir }
52 }
53
54 #[must_use]
56 pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
57 Self { cache_dir }
58 }
59
60 #[must_use]
62 pub fn cache_dir(&self) -> &Path {
63 &self.cache_dir
64 }
65
66 pub fn fetch(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
71 if let Some(ref nar_hash) = locked.nar_hash {
73 let cache_key = sanitize_hash(nar_hash);
74 let cached = self.cache_dir.join(&cache_key);
75 if cached.exists() {
76 let resolved = find_single_subdir_or_self(&cached);
77 if is_non_empty_dir(&resolved) {
82 return Ok(resolved);
83 }
84 let _ = std::fs::remove_dir_all(&cached);
86 }
87 }
88
89 match locked.source_type.as_str() {
90 "github" => self.fetch_github(locked),
91 "gitlab" => self.fetch_gitlab(locked),
92 "sourcehut" => self.fetch_sourcehut(locked),
93 "path" => Self::fetch_path(locked),
94 "git" => self.fetch_git(locked),
95 "tarball" | "file" => self.fetch_tarball(locked),
96 other => Err(FetchError::UnsupportedType(other.to_string())),
97 }
98 }
99
100 #[must_use]
102 pub fn github_archive_url(owner: &str, repo: &str, rev: &str) -> String {
103 format!("https://github.com/{owner}/{repo}/archive/{rev}.tar.gz")
104 }
105
106 #[must_use]
112 pub fn gitlab_archive_url(host: Option<&str>, owner: &str, repo: &str, rev: &str) -> String {
113 let host = host.unwrap_or("gitlab.com");
114 format!(
115 "https://{host}/{owner}/{repo}/-/archive/{rev}/{repo}-{rev}.tar.gz"
116 )
117 }
118
119 #[must_use]
123 pub fn sourcehut_archive_url(owner: &str, repo: &str, rev: &str) -> String {
124 let owner_prefix = if owner.starts_with('~') {
125 owner.to_string()
126 } else {
127 format!("~{owner}")
128 };
129 format!("https://git.sr.ht/{owner_prefix}/{repo}/archive/{rev}.tar.gz")
130 }
131
132 fn fetch_github(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
135 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
136 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
137 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
138
139 let url = Self::github_archive_url(owner, repo, rev);
140 let dest = self.dest_dir(locked, &format!("github-{owner}-{repo}-{rev}"));
141 std::fs::create_dir_all(&dest)?;
142
143 let bytes = match download_bytes(&url) {
146 Ok(b) => b,
147 Err(e) => {
148 let _ = std::fs::remove_dir_all(&dest);
149 return Err(e);
150 }
151 };
152 if let Err(e) = extract_tar_gz(&bytes, &dest) {
153 let _ = std::fs::remove_dir_all(&dest);
154 return Err(e);
155 }
156
157 Ok(find_single_subdir_or_self(&dest))
158 }
159
160 fn fetch_archive(
164 &self,
165 locked: &LockedInput,
166 url: &str,
167 cache_key: &str,
168 ) -> Result<PathBuf, FetchError> {
169 let dest = self.dest_dir(locked, cache_key);
170 std::fs::create_dir_all(&dest)?;
171 let bytes = match download_bytes(url) {
172 Ok(b) => b,
173 Err(e) => {
174 let _ = std::fs::remove_dir_all(&dest);
175 return Err(e);
176 }
177 };
178 if let Err(e) = extract_tar_gz(&bytes, &dest) {
179 let _ = std::fs::remove_dir_all(&dest);
180 return Err(e);
181 }
182 Ok(find_single_subdir_or_self(&dest))
183 }
184
185 fn fetch_gitlab(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
186 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
187 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
188 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
189 let host = locked.host.as_deref();
190 let url = Self::gitlab_archive_url(host, owner, repo, rev);
191 let host_tag = host.unwrap_or("gitlab.com").replace('.', "_");
192 self.fetch_archive(locked, &url, &format!("gitlab-{host_tag}-{owner}-{repo}-{rev}"))
193 }
194
195 fn fetch_sourcehut(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
196 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
197 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
198 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
199 let url = Self::sourcehut_archive_url(owner, repo, rev);
200 let sanitized_owner = owner.trim_start_matches('~');
201 self.fetch_archive(
202 locked,
203 &url,
204 &format!("sourcehut-{sanitized_owner}-{repo}-{rev}"),
205 )
206 }
207
208 fn fetch_path(locked: &LockedInput) -> Result<PathBuf, FetchError> {
209 let path = locked
210 .path
211 .as_deref()
212 .ok_or(FetchError::MissingField("path"))?;
213 Ok(PathBuf::from(path))
214 }
215
216 fn fetch_git(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
217 let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
218 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
219
220 let short_rev: String = rev.chars().take(12).collect();
221 let dest = self.dest_dir(locked, &format!("git-{short_rev}"));
222
223 if dest.exists() {
224 if is_non_empty_dir(&dest) {
225 return Ok(dest);
226 }
227 let _ = std::fs::remove_dir_all(&dest);
228 }
229
230 if let Some(tarball_url) = github_tarball_from_git_url(url, rev) {
234 std::fs::create_dir_all(&dest)?;
235 match download_bytes(&tarball_url) {
236 Ok(bytes) => {
237 if let Err(e) = extract_tar_gz(&bytes, &dest) {
238 let _ = std::fs::remove_dir_all(&dest);
239 return Err(e);
240 }
241 return Ok(find_single_subdir_or_self(&dest));
242 }
243 Err(e) => {
244 let _ = std::fs::remove_dir_all(&dest);
246 tracing::debug!(url = %tarball_url, error = %e, "Tarball fallback failed, trying git CLI");
247 }
248 }
249 }
250
251 let status = std::process::Command::new("git")
253 .args(["clone", "--depth", "1", url])
254 .arg(&dest)
255 .stdout(std::process::Stdio::null())
256 .stderr(std::process::Stdio::null())
257 .status()
258 .map_err(|e| FetchError::Download(format!(
259 "git clone failed (git not in PATH?): {e}"
260 )))?;
261 if !status.success() {
262 let _ = std::fs::remove_dir_all(&dest);
263 return Err(FetchError::Download(format!(
264 "git clone failed for {url} (exit code: {})",
265 status.code().unwrap_or(-1)
266 )));
267 }
268
269 crate::git::checkout_rev(&dest, rev)
271 .map_err(|e| FetchError::Download(format!("git checkout {rev}: {e}")))?;
272
273 Ok(dest)
274 }
275
276 fn fetch_tarball(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
277 let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
278
279 let hash_suffix = locked
280 .nar_hash
281 .as_deref()
282 .map_or_else(|| url_to_safe_name(url), sanitize_hash);
283 let dest = self.dest_dir(locked, &format!("tarball-{hash_suffix}"));
284
285 if dest.exists() {
286 let resolved = find_single_subdir_or_self(&dest);
287 if is_non_empty_dir(&resolved) {
288 return Ok(resolved);
289 }
290 let _ = std::fs::remove_dir_all(&dest);
291 }
292
293 std::fs::create_dir_all(&dest)?;
294 let bytes = match download_bytes(url) {
295 Ok(b) => b,
296 Err(e) => {
297 let _ = std::fs::remove_dir_all(&dest);
298 return Err(e);
299 }
300 };
301 if let Err(e) = extract_tar_gz(&bytes, &dest) {
302 let _ = std::fs::remove_dir_all(&dest);
303 return Err(e);
304 }
305
306 Ok(find_single_subdir_or_self(&dest))
307 }
308
309 fn dest_dir(&self, locked: &LockedInput, fallback: &str) -> PathBuf {
311 if let Some(ref nar_hash) = locked.nar_hash {
312 self.cache_dir.join(sanitize_hash(nar_hash))
313 } else {
314 self.cache_dir.join(fallback)
315 }
316 }
317}
318
319fn github_tarball_from_git_url(url: &str, rev: &str) -> Option<String> {
326 let stripped = url
327 .strip_prefix("https://github.com/")
328 .or_else(|| url.strip_prefix("git+https://github.com/"))
329 .or_else(|| url.strip_prefix("http://github.com/"))?;
330 let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
331 let parts: Vec<&str> = stripped.split('/').collect();
333 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
334 Some(format!(
335 "https://github.com/{}/{}/archive/{rev}.tar.gz",
336 parts[0], parts[1]
337 ))
338 } else {
339 None
340 }
341}
342
343fn sanitize_hash(hash: &str) -> String {
345 hash.replace(':', "-").replace('/', "_").replace('=', "")
346}
347
348fn is_non_empty_dir(dir: &Path) -> bool {
350 std::fs::read_dir(dir)
351 .ok()
352 .is_some_and(|mut rd| rd.next().is_some())
353}
354
355fn find_single_subdir_or_self(dir: &Path) -> PathBuf {
359 let entries: Vec<_> = std::fs::read_dir(dir)
360 .ok()
361 .into_iter()
362 .flatten()
363 .filter_map(|e| e.ok())
364 .collect();
365 if entries.len() == 1 && entries[0].path().is_dir() {
366 entries[0].path()
367 } else {
368 dir.to_path_buf()
369 }
370}
371
372fn download_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
379 let mut req = ureq::get(url);
380
381 if let Some(token) = github_token_for_url(url) {
388 req = req.header("Authorization", &format!("token {token}"));
389 }
390
391 let mut response = req
392 .call()
393 .map_err(|e| FetchError::Download(format!("{url}: {e}")))?;
394
395 if !response.status().is_success() {
396 return Err(FetchError::Download(format!(
397 "{url}: HTTP {}",
398 response.status().as_u16()
399 )));
400 }
401
402 response
403 .body_mut()
404 .with_config()
405 .limit(512 * 1024 * 1024)
406 .read_to_vec()
407 .map_err(|e| FetchError::Download(format!("{url}: {e}")))
408}
409
410fn github_token_for_url(url: &str) -> Option<String> {
421 if !url.starts_with("https://github.com/")
422 && !url.starts_with("https://api.github.com/")
423 {
424 return None;
425 }
426 if let Ok(t) = std::env::var("GITHUB_TOKEN") {
427 if !t.is_empty() {
428 return Some(t);
429 }
430 }
431 if let Ok(cfg) = std::env::var("NIX_CONFIG") {
432 if let Some(t) = parse_access_tokens(&cfg, "github.com") {
433 return Some(t);
434 }
435 }
436 if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
437 let nix_conf = home.join(".config/nix/nix.conf");
438 if let Ok(cfg) = std::fs::read_to_string(&nix_conf) {
439 if let Some(t) = parse_access_tokens(&cfg, "github.com") {
440 return Some(t);
441 }
442 }
443 let gh_hosts = home.join(".config/gh/hosts.yml");
444 if let Ok(yml) = std::fs::read_to_string(&gh_hosts) {
445 if let Some(t) = parse_gh_hosts_token(&yml, "github.com") {
446 return Some(t);
447 }
448 }
449 }
450 None
451}
452
453fn parse_access_tokens(cfg: &str, host: &str) -> Option<String> {
456 for line in cfg.lines() {
457 let trimmed = line.trim();
458 if let Some(rest) = trimmed.strip_prefix("access-tokens") {
459 let rest = rest.trim_start().trim_start_matches('=').trim();
460 for pair in rest.split_whitespace() {
461 if let Some((h, t)) = pair.split_once('=') {
462 if h == host {
463 return Some(t.to_string());
464 }
465 }
466 }
467 }
468 }
469 None
470}
471
472fn parse_gh_hosts_token(yml: &str, host: &str) -> Option<String> {
477 let mut in_host = false;
478 for line in yml.lines() {
479 let raw = line;
480 let trimmed = raw.trim();
481 if trimmed.starts_with(host) && trimmed.ends_with(':') {
482 in_host = true;
483 continue;
484 }
485 if !raw.starts_with(' ') && !raw.starts_with('\t') && !trimmed.is_empty() {
486 in_host = false;
487 }
488 if in_host {
489 if let Some(rest) = trimmed.strip_prefix("oauth_token:") {
490 return Some(rest.trim().to_string());
491 }
492 }
493 }
494 None
495}
496
497fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<(), FetchError> {
499 let gz = flate2::read::GzDecoder::new(bytes);
500
501 let mut buffered = std::io::BufReader::new(gz);
504 let mut peek = [0u8; 1];
505 match buffered.read(&mut peek) {
507 Ok(0) => {
508 return Err(FetchError::Extract("empty archive".into()));
509 }
510 Err(e) => {
511 return Err(FetchError::Extract(format!("gzip decompression: {e}")));
512 }
513 Ok(_) => {
514 let cursor = std::io::Cursor::new(peek);
516 let chain = cursor.chain(buffered);
517 let mut archive = tar::Archive::new(chain);
518 archive
519 .unpack(dest)
520 .map_err(|e| FetchError::Extract(format!("tar unpack: {e}")))?;
521 }
522 }
523
524 Ok(())
525}
526
527fn url_to_safe_name(url: &str) -> String {
529 url.chars()
530 .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
531 .collect()
532}
533
534fn dirs_cache_dir() -> PathBuf {
536 if let Ok(xdg) = std::env::var("XDG_CACHE_HOME")
538 && !xdg.is_empty() {
539 return PathBuf::from(xdg);
540 }
541 if let Some(home) = std::env::var_os("HOME") {
542 let default = PathBuf::from(home).join(".cache");
543 if default.exists() || std::fs::create_dir_all(&default).is_ok() {
544 return default;
545 }
546 }
547 PathBuf::from("/tmp")
548}
549
550#[cfg(test)]
553mod tests {
554 use super::*;
555 use std::collections::BTreeMap;
556
557 fn make_locked(source_type: &str) -> LockedInput {
559 LockedInput {
560 source_type: source_type.to_string(),
561 owner: None,
562 repo: None,
563 rev: None,
564 nar_hash: None,
565 last_modified: None,
566 path: None,
567 url: None,
568 git_ref: None,
569 dir: None,
570 host: None,
571 extra: BTreeMap::new(),
572 }
573 }
574
575 #[test]
578 fn sanitize_hash_replaces_special_chars() {
579 assert_eq!(
580 sanitize_hash("sha256-AAAAAAAAAAAAAAAAAAAAAA="),
581 "sha256-AAAAAAAAAAAAAAAAAAAAAA"
582 );
583 assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
584 }
585
586 #[test]
589 fn find_single_subdir_returns_child_when_one_dir() {
590 let tmp = tempfile::tempdir().unwrap();
591 let child = tmp.path().join("repo-abc123");
592 std::fs::create_dir(&child).unwrap();
593 std::fs::write(child.join("file.txt"), "hello").unwrap();
594
595 let result = find_single_subdir_or_self(tmp.path());
596 assert_eq!(result, child);
597 }
598
599 #[test]
600 fn find_single_subdir_returns_self_when_multiple() {
601 let tmp = tempfile::tempdir().unwrap();
602 std::fs::create_dir(tmp.path().join("a")).unwrap();
603 std::fs::create_dir(tmp.path().join("b")).unwrap();
604
605 let result = find_single_subdir_or_self(tmp.path());
606 assert_eq!(result, tmp.path());
607 }
608
609 #[test]
610 fn find_single_subdir_returns_self_when_empty() {
611 let tmp = tempfile::tempdir().unwrap();
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_child_is_file() {
618 let tmp = tempfile::tempdir().unwrap();
619 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
620 let result = find_single_subdir_or_self(tmp.path());
621 assert_eq!(result, tmp.path());
622 }
623
624 #[test]
627 fn url_to_safe_name_replaces_slashes_and_colons() {
628 let name = url_to_safe_name("https://example.com/foo/bar.tar.gz");
629 assert!(!name.contains('/'));
630 assert!(!name.contains(':'));
631 assert!(name.contains("example"));
632 }
633
634 #[test]
637 fn fetcher_with_custom_cache_dir() {
638 let tmp = tempfile::tempdir().unwrap();
639 let fetcher = InputFetcher::with_cache_dir(tmp.path().to_path_buf());
640 assert_eq!(fetcher.cache_dir(), tmp.path());
641 }
642
643 #[test]
644 fn fetcher_default_cache_dir_exists() {
645 let fetcher = InputFetcher::new();
646 let path_str = fetcher.cache_dir().to_string_lossy();
648 assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
649 }
650
651 #[test]
654 fn fetch_path_returns_filesystem_path() {
655 let tmp = tempfile::tempdir().unwrap();
656 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
657
658 let mut locked = make_locked("path");
659 locked.path = Some("/var/empty/dep".to_string());
660
661 let result = fetcher.fetch(&locked).unwrap();
662 assert_eq!(result, PathBuf::from("/var/empty/dep"));
663 }
664
665 #[test]
666 fn fetch_path_missing_field_errors() {
667 let tmp = tempfile::tempdir().unwrap();
668 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
669 let locked = make_locked("path");
670 let result = fetcher.fetch(&locked);
671 assert!(result.is_err());
672 assert!(result.unwrap_err().to_string().contains("path"));
673 }
674
675 #[test]
678 fn fetch_unsupported_type_returns_error() {
679 let tmp = tempfile::tempdir().unwrap();
685 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
686 let locked = make_locked("mercurial");
687 let result = fetcher.fetch(&locked);
688 assert!(matches!(result, Err(FetchError::UnsupportedType(_))));
689 }
690
691 #[test]
692 fn gitlab_archive_url_is_well_formed() {
693 assert_eq!(
694 InputFetcher::gitlab_archive_url(None, "group", "proj", "abc123"),
695 "https://gitlab.com/group/proj/-/archive/abc123/proj-abc123.tar.gz"
696 );
697 }
698
699 #[test]
700 fn gitlab_archive_url_honors_custom_host() {
701 assert_eq!(
702 InputFetcher::gitlab_archive_url(Some("gitlab.gnome.org"), "GNOME", "gnome-shell", "abc"),
703 "https://gitlab.gnome.org/GNOME/gnome-shell/-/archive/abc/gnome-shell-abc.tar.gz"
704 );
705 }
706
707 #[test]
708 fn sourcehut_archive_url_prepends_tilde() {
709 assert_eq!(
713 InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
714 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
715 );
716 assert_eq!(
718 InputFetcher::sourcehut_archive_url("~emersion", "page", "HEAD"),
719 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
720 );
721 }
722
723 #[test]
726 fn cache_hit_returns_cached_path() {
727 let tmp = tempfile::tempdir().unwrap();
728 let cache_dir = tmp.path().join("cache");
729 std::fs::create_dir_all(&cache_dir).unwrap();
730
731 let hash = "sha256-TESTCACHEHIT";
733 let cached_dir = cache_dir.join(sanitize_hash(hash));
734 std::fs::create_dir_all(&cached_dir).unwrap();
735 std::fs::write(cached_dir.join("flake.nix"), "{}").unwrap();
736
737 let fetcher = InputFetcher::with_cache_dir(cache_dir);
738 let mut locked = make_locked("github");
739 locked.nar_hash = Some(hash.to_string());
740 let result = fetcher.fetch(&locked).unwrap();
743 assert_eq!(result, cached_dir);
745 }
746
747 #[test]
750 fn github_archive_url_format() {
751 let url = InputFetcher::github_archive_url("nixos", "nixpkgs", "abc123");
752 assert_eq!(
753 url,
754 "https://github.com/nixos/nixpkgs/archive/abc123.tar.gz"
755 );
756 }
757
758 #[test]
761 fn fetch_github_missing_owner_errors() {
762 let tmp = tempfile::tempdir().unwrap();
763 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
764 let mut locked = make_locked("github");
765 locked.repo = Some("nixpkgs".into());
766 locked.rev = Some("abc123".into());
767 let result = fetcher.fetch(&locked);
768 assert!(result.is_err());
769 assert!(result.unwrap_err().to_string().contains("owner"));
770 }
771
772 #[test]
773 fn fetch_github_missing_rev_errors() {
774 let tmp = tempfile::tempdir().unwrap();
775 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
776 let mut locked = make_locked("github");
777 locked.owner = Some("nixos".into());
778 locked.repo = Some("nixpkgs".into());
779 let result = fetcher.fetch(&locked);
780 assert!(result.is_err());
781 assert!(result.unwrap_err().to_string().contains("rev"));
782 }
783
784 #[test]
787 fn fetch_git_missing_url_errors() {
788 let tmp = tempfile::tempdir().unwrap();
789 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
790 let mut locked = make_locked("git");
791 locked.rev = Some("abc123".into());
792 let result = fetcher.fetch(&locked);
793 assert!(result.is_err());
794 assert!(result.unwrap_err().to_string().contains("url"));
795 }
796
797 #[test]
798 fn fetch_git_missing_rev_errors() {
799 let tmp = tempfile::tempdir().unwrap();
800 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
801 let mut locked = make_locked("git");
802 locked.url = Some("https://example.com/repo.git".into());
803 let result = fetcher.fetch(&locked);
804 assert!(result.is_err());
805 assert!(result.unwrap_err().to_string().contains("rev"));
806 }
807
808 #[test]
811 fn fetch_tarball_missing_url_errors() {
812 let tmp = tempfile::tempdir().unwrap();
813 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
814 let locked = make_locked("tarball");
815 let result = fetcher.fetch(&locked);
816 assert!(result.is_err());
817 assert!(result.unwrap_err().to_string().contains("url"));
818 }
819
820 #[test]
823 fn extract_tar_gz_empty_archive_errors() {
824 let tmp = tempfile::tempdir().unwrap();
825 let result = extract_tar_gz(&[], tmp.path());
826 assert!(result.is_err());
827 }
828
829 #[test]
830 fn extract_tar_gz_invalid_data_errors() {
831 let tmp = tempfile::tempdir().unwrap();
832 let result = extract_tar_gz(b"not a gzip stream at all", tmp.path());
833 assert!(result.is_err());
834 }
835
836 #[test]
839 fn dest_dir_uses_nar_hash_when_present() {
840 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
841 let mut locked = make_locked("github");
842 locked.nar_hash = Some("sha256-ABC123=".to_string());
843 let dest = fetcher.dest_dir(&locked, "fallback");
844 assert!(dest.to_string_lossy().contains("sha256-ABC123"));
845 assert!(!dest.to_string_lossy().contains("fallback"));
846 }
847
848 #[test]
849 fn dest_dir_uses_fallback_when_no_hash() {
850 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
851 let locked = make_locked("github");
852 let dest = fetcher.dest_dir(&locked, "fallback-name");
853 assert!(dest.to_string_lossy().contains("fallback-name"));
854 }
855
856 #[test]
859 fn is_non_empty_dir_returns_true_for_non_empty() {
860 let tmp = tempfile::tempdir().unwrap();
861 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
862 assert!(is_non_empty_dir(tmp.path()));
863 }
864
865 #[test]
866 fn is_non_empty_dir_returns_false_for_empty() {
867 let tmp = tempfile::tempdir().unwrap();
868 assert!(!is_non_empty_dir(tmp.path()));
869 }
870
871 #[test]
872 fn is_non_empty_dir_returns_false_for_missing() {
873 assert!(!is_non_empty_dir(Path::new("/nonexistent/path/12345")));
874 }
875
876 #[test]
879 fn empty_cache_dir_is_treated_as_miss() {
880 let tmp = tempfile::tempdir().unwrap();
881 let cache_dir = tmp.path().join("cache");
882 std::fs::create_dir_all(&cache_dir).unwrap();
883
884 let hash = "sha256-EMPTYTEST";
886 let cached_dir = cache_dir.join(sanitize_hash(hash));
887 std::fs::create_dir_all(&cached_dir).unwrap();
888 assert!(std::fs::read_dir(&cached_dir).unwrap().next().is_none());
890
891 let fetcher = InputFetcher::with_cache_dir(cache_dir);
892 let mut locked = make_locked("github");
893 locked.nar_hash = Some(hash.to_string());
894 let result = fetcher.fetch(&locked);
898 assert!(result.is_err(), "should not return stale empty cache");
899 assert!(!cached_dir.exists(), "stale cache dir should be removed");
901 }
902
903 #[test]
906 fn tarball_from_https_github() {
907 let url = github_tarball_from_git_url(
908 "https://github.com/NixOS/nixpkgs.git",
909 "abc123",
910 );
911 assert_eq!(
912 url.as_deref(),
913 Some("https://github.com/NixOS/nixpkgs/archive/abc123.tar.gz")
914 );
915 }
916
917 #[test]
918 fn tarball_from_git_plus_https() {
919 let url = github_tarball_from_git_url(
920 "git+https://github.com/NixOS/nixpkgs",
921 "def456",
922 );
923 assert_eq!(
924 url.as_deref(),
925 Some("https://github.com/NixOS/nixpkgs/archive/def456.tar.gz")
926 );
927 }
928
929 #[test]
930 fn tarball_from_non_github_returns_none() {
931 assert!(github_tarball_from_git_url("https://gitlab.com/foo/bar.git", "abc").is_none());
932 assert!(github_tarball_from_git_url("ssh://git@github.com/foo/bar", "abc").is_none());
933 }
934
935 #[test]
936 fn tarball_from_malformed_path_returns_none() {
937 assert!(github_tarball_from_git_url("https://github.com/", "abc").is_none());
938 assert!(github_tarball_from_git_url("https://github.com/only-owner", "abc").is_none());
939 }
940}