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 Ok(xdg) = std::env::var("XDG_CACHE_HOME")
539 && !xdg.is_empty() {
540 return PathBuf::from(xdg);
541 }
542 if let Some(home) = std::env::var_os("HOME") {
543 let default = PathBuf::from(home).join(".cache");
544 if default.exists() || std::fs::create_dir_all(&default).is_ok() {
545 return default;
546 }
547 }
548 PathBuf::from("/tmp")
549}
550
551#[cfg(test)]
554mod tests {
555 use super::*;
556 use std::collections::BTreeMap;
557
558 fn make_locked(source_type: &str) -> LockedInput {
560 LockedInput {
561 source_type: source_type.to_string(),
562 owner: None,
563 repo: None,
564 rev: None,
565 nar_hash: None,
566 last_modified: None,
567 path: None,
568 url: None,
569 git_ref: None,
570 dir: None,
571 host: None,
572 extra: BTreeMap::new(),
573 }
574 }
575
576 #[test]
579 fn sanitize_hash_replaces_special_chars() {
580 assert_eq!(
581 sanitize_hash("sha256-AAAAAAAAAAAAAAAAAAAAAA="),
582 "sha256-AAAAAAAAAAAAAAAAAAAAAA"
583 );
584 assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
585 }
586
587 #[test]
590 fn find_single_subdir_returns_child_when_one_dir() {
591 let tmp = tempfile::tempdir().unwrap();
592 let child = tmp.path().join("repo-abc123");
593 std::fs::create_dir(&child).unwrap();
594 std::fs::write(child.join("file.txt"), "hello").unwrap();
595
596 let result = find_single_subdir_or_self(tmp.path());
597 assert_eq!(result, child);
598 }
599
600 #[test]
601 fn find_single_subdir_returns_self_when_multiple() {
602 let tmp = tempfile::tempdir().unwrap();
603 std::fs::create_dir(tmp.path().join("a")).unwrap();
604 std::fs::create_dir(tmp.path().join("b")).unwrap();
605
606 let result = find_single_subdir_or_self(tmp.path());
607 assert_eq!(result, tmp.path());
608 }
609
610 #[test]
611 fn find_single_subdir_returns_self_when_empty() {
612 let tmp = tempfile::tempdir().unwrap();
613 let result = find_single_subdir_or_self(tmp.path());
614 assert_eq!(result, tmp.path());
615 }
616
617 #[test]
618 fn find_single_subdir_returns_self_when_child_is_file() {
619 let tmp = tempfile::tempdir().unwrap();
620 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
621 let result = find_single_subdir_or_self(tmp.path());
622 assert_eq!(result, tmp.path());
623 }
624
625 #[test]
628 fn url_to_safe_name_replaces_slashes_and_colons() {
629 let name = url_to_safe_name("https://example.com/foo/bar.tar.gz");
630 assert!(!name.contains('/'));
631 assert!(!name.contains(':'));
632 assert!(name.contains("example"));
633 }
634
635 #[test]
638 fn fetcher_with_custom_cache_dir() {
639 let tmp = tempfile::tempdir().unwrap();
640 let fetcher = InputFetcher::with_cache_dir(tmp.path().to_path_buf());
641 assert_eq!(fetcher.cache_dir(), tmp.path());
642 }
643
644 #[test]
645 fn fetcher_default_cache_dir_exists() {
646 let fetcher = InputFetcher::new();
647 let path_str = fetcher.cache_dir().to_string_lossy();
649 assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
650 }
651
652 #[test]
655 fn fetch_path_returns_filesystem_path() {
656 let tmp = tempfile::tempdir().unwrap();
657 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
658
659 let mut locked = make_locked("path");
660 locked.path = Some("/var/empty/dep".to_string());
661
662 let result = fetcher.fetch(&locked).unwrap();
663 assert_eq!(result, PathBuf::from("/var/empty/dep"));
664 }
665
666 #[test]
667 fn fetch_path_missing_field_errors() {
668 let tmp = tempfile::tempdir().unwrap();
669 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
670 let locked = make_locked("path");
671 let result = fetcher.fetch(&locked);
672 assert!(result.is_err());
673 assert!(result.unwrap_err().to_string().contains("path"));
674 }
675
676 #[test]
679 fn fetch_unsupported_type_returns_error() {
680 let tmp = tempfile::tempdir().unwrap();
686 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
687 let locked = make_locked("mercurial");
688 let result = fetcher.fetch(&locked);
689 assert!(matches!(result, Err(FetchError::UnsupportedType(_))));
690 }
691
692 #[test]
693 fn gitlab_archive_url_is_well_formed() {
694 assert_eq!(
695 InputFetcher::gitlab_archive_url(None, "group", "proj", "abc123"),
696 "https://gitlab.com/group/proj/-/archive/abc123/proj-abc123.tar.gz"
697 );
698 }
699
700 #[test]
701 fn gitlab_archive_url_honors_custom_host() {
702 assert_eq!(
703 InputFetcher::gitlab_archive_url(Some("gitlab.gnome.org"), "GNOME", "gnome-shell", "abc"),
704 "https://gitlab.gnome.org/GNOME/gnome-shell/-/archive/abc/gnome-shell-abc.tar.gz"
705 );
706 }
707
708 #[test]
709 fn sourcehut_archive_url_prepends_tilde() {
710 assert_eq!(
714 InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
715 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
716 );
717 assert_eq!(
719 InputFetcher::sourcehut_archive_url("~emersion", "page", "HEAD"),
720 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
721 );
722 }
723
724 #[test]
727 fn cache_hit_returns_cached_path() {
728 let tmp = tempfile::tempdir().unwrap();
729 let cache_dir = tmp.path().join("cache");
730 std::fs::create_dir_all(&cache_dir).unwrap();
731
732 let hash = "sha256-TESTCACHEHIT";
734 let cached_dir = cache_dir.join(sanitize_hash(hash));
735 std::fs::create_dir_all(&cached_dir).unwrap();
736 std::fs::write(cached_dir.join("flake.nix"), "{}").unwrap();
737
738 let fetcher = InputFetcher::with_cache_dir(cache_dir);
739 let mut locked = make_locked("github");
740 locked.nar_hash = Some(hash.to_string());
741 let result = fetcher.fetch(&locked).unwrap();
744 assert_eq!(result, cached_dir);
746 }
747
748 #[test]
751 fn github_archive_url_format() {
752 let url = InputFetcher::github_archive_url("nixos", "nixpkgs", "abc123");
753 assert_eq!(
754 url,
755 "https://github.com/nixos/nixpkgs/archive/abc123.tar.gz"
756 );
757 }
758
759 #[test]
762 fn fetch_github_missing_owner_errors() {
763 let tmp = tempfile::tempdir().unwrap();
764 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
765 let mut locked = make_locked("github");
766 locked.repo = Some("nixpkgs".into());
767 locked.rev = Some("abc123".into());
768 let result = fetcher.fetch(&locked);
769 assert!(result.is_err());
770 assert!(result.unwrap_err().to_string().contains("owner"));
771 }
772
773 #[test]
774 fn fetch_github_missing_rev_errors() {
775 let tmp = tempfile::tempdir().unwrap();
776 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
777 let mut locked = make_locked("github");
778 locked.owner = Some("nixos".into());
779 locked.repo = Some("nixpkgs".into());
780 let result = fetcher.fetch(&locked);
781 assert!(result.is_err());
782 assert!(result.unwrap_err().to_string().contains("rev"));
783 }
784
785 #[test]
788 fn fetch_git_missing_url_errors() {
789 let tmp = tempfile::tempdir().unwrap();
790 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
791 let mut locked = make_locked("git");
792 locked.rev = Some("abc123".into());
793 let result = fetcher.fetch(&locked);
794 assert!(result.is_err());
795 assert!(result.unwrap_err().to_string().contains("url"));
796 }
797
798 #[test]
799 fn fetch_git_missing_rev_errors() {
800 let tmp = tempfile::tempdir().unwrap();
801 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
802 let mut locked = make_locked("git");
803 locked.url = Some("https://example.com/repo.git".into());
804 let result = fetcher.fetch(&locked);
805 assert!(result.is_err());
806 assert!(result.unwrap_err().to_string().contains("rev"));
807 }
808
809 #[test]
812 fn fetch_tarball_missing_url_errors() {
813 let tmp = tempfile::tempdir().unwrap();
814 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
815 let locked = make_locked("tarball");
816 let result = fetcher.fetch(&locked);
817 assert!(result.is_err());
818 assert!(result.unwrap_err().to_string().contains("url"));
819 }
820
821 #[test]
824 fn extract_tar_gz_empty_archive_errors() {
825 let tmp = tempfile::tempdir().unwrap();
826 let result = extract_tar_gz(&[], tmp.path());
827 assert!(result.is_err());
828 }
829
830 #[test]
831 fn extract_tar_gz_invalid_data_errors() {
832 let tmp = tempfile::tempdir().unwrap();
833 let result = extract_tar_gz(b"not a gzip stream at all", tmp.path());
834 assert!(result.is_err());
835 }
836
837 #[test]
840 fn dest_dir_uses_nar_hash_when_present() {
841 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
842 let mut locked = make_locked("github");
843 locked.nar_hash = Some("sha256-ABC123=".to_string());
844 let dest = fetcher.dest_dir(&locked, "fallback");
845 assert!(dest.to_string_lossy().contains("sha256-ABC123"));
846 assert!(!dest.to_string_lossy().contains("fallback"));
847 }
848
849 #[test]
850 fn dest_dir_uses_fallback_when_no_hash() {
851 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
852 let locked = make_locked("github");
853 let dest = fetcher.dest_dir(&locked, "fallback-name");
854 assert!(dest.to_string_lossy().contains("fallback-name"));
855 }
856
857 #[test]
860 fn is_non_empty_dir_returns_true_for_non_empty() {
861 let tmp = tempfile::tempdir().unwrap();
862 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
863 assert!(is_non_empty_dir(tmp.path()));
864 }
865
866 #[test]
867 fn is_non_empty_dir_returns_false_for_empty() {
868 let tmp = tempfile::tempdir().unwrap();
869 assert!(!is_non_empty_dir(tmp.path()));
870 }
871
872 #[test]
873 fn is_non_empty_dir_returns_false_for_missing() {
874 assert!(!is_non_empty_dir(Path::new("/nonexistent/path/12345")));
875 }
876
877 #[test]
880 fn empty_cache_dir_is_treated_as_miss() {
881 let tmp = tempfile::tempdir().unwrap();
882 let cache_dir = tmp.path().join("cache");
883 std::fs::create_dir_all(&cache_dir).unwrap();
884
885 let hash = "sha256-EMPTYTEST";
887 let cached_dir = cache_dir.join(sanitize_hash(hash));
888 std::fs::create_dir_all(&cached_dir).unwrap();
889 assert!(std::fs::read_dir(&cached_dir).unwrap().next().is_none());
891
892 let fetcher = InputFetcher::with_cache_dir(cache_dir);
893 let mut locked = make_locked("github");
894 locked.nar_hash = Some(hash.to_string());
895 let result = fetcher.fetch(&locked);
899 assert!(result.is_err(), "should not return stale empty cache");
900 assert!(!cached_dir.exists(), "stale cache dir should be removed");
902 }
903
904 #[test]
907 fn tarball_from_https_github() {
908 let url = github_tarball_from_git_url(
909 "https://github.com/NixOS/nixpkgs.git",
910 "abc123",
911 );
912 assert_eq!(
913 url.as_deref(),
914 Some("https://github.com/NixOS/nixpkgs/archive/abc123.tar.gz")
915 );
916 }
917
918 #[test]
919 fn tarball_from_git_plus_https() {
920 let url = github_tarball_from_git_url(
921 "git+https://github.com/NixOS/nixpkgs",
922 "def456",
923 );
924 assert_eq!(
925 url.as_deref(),
926 Some("https://github.com/NixOS/nixpkgs/archive/def456.tar.gz")
927 );
928 }
929
930 #[test]
931 fn tarball_from_non_github_returns_none() {
932 assert!(github_tarball_from_git_url("https://gitlab.com/foo/bar.git", "abc").is_none());
933 assert!(github_tarball_from_git_url("ssh://git@github.com/foo/bar", "abc").is_none());
934 }
935
936 #[test]
937 fn tarball_from_malformed_path_returns_none() {
938 assert!(github_tarball_from_git_url("https://github.com/", "abc").is_none());
939 assert!(github_tarball_from_git_url("https://github.com/only-owner", "abc").is_none());
940 }
941}
942
943
944pub fn resolve_flake_dir(flake_ref: &FlakeRef) -> Result<std::path::PathBuf, FetchError> {
964 match flake_ref.local_dir() {
965 Some(p) => Ok(p.to_path_buf()),
966 None => {
967 let locked = flake_ref
968 .source
969 .locked_input()
970 .ok_or_else(|| FetchError::UnsupportedType("non-fetchable flake source".into()))?;
971 InputFetcher::new().fetch(&locked)
972 }
973 }
974}