1use std::io::Read as _;
8use std::path::{Path, PathBuf};
9
10use sui_compat::flake::LockedInput;
11use sui_compat::flake_ref::FlakeRef;
12
13#[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
30pub 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 #[must_use]
50 pub fn new() -> Self {
51 let cache_dir = dirs_cache_dir().join("sui/inputs");
52 Self { cache_dir }
53 }
54
55 #[must_use]
57 pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
58 Self { cache_dir }
59 }
60
61 #[must_use]
63 pub fn cache_dir(&self) -> &Path {
64 &self.cache_dir
65 }
66
67 pub fn fetch(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
72 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 if is_non_empty_dir(&resolved) {
83 return Ok(resolved);
84 }
85 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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
320fn 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 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
344fn sanitize_hash(hash: &str) -> String {
346 hash.replace(':', "-").replace('/', "_").replace('=', "")
347}
348
349fn 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
356fn 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
373fn download_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
380 let mut req = ureq::get(url);
381
382 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
411fn 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
454fn 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
473fn 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
498fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<(), FetchError> {
500 let gz = flate2::read::GzDecoder::new(bytes);
501
502 let mut buffered = std::io::BufReader::new(gz);
505 let mut peek = [0u8; 1];
506 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 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
528fn 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
535fn dirs_cache_dir() -> PathBuf {
537 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#[cfg(test)]
560mod tests {
561 use super::*;
562 use std::collections::BTreeMap;
563
564 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 #[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 #[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 #[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 #[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 let path_str = fetcher.cache_dir().to_string_lossy();
655 assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
656 }
657
658 #[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 #[test]
685 fn fetch_unsupported_type_returns_error() {
686 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 assert_eq!(
720 InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
721 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
722 );
723 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 #[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 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 let result = fetcher.fetch(&locked).unwrap();
750 assert_eq!(result, cached_dir);
752 }
753
754 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let hash = "sha256-EMPTYTEST";
893 let cached_dir = cache_dir.join(sanitize_hash(hash));
894 std::fs::create_dir_all(&cached_dir).unwrap();
895 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 let result = fetcher.fetch(&locked);
905 assert!(result.is_err(), "should not return stale empty cache");
906 assert!(!cached_dir.exists(), "stale cache dir should be removed");
908 }
909
910 #[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
950pub 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}