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 self.fetch_archive(locked, &url, &format!("github-{owner}-{repo}-{rev}"), rev)
146 }
147
148 fn fetch_archive(
178 &self,
179 locked: &LockedInput,
180 url: &str,
181 cache_key: &str,
182 rev: &str,
183 ) -> Result<PathBuf, FetchError> {
184 let dest = self.dest_dir(locked, cache_key);
185
186 if is_immutable_rev(rev) && is_non_empty_dir(&dest) {
199 return Ok(find_single_subdir_or_self(&dest));
200 }
201
202 let staging = staging_path(&dest);
203 let _ = std::fs::remove_dir_all(&staging);
206 std::fs::create_dir_all(&staging)?;
207
208 let bytes = match download_bytes(url) {
209 Ok(b) => b,
210 Err(e) => {
211 let _ = std::fs::remove_dir_all(&staging);
212 return Err(e);
213 }
214 };
215 if let Err(e) = extract_tar_gz(&bytes, &staging) {
216 let _ = std::fs::remove_dir_all(&staging);
217 return Err(e);
218 }
219
220 if dest.exists() {
225 let _ = std::fs::remove_dir_all(&dest);
226 }
227 if std::fs::rename(&staging, &dest).is_err() {
228 let _ = std::fs::remove_dir_all(&staging);
229 if !is_non_empty_dir(&dest) {
231 return Err(FetchError::Extract(
232 "could not publish the fetched tree and no other process left one".into(),
233 ));
234 }
235 }
236 Ok(find_single_subdir_or_self(&dest))
237 }
238
239 fn fetch_gitlab(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
240 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
241 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
242 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
243 let host = locked.host.as_deref();
244 let url = Self::gitlab_archive_url(host, owner, repo, rev);
245 let host_tag = host.unwrap_or("gitlab.com").replace('.', "_");
246 self.fetch_archive(locked, &url, &format!("gitlab-{host_tag}-{owner}-{repo}-{rev}"), rev)
247 }
248
249 fn fetch_sourcehut(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
250 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
251 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
252 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
253 let url = Self::sourcehut_archive_url(owner, repo, rev);
254 let sanitized_owner = owner.trim_start_matches('~');
255 self.fetch_archive(
256 locked,
257 &url,
258 &format!("sourcehut-{sanitized_owner}-{repo}-{rev}"),
259 rev,
260 )
261 }
262
263 fn fetch_path(locked: &LockedInput) -> Result<PathBuf, FetchError> {
264 let path = locked
265 .path
266 .as_deref()
267 .ok_or(FetchError::MissingField("path"))?;
268 Ok(PathBuf::from(path))
269 }
270
271 fn fetch_git(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
272 let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
273 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
274
275 let short_rev: String = rev.chars().take(12).collect();
276 let dest = self.dest_dir(locked, &format!("git-{short_rev}"));
277
278 if dest.exists() {
279 if is_non_empty_dir(&dest) {
280 return Ok(dest);
281 }
282 let _ = std::fs::remove_dir_all(&dest);
283 }
284
285 if let Some(tarball_url) = github_tarball_from_git_url(url, rev) {
289 std::fs::create_dir_all(&dest)?;
290 match download_bytes(&tarball_url) {
291 Ok(bytes) => {
292 if let Err(e) = extract_tar_gz(&bytes, &dest) {
293 let _ = std::fs::remove_dir_all(&dest);
294 return Err(e);
295 }
296 return Ok(find_single_subdir_or_self(&dest));
297 }
298 Err(e) => {
299 let _ = std::fs::remove_dir_all(&dest);
301 tracing::debug!(url = %tarball_url, error = %e, "Tarball fallback failed, trying git CLI");
302 }
303 }
304 }
305
306 let status = std::process::Command::new("git")
308 .args(["clone", "--depth", "1", url])
309 .arg(&dest)
310 .stdout(std::process::Stdio::null())
311 .stderr(std::process::Stdio::null())
312 .status()
313 .map_err(|e| FetchError::Download(format!(
314 "git clone failed (git not in PATH?): {e}"
315 )))?;
316 if !status.success() {
317 let _ = std::fs::remove_dir_all(&dest);
318 return Err(FetchError::Download(format!(
319 "git clone failed for {url} (exit code: {})",
320 status.code().unwrap_or(-1)
321 )));
322 }
323
324 crate::git::checkout_rev(&dest, rev)
326 .map_err(|e| FetchError::Download(format!("git checkout {rev}: {e}")))?;
327
328 Ok(dest)
329 }
330
331 fn fetch_tarball(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
332 let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
333
334 let hash_suffix = locked
335 .nar_hash
336 .as_deref()
337 .map_or_else(|| url_to_safe_name(url), sanitize_hash);
338 let dest = self.dest_dir(locked, &format!("tarball-{hash_suffix}"));
339
340 if dest.exists() {
341 let resolved = find_single_subdir_or_self(&dest);
342 if is_non_empty_dir(&resolved) {
343 return Ok(resolved);
344 }
345 let _ = std::fs::remove_dir_all(&dest);
346 }
347
348 std::fs::create_dir_all(&dest)?;
349 let bytes = match download_bytes(url) {
350 Ok(b) => b,
351 Err(e) => {
352 let _ = std::fs::remove_dir_all(&dest);
353 return Err(e);
354 }
355 };
356 if let Err(e) = extract_tar_gz(&bytes, &dest) {
357 let _ = std::fs::remove_dir_all(&dest);
358 return Err(e);
359 }
360
361 Ok(find_single_subdir_or_self(&dest))
362 }
363
364 fn dest_dir(&self, locked: &LockedInput, fallback: &str) -> PathBuf {
366 if let Some(ref nar_hash) = locked.nar_hash {
367 self.cache_dir.join(sanitize_hash(nar_hash))
368 } else {
369 self.cache_dir.join(fallback)
370 }
371 }
372}
373
374fn github_tarball_from_git_url(url: &str, rev: &str) -> Option<String> {
381 let stripped = url
382 .strip_prefix("https://github.com/")
383 .or_else(|| url.strip_prefix("git+https://github.com/"))
384 .or_else(|| url.strip_prefix("http://github.com/"))?;
385 let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
386 let parts: Vec<&str> = stripped.split('/').collect();
388 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
389 Some(format!(
390 "https://github.com/{}/{}/archive/{rev}.tar.gz",
391 parts[0], parts[1]
392 ))
393 } else {
394 None
395 }
396}
397
398fn sanitize_hash(hash: &str) -> String {
400 hash.replace(':', "-").replace('/', "_").replace('=', "")
401}
402
403fn is_immutable_rev(rev: &str) -> bool {
410 matches!(rev.len(), 40 | 64) && rev.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
411}
412
413fn staging_path(dest: &Path) -> PathBuf {
416 let name = dest
417 .file_name()
418 .map_or_else(|| "fetch".to_string(), |n| n.to_string_lossy().into_owned());
419 let tmp = [".", &name, ".tmp-", &std::process::id().to_string()].concat();
420 dest.parent()
421 .map_or_else(|| PathBuf::from(&tmp), |p| p.join(&tmp))
422}
423
424fn is_non_empty_dir(dir: &Path) -> bool {
426 std::fs::read_dir(dir)
427 .ok()
428 .is_some_and(|mut rd| rd.next().is_some())
429}
430
431fn find_single_subdir_or_self(dir: &Path) -> PathBuf {
435 let entries: Vec<_> = std::fs::read_dir(dir)
436 .ok()
437 .into_iter()
438 .flatten()
439 .filter_map(|e| e.ok())
440 .collect();
441 if entries.len() == 1 && entries[0].path().is_dir() {
442 entries[0].path()
443 } else {
444 dir.to_path_buf()
445 }
446}
447
448fn download_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
455 let mut req = ureq::get(url);
456
457 if let Some(token) = github_token_for_url(url) {
464 req = req.header("Authorization", &format!("token {token}"));
465 }
466
467 let mut response = req
468 .call()
469 .map_err(|e| FetchError::Download(format!("{url}: {e}")))?;
470
471 if !response.status().is_success() {
472 return Err(FetchError::Download(format!(
473 "{url}: HTTP {}",
474 response.status().as_u16()
475 )));
476 }
477
478 response
479 .body_mut()
480 .with_config()
481 .limit(512 * 1024 * 1024)
482 .read_to_vec()
483 .map_err(|e| FetchError::Download(format!("{url}: {e}")))
484}
485
486fn github_token_for_url(url: &str) -> Option<String> {
497 if !url.starts_with("https://github.com/")
498 && !url.starts_with("https://api.github.com/")
499 {
500 return None;
501 }
502 if let Ok(t) = std::env::var("GITHUB_TOKEN") {
503 if !t.is_empty() {
504 return Some(t);
505 }
506 }
507 if let Ok(cfg) = std::env::var("NIX_CONFIG") {
508 if let Some(t) = parse_access_tokens(&cfg, "github.com") {
509 return Some(t);
510 }
511 }
512 if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
513 let nix_conf = home.join(".config/nix/nix.conf");
514 if let Ok(cfg) = std::fs::read_to_string(&nix_conf) {
515 if let Some(t) = parse_access_tokens(&cfg, "github.com") {
516 return Some(t);
517 }
518 }
519 let gh_hosts = home.join(".config/gh/hosts.yml");
520 if let Ok(yml) = std::fs::read_to_string(&gh_hosts) {
521 if let Some(t) = parse_gh_hosts_token(&yml, "github.com") {
522 return Some(t);
523 }
524 }
525 }
526 None
527}
528
529fn parse_access_tokens(cfg: &str, host: &str) -> Option<String> {
532 for line in cfg.lines() {
533 let trimmed = line.trim();
534 if let Some(rest) = trimmed.strip_prefix("access-tokens") {
535 let rest = rest.trim_start().trim_start_matches('=').trim();
536 for pair in rest.split_whitespace() {
537 if let Some((h, t)) = pair.split_once('=') {
538 if h == host {
539 return Some(t.to_string());
540 }
541 }
542 }
543 }
544 }
545 None
546}
547
548fn parse_gh_hosts_token(yml: &str, host: &str) -> Option<String> {
553 let mut in_host = false;
554 for line in yml.lines() {
555 let raw = line;
556 let trimmed = raw.trim();
557 if trimmed.starts_with(host) && trimmed.ends_with(':') {
558 in_host = true;
559 continue;
560 }
561 if !raw.starts_with(' ') && !raw.starts_with('\t') && !trimmed.is_empty() {
562 in_host = false;
563 }
564 if in_host {
565 if let Some(rest) = trimmed.strip_prefix("oauth_token:") {
566 return Some(rest.trim().to_string());
567 }
568 }
569 }
570 None
571}
572
573fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<(), FetchError> {
575 let gz = flate2::read::GzDecoder::new(bytes);
576
577 let mut buffered = std::io::BufReader::new(gz);
580 let mut peek = [0u8; 1];
581 match buffered.read(&mut peek) {
583 Ok(0) => {
584 return Err(FetchError::Extract("empty archive".into()));
585 }
586 Err(e) => {
587 return Err(FetchError::Extract(format!("gzip decompression: {e}")));
588 }
589 Ok(_) => {
590 let cursor = std::io::Cursor::new(peek);
592 let chain = cursor.chain(buffered);
593 let mut archive = tar::Archive::new(chain);
594 archive
595 .unpack(dest)
596 .map_err(|e| FetchError::Extract(format!("tar unpack: {e}")))?;
597 }
598 }
599
600 Ok(())
601}
602
603fn url_to_safe_name(url: &str) -> String {
605 url.chars()
606 .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
607 .collect()
608}
609
610fn dirs_cache_dir() -> PathBuf {
612 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
615 .map(PathBuf::from)
616 .filter(|p| p.is_absolute())
617 {
618 return xdg;
619 }
620 if let Some(home) = std::env::var_os("HOME")
621 .map(PathBuf::from)
622 .filter(|p| p.is_absolute())
623 {
624 let default = home.join(".cache");
625 if default.exists() || std::fs::create_dir_all(&default).is_ok() {
626 return default;
627 }
628 }
629 PathBuf::from("/tmp")
630}
631
632#[cfg(test)]
635mod tests {
636 use super::*;
637 use std::collections::BTreeMap;
638
639 fn make_locked(source_type: &str) -> LockedInput {
641 LockedInput {
642 source_type: source_type.to_string(),
643 owner: None,
644 repo: None,
645 rev: None,
646 nar_hash: None,
647 last_modified: None,
648 path: None,
649 url: None,
650 git_ref: None,
651 dir: None,
652 host: None,
653 extra: BTreeMap::new(),
654 }
655 }
656
657 #[test]
660 fn sanitize_hash_replaces_special_chars() {
661 assert_eq!(
662 sanitize_hash("sha256-AAAAAAAAAAAAAAAAAAAAAA="),
663 "sha256-AAAAAAAAAAAAAAAAAAAAAA"
664 );
665 assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
666 }
667
668 #[test]
671 fn only_a_full_object_id_is_treated_as_immutable() {
672 assert!(is_immutable_rev("7fd33221240a3ab97781a066c5efe0124979527f"));
674 assert!(is_immutable_rev(&"a".repeat(64)));
675
676 assert!(!is_immutable_rev("main"), "a branch name is not a commit");
683 assert!(!is_immutable_rev("v1.2.3"), "a tag can be moved");
684 assert!(!is_immutable_rev("7fd3322"), "a short rev is ambiguous");
685 assert!(!is_immutable_rev(""), "an empty rev names nothing");
686
687 assert!(!is_immutable_rev(&"z".repeat(40)));
689 assert!(!is_immutable_rev(&"A".repeat(40)));
692 }
693
694 #[test]
697 fn staging_is_a_sibling_so_the_publish_rename_is_atomic() {
698 let dest = std::path::Path::new("/cache/sui/inputs/sha256-abc/github-o-r-deadbeef");
699 let staging = staging_path(dest);
700 assert_eq!(
701 staging.parent(),
702 dest.parent(),
703 "staging in /tmp would put the rename across filesystems, where it \
704 is a copy — and a copy is not atomic, which is the whole point"
705 );
706 assert_ne!(staging, dest.to_path_buf());
707 let name = staging.file_name().unwrap().to_string_lossy().into_owned();
708 assert!(name.starts_with('.'), "hidden, so it is not mistaken for a tree");
709 assert!(
710 name.contains(&std::process::id().to_string()),
711 "pid-scoped, so two concurrent fetchers cannot share a staging dir"
712 );
713 let dotted = std::path::Path::new("/c/github-o-r-1.2.3");
716 assert!(
717 staging_path(dotted)
718 .file_name()
719 .unwrap()
720 .to_string_lossy()
721 .contains("github-o-r-1.2.3"),
722 "the full directory name must survive into the staging name"
723 );
724 }
725
726 #[test]
729 fn find_single_subdir_returns_child_when_one_dir() {
730 let tmp = tempfile::tempdir().unwrap();
731 let child = tmp.path().join("repo-abc123");
732 std::fs::create_dir(&child).unwrap();
733 std::fs::write(child.join("file.txt"), "hello").unwrap();
734
735 let result = find_single_subdir_or_self(tmp.path());
736 assert_eq!(result, child);
737 }
738
739 #[test]
740 fn find_single_subdir_returns_self_when_multiple() {
741 let tmp = tempfile::tempdir().unwrap();
742 std::fs::create_dir(tmp.path().join("a")).unwrap();
743 std::fs::create_dir(tmp.path().join("b")).unwrap();
744
745 let result = find_single_subdir_or_self(tmp.path());
746 assert_eq!(result, tmp.path());
747 }
748
749 #[test]
750 fn find_single_subdir_returns_self_when_empty() {
751 let tmp = tempfile::tempdir().unwrap();
752 let result = find_single_subdir_or_self(tmp.path());
753 assert_eq!(result, tmp.path());
754 }
755
756 #[test]
757 fn find_single_subdir_returns_self_when_child_is_file() {
758 let tmp = tempfile::tempdir().unwrap();
759 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
760 let result = find_single_subdir_or_self(tmp.path());
761 assert_eq!(result, tmp.path());
762 }
763
764 #[test]
767 fn url_to_safe_name_replaces_slashes_and_colons() {
768 let name = url_to_safe_name("https://example.com/foo/bar.tar.gz");
769 assert!(!name.contains('/'));
770 assert!(!name.contains(':'));
771 assert!(name.contains("example"));
772 }
773
774 #[test]
777 fn fetcher_with_custom_cache_dir() {
778 let tmp = tempfile::tempdir().unwrap();
779 let fetcher = InputFetcher::with_cache_dir(tmp.path().to_path_buf());
780 assert_eq!(fetcher.cache_dir(), tmp.path());
781 }
782
783 #[test]
784 fn fetcher_default_cache_dir_exists() {
785 let fetcher = InputFetcher::new();
786 let path_str = fetcher.cache_dir().to_string_lossy();
788 assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
789 }
790
791 #[test]
794 fn fetch_path_returns_filesystem_path() {
795 let tmp = tempfile::tempdir().unwrap();
796 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
797
798 let mut locked = make_locked("path");
799 locked.path = Some("/var/empty/dep".to_string());
800
801 let result = fetcher.fetch(&locked).unwrap();
802 assert_eq!(result, PathBuf::from("/var/empty/dep"));
803 }
804
805 #[test]
806 fn fetch_path_missing_field_errors() {
807 let tmp = tempfile::tempdir().unwrap();
808 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
809 let locked = make_locked("path");
810 let result = fetcher.fetch(&locked);
811 assert!(result.is_err());
812 assert!(result.unwrap_err().to_string().contains("path"));
813 }
814
815 #[test]
818 fn fetch_unsupported_type_returns_error() {
819 let tmp = tempfile::tempdir().unwrap();
825 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
826 let locked = make_locked("mercurial");
827 let result = fetcher.fetch(&locked);
828 assert!(matches!(result, Err(FetchError::UnsupportedType(_))));
829 }
830
831 #[test]
832 fn gitlab_archive_url_is_well_formed() {
833 assert_eq!(
834 InputFetcher::gitlab_archive_url(None, "group", "proj", "abc123"),
835 "https://gitlab.com/group/proj/-/archive/abc123/proj-abc123.tar.gz"
836 );
837 }
838
839 #[test]
840 fn gitlab_archive_url_honors_custom_host() {
841 assert_eq!(
842 InputFetcher::gitlab_archive_url(Some("gitlab.gnome.org"), "GNOME", "gnome-shell", "abc"),
843 "https://gitlab.gnome.org/GNOME/gnome-shell/-/archive/abc/gnome-shell-abc.tar.gz"
844 );
845 }
846
847 #[test]
848 fn sourcehut_archive_url_prepends_tilde() {
849 assert_eq!(
853 InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
854 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
855 );
856 assert_eq!(
858 InputFetcher::sourcehut_archive_url("~emersion", "page", "HEAD"),
859 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
860 );
861 }
862
863 #[test]
866 fn cache_hit_returns_cached_path() {
867 let tmp = tempfile::tempdir().unwrap();
868 let cache_dir = tmp.path().join("cache");
869 std::fs::create_dir_all(&cache_dir).unwrap();
870
871 let hash = "sha256-TESTCACHEHIT";
873 let cached_dir = cache_dir.join(sanitize_hash(hash));
874 std::fs::create_dir_all(&cached_dir).unwrap();
875 std::fs::write(cached_dir.join("flake.nix"), "{}").unwrap();
876
877 let fetcher = InputFetcher::with_cache_dir(cache_dir);
878 let mut locked = make_locked("github");
879 locked.nar_hash = Some(hash.to_string());
880 let result = fetcher.fetch(&locked).unwrap();
883 assert_eq!(result, cached_dir);
885 }
886
887 #[test]
890 fn github_archive_url_format() {
891 let url = InputFetcher::github_archive_url("nixos", "nixpkgs", "abc123");
892 assert_eq!(
893 url,
894 "https://github.com/nixos/nixpkgs/archive/abc123.tar.gz"
895 );
896 }
897
898 #[test]
901 fn fetch_github_missing_owner_errors() {
902 let tmp = tempfile::tempdir().unwrap();
903 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
904 let mut locked = make_locked("github");
905 locked.repo = Some("nixpkgs".into());
906 locked.rev = Some("abc123".into());
907 let result = fetcher.fetch(&locked);
908 assert!(result.is_err());
909 assert!(result.unwrap_err().to_string().contains("owner"));
910 }
911
912 #[test]
913 fn fetch_github_missing_rev_errors() {
914 let tmp = tempfile::tempdir().unwrap();
915 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
916 let mut locked = make_locked("github");
917 locked.owner = Some("nixos".into());
918 locked.repo = Some("nixpkgs".into());
919 let result = fetcher.fetch(&locked);
920 assert!(result.is_err());
921 assert!(result.unwrap_err().to_string().contains("rev"));
922 }
923
924 #[test]
927 fn fetch_git_missing_url_errors() {
928 let tmp = tempfile::tempdir().unwrap();
929 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
930 let mut locked = make_locked("git");
931 locked.rev = Some("abc123".into());
932 let result = fetcher.fetch(&locked);
933 assert!(result.is_err());
934 assert!(result.unwrap_err().to_string().contains("url"));
935 }
936
937 #[test]
938 fn fetch_git_missing_rev_errors() {
939 let tmp = tempfile::tempdir().unwrap();
940 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
941 let mut locked = make_locked("git");
942 locked.url = Some("https://example.com/repo.git".into());
943 let result = fetcher.fetch(&locked);
944 assert!(result.is_err());
945 assert!(result.unwrap_err().to_string().contains("rev"));
946 }
947
948 #[test]
951 fn fetch_tarball_missing_url_errors() {
952 let tmp = tempfile::tempdir().unwrap();
953 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
954 let locked = make_locked("tarball");
955 let result = fetcher.fetch(&locked);
956 assert!(result.is_err());
957 assert!(result.unwrap_err().to_string().contains("url"));
958 }
959
960 #[test]
963 fn extract_tar_gz_empty_archive_errors() {
964 let tmp = tempfile::tempdir().unwrap();
965 let result = extract_tar_gz(&[], tmp.path());
966 assert!(result.is_err());
967 }
968
969 #[test]
970 fn extract_tar_gz_invalid_data_errors() {
971 let tmp = tempfile::tempdir().unwrap();
972 let result = extract_tar_gz(b"not a gzip stream at all", tmp.path());
973 assert!(result.is_err());
974 }
975
976 #[test]
979 fn dest_dir_uses_nar_hash_when_present() {
980 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
981 let mut locked = make_locked("github");
982 locked.nar_hash = Some("sha256-ABC123=".to_string());
983 let dest = fetcher.dest_dir(&locked, "fallback");
984 assert!(dest.to_string_lossy().contains("sha256-ABC123"));
985 assert!(!dest.to_string_lossy().contains("fallback"));
986 }
987
988 #[test]
989 fn dest_dir_uses_fallback_when_no_hash() {
990 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
991 let locked = make_locked("github");
992 let dest = fetcher.dest_dir(&locked, "fallback-name");
993 assert!(dest.to_string_lossy().contains("fallback-name"));
994 }
995
996 #[test]
999 fn is_non_empty_dir_returns_true_for_non_empty() {
1000 let tmp = tempfile::tempdir().unwrap();
1001 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1002 assert!(is_non_empty_dir(tmp.path()));
1003 }
1004
1005 #[test]
1006 fn is_non_empty_dir_returns_false_for_empty() {
1007 let tmp = tempfile::tempdir().unwrap();
1008 assert!(!is_non_empty_dir(tmp.path()));
1009 }
1010
1011 #[test]
1012 fn is_non_empty_dir_returns_false_for_missing() {
1013 assert!(!is_non_empty_dir(Path::new("/nonexistent/path/12345")));
1014 }
1015
1016 #[test]
1019 fn empty_cache_dir_is_treated_as_miss() {
1020 let tmp = tempfile::tempdir().unwrap();
1021 let cache_dir = tmp.path().join("cache");
1022 std::fs::create_dir_all(&cache_dir).unwrap();
1023
1024 let hash = "sha256-EMPTYTEST";
1026 let cached_dir = cache_dir.join(sanitize_hash(hash));
1027 std::fs::create_dir_all(&cached_dir).unwrap();
1028 assert!(std::fs::read_dir(&cached_dir).unwrap().next().is_none());
1030
1031 let fetcher = InputFetcher::with_cache_dir(cache_dir);
1032 let mut locked = make_locked("github");
1033 locked.nar_hash = Some(hash.to_string());
1034 let result = fetcher.fetch(&locked);
1038 assert!(result.is_err(), "should not return stale empty cache");
1039 assert!(!cached_dir.exists(), "stale cache dir should be removed");
1041 }
1042
1043 #[test]
1046 fn tarball_from_https_github() {
1047 let url = github_tarball_from_git_url(
1048 "https://github.com/NixOS/nixpkgs.git",
1049 "abc123",
1050 );
1051 assert_eq!(
1052 url.as_deref(),
1053 Some("https://github.com/NixOS/nixpkgs/archive/abc123.tar.gz")
1054 );
1055 }
1056
1057 #[test]
1058 fn tarball_from_git_plus_https() {
1059 let url = github_tarball_from_git_url(
1060 "git+https://github.com/NixOS/nixpkgs",
1061 "def456",
1062 );
1063 assert_eq!(
1064 url.as_deref(),
1065 Some("https://github.com/NixOS/nixpkgs/archive/def456.tar.gz")
1066 );
1067 }
1068
1069 #[test]
1070 fn tarball_from_non_github_returns_none() {
1071 assert!(github_tarball_from_git_url("https://gitlab.com/foo/bar.git", "abc").is_none());
1072 assert!(github_tarball_from_git_url("ssh://git@github.com/foo/bar", "abc").is_none());
1073 }
1074
1075 #[test]
1076 fn tarball_from_malformed_path_returns_none() {
1077 assert!(github_tarball_from_git_url("https://github.com/", "abc").is_none());
1078 assert!(github_tarball_from_git_url("https://github.com/only-owner", "abc").is_none());
1079 }
1080}
1081
1082
1083pub fn resolve_flake_dir(flake_ref: &FlakeRef) -> Result<std::path::PathBuf, FetchError> {
1103 match flake_ref.local_dir() {
1104 Some(p) => Ok(p.to_path_buf()),
1105 None => {
1106 let locked = flake_ref
1107 .source
1108 .locked_input()
1109 .ok_or_else(|| FetchError::UnsupportedType("non-fetchable flake source".into()))?;
1110 InputFetcher::new().fetch(&locked)
1111 }
1112 }
1113}