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)]
40#[non_exhaustive]
41pub enum FetchError {
42 #[error("unsupported input type: {0}")]
43 UnsupportedType(String),
44 #[error("missing required field: {0}")]
45 MissingField(&'static str),
46 #[error("download failed: {0}")]
50 Download(String),
51 #[error("throttled by {url} (HTTP {status}){}", match retry_after {
56 Some(s) => format!(", retry after {s}s"),
57 None => String::new(),
58 })]
59 Throttled {
60 url: String,
61 status: u16,
62 retry_after: Option<u64>,
63 },
64 #[error("not authorized for {url} (HTTP {status}) — check the access token")]
67 Unauthorized { url: String, status: u16 },
68 #[error("{url} not found (HTTP 404) — or present but invisible to this credential")]
72 NotFound { url: String },
73 #[error("{url} returned HTTP {status}")]
76 UnexpectedStatus { url: String, status: u16 },
77 #[error("I/O error: {0}")]
78 Io(#[from] std::io::Error),
79 #[error("archive extraction failed: {0}")]
80 Extract(String),
81}
82
83impl FetchError {
84 #[must_use]
89 pub fn status(&self) -> Option<u16> {
90 match self {
91 Self::Throttled { status, .. }
92 | Self::Unauthorized { status, .. }
93 | Self::UnexpectedStatus { status, .. } => Some(*status),
94 Self::NotFound { .. } => Some(404),
95 _ => None,
96 }
97 }
98
99 #[must_use]
107 pub fn is_throttled(&self) -> bool {
108 matches!(self, Self::Throttled { .. })
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
122#[serde(rename_all = "kebab-case")]
123pub enum FailureKind {
124 Throttled,
127 Unauthorized,
130 NotFound,
132 UnexpectedStatus,
134 Transport,
136 Local,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
144pub struct InputFailure {
145 pub input: String,
147 pub kind: FailureKind,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub status: Option<u16>,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub retry_after: Option<u64>,
154 pub recoverable_elsewhere: bool,
157 pub message: String,
160}
161
162impl InputFailure {
163 #[must_use]
165 pub fn from_error(input: &str, err: &FetchError) -> Self {
166 let kind = match err {
167 FetchError::Throttled { .. } => FailureKind::Throttled,
168 FetchError::Unauthorized { .. } => FailureKind::Unauthorized,
169 FetchError::NotFound { .. } => FailureKind::NotFound,
170 FetchError::UnexpectedStatus { .. } => FailureKind::UnexpectedStatus,
171 FetchError::Download(_) => FailureKind::Transport,
172 _ => FailureKind::Local,
173 };
174 Self {
175 input: input.to_string(),
176 kind,
177 status: err.status(),
178 retry_after: match err {
179 FetchError::Throttled { retry_after, .. } => *retry_after,
180 _ => None,
181 },
182 recoverable_elsewhere: err.is_throttled(),
183 message: err.to_string(),
184 }
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
195pub struct ArchiveReport {
196 pub scanned: usize,
197 pub already_present: usize,
198 pub fetched: usize,
199 pub failures: Vec<InputFailure>,
200}
201
202impl ArchiveReport {
203 #[must_use]
208 pub fn is_complete(&self) -> bool {
209 self.scanned > 0 && self.failures.is_empty()
210 }
211
212 #[must_use]
214 pub fn recoverable(&self) -> impl Iterator<Item = &InputFailure> {
215 self.failures.iter().filter(|f| f.recoverable_elsewhere)
216 }
217}
218
219pub struct InputFetcher {
227 cache_dir: PathBuf,
228}
229
230impl Default for InputFetcher {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236impl InputFetcher {
237 #[must_use]
239 pub fn new() -> Self {
240 let cache_dir = dirs_cache_dir().join("sui/inputs");
241 Self { cache_dir }
242 }
243
244 #[must_use]
246 pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
247 Self { cache_dir }
248 }
249
250 #[must_use]
252 pub fn cache_dir(&self) -> &Path {
253 &self.cache_dir
254 }
255
256 #[must_use]
265 pub fn is_cached(&self, locked: &LockedInput) -> bool {
266 self.cache_probe(locked).is_some()
267 }
268
269 fn cache_probe(&self, locked: &LockedInput) -> Option<PathBuf> {
274 let nar_hash = locked.nar_hash.as_ref()?;
275 let cached = self.cache_dir.join(sanitize_hash(nar_hash));
276 if !cached.exists() {
277 return None;
278 }
279 let resolved = find_single_subdir_or_self(&cached);
280 is_non_empty_dir(&resolved).then_some(resolved)
281 }
282
283 pub fn fetch(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
288 if let Some(resolved) = self.cache_probe(locked) {
292 return Ok(resolved);
293 }
294 if let Some(ref nar_hash) = locked.nar_hash {
298 let cached = self.cache_dir.join(sanitize_hash(nar_hash));
299 if cached.exists() {
300 let _ = std::fs::remove_dir_all(&cached);
301 }
302 }
303
304 match locked.source_type.as_str() {
305 "github" => self.fetch_github(locked),
306 "gitlab" => self.fetch_gitlab(locked),
307 "sourcehut" => self.fetch_sourcehut(locked),
308 "path" => Self::fetch_path(locked),
309 "git" => self.fetch_git(locked),
310 "tarball" | "file" => self.fetch_tarball(locked),
311 other => Err(FetchError::UnsupportedType(other.to_string())),
312 }
313 }
314
315 #[must_use]
317 pub fn github_archive_url(owner: &str, repo: &str, rev: &str) -> String {
318 format!("https://github.com/{owner}/{repo}/archive/{rev}.tar.gz")
319 }
320
321 #[must_use]
327 pub fn gitlab_archive_url(host: Option<&str>, owner: &str, repo: &str, rev: &str) -> String {
328 let host = host.unwrap_or("gitlab.com");
329 format!(
330 "https://{host}/{owner}/{repo}/-/archive/{rev}/{repo}-{rev}.tar.gz"
331 )
332 }
333
334 #[must_use]
338 pub fn sourcehut_archive_url(owner: &str, repo: &str, rev: &str) -> String {
339 let owner_prefix = if owner.starts_with('~') {
340 owner.to_string()
341 } else {
342 format!("~{owner}")
343 };
344 format!("https://git.sr.ht/{owner_prefix}/{repo}/archive/{rev}.tar.gz")
345 }
346
347 fn fetch_github(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
350 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
351 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
352 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
353
354 let url = Self::github_archive_url(owner, repo, rev);
355 self.fetch_archive(locked, &url, &format!("github-{owner}-{repo}-{rev}"), rev)
360 }
361
362 fn fetch_archive(
392 &self,
393 locked: &LockedInput,
394 url: &str,
395 cache_key: &str,
396 rev: &str,
397 ) -> Result<PathBuf, FetchError> {
398 let dest = self.dest_dir(locked, cache_key);
399
400 if is_immutable_rev(rev) && is_non_empty_dir(&dest) {
413 return Ok(find_single_subdir_or_self(&dest));
414 }
415
416 let staging = staging_path(&dest);
417 let _ = std::fs::remove_dir_all(&staging);
420 std::fs::create_dir_all(&staging)?;
421
422 let bytes = match download_bytes(url) {
423 Ok(b) => b,
424 Err(e) => {
425 let _ = std::fs::remove_dir_all(&staging);
426 return Err(e);
427 }
428 };
429 if let Err(e) = extract_tar_gz(&bytes, &staging) {
430 let _ = std::fs::remove_dir_all(&staging);
431 return Err(e);
432 }
433
434 publish(&staging, &dest, is_immutable_rev(rev))?;
435 Ok(find_single_subdir_or_self(&dest))
436 }
437
438 fn fetch_gitlab(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
439 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
440 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
441 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
442 let host = locked.host.as_deref();
443 let url = Self::gitlab_archive_url(host, owner, repo, rev);
444 let host_tag = host.unwrap_or("gitlab.com").replace('.', "_");
445 self.fetch_archive(locked, &url, &format!("gitlab-{host_tag}-{owner}-{repo}-{rev}"), rev)
446 }
447
448 fn fetch_sourcehut(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
449 let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
450 let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
451 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
452 let url = Self::sourcehut_archive_url(owner, repo, rev);
453 let sanitized_owner = owner.trim_start_matches('~');
454 self.fetch_archive(
455 locked,
456 &url,
457 &format!("sourcehut-{sanitized_owner}-{repo}-{rev}"),
458 rev,
459 )
460 }
461
462 fn fetch_path(locked: &LockedInput) -> Result<PathBuf, FetchError> {
463 let path = locked
464 .path
465 .as_deref()
466 .ok_or(FetchError::MissingField("path"))?;
467 Ok(PathBuf::from(path))
468 }
469
470 fn fetch_git(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
471 let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
472 let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
473
474 let short_rev: String = rev.chars().take(12).collect();
475 let dest = self.dest_dir(locked, &format!("git-{short_rev}"));
476
477 let immutable = is_immutable_rev(rev);
480 if immutable && is_non_empty_dir(&dest) {
481 return Ok(dest);
482 }
483
484 let staging = staging_path(&dest);
490 let _ = std::fs::remove_dir_all(&staging);
491
492 if let Some(tarball_url) = github_tarball_from_git_url(url, rev) {
496 std::fs::create_dir_all(&staging)?;
497 match download_bytes(&tarball_url) {
498 Ok(bytes) => {
499 if let Err(e) = extract_tar_gz(&bytes, &staging) {
500 let _ = std::fs::remove_dir_all(&staging);
501 return Err(e);
502 }
503 publish(&staging, &dest, immutable)?;
504 return Ok(find_single_subdir_or_self(&dest));
505 }
506 Err(e) => {
507 let _ = std::fs::remove_dir_all(&staging);
509 tracing::debug!(url = %tarball_url, error = %e, "Tarball fallback failed, trying git CLI");
510 }
511 }
512 }
513
514 let status = std::process::Command::new("git")
516 .args(["clone", "--depth", "1", url])
517 .arg(&staging)
518 .stdout(std::process::Stdio::null())
519 .stderr(std::process::Stdio::null())
520 .status()
521 .map_err(|e| FetchError::Download(format!(
522 "git clone failed (git not in PATH?): {e}"
523 )))?;
524 if !status.success() {
525 let _ = std::fs::remove_dir_all(&staging);
526 return Err(FetchError::Download(format!(
527 "git clone failed for {url} (exit code: {})",
528 status.code().unwrap_or(-1)
529 )));
530 }
531
532 if let Err(e) = crate::git::checkout_rev(&staging, rev) {
539 let _ = std::fs::remove_dir_all(&staging);
540 return Err(FetchError::Download(format!("git checkout {rev}: {e}")));
541 }
542
543 publish(&staging, &dest, immutable)?;
544 Ok(dest)
545 }
546
547 fn fetch_tarball(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
548 let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
549
550 let hash_suffix = locked
551 .nar_hash
552 .as_deref()
553 .map_or_else(|| url_to_safe_name(url), sanitize_hash);
554 let dest = self.dest_dir(locked, &format!("tarball-{hash_suffix}"));
555
556 let immutable = locked.nar_hash.is_some();
561 if immutable && is_non_empty_dir(&dest) {
562 return Ok(find_single_subdir_or_self(&dest));
563 }
564
565 let staging = staging_path(&dest);
570 let _ = std::fs::remove_dir_all(&staging);
571 std::fs::create_dir_all(&staging)?;
572
573 let bytes = match download_bytes(url) {
574 Ok(b) => b,
575 Err(e) => {
576 let _ = std::fs::remove_dir_all(&staging);
577 return Err(e);
578 }
579 };
580 if let Err(e) = extract_tar_gz(&bytes, &staging) {
581 let _ = std::fs::remove_dir_all(&staging);
582 return Err(e);
583 }
584
585 publish(&staging, &dest, immutable)?;
586 Ok(find_single_subdir_or_self(&dest))
587 }
588
589 fn dest_dir(&self, locked: &LockedInput, fallback: &str) -> PathBuf {
591 if let Some(ref nar_hash) = locked.nar_hash {
592 self.cache_dir.join(sanitize_hash(nar_hash))
593 } else {
594 self.cache_dir.join(fallback)
595 }
596 }
597}
598
599fn github_tarball_from_git_url(url: &str, rev: &str) -> Option<String> {
606 let stripped = url
607 .strip_prefix("https://github.com/")
608 .or_else(|| url.strip_prefix("git+https://github.com/"))
609 .or_else(|| url.strip_prefix("http://github.com/"))?;
610 let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
611 let parts: Vec<&str> = stripped.split('/').collect();
613 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
614 Some(format!(
615 "https://github.com/{}/{}/archive/{rev}.tar.gz",
616 parts[0], parts[1]
617 ))
618 } else {
619 None
620 }
621}
622
623fn sanitize_hash(hash: &str) -> String {
646 let mapped = hash.replace(':', "-").replace('/', "_").replace('=', "");
647 let shaped = !mapped.is_empty()
648 && mapped != "."
649 && mapped != ".."
650 && mapped
651 .bytes()
652 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-'));
653 if shaped {
654 mapped
655 } else {
656 use sha2::Digest as _;
659 let d = sha2::Sha256::digest(hash.as_bytes());
660 let mut out = String::with_capacity(2 + 64);
661 out.push_str("h-");
662 for b in d {
663 use std::fmt::Write as _;
664 let _ = write!(out, "{b:02x}");
665 }
666 out
667 }
668}
669
670fn is_immutable_rev(rev: &str) -> bool {
677 matches!(rev.len(), 40 | 64) && rev.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
678}
679
680fn staging_path(dest: &Path) -> PathBuf {
697 let name = dest
698 .file_name()
699 .map_or_else(|| "fetch".to_string(), |n| n.to_string_lossy().into_owned());
700 let tid = format!("{:?}", std::thread::current().id());
703 let tid: String = tid.chars().filter(char::is_ascii_digit).collect();
704 let tmp = [
705 ".",
706 &name,
707 ".tmp-",
708 &std::process::id().to_string(),
709 "-",
710 &tid,
711 ]
712 .concat();
713 dest.parent()
714 .map_or_else(|| PathBuf::from(&tmp), |p| p.join(&tmp))
715}
716
717fn publish(staging: &Path, dest: &Path, immutable: bool) -> Result<(), FetchError> {
738 if immutable && is_non_empty_dir(dest) {
739 let _ = std::fs::remove_dir_all(staging);
740 return Ok(());
741 }
742
743 let aside = with_suffix(staging, ".old");
744 let _ = std::fs::remove_dir_all(&aside);
745 let moved_aside = dest.exists() && std::fs::rename(dest, &aside).is_ok();
746
747 match std::fs::rename(staging, dest) {
748 Ok(()) => {
749 if moved_aside {
750 let _ = std::fs::remove_dir_all(&aside);
751 }
752 Ok(())
753 }
754 Err(_) => {
755 if moved_aside && !dest.exists() {
758 let _ = std::fs::rename(&aside, dest);
759 }
760 let _ = std::fs::remove_dir_all(staging);
761 let _ = std::fs::remove_dir_all(&aside);
762 if is_non_empty_dir(dest) {
763 Ok(())
765 } else {
766 Err(FetchError::Extract(
767 "could not publish the fetched tree and no other process left one".into(),
768 ))
769 }
770 }
771 }
772}
773
774fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
777 let name = path
778 .file_name()
779 .map_or_else(|| "x".to_string(), |n| n.to_string_lossy().into_owned());
780 path.parent().map_or_else(
781 || PathBuf::from([&name, suffix].concat()),
782 |p| p.join([&name, suffix].concat()),
783 )
784}
785
786fn is_non_empty_dir(dir: &Path) -> bool {
788 std::fs::read_dir(dir)
789 .ok()
790 .is_some_and(|mut rd| rd.next().is_some())
791}
792
793fn find_single_subdir_or_self(dir: &Path) -> PathBuf {
797 let entries: Vec<_> = std::fs::read_dir(dir)
798 .ok()
799 .into_iter()
800 .flatten()
801 .filter_map(|e| e.ok())
802 .collect();
803 if entries.len() == 1 && entries[0].path().is_dir() {
804 entries[0].path()
805 } else {
806 dir.to_path_buf()
807 }
808}
809
810fn download_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
817 let agent: ureq::Agent = ureq::Agent::config_builder()
831 .http_status_as_error(false)
832 .build()
833 .into();
834 let mut req = agent.get(url);
835
836 if let Some(token) = github_token_for_url(url) {
843 req = req.header("Authorization", &format!("token {token}"));
844 }
845
846 let mut response = req
847 .call()
848 .map_err(|e| FetchError::Download(format!("{url}: {e}")))?;
849
850 if !response.status().is_success() {
851 return Err(classify_status(url, &response));
852 }
853
854 response
855 .body_mut()
856 .with_config()
857 .limit(512 * 1024 * 1024)
858 .read_to_vec()
859 .map_err(|e| FetchError::Download(format!("{url}: {e}")))
860}
861
862fn classify_status<B>(url: &str, response: &ureq::http::Response<B>) -> FetchError {
869 let status = response.status().as_u16();
870 let retry_after = retry_after_seconds(response.headers());
871
872 let throttled = status == 429 || (status == 403 && retry_after.is_some());
878
879 if throttled {
880 FetchError::Throttled {
881 url: url.to_string(),
882 status,
883 retry_after,
884 }
885 } else if status == 404 {
886 FetchError::NotFound {
887 url: url.to_string(),
888 }
889 } else if status == 401 || status == 403 {
890 FetchError::Unauthorized {
891 url: url.to_string(),
892 status,
893 }
894 } else {
895 FetchError::UnexpectedStatus {
896 url: url.to_string(),
897 status,
898 }
899 }
900}
901
902fn retry_after_seconds(headers: &ureq::http::HeaderMap) -> Option<u64> {
910 headers
911 .get("retry-after")?
912 .to_str()
913 .ok()?
914 .trim()
915 .parse::<u64>()
916 .ok()
917}
918
919fn github_token_for_url(url: &str) -> Option<String> {
930 if !url.starts_with("https://github.com/")
931 && !url.starts_with("https://api.github.com/")
932 {
933 return None;
934 }
935 if let Ok(t) = std::env::var("GITHUB_TOKEN") {
936 if !t.is_empty() {
937 return Some(t);
938 }
939 }
940 if let Ok(cfg) = std::env::var("NIX_CONFIG") {
941 if let Some(t) = parse_access_tokens(&cfg, "github.com") {
942 return Some(t);
943 }
944 }
945 if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
946 let nix_conf = home.join(".config/nix/nix.conf");
947 if let Ok(cfg) = std::fs::read_to_string(&nix_conf) {
948 if let Some(t) = parse_access_tokens(&cfg, "github.com") {
949 return Some(t);
950 }
951 }
952 let gh_hosts = home.join(".config/gh/hosts.yml");
953 if let Ok(yml) = std::fs::read_to_string(&gh_hosts) {
954 if let Some(t) = parse_gh_hosts_token(&yml, "github.com") {
955 return Some(t);
956 }
957 }
958 }
959 None
960}
961
962fn parse_access_tokens(cfg: &str, host: &str) -> Option<String> {
965 for line in cfg.lines() {
966 let trimmed = line.trim();
967 if let Some(rest) = trimmed.strip_prefix("access-tokens") {
968 let rest = rest.trim_start().trim_start_matches('=').trim();
969 for pair in rest.split_whitespace() {
970 if let Some((h, t)) = pair.split_once('=') {
971 if h == host {
972 return Some(t.to_string());
973 }
974 }
975 }
976 }
977 }
978 None
979}
980
981fn parse_gh_hosts_token(yml: &str, host: &str) -> Option<String> {
986 let mut in_host = false;
987 for line in yml.lines() {
988 let raw = line;
989 let trimmed = raw.trim();
990 if trimmed.starts_with(host) && trimmed.ends_with(':') {
991 in_host = true;
992 continue;
993 }
994 if !raw.starts_with(' ') && !raw.starts_with('\t') && !trimmed.is_empty() {
995 in_host = false;
996 }
997 if in_host {
998 if let Some(rest) = trimmed.strip_prefix("oauth_token:") {
999 return Some(rest.trim().to_string());
1000 }
1001 }
1002 }
1003 None
1004}
1005
1006fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<(), FetchError> {
1008 let gz = flate2::read::GzDecoder::new(bytes);
1009
1010 let mut buffered = std::io::BufReader::new(gz);
1013 let mut peek = [0u8; 1];
1014 match buffered.read(&mut peek) {
1016 Ok(0) => {
1017 return Err(FetchError::Extract("empty archive".into()));
1018 }
1019 Err(e) => {
1020 return Err(FetchError::Extract(format!("gzip decompression: {e}")));
1021 }
1022 Ok(_) => {
1023 let cursor = std::io::Cursor::new(peek);
1025 let chain = cursor.chain(buffered);
1026 let mut archive = tar::Archive::new(chain);
1027 archive
1028 .unpack(dest)
1029 .map_err(|e| FetchError::Extract(format!("tar unpack: {e}")))?;
1030 }
1031 }
1032
1033 Ok(())
1034}
1035
1036fn url_to_safe_name(url: &str) -> String {
1038 url.chars()
1039 .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
1040 .collect()
1041}
1042
1043fn dirs_cache_dir() -> PathBuf {
1045 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
1048 .map(PathBuf::from)
1049 .filter(|p| p.is_absolute())
1050 {
1051 return xdg;
1052 }
1053 if let Some(home) = std::env::var_os("HOME")
1054 .map(PathBuf::from)
1055 .filter(|p| p.is_absolute())
1056 {
1057 let default = home.join(".cache");
1058 if default.exists() || std::fs::create_dir_all(&default).is_ok() {
1059 return default;
1060 }
1061 }
1062 PathBuf::from("/tmp")
1063}
1064
1065#[cfg(test)]
1068mod archive_report_tests {
1069 use super::*;
1070
1071 fn throttled(retry: Option<u64>) -> FetchError {
1072 FetchError::Throttled { url: "u".into(), status: 429, retry_after: retry }
1073 }
1074
1075 #[test]
1076 fn a_throttle_becomes_a_machine_readable_tag_with_the_servers_advice() {
1077 let f = InputFailure::from_error("nixpkgs", &throttled(Some(120)));
1080 assert_eq!(f.kind, FailureKind::Throttled);
1081 assert_eq!(f.status, Some(429));
1082 assert_eq!(f.retry_after, Some(120));
1083 assert!(f.recoverable_elsewhere);
1084 assert_eq!(f.input, "nixpkgs", "the report must name WHICH input");
1085 }
1086
1087 #[test]
1088 fn each_error_maps_to_its_own_kind() {
1089 let cases: Vec<(FetchError, FailureKind)> = vec![
1090 (throttled(None), FailureKind::Throttled),
1091 (FetchError::Unauthorized { url: "u".into(), status: 403 }, FailureKind::Unauthorized),
1092 (FetchError::NotFound { url: "u".into() }, FailureKind::NotFound),
1093 (FetchError::UnexpectedStatus { url: "u".into(), status: 503 }, FailureKind::UnexpectedStatus),
1094 (FetchError::Download("dns".into()), FailureKind::Transport),
1095 (FetchError::UnsupportedType("hg".into()), FailureKind::Local),
1096 (FetchError::Extract("bad tar".into()), FailureKind::Local),
1097 ];
1098 for (err, want) in cases {
1099 let got = InputFailure::from_error("i", &err).kind;
1100 assert_eq!(got, want, "{err:?} classified as {got:?}");
1101 }
1102 }
1103
1104 #[test]
1105 fn only_a_throttle_is_marked_recoverable_elsewhere() {
1106 for e in [
1107 FetchError::Unauthorized { url: "u".into(), status: 401 },
1108 FetchError::NotFound { url: "u".into() },
1109 FetchError::UnexpectedStatus { url: "u".into(), status: 500 },
1110 FetchError::Download("tls".into()),
1111 ] {
1112 assert!(
1113 !InputFailure::from_error("i", &e).recoverable_elsewhere,
1114 "{e:?} must not claim another egress would help"
1115 );
1116 }
1117 }
1118
1119 #[test]
1120 fn an_empty_walk_is_NOT_complete() {
1121 let empty = ArchiveReport { scanned: 0, already_present: 0, fetched: 0, failures: vec![] };
1125 assert!(!empty.is_complete(), "an empty walk has not earned 'complete'");
1126
1127 let real = ArchiveReport { scanned: 3, already_present: 3, fetched: 0, failures: vec![] };
1128 assert!(real.is_complete());
1129 }
1130
1131 #[test]
1132 fn recoverable_filters_to_exactly_the_throttles() {
1133 let r = ArchiveReport {
1134 scanned: 4,
1135 already_present: 1,
1136 fetched: 0,
1137 failures: vec![
1138 InputFailure::from_error("a", &throttled(Some(5))),
1139 InputFailure::from_error("b", &FetchError::NotFound { url: "u".into() }),
1140 InputFailure::from_error("c", &throttled(None)),
1141 ],
1142 };
1143 let names: Vec<&str> = r.recoverable().map(|f| f.input.as_str()).collect();
1144 assert_eq!(names, vec!["a", "c"]);
1145 assert!(!r.is_complete());
1146 }
1147
1148 #[test]
1149 fn the_json_shape_is_the_contract_a_consumer_reads() {
1150 let r = ArchiveReport {
1154 scanned: 2,
1155 already_present: 1,
1156 fetched: 0,
1157 failures: vec![InputFailure::from_error("nixpkgs", &throttled(Some(90)))],
1158 };
1159 let v: serde_json::Value = serde_json::to_value(&r).expect("serializes");
1160 assert_eq!(v["scanned"], 2);
1161 assert_eq!(v["already_present"], 1);
1162 assert_eq!(v["failures"][0]["kind"], "throttled", "kebab-case tag");
1163 assert_eq!(v["failures"][0]["status"], 429);
1164 assert_eq!(v["failures"][0]["retry_after"], 90);
1165 assert_eq!(v["failures"][0]["recoverable_elsewhere"], true);
1166 assert_eq!(v["failures"][0]["input"], "nixpkgs");
1167
1168 let r2 = ArchiveReport {
1171 scanned: 1,
1172 already_present: 0,
1173 fetched: 0,
1174 failures: vec![InputFailure::from_error("x", &FetchError::Download("dns".into()))],
1175 };
1176 let v2: serde_json::Value = serde_json::to_value(&r2).unwrap();
1177 assert!(v2["failures"][0].get("status").is_none());
1178 assert!(v2["failures"][0].get("retry_after").is_none());
1179 assert_eq!(v2["failures"][0]["kind"], "transport");
1180 }
1181}
1182
1183#[cfg(test)]
1184mod status_classification_tests {
1185 use super::*;
1186
1187 fn resp(status: u16, headers: &[(&str, &str)]) -> ureq::http::Response<()> {
1189 let mut b = ureq::http::Response::builder().status(status);
1190 for (k, v) in headers {
1191 b = b.header(*k, *v);
1192 }
1193 b.body(()).expect("a status + headers response always builds")
1194 }
1195
1196 const URL: &str = "https://api.github.com/repos/o/r/tarball/deadbeef";
1197
1198 #[test]
1199 fn a_429_is_throttled_and_keeps_the_servers_own_retry_after() {
1200 let e = classify_status(URL, &resp(429, &[("retry-after", "120")]));
1203 assert!(matches!(
1204 e,
1205 FetchError::Throttled {
1206 status: 429,
1207 retry_after: Some(120),
1208 ..
1209 }
1210 ));
1211 assert!(e.is_throttled());
1212 assert_eq!(e.status(), Some(429));
1213 }
1214
1215 #[test]
1216 fn a_429_without_a_header_is_still_throttled() {
1217 let e = classify_status(URL, &resp(429, &[]));
1220 assert!(matches!(e, FetchError::Throttled { retry_after: None, .. }));
1221 assert!(e.is_throttled());
1222 }
1223
1224 #[test]
1225 fn a_403_with_retry_after_is_a_throttle_not_a_credential_fault() {
1226 let e = classify_status(URL, &resp(403, &[("retry-after", "60")]));
1229 assert!(
1230 e.is_throttled(),
1231 "a 403 that says 'come back later' is a throttle, got {e:?}"
1232 );
1233 }
1234
1235 #[test]
1236 fn a_bare_403_is_a_credential_fault_and_NOT_recoverable_elsewhere() {
1237 let e = classify_status(URL, &resp(403, &[]));
1241 assert!(matches!(e, FetchError::Unauthorized { status: 403, .. }));
1242 assert!(!e.is_throttled());
1243 }
1244
1245 #[test]
1246 fn a_404_says_it_may_be_an_invisible_private_input() {
1247 let e = classify_status(URL, &resp(404, &[]));
1248 assert!(matches!(e, FetchError::NotFound { .. }));
1249 assert_eq!(e.status(), Some(404));
1250 let msg = e.to_string();
1253 assert!(msg.contains("invisible to this credential"), "got {msg}");
1254 }
1255
1256 #[test]
1257 fn an_unhandled_status_arrives_as_a_NUMBER_never_as_prose() {
1258 let e = classify_status(URL, &resp(503, &[]));
1262 assert!(matches!(e, FetchError::UnexpectedStatus { status: 503, .. }));
1263 assert_eq!(e.status(), Some(503));
1264 assert!(!e.is_throttled());
1265 }
1266
1267 #[test]
1268 fn no_two_http_failures_render_the_same_bytes() {
1269 let u = URL.to_string();
1284 let cases: Vec<(&str, FetchError)> = vec![
1285 ("Throttled(no advice)", FetchError::Throttled { url: u.clone(), status: 404, retry_after: None }),
1286 ("Throttled(advice)", FetchError::Throttled { url: u.clone(), status: 404, retry_after: Some(30) }),
1287 ("Unauthorized", FetchError::Unauthorized { url: u.clone(), status: 404 }),
1288 ("NotFound", FetchError::NotFound { url: u.clone() }),
1289 ("UnexpectedStatus", FetchError::UnexpectedStatus { url: u.clone(), status: 404 }),
1290 ("Download", FetchError::Download(format!("{u}: connection reset"))),
1291 ];
1292
1293 for (i, (name_a, a)) in cases.iter().enumerate() {
1294 for (name_b, b) in cases.iter().skip(i + 1) {
1295 assert_ne!(
1296 a.to_string(),
1297 b.to_string(),
1298 "{name_a} and {name_b} render identically at the same status \
1299 — a caller cannot distinguish them"
1300 );
1301 }
1302 }
1303 }
1304
1305 #[test]
1306 fn an_http_date_retry_after_yields_none_rather_than_a_guess() {
1307 let h = resp(429, &[("retry-after", "Wed, 21 Oct 2026 07:28:00 GMT")]);
1311 assert_eq!(retry_after_seconds(h.headers()), None);
1312 assert!(classify_status(URL, &h).is_throttled());
1314 }
1315
1316 #[test]
1317 fn a_junk_retry_after_does_not_panic_or_lie() {
1318 for v in ["", " ", "abc", "-5", "12.5", "9999999999999999999999"] {
1319 let h = resp(429, &[("retry-after", v)]);
1320 assert_eq!(
1321 retry_after_seconds(h.headers()),
1322 None,
1323 "{v:?} must not parse"
1324 );
1325 }
1326 assert_eq!(retry_after_seconds(resp(429, &[("retry-after", " 30 ")]).headers()), Some(30));
1327 }
1328
1329 #[test]
1330 fn a_transport_failure_is_not_given_a_status() {
1331 let e = FetchError::Download("dns failure".into());
1335 assert_eq!(e.status(), None);
1336 assert!(!e.is_throttled());
1337 }
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use super::*;
1343 use std::collections::BTreeMap;
1344
1345 fn make_locked(source_type: &str) -> LockedInput {
1347 LockedInput {
1348 source_type: source_type.to_string(),
1349 owner: None,
1350 repo: None,
1351 rev: None,
1352 nar_hash: None,
1353 last_modified: None,
1354 path: None,
1355 url: None,
1356 git_ref: None,
1357 dir: None,
1358 host: None,
1359 extra: BTreeMap::new(),
1360 }
1361 }
1362
1363 #[test]
1366 fn sanitize_hash_replaces_special_chars() {
1367 assert_eq!(
1368 sanitize_hash("sha256-AAAAAAAAAAAAAAAAAAAAAA="),
1369 "sha256-AAAAAAAAAAAAAAAAAAAAAA"
1370 );
1371 assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
1372 }
1373
1374 #[test]
1377 fn a_traversal_hash_cannot_become_a_path_component() {
1378 for hostile in ["..", ".", "", "../..", "..\u{0}"] {
1383 let s = sanitize_hash(hostile);
1384 assert!(
1385 s != ".." && s != "." && !s.is_empty(),
1386 "{hostile:?} sanitized to {s:?}, still a meaningful component"
1387 );
1388 assert!(
1389 !s.contains('/') && !s.contains('\\'),
1390 "{hostile:?} sanitized to {s:?}, still a separator"
1391 );
1392 }
1393 assert_eq!(sanitize_hash(".."), sanitize_hash(".."));
1395 assert_ne!(sanitize_hash(".."), sanitize_hash("."));
1397 }
1398
1399 #[test]
1400 fn a_well_formed_hash_is_untouched_by_the_guard() {
1401 assert_eq!(
1404 sanitize_hash("sha256-avzRM+ffKgikqMRcOhhYp3ifgwXMGbH0rEGEZPEGMYE="),
1405 "sha256-avzRM+ffKgikqMRcOhhYp3ifgwXMGbH0rEGEZPEGMYE"
1406 );
1407 assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
1408 }
1409
1410 #[test]
1413 fn publishing_an_immutable_tree_adopts_the_winner_and_deletes_nothing() {
1414 let tmp = tempfile::tempdir().unwrap();
1415 let dest = tmp.path().join("github-o-r-deadbeef");
1416 let staging = staging_path(&dest);
1417 std::fs::create_dir_all(&dest).unwrap();
1419 std::fs::write(dest.join("theirs"), b"x").unwrap();
1420 std::fs::create_dir_all(&staging).unwrap();
1421 std::fs::write(staging.join("ours"), b"y").unwrap();
1422
1423 publish(&staging, &dest, true).unwrap();
1424
1425 assert!(
1426 dest.join("theirs").exists(),
1427 "an immutable tree is content-addressed: the winner's tree IS ours, \
1428 and deleting it to install an identical one is pure risk"
1429 );
1430 assert!(!staging.exists(), "our staging must be cleaned up");
1431 }
1432
1433 #[test]
1434 fn publishing_a_mutable_tree_replaces_it_without_a_delete_in_place() {
1435 let tmp = tempfile::tempdir().unwrap();
1436 let dest = tmp.path().join("github-o-r-main");
1437 let staging = staging_path(&dest);
1438 std::fs::create_dir_all(&dest).unwrap();
1439 std::fs::write(dest.join("old"), b"x").unwrap();
1440 std::fs::create_dir_all(&staging).unwrap();
1441 std::fs::write(staging.join("new"), b"y").unwrap();
1442
1443 publish(&staging, &dest, false).unwrap();
1444
1445 assert!(dest.join("new").exists(), "the new tree must be published");
1446 assert!(!dest.join("old").exists(), "and must REPLACE, not union");
1447 assert!(!staging.exists());
1448 let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
1450 .unwrap()
1451 .filter_map(Result::ok)
1452 .map(|e| e.file_name().to_string_lossy().into_owned())
1453 .filter(|n| n.contains(".old"))
1454 .collect();
1455 assert!(leftovers.is_empty(), "aside dirs left behind: {leftovers:?}");
1456 }
1457
1458 #[test]
1459 fn staging_is_scoped_by_thread_not_only_by_pid() {
1460 let dest = std::path::Path::new("/c/inputs/github-o-r-deadbeef");
1465 let here = staging_path(dest);
1466 let there = std::thread::spawn(move || staging_path(dest))
1467 .join()
1468 .unwrap();
1469 assert_ne!(
1470 here, there,
1471 "two threads must not share a staging directory"
1472 );
1473 }
1474
1475 #[test]
1478 fn only_a_full_object_id_is_treated_as_immutable() {
1479 assert!(is_immutable_rev("7fd33221240a3ab97781a066c5efe0124979527f"));
1481 assert!(is_immutable_rev(&"a".repeat(64)));
1482
1483 assert!(!is_immutable_rev("main"), "a branch name is not a commit");
1490 assert!(!is_immutable_rev("v1.2.3"), "a tag can be moved");
1491 assert!(!is_immutable_rev("7fd3322"), "a short rev is ambiguous");
1492 assert!(!is_immutable_rev(""), "an empty rev names nothing");
1493
1494 assert!(!is_immutable_rev(&"z".repeat(40)));
1496 assert!(!is_immutable_rev(&"A".repeat(40)));
1499 }
1500
1501 #[test]
1504 fn staging_is_a_sibling_so_the_publish_rename_is_atomic() {
1505 let dest = std::path::Path::new("/cache/sui/inputs/sha256-abc/github-o-r-deadbeef");
1506 let staging = staging_path(dest);
1507 assert_eq!(
1508 staging.parent(),
1509 dest.parent(),
1510 "staging in /tmp would put the rename across filesystems, where it \
1511 is a copy — and a copy is not atomic, which is the whole point"
1512 );
1513 assert_ne!(staging, dest.to_path_buf());
1514 let name = staging.file_name().unwrap().to_string_lossy().into_owned();
1515 assert!(name.starts_with('.'), "hidden, so it is not mistaken for a tree");
1516 assert!(
1517 name.contains(&std::process::id().to_string()),
1518 "pid-scoped, so two concurrent fetchers cannot share a staging dir"
1519 );
1520 let dotted = std::path::Path::new("/c/github-o-r-1.2.3");
1523 assert!(
1524 staging_path(dotted)
1525 .file_name()
1526 .unwrap()
1527 .to_string_lossy()
1528 .contains("github-o-r-1.2.3"),
1529 "the full directory name must survive into the staging name"
1530 );
1531 }
1532
1533 #[test]
1536 fn find_single_subdir_returns_child_when_one_dir() {
1537 let tmp = tempfile::tempdir().unwrap();
1538 let child = tmp.path().join("repo-abc123");
1539 std::fs::create_dir(&child).unwrap();
1540 std::fs::write(child.join("file.txt"), "hello").unwrap();
1541
1542 let result = find_single_subdir_or_self(tmp.path());
1543 assert_eq!(result, child);
1544 }
1545
1546 #[test]
1547 fn find_single_subdir_returns_self_when_multiple() {
1548 let tmp = tempfile::tempdir().unwrap();
1549 std::fs::create_dir(tmp.path().join("a")).unwrap();
1550 std::fs::create_dir(tmp.path().join("b")).unwrap();
1551
1552 let result = find_single_subdir_or_self(tmp.path());
1553 assert_eq!(result, tmp.path());
1554 }
1555
1556 #[test]
1557 fn find_single_subdir_returns_self_when_empty() {
1558 let tmp = tempfile::tempdir().unwrap();
1559 let result = find_single_subdir_or_self(tmp.path());
1560 assert_eq!(result, tmp.path());
1561 }
1562
1563 #[test]
1564 fn find_single_subdir_returns_self_when_child_is_file() {
1565 let tmp = tempfile::tempdir().unwrap();
1566 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1567 let result = find_single_subdir_or_self(tmp.path());
1568 assert_eq!(result, tmp.path());
1569 }
1570
1571 #[test]
1574 fn url_to_safe_name_replaces_slashes_and_colons() {
1575 let name = url_to_safe_name("https://example.com/foo/bar.tar.gz");
1576 assert!(!name.contains('/'));
1577 assert!(!name.contains(':'));
1578 assert!(name.contains("example"));
1579 }
1580
1581 #[test]
1584 fn fetcher_with_custom_cache_dir() {
1585 let tmp = tempfile::tempdir().unwrap();
1586 let fetcher = InputFetcher::with_cache_dir(tmp.path().to_path_buf());
1587 assert_eq!(fetcher.cache_dir(), tmp.path());
1588 }
1589
1590 #[test]
1591 fn fetcher_default_cache_dir_exists() {
1592 let fetcher = InputFetcher::new();
1593 let path_str = fetcher.cache_dir().to_string_lossy();
1595 assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
1596 }
1597
1598 #[test]
1601 fn fetch_path_returns_filesystem_path() {
1602 let tmp = tempfile::tempdir().unwrap();
1603 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1604
1605 let mut locked = make_locked("path");
1606 locked.path = Some("/var/empty/dep".to_string());
1607
1608 let result = fetcher.fetch(&locked).unwrap();
1609 assert_eq!(result, PathBuf::from("/var/empty/dep"));
1610 }
1611
1612 #[test]
1613 fn fetch_path_missing_field_errors() {
1614 let tmp = tempfile::tempdir().unwrap();
1615 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1616 let locked = make_locked("path");
1617 let result = fetcher.fetch(&locked);
1618 assert!(result.is_err());
1619 assert!(result.unwrap_err().to_string().contains("path"));
1620 }
1621
1622 #[test]
1625 fn fetch_unsupported_type_returns_error() {
1626 let tmp = tempfile::tempdir().unwrap();
1632 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1633 let locked = make_locked("mercurial");
1634 let result = fetcher.fetch(&locked);
1635 assert!(matches!(result, Err(FetchError::UnsupportedType(_))));
1636 }
1637
1638 #[test]
1639 fn gitlab_archive_url_is_well_formed() {
1640 assert_eq!(
1641 InputFetcher::gitlab_archive_url(None, "group", "proj", "abc123"),
1642 "https://gitlab.com/group/proj/-/archive/abc123/proj-abc123.tar.gz"
1643 );
1644 }
1645
1646 #[test]
1647 fn gitlab_archive_url_honors_custom_host() {
1648 assert_eq!(
1649 InputFetcher::gitlab_archive_url(Some("gitlab.gnome.org"), "GNOME", "gnome-shell", "abc"),
1650 "https://gitlab.gnome.org/GNOME/gnome-shell/-/archive/abc/gnome-shell-abc.tar.gz"
1651 );
1652 }
1653
1654 #[test]
1655 fn sourcehut_archive_url_prepends_tilde() {
1656 assert_eq!(
1660 InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
1661 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
1662 );
1663 assert_eq!(
1665 InputFetcher::sourcehut_archive_url("~emersion", "page", "HEAD"),
1666 "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
1667 );
1668 }
1669
1670 #[test]
1673 fn cache_hit_returns_cached_path() {
1674 let tmp = tempfile::tempdir().unwrap();
1675 let cache_dir = tmp.path().join("cache");
1676 std::fs::create_dir_all(&cache_dir).unwrap();
1677
1678 let hash = "sha256-TESTCACHEHIT";
1680 let cached_dir = cache_dir.join(sanitize_hash(hash));
1681 std::fs::create_dir_all(&cached_dir).unwrap();
1682 std::fs::write(cached_dir.join("flake.nix"), "{}").unwrap();
1683
1684 let fetcher = InputFetcher::with_cache_dir(cache_dir);
1685 let mut locked = make_locked("github");
1686 locked.nar_hash = Some(hash.to_string());
1687 let result = fetcher.fetch(&locked).unwrap();
1690 assert_eq!(result, cached_dir);
1692 }
1693
1694 #[test]
1697 fn github_archive_url_format() {
1698 let url = InputFetcher::github_archive_url("nixos", "nixpkgs", "abc123");
1699 assert_eq!(
1700 url,
1701 "https://github.com/nixos/nixpkgs/archive/abc123.tar.gz"
1702 );
1703 }
1704
1705 #[test]
1708 fn fetch_github_missing_owner_errors() {
1709 let tmp = tempfile::tempdir().unwrap();
1710 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1711 let mut locked = make_locked("github");
1712 locked.repo = Some("nixpkgs".into());
1713 locked.rev = Some("abc123".into());
1714 let result = fetcher.fetch(&locked);
1715 assert!(result.is_err());
1716 assert!(result.unwrap_err().to_string().contains("owner"));
1717 }
1718
1719 #[test]
1720 fn fetch_github_missing_rev_errors() {
1721 let tmp = tempfile::tempdir().unwrap();
1722 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1723 let mut locked = make_locked("github");
1724 locked.owner = Some("nixos".into());
1725 locked.repo = Some("nixpkgs".into());
1726 let result = fetcher.fetch(&locked);
1727 assert!(result.is_err());
1728 assert!(result.unwrap_err().to_string().contains("rev"));
1729 }
1730
1731 #[test]
1734 fn fetch_git_missing_url_errors() {
1735 let tmp = tempfile::tempdir().unwrap();
1736 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1737 let mut locked = make_locked("git");
1738 locked.rev = Some("abc123".into());
1739 let result = fetcher.fetch(&locked);
1740 assert!(result.is_err());
1741 assert!(result.unwrap_err().to_string().contains("url"));
1742 }
1743
1744 #[test]
1745 fn fetch_git_missing_rev_errors() {
1746 let tmp = tempfile::tempdir().unwrap();
1747 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1748 let mut locked = make_locked("git");
1749 locked.url = Some("https://example.com/repo.git".into());
1750 let result = fetcher.fetch(&locked);
1751 assert!(result.is_err());
1752 assert!(result.unwrap_err().to_string().contains("rev"));
1753 }
1754
1755 #[test]
1758 fn fetch_tarball_missing_url_errors() {
1759 let tmp = tempfile::tempdir().unwrap();
1760 let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
1761 let locked = make_locked("tarball");
1762 let result = fetcher.fetch(&locked);
1763 assert!(result.is_err());
1764 assert!(result.unwrap_err().to_string().contains("url"));
1765 }
1766
1767 #[test]
1770 fn extract_tar_gz_empty_archive_errors() {
1771 let tmp = tempfile::tempdir().unwrap();
1772 let result = extract_tar_gz(&[], tmp.path());
1773 assert!(result.is_err());
1774 }
1775
1776 #[test]
1777 fn extract_tar_gz_invalid_data_errors() {
1778 let tmp = tempfile::tempdir().unwrap();
1779 let result = extract_tar_gz(b"not a gzip stream at all", tmp.path());
1780 assert!(result.is_err());
1781 }
1782
1783 #[test]
1786 fn dest_dir_uses_nar_hash_when_present() {
1787 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
1788 let mut locked = make_locked("github");
1789 locked.nar_hash = Some("sha256-ABC123=".to_string());
1790 let dest = fetcher.dest_dir(&locked, "fallback");
1791 assert!(dest.to_string_lossy().contains("sha256-ABC123"));
1792 assert!(!dest.to_string_lossy().contains("fallback"));
1793 }
1794
1795 #[test]
1796 fn dest_dir_uses_fallback_when_no_hash() {
1797 let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
1798 let locked = make_locked("github");
1799 let dest = fetcher.dest_dir(&locked, "fallback-name");
1800 assert!(dest.to_string_lossy().contains("fallback-name"));
1801 }
1802
1803 #[test]
1806 fn is_non_empty_dir_returns_true_for_non_empty() {
1807 let tmp = tempfile::tempdir().unwrap();
1808 std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
1809 assert!(is_non_empty_dir(tmp.path()));
1810 }
1811
1812 #[test]
1813 fn is_non_empty_dir_returns_false_for_empty() {
1814 let tmp = tempfile::tempdir().unwrap();
1815 assert!(!is_non_empty_dir(tmp.path()));
1816 }
1817
1818 #[test]
1819 fn is_non_empty_dir_returns_false_for_missing() {
1820 assert!(!is_non_empty_dir(Path::new("/nonexistent/path/12345")));
1821 }
1822
1823 #[test]
1826 fn empty_cache_dir_is_treated_as_miss() {
1827 let tmp = tempfile::tempdir().unwrap();
1828 let cache_dir = tmp.path().join("cache");
1829 std::fs::create_dir_all(&cache_dir).unwrap();
1830
1831 let hash = "sha256-EMPTYTEST";
1833 let cached_dir = cache_dir.join(sanitize_hash(hash));
1834 std::fs::create_dir_all(&cached_dir).unwrap();
1835 assert!(std::fs::read_dir(&cached_dir).unwrap().next().is_none());
1837
1838 let fetcher = InputFetcher::with_cache_dir(cache_dir);
1839 let mut locked = make_locked("github");
1840 locked.nar_hash = Some(hash.to_string());
1841 let result = fetcher.fetch(&locked);
1845 assert!(result.is_err(), "should not return stale empty cache");
1846 assert!(!cached_dir.exists(), "stale cache dir should be removed");
1848 }
1849
1850 #[test]
1853 fn tarball_from_https_github() {
1854 let url = github_tarball_from_git_url(
1855 "https://github.com/NixOS/nixpkgs.git",
1856 "abc123",
1857 );
1858 assert_eq!(
1859 url.as_deref(),
1860 Some("https://github.com/NixOS/nixpkgs/archive/abc123.tar.gz")
1861 );
1862 }
1863
1864 #[test]
1865 fn tarball_from_git_plus_https() {
1866 let url = github_tarball_from_git_url(
1867 "git+https://github.com/NixOS/nixpkgs",
1868 "def456",
1869 );
1870 assert_eq!(
1871 url.as_deref(),
1872 Some("https://github.com/NixOS/nixpkgs/archive/def456.tar.gz")
1873 );
1874 }
1875
1876 #[test]
1877 fn tarball_from_non_github_returns_none() {
1878 assert!(github_tarball_from_git_url("https://gitlab.com/foo/bar.git", "abc").is_none());
1879 assert!(github_tarball_from_git_url("ssh://git@github.com/foo/bar", "abc").is_none());
1880 }
1881
1882 #[test]
1883 fn tarball_from_malformed_path_returns_none() {
1884 assert!(github_tarball_from_git_url("https://github.com/", "abc").is_none());
1885 assert!(github_tarball_from_git_url("https://github.com/only-owner", "abc").is_none());
1886 }
1887}
1888
1889
1890pub fn resolve_flake_dir(flake_ref: &FlakeRef) -> Result<std::path::PathBuf, FetchError> {
1910 match flake_ref.local_dir() {
1911 Some(p) => Ok(p.to_path_buf()),
1912 None => {
1913 let locked = flake_ref
1914 .source
1915 .locked_input()
1916 .ok_or_else(|| FetchError::UnsupportedType("non-fetchable flake source".into()))?;
1917 InputFetcher::new().fetch(&locked)
1918 }
1919 }
1920}