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 publish(&staging, &dest, is_immutable_rev(rev))?;
221 Ok(find_single_subdir_or_self(&dest))
222 }
223
224 fn fetch_gitlab(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
225 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
226 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
227 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
228 let host = locked.host.as_deref();
229 let url = Self::gitlab_archive_url(host, owner, repo, rev);
230 let host_tag = host.unwrap_or("gitlab.com").replace('.', "_");
231 self.fetch_archive(locked, &url, &format!("gitlab-{host_tag}-{owner}-{repo}-{rev}"), rev)
232 }
233
234 fn fetch_sourcehut(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
235 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
236 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
237 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
238 let url = Self::sourcehut_archive_url(owner, repo, rev);
239 let sanitized_owner = owner.trim_start_matches('~');
240 self.fetch_archive(
241 locked,
242 &url,
243 &format!("sourcehut-{sanitized_owner}-{repo}-{rev}"),
244 rev,
245 )
246 }
247
248 fn fetch_path(locked: &LockedInput) -> Result<PathBuf, FetchError> {
249 let path = locked
250 .path
251 .as_deref()
252 .ok_or(FetchError::MissingField("path"))?;
253 Ok(PathBuf::from(path))
254 }
255
256 fn fetch_git(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
257 let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
258 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
259
260 let short_rev: String = rev.chars().take(12).collect();
261 let dest = self.dest_dir(locked, &format!("git-{short_rev}"));
262
263 let immutable = is_immutable_rev(rev);
266 if immutable && is_non_empty_dir(&dest) {
267 return Ok(dest);
268 }
269
270 let staging = staging_path(&dest);
276 let _ = std::fs::remove_dir_all(&staging);
277
278 if let Some(tarball_url) = github_tarball_from_git_url(url, rev) {
282 std::fs::create_dir_all(&staging)?;
283 match download_bytes(&tarball_url) {
284 Ok(bytes) => {
285 if let Err(e) = extract_tar_gz(&bytes, &staging) {
286 let _ = std::fs::remove_dir_all(&staging);
287 return Err(e);
288 }
289 publish(&staging, &dest, immutable)?;
290 return Ok(find_single_subdir_or_self(&dest));
291 }
292 Err(e) => {
293 let _ = std::fs::remove_dir_all(&staging);
295 tracing::debug!(url = %tarball_url, error = %e, "Tarball fallback failed, trying git CLI");
296 }
297 }
298 }
299
300 let status = std::process::Command::new("git")
302 .args(["clone", "--depth", "1", url])
303 .arg(&staging)
304 .stdout(std::process::Stdio::null())
305 .stderr(std::process::Stdio::null())
306 .status()
307 .map_err(|e| FetchError::Download(format!(
308 "git clone failed (git not in PATH?): {e}"
309 )))?;
310 if !status.success() {
311 let _ = std::fs::remove_dir_all(&staging);
312 return Err(FetchError::Download(format!(
313 "git clone failed for {url} (exit code: {})",
314 status.code().unwrap_or(-1)
315 )));
316 }
317
318 if let Err(e) = crate::git::checkout_rev(&staging, rev) {
325 let _ = std::fs::remove_dir_all(&staging);
326 return Err(FetchError::Download(format!("git checkout {rev}: {e}")));
327 }
328
329 publish(&staging, &dest, immutable)?;
330 Ok(dest)
331 }
332
333 fn fetch_tarball(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
334 let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
335
336 let hash_suffix = locked
337 .nar_hash
338 .as_deref()
339 .map_or_else(|| url_to_safe_name(url), sanitize_hash);
340 let dest = self.dest_dir(locked, &format!("tarball-{hash_suffix}"));
341
342 let immutable = locked.nar_hash.is_some();
347 if immutable && is_non_empty_dir(&dest) {
348 return Ok(find_single_subdir_or_self(&dest));
349 }
350
351 let staging = staging_path(&dest);
356 let _ = std::fs::remove_dir_all(&staging);
357 std::fs::create_dir_all(&staging)?;
358
359 let bytes = match download_bytes(url) {
360 Ok(b) => b,
361 Err(e) => {
362 let _ = std::fs::remove_dir_all(&staging);
363 return Err(e);
364 }
365 };
366 if let Err(e) = extract_tar_gz(&bytes, &staging) {
367 let _ = std::fs::remove_dir_all(&staging);
368 return Err(e);
369 }
370
371 publish(&staging, &dest, immutable)?;
372 Ok(find_single_subdir_or_self(&dest))
373 }
374
375 fn dest_dir(&self, locked: &LockedInput, fallback: &str) -> PathBuf {
377 if let Some(ref nar_hash) = locked.nar_hash {
378 self.cache_dir.join(sanitize_hash(nar_hash))
379 } else {
380 self.cache_dir.join(fallback)
381 }
382 }
383}
384
385fn github_tarball_from_git_url(url: &str, rev: &str) -> Option<String> {
392 let stripped = url
393 .strip_prefix("https://github.com/")
394 .or_else(|| url.strip_prefix("git+https://github.com/"))
395 .or_else(|| url.strip_prefix("http://github.com/"))?;
396 let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
397 let parts: Vec<&str> = stripped.split('/').collect();
399 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
400 Some(format!(
401 "https://github.com/{}/{}/archive/{rev}.tar.gz",
402 parts[0], parts[1]
403 ))
404 } else {
405 None
406 }
407}
408
409fn sanitize_hash(hash: &str) -> String {
432 let mapped = hash.replace(':', "-").replace('/', "_").replace('=', "");
433 let shaped = !mapped.is_empty()
434 && mapped != "."
435 && mapped != ".."
436 && mapped
437 .bytes()
438 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-'));
439 if shaped {
440 mapped
441 } else {
442 use sha2::Digest as _;
445 let d = sha2::Sha256::digest(hash.as_bytes());
446 let mut out = String::with_capacity(2 + 64);
447 out.push_str("h-");
448 for b in d {
449 use std::fmt::Write as _;
450 let _ = write!(out, "{b:02x}");
451 }
452 out
453 }
454}
455
456fn is_immutable_rev(rev: &str) -> bool {
463 matches!(rev.len(), 40 | 64) && rev.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
464}
465
466fn staging_path(dest: &Path) -> PathBuf {
483 let name = dest
484 .file_name()
485 .map_or_else(|| "fetch".to_string(), |n| n.to_string_lossy().into_owned());
486 let tid = format!("{:?}", std::thread::current().id());
489 let tid: String = tid.chars().filter(char::is_ascii_digit).collect();
490 let tmp = [
491 ".",
492 &name,
493 ".tmp-",
494 &std::process::id().to_string(),
495 "-",
496 &tid,
497 ]
498 .concat();
499 dest.parent()
500 .map_or_else(|| PathBuf::from(&tmp), |p| p.join(&tmp))
501}
502
503fn publish(staging: &Path, dest: &Path, immutable: bool) -> Result<(), FetchError> {
524 if immutable && is_non_empty_dir(dest) {
525 let _ = std::fs::remove_dir_all(staging);
526 return Ok(());
527 }
528
529 let aside = with_suffix(staging, ".old");
530 let _ = std::fs::remove_dir_all(&aside);
531 let moved_aside = dest.exists() && std::fs::rename(dest, &aside).is_ok();
532
533 match std::fs::rename(staging, dest) {
534 Ok(()) => {
535 if moved_aside {
536 let _ = std::fs::remove_dir_all(&aside);
537 }
538 Ok(())
539 }
540 Err(_) => {
541 if moved_aside && !dest.exists() {
544 let _ = std::fs::rename(&aside, dest);
545 }
546 let _ = std::fs::remove_dir_all(staging);
547 let _ = std::fs::remove_dir_all(&aside);
548 if is_non_empty_dir(dest) {
549 Ok(())
551 } else {
552 Err(FetchError::Extract(
553 "could not publish the fetched tree and no other process left one".into(),
554 ))
555 }
556 }
557 }
558}
559
560fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
563 let name = path
564 .file_name()
565 .map_or_else(|| "x".to_string(), |n| n.to_string_lossy().into_owned());
566 path.parent().map_or_else(
567 || PathBuf::from([&name, suffix].concat()),
568 |p| p.join([&name, suffix].concat()),
569 )
570}
571
572fn is_non_empty_dir(dir: &Path) -> bool {
574 std::fs::read_dir(dir)
575 .ok()
576 .is_some_and(|mut rd| rd.next().is_some())
577}
578
579fn find_single_subdir_or_self(dir: &Path) -> PathBuf {
583 let entries: Vec<_> = std::fs::read_dir(dir)
584 .ok()
585 .into_iter()
586 .flatten()
587 .filter_map(|e| e.ok())
588 .collect();
589 if entries.len() == 1 && entries[0].path().is_dir() {
590 entries[0].path()
591 } else {
592 dir.to_path_buf()
593 }
594}
595
596fn download_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
603 let mut req = ureq::get(url);
604
605 if let Some(token) = github_token_for_url(url) {
612 req = req.header("Authorization", &format!("token {token}"));
613 }
614
615 let mut response = req
616 .call()
617 .map_err(|e| FetchError::Download(format!("{url}: {e}")))?;
618
619 if !response.status().is_success() {
620 return Err(FetchError::Download(format!(
621 "{url}: HTTP {}",
622 response.status().as_u16()
623 )));
624 }
625
626 response
627 .body_mut()
628 .with_config()
629 .limit(512 * 1024 * 1024)
630 .read_to_vec()
631 .map_err(|e| FetchError::Download(format!("{url}: {e}")))
632}
633
634fn github_token_for_url(url: &str) -> Option<String> {
645 if !url.starts_with("https://github.com/")
646 && !url.starts_with("https://api.github.com/")
647 {
648 return None;
649 }
650 if let Ok(t) = std::env::var("GITHUB_TOKEN") {
651 if !t.is_empty() {
652 return Some(t);
653 }
654 }
655 if let Ok(cfg) = std::env::var("NIX_CONFIG") {
656 if let Some(t) = parse_access_tokens(&cfg, "github.com") {
657 return Some(t);
658 }
659 }
660 if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
661 let nix_conf = home.join(".config/nix/nix.conf");
662 if let Ok(cfg) = std::fs::read_to_string(&nix_conf) {
663 if let Some(t) = parse_access_tokens(&cfg, "github.com") {
664 return Some(t);
665 }
666 }
667 let gh_hosts = home.join(".config/gh/hosts.yml");
668 if let Ok(yml) = std::fs::read_to_string(&gh_hosts) {
669 if let Some(t) = parse_gh_hosts_token(&yml, "github.com") {
670 return Some(t);
671 }
672 }
673 }
674 None
675}
676
677fn parse_access_tokens(cfg: &str, host: &str) -> Option<String> {
680 for line in cfg.lines() {
681 let trimmed = line.trim();
682 if let Some(rest) = trimmed.strip_prefix("access-tokens") {
683 let rest = rest.trim_start().trim_start_matches('=').trim();
684 for pair in rest.split_whitespace() {
685 if let Some((h, t)) = pair.split_once('=') {
686 if h == host {
687 return Some(t.to_string());
688 }
689 }
690 }
691 }
692 }
693 None
694}
695
696fn parse_gh_hosts_token(yml: &str, host: &str) -> Option<String> {
701 let mut in_host = false;
702 for line in yml.lines() {
703 let raw = line;
704 let trimmed = raw.trim();
705 if trimmed.starts_with(host) && trimmed.ends_with(':') {
706 in_host = true;
707 continue;
708 }
709 if !raw.starts_with(' ') && !raw.starts_with('\t') && !trimmed.is_empty() {
710 in_host = false;
711 }
712 if in_host {
713 if let Some(rest) = trimmed.strip_prefix("oauth_token:") {
714 return Some(rest.trim().to_string());
715 }
716 }
717 }
718 None
719}
720
721fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<(), FetchError> {
723 let gz = flate2::read::GzDecoder::new(bytes);
724
725 let mut buffered = std::io::BufReader::new(gz);
728 let mut peek = [0u8; 1];
729 match buffered.read(&mut peek) {
731 Ok(0) => {
732 return Err(FetchError::Extract("empty archive".into()));
733 }
734 Err(e) => {
735 return Err(FetchError::Extract(format!("gzip decompression: {e}")));
736 }
737 Ok(_) => {
738 let cursor = std::io::Cursor::new(peek);
740 let chain = cursor.chain(buffered);
741 let mut archive = tar::Archive::new(chain);
742 archive
743 .unpack(dest)
744 .map_err(|e| FetchError::Extract(format!("tar unpack: {e}")))?;
745 }
746 }
747
748 Ok(())
749}
750
751fn url_to_safe_name(url: &str) -> String {
753 url.chars()
754 .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
755 .collect()
756}
757
758fn dirs_cache_dir() -> PathBuf {
760 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
763 .map(PathBuf::from)
764 .filter(|p| p.is_absolute())
765 {
766 return xdg;
767 }
768 if let Some(home) = std::env::var_os("HOME")
769 .map(PathBuf::from)
770 .filter(|p| p.is_absolute())
771 {
772 let default = home.join(".cache");
773 if default.exists() || std::fs::create_dir_all(&default).is_ok() {
774 return default;
775 }
776 }
777 PathBuf::from("/tmp")
778}
779
780#[cfg(test)]
783mod tests {
784 use super::*;
785 use std::collections::BTreeMap;
786
787 fn make_locked(source_type: &str) -> LockedInput {
789 LockedInput {
790 source_type: source_type.to_string(),
791 owner: None,
792 repo: None,
793 rev: None,
794 nar_hash: None,
795 last_modified: None,
796 path: None,
797 url: None,
798 git_ref: None,
799 dir: None,
800 host: None,
801 extra: BTreeMap::new(),
802 }
803 }
804
805 #[test]
808 fn sanitize_hash_replaces_special_chars() {
809 assert_eq!(
810 sanitize_hash("sha256-AAAAAAAAAAAAAAAAAAAAAA="),
811 "sha256-AAAAAAAAAAAAAAAAAAAAAA"
812 );
813 assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
814 }
815
816 #[test]
819 fn a_traversal_hash_cannot_become_a_path_component() {
820 for hostile in ["..", ".", "", "../..", "..\u{0}"] {
825 let s = sanitize_hash(hostile);
826 assert!(
827 s != ".." && s != "." && !s.is_empty(),
828 "{hostile:?} sanitized to {s:?}, still a meaningful component"
829 );
830 assert!(
831 !s.contains('/') && !s.contains('\\'),
832 "{hostile:?} sanitized to {s:?}, still a separator"
833 );
834 }
835 assert_eq!(sanitize_hash(".."), sanitize_hash(".."));
837 assert_ne!(sanitize_hash(".."), sanitize_hash("."));
839 }
840
841 #[test]
842 fn a_well_formed_hash_is_untouched_by_the_guard() {
843 assert_eq!(
846 sanitize_hash("sha256-avzRM+ffKgikqMRcOhhYp3ifgwXMGbH0rEGEZPEGMYE="),
847 "sha256-avzRM+ffKgikqMRcOhhYp3ifgwXMGbH0rEGEZPEGMYE"
848 );
849 assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
850 }
851
852 #[test]
855 fn publishing_an_immutable_tree_adopts_the_winner_and_deletes_nothing() {
856 let tmp = tempfile::tempdir().unwrap();
857 let dest = tmp.path().join("github-o-r-deadbeef");
858 let staging = staging_path(&dest);
859 std::fs::create_dir_all(&dest).unwrap();
861 std::fs::write(dest.join("theirs"), b"x").unwrap();
862 std::fs::create_dir_all(&staging).unwrap();
863 std::fs::write(staging.join("ours"), b"y").unwrap();
864
865 publish(&staging, &dest, true).unwrap();
866
867 assert!(
868 dest.join("theirs").exists(),
869 "an immutable tree is content-addressed: the winner's tree IS ours, \
870 and deleting it to install an identical one is pure risk"
871 );
872 assert!(!staging.exists(), "our staging must be cleaned up");
873 }
874
875 #[test]
876 fn publishing_a_mutable_tree_replaces_it_without_a_delete_in_place() {
877 let tmp = tempfile::tempdir().unwrap();
878 let dest = tmp.path().join("github-o-r-main");
879 let staging = staging_path(&dest);
880 std::fs::create_dir_all(&dest).unwrap();
881 std::fs::write(dest.join("old"), b"x").unwrap();
882 std::fs::create_dir_all(&staging).unwrap();
883 std::fs::write(staging.join("new"), b"y").unwrap();
884
885 publish(&staging, &dest, false).unwrap();
886
887 assert!(dest.join("new").exists(), "the new tree must be published");
888 assert!(!dest.join("old").exists(), "and must REPLACE, not union");
889 assert!(!staging.exists());
890 let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
892 .unwrap()
893 .filter_map(Result::ok)
894 .map(|e| e.file_name().to_string_lossy().into_owned())
895 .filter(|n| n.contains(".old"))
896 .collect();
897 assert!(leftovers.is_empty(), "aside dirs left behind: {leftovers:?}");
898 }
899
900 #[test]
901 fn staging_is_scoped_by_thread_not_only_by_pid() {
902 let dest = std::path::Path::new("/c/inputs/github-o-r-deadbeef");
907 let here = staging_path(dest);
908 let there = std::thread::spawn(move || staging_path(dest))
909 .join()
910 .unwrap();
911 assert_ne!(
912 here, there,
913 "two threads must not share a staging directory"
914 );
915 }
916
917 #[test]
920 fn only_a_full_object_id_is_treated_as_immutable() {
921 assert!(is_immutable_rev("7fd33221240a3ab97781a066c5efe0124979527f"));
923 assert!(is_immutable_rev(&"a".repeat(64)));
924
925 assert!(!is_immutable_rev("main"), "a branch name is not a commit");
932 assert!(!is_immutable_rev("v1.2.3"), "a tag can be moved");
933 assert!(!is_immutable_rev("7fd3322"), "a short rev is ambiguous");
934 assert!(!is_immutable_rev(""), "an empty rev names nothing");
935
936 assert!(!is_immutable_rev(&"z".repeat(40)));
938 assert!(!is_immutable_rev(&"A".repeat(40)));
941 }
942
943 #[test]
946 fn staging_is_a_sibling_so_the_publish_rename_is_atomic() {
947 let dest = std::path::Path::new("/cache/sui/inputs/sha256-abc/github-o-r-deadbeef");
948 let staging = staging_path(dest);
949 assert_eq!(
950 staging.parent(),
951 dest.parent(),
952 "staging in /tmp would put the rename across filesystems, where it \
953 is a copy — and a copy is not atomic, which is the whole point"
954 );
955 assert_ne!(staging, dest.to_path_buf());
956 let name = staging.file_name().unwrap().to_string_lossy().into_owned();
957 assert!(name.starts_with('.'), "hidden, so it is not mistaken for a tree");
958 assert!(
959 name.contains(&std::process::id().to_string()),
960 "pid-scoped, so two concurrent fetchers cannot share a staging dir"
961 );
962 let dotted = std::path::Path::new("/c/github-o-r-1.2.3");
965 assert!(
966 staging_path(dotted)
967 .file_name()
968 .unwrap()
969 .to_string_lossy()
970 .contains("github-o-r-1.2.3"),
971 "the full directory name must survive into the staging name"
972 );
973 }
974
975 #[test]
978 fn find_single_subdir_returns_child_when_one_dir() {
979 let tmp = tempfile::tempdir().unwrap();
980 let child = tmp.path().join("repo-abc123");
981 std::fs::create_dir(&child).unwrap();
982 std::fs::write(child.join("file.txt"), "hello").unwrap();
983
984 let result = find_single_subdir_or_self(tmp.path());
985 assert_eq!(result, child);
986 }
987
988 #[test]
989 fn find_single_subdir_returns_self_when_multiple() {
990 let tmp = tempfile::tempdir().unwrap();
991 std::fs::create_dir(tmp.path().join("a")).unwrap();
992 std::fs::create_dir(tmp.path().join("b")).unwrap();
993
994 let result = find_single_subdir_or_self(tmp.path());
995 assert_eq!(result, tmp.path());
996 }
997
998 #[test]
999 fn find_single_subdir_returns_self_when_empty() {
1000 let tmp = tempfile::tempdir().unwrap();
1001 let result = find_single_subdir_or_self(tmp.path());
1002 assert_eq!(result, tmp.path());
1003 }
1004
1005 #[test]
1006 fn find_single_subdir_returns_self_when_child_is_file() {
1007 let tmp = tempfile::tempdir().unwrap();
1008 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1009 let result = find_single_subdir_or_self(tmp.path());
1010 assert_eq!(result, tmp.path());
1011 }
1012
1013 #[test]
1016 fn url_to_safe_name_replaces_slashes_and_colons() {
1017 let name = url_to_safe_name("https://example.com/foo/bar.tar.gz");
1018 assert!(!name.contains('/'));
1019 assert!(!name.contains(':'));
1020 assert!(name.contains("example"));
1021 }
1022
1023 #[test]
1026 fn fetcher_with_custom_cache_dir() {
1027 let tmp = tempfile::tempdir().unwrap();
1028 let fetcher = InputFetcher::with_cache_dir(tmp.path().to_path_buf());
1029 assert_eq!(fetcher.cache_dir(), tmp.path());
1030 }
1031
1032 #[test]
1033 fn fetcher_default_cache_dir_exists() {
1034 let fetcher = InputFetcher::new();
1035 let path_str = fetcher.cache_dir().to_string_lossy();
1037 assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
1038 }
1039
1040 #[test]
1043 fn fetch_path_returns_filesystem_path() {
1044 let tmp = tempfile::tempdir().unwrap();
1045 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1046
1047 let mut locked = make_locked("path");
1048 locked.path = Some("/var/empty/dep".to_string());
1049
1050 let result = fetcher.fetch(&locked).unwrap();
1051 assert_eq!(result, PathBuf::from("/var/empty/dep"));
1052 }
1053
1054 #[test]
1055 fn fetch_path_missing_field_errors() {
1056 let tmp = tempfile::tempdir().unwrap();
1057 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1058 let locked = make_locked("path");
1059 let result = fetcher.fetch(&locked);
1060 assert!(result.is_err());
1061 assert!(result.unwrap_err().to_string().contains("path"));
1062 }
1063
1064 #[test]
1067 fn fetch_unsupported_type_returns_error() {
1068 let tmp = tempfile::tempdir().unwrap();
1074 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1075 let locked = make_locked("mercurial");
1076 let result = fetcher.fetch(&locked);
1077 assert!(matches!(result, Err(FetchError::UnsupportedType(_))));
1078 }
1079
1080 #[test]
1081 fn gitlab_archive_url_is_well_formed() {
1082 assert_eq!(
1083 InputFetcher::gitlab_archive_url(None, "group", "proj", "abc123"),
1084 "https://gitlab.com/group/proj/-/archive/abc123/proj-abc123.tar.gz"
1085 );
1086 }
1087
1088 #[test]
1089 fn gitlab_archive_url_honors_custom_host() {
1090 assert_eq!(
1091 InputFetcher::gitlab_archive_url(Some("gitlab.gnome.org"), "GNOME", "gnome-shell", "abc"),
1092 "https://gitlab.gnome.org/GNOME/gnome-shell/-/archive/abc/gnome-shell-abc.tar.gz"
1093 );
1094 }
1095
1096 #[test]
1097 fn sourcehut_archive_url_prepends_tilde() {
1098 assert_eq!(
1102 InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
1103 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
1104 );
1105 assert_eq!(
1107 InputFetcher::sourcehut_archive_url("~emersion", "page", "HEAD"),
1108 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
1109 );
1110 }
1111
1112 #[test]
1115 fn cache_hit_returns_cached_path() {
1116 let tmp = tempfile::tempdir().unwrap();
1117 let cache_dir = tmp.path().join("cache");
1118 std::fs::create_dir_all(&cache_dir).unwrap();
1119
1120 let hash = "sha256-TESTCACHEHIT";
1122 let cached_dir = cache_dir.join(sanitize_hash(hash));
1123 std::fs::create_dir_all(&cached_dir).unwrap();
1124 std::fs::write(cached_dir.join("flake.nix"), "{}").unwrap();
1125
1126 let fetcher = InputFetcher::with_cache_dir(cache_dir);
1127 let mut locked = make_locked("github");
1128 locked.nar_hash = Some(hash.to_string());
1129 let result = fetcher.fetch(&locked).unwrap();
1132 assert_eq!(result, cached_dir);
1134 }
1135
1136 #[test]
1139 fn github_archive_url_format() {
1140 let url = InputFetcher::github_archive_url("nixos", "nixpkgs", "abc123");
1141 assert_eq!(
1142 url,
1143 "https://github.com/nixos/nixpkgs/archive/abc123.tar.gz"
1144 );
1145 }
1146
1147 #[test]
1150 fn fetch_github_missing_owner_errors() {
1151 let tmp = tempfile::tempdir().unwrap();
1152 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1153 let mut locked = make_locked("github");
1154 locked.repo = Some("nixpkgs".into());
1155 locked.rev = Some("abc123".into());
1156 let result = fetcher.fetch(&locked);
1157 assert!(result.is_err());
1158 assert!(result.unwrap_err().to_string().contains("owner"));
1159 }
1160
1161 #[test]
1162 fn fetch_github_missing_rev_errors() {
1163 let tmp = tempfile::tempdir().unwrap();
1164 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1165 let mut locked = make_locked("github");
1166 locked.owner = Some("nixos".into());
1167 locked.repo = Some("nixpkgs".into());
1168 let result = fetcher.fetch(&locked);
1169 assert!(result.is_err());
1170 assert!(result.unwrap_err().to_string().contains("rev"));
1171 }
1172
1173 #[test]
1176 fn fetch_git_missing_url_errors() {
1177 let tmp = tempfile::tempdir().unwrap();
1178 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1179 let mut locked = make_locked("git");
1180 locked.rev = Some("abc123".into());
1181 let result = fetcher.fetch(&locked);
1182 assert!(result.is_err());
1183 assert!(result.unwrap_err().to_string().contains("url"));
1184 }
1185
1186 #[test]
1187 fn fetch_git_missing_rev_errors() {
1188 let tmp = tempfile::tempdir().unwrap();
1189 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1190 let mut locked = make_locked("git");
1191 locked.url = Some("https://example.com/repo.git".into());
1192 let result = fetcher.fetch(&locked);
1193 assert!(result.is_err());
1194 assert!(result.unwrap_err().to_string().contains("rev"));
1195 }
1196
1197 #[test]
1200 fn fetch_tarball_missing_url_errors() {
1201 let tmp = tempfile::tempdir().unwrap();
1202 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1203 let locked = make_locked("tarball");
1204 let result = fetcher.fetch(&locked);
1205 assert!(result.is_err());
1206 assert!(result.unwrap_err().to_string().contains("url"));
1207 }
1208
1209 #[test]
1212 fn extract_tar_gz_empty_archive_errors() {
1213 let tmp = tempfile::tempdir().unwrap();
1214 let result = extract_tar_gz(&[], tmp.path());
1215 assert!(result.is_err());
1216 }
1217
1218 #[test]
1219 fn extract_tar_gz_invalid_data_errors() {
1220 let tmp = tempfile::tempdir().unwrap();
1221 let result = extract_tar_gz(b"not a gzip stream at all", tmp.path());
1222 assert!(result.is_err());
1223 }
1224
1225 #[test]
1228 fn dest_dir_uses_nar_hash_when_present() {
1229 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
1230 let mut locked = make_locked("github");
1231 locked.nar_hash = Some("sha256-ABC123=".to_string());
1232 let dest = fetcher.dest_dir(&locked, "fallback");
1233 assert!(dest.to_string_lossy().contains("sha256-ABC123"));
1234 assert!(!dest.to_string_lossy().contains("fallback"));
1235 }
1236
1237 #[test]
1238 fn dest_dir_uses_fallback_when_no_hash() {
1239 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
1240 let locked = make_locked("github");
1241 let dest = fetcher.dest_dir(&locked, "fallback-name");
1242 assert!(dest.to_string_lossy().contains("fallback-name"));
1243 }
1244
1245 #[test]
1248 fn is_non_empty_dir_returns_true_for_non_empty() {
1249 let tmp = tempfile::tempdir().unwrap();
1250 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1251 assert!(is_non_empty_dir(tmp.path()));
1252 }
1253
1254 #[test]
1255 fn is_non_empty_dir_returns_false_for_empty() {
1256 let tmp = tempfile::tempdir().unwrap();
1257 assert!(!is_non_empty_dir(tmp.path()));
1258 }
1259
1260 #[test]
1261 fn is_non_empty_dir_returns_false_for_missing() {
1262 assert!(!is_non_empty_dir(Path::new("/nonexistent/path/12345")));
1263 }
1264
1265 #[test]
1268 fn empty_cache_dir_is_treated_as_miss() {
1269 let tmp = tempfile::tempdir().unwrap();
1270 let cache_dir = tmp.path().join("cache");
1271 std::fs::create_dir_all(&cache_dir).unwrap();
1272
1273 let hash = "sha256-EMPTYTEST";
1275 let cached_dir = cache_dir.join(sanitize_hash(hash));
1276 std::fs::create_dir_all(&cached_dir).unwrap();
1277 assert!(std::fs::read_dir(&cached_dir).unwrap().next().is_none());
1279
1280 let fetcher = InputFetcher::with_cache_dir(cache_dir);
1281 let mut locked = make_locked("github");
1282 locked.nar_hash = Some(hash.to_string());
1283 let result = fetcher.fetch(&locked);
1287 assert!(result.is_err(), "should not return stale empty cache");
1288 assert!(!cached_dir.exists(), "stale cache dir should be removed");
1290 }
1291
1292 #[test]
1295 fn tarball_from_https_github() {
1296 let url = github_tarball_from_git_url(
1297 "https://github.com/NixOS/nixpkgs.git",
1298 "abc123",
1299 );
1300 assert_eq!(
1301 url.as_deref(),
1302 Some("https://github.com/NixOS/nixpkgs/archive/abc123.tar.gz")
1303 );
1304 }
1305
1306 #[test]
1307 fn tarball_from_git_plus_https() {
1308 let url = github_tarball_from_git_url(
1309 "git+https://github.com/NixOS/nixpkgs",
1310 "def456",
1311 );
1312 assert_eq!(
1313 url.as_deref(),
1314 Some("https://github.com/NixOS/nixpkgs/archive/def456.tar.gz")
1315 );
1316 }
1317
1318 #[test]
1319 fn tarball_from_non_github_returns_none() {
1320 assert!(github_tarball_from_git_url("https://gitlab.com/foo/bar.git", "abc").is_none());
1321 assert!(github_tarball_from_git_url("ssh://git@github.com/foo/bar", "abc").is_none());
1322 }
1323
1324 #[test]
1325 fn tarball_from_malformed_path_returns_none() {
1326 assert!(github_tarball_from_git_url("https://github.com/", "abc").is_none());
1327 assert!(github_tarball_from_git_url("https://github.com/only-owner", "abc").is_none());
1328 }
1329}
1330
1331
1332pub fn resolve_flake_dir(flake_ref: &FlakeRef) -> Result<std::path::PathBuf, FetchError> {
1352 match flake_ref.local_dir() {
1353 Some(p) => Ok(p.to_path_buf()),
1354 None => {
1355 let locked = flake_ref
1356 .source
1357 .locked_input()
1358 .ok_or_else(|| FetchError::UnsupportedType("non-fetchable flake source".into()))?;
1359 InputFetcher::new().fetch(&locked)
1360 }
1361 }
1362}