1use std::fs;
2use std::io::Read as _;
3use std::path::{Component, Path, PathBuf};
4use std::process::Command;
5
6use flate2::read::GzDecoder;
7use tempfile::TempDir;
8
9use crate::error::GitClosureError;
10use crate::utils::{
11 ensure_no_symlink_ancestors, lexical_normalize, reject_if_symlink, truncate_stderr,
12};
13
14type Result<T> = std::result::Result<T, GitClosureError>;
15
16const GITHUB_API_BASE: &str = "https://api.github.com/repos";
17const GITHUB_TOKEN_ENV: &str = "GCL_GITHUB_TOKEN";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ProviderKind {
21 Auto,
22 Local,
23 GitClone,
24 Nix,
25 GithubApi,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum SourceSpec {
30 LocalPath(PathBuf),
31 GitHubRepo {
32 owner: String,
33 repo: String,
34 reference: Option<String>,
35 },
36 GitLabRepo {
37 group: String,
38 repo: String,
39 reference: Option<String>,
40 },
41 NixFlakeRef(String),
42 GitRemoteUrl(String),
43 Unknown(String),
44}
45
46impl SourceSpec {
47 pub fn parse(source: &str) -> Result<Self> {
48 if source.trim().is_empty() {
49 return Err(GitClosureError::Parse(
50 "source must not be empty".to_string(),
51 ));
52 }
53
54 if Path::new(source).exists() {
55 return Ok(Self::LocalPath(PathBuf::from(source)));
56 }
57
58 if looks_like_nix_flake_ref(source) {
59 return Ok(Self::NixFlakeRef(source.to_string()));
60 }
61
62 if let Some(rest) = source.strip_prefix("gh:") {
63 return parse_hosted_repo(rest, "github", false).map(|(owner, repo, reference)| {
64 Self::GitHubRepo {
65 owner,
66 repo,
67 reference,
68 }
69 });
70 }
71
72 if let Some(rest) = source.strip_prefix("gl:") {
73 return parse_hosted_repo(rest, "gitlab", true).map(|(group, repo, reference)| {
74 Self::GitLabRepo {
75 group,
76 repo,
77 reference,
78 }
79 });
80 }
81
82 if let Some(rest) = source.strip_prefix("https://github.com/") {
83 if let Ok((owner, repo, reference)) = parse_hosted_repo(rest, "github", false) {
84 return Ok(Self::GitHubRepo {
85 owner,
86 repo,
87 reference,
88 });
89 }
90 return Ok(Self::Unknown(source.to_string()));
91 }
92
93 if let Some(rest) = source.strip_prefix("https://gitlab.com/") {
94 if let Ok((group, repo, reference)) = parse_hosted_repo(rest, "gitlab", true) {
95 return Ok(Self::GitLabRepo {
96 group,
97 repo,
98 reference,
99 });
100 }
101 return Ok(Self::Unknown(source.to_string()));
102 }
103
104 if source.starts_with("http://")
105 || source.starts_with("https://")
106 || source.starts_with("git@")
107 || source.ends_with(".git")
108 {
109 return Ok(Self::GitRemoteUrl(source.to_string()));
110 }
111
112 Ok(Self::Unknown(source.to_string()))
113 }
114}
115
116pub struct FetchedSource {
117 pub root: PathBuf,
118 _tempdir: Option<TempDir>,
121}
122
123impl FetchedSource {
124 pub fn local(root: PathBuf) -> Self {
125 Self {
126 root,
127 _tempdir: None,
128 }
129 }
130
131 pub fn temporary(root: PathBuf, tempdir: TempDir) -> Self {
132 Self {
133 root,
134 _tempdir: Some(tempdir),
135 }
136 }
137}
138
139pub trait Provider {
140 fn fetch(&self, source: &str) -> Result<FetchedSource>;
141}
142
143pub fn fetch_source(source: &str, provider_kind: ProviderKind) -> Result<FetchedSource> {
144 let spec = SourceSpec::parse(source)?;
145 let local = LocalProvider;
146 let git = GitCloneProvider;
147 let nix = NixProvider;
148 let github_api = GithubApiProvider;
149
150 let selected = choose_provider(&spec, provider_kind)?;
151
152 match selected {
153 ProviderKind::Local => local.fetch(source),
154 ProviderKind::GitClone => git.fetch(source),
155 ProviderKind::Nix => nix.fetch(source),
156 ProviderKind::GithubApi => github_api.fetch(source),
157 ProviderKind::Auto => unreachable!("auto is resolved by choose_provider"),
158 }
159}
160
161fn choose_provider(spec: &SourceSpec, requested: ProviderKind) -> Result<ProviderKind> {
162 if requested != ProviderKind::Auto {
163 return Ok(requested);
164 }
165
166 let selected = match spec {
167 SourceSpec::LocalPath(_) => ProviderKind::Local,
168 SourceSpec::NixFlakeRef(_) => ProviderKind::Nix,
169 SourceSpec::GitHubRepo { .. } => ProviderKind::GithubApi,
170 SourceSpec::GitLabRepo { .. } | SourceSpec::GitRemoteUrl(_) => ProviderKind::GitClone,
171 SourceSpec::Unknown(value) => {
172 return Err(GitClosureError::Parse(format!(
173 "unsupported source syntax for auto provider: {value}"
174 )));
175 }
176 };
177 Ok(selected)
178}
179
180pub struct LocalProvider;
181
182impl Provider for LocalProvider {
183 fn fetch(&self, source: &str) -> Result<FetchedSource> {
184 let path = Path::new(source);
185 if !path.exists() {
186 return Err(GitClosureError::Parse(format!(
187 "local source path does not exist: {source}"
188 )));
189 }
190 let absolute = fs::canonicalize(path)?;
191 Ok(FetchedSource::local(absolute))
192 }
193}
194
195pub struct GitCloneProvider;
196
197impl Provider for GitCloneProvider {
198 fn fetch(&self, source: &str) -> Result<FetchedSource> {
199 let parsed = parse_git_source(source)?;
200 let tempdir = TempDir::new()?;
201 let checkout = tempdir.path().join("repo");
202 let checkout_str = checkout
203 .to_str()
204 .ok_or_else(|| GitClosureError::Parse("invalid checkout path".to_string()))?;
205
206 let clone_output = run_command_output(
207 "git",
208 &[
209 "clone",
210 "--depth",
211 "1",
212 "--no-tags",
213 &parsed.url,
214 checkout_str,
215 ],
216 None,
217 )?;
218
219 if !clone_output.status.success() {
220 return Err(GitClosureError::CommandExitFailure {
221 command: "git",
222 status: clone_output.status.to_string(),
223 stderr: truncate_stderr(&clone_output.stderr),
224 });
225 }
226
227 if let Some(reference) = parsed.reference {
228 let fetch_output = run_command_output(
229 "git",
230 &[
231 "-C",
232 checkout_str,
233 "fetch",
234 "--depth",
235 "1",
236 "origin",
237 &reference,
238 ],
239 None,
240 )?;
241
242 if !fetch_output.status.success() {
243 return Err(GitClosureError::CommandExitFailure {
244 command: "git",
245 status: fetch_output.status.to_string(),
246 stderr: truncate_stderr(&fetch_output.stderr),
247 });
248 }
249
250 let checkout_output = run_command_output(
251 "git",
252 &["-C", checkout_str, "checkout", "--detach", "FETCH_HEAD"],
253 None,
254 )?;
255
256 if !checkout_output.status.success() {
257 return Err(GitClosureError::CommandExitFailure {
258 command: "git",
259 status: checkout_output.status.to_string(),
260 stderr: truncate_stderr(&checkout_output.stderr),
261 });
262 }
263 }
264
265 Ok(FetchedSource::temporary(checkout, tempdir))
266 }
267}
268
269pub struct NixProvider;
270
271impl Provider for NixProvider {
272 fn fetch(&self, source: &str) -> Result<FetchedSource> {
273 let normalized = source.strip_prefix("nix:").unwrap_or(source);
274 let output = run_command_output("nix", &["flake", "metadata", normalized, "--json"], None)?;
275
276 if !output.status.success() {
277 return Err(GitClosureError::CommandExitFailure {
278 command: "nix",
279 status: output.status.to_string(),
280 stderr: truncate_stderr(&output.stderr),
281 });
282 }
283
284 let path = parse_nix_metadata_path(&output.stdout)?;
285 if !path.is_dir() {
286 return Err(GitClosureError::Parse(format!(
287 "nix flake metadata path is not a directory: {}",
288 path.display()
289 )));
290 }
291
292 Ok(FetchedSource::local(path))
293 }
294}
295
296pub struct GithubApiProvider;
297
298impl Provider for GithubApiProvider {
299 fn fetch(&self, source: &str) -> Result<FetchedSource> {
300 let parsed = parse_github_api_source(source)?;
301 let tarball = download_github_tarball(&parsed)?;
302
303 let tempdir = TempDir::new()?;
304 let checkout = tempdir.path().join("repo");
305 fs::create_dir_all(&checkout)?;
306
307 extract_github_tarball(&tarball, &checkout)?;
308 Ok(FetchedSource::temporary(checkout, tempdir))
309 }
310}
311
312#[derive(Debug, serde::Deserialize)]
313struct NixFlakeMetadata {
314 path: String,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
318struct ParsedGitSource {
319 url: String,
320 reference: Option<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq)]
324struct ParsedGithubApiSource {
325 owner: String,
326 repo: String,
327 reference: Option<String>,
328}
329
330impl ParsedGithubApiSource {
331 fn archive_url(&self) -> String {
332 let reference = self.reference.as_deref().unwrap_or("HEAD");
333 format!(
334 "{GITHUB_API_BASE}/{}/{}/tarball/{reference}",
335 self.owner, self.repo
336 )
337 }
338
339 fn display_name(&self) -> String {
340 let reference = self.reference.as_deref().unwrap_or("HEAD");
341 format!("{}/{}@{reference}", self.owner, self.repo)
342 }
343}
344
345fn parse_github_api_source(source: &str) -> Result<ParsedGithubApiSource> {
346 match SourceSpec::parse(source)? {
347 SourceSpec::GitHubRepo {
348 owner,
349 repo,
350 reference,
351 } => Ok(ParsedGithubApiSource {
352 owner,
353 repo,
354 reference,
355 }),
356 _ => Err(GitClosureError::Parse(format!(
357 "github-api provider requires a GitHub source (gh:owner/repo[@ref] or https://github.com/owner/repo[@ref]); got: {source}"
358 ))),
359 }
360}
361
362fn download_github_tarball(source: &ParsedGithubApiSource) -> Result<Vec<u8>> {
363 let url = source.archive_url();
364 let token = std::env::var(GITHUB_TOKEN_ENV)
365 .ok()
366 .filter(|v| !v.is_empty());
367 download_tarball_url(&url, &source.display_name(), token.as_deref())
368}
369
370fn download_tarball_url(url: &str, source_name: &str, token: Option<&str>) -> Result<Vec<u8>> {
371 let agent = ureq::builder().build();
372 let mut request = agent
373 .get(url)
374 .set("Accept", "application/vnd.github+json")
375 .set("User-Agent", "git-closure");
376 if let Some(token) = token {
377 request = request.set("Authorization", &format!("Bearer {token}"));
378 }
379
380 match request.call() {
381 Ok(response) => {
382 let mut body = Vec::new();
383 response
384 .into_reader()
385 .read_to_end(&mut body)
386 .map_err(|err| {
387 GitClosureError::Parse(format!(
388 "github-api: failed to read tarball response for {source_name}: {err}",
389 ))
390 })?;
391 Ok(body)
392 }
393 Err(ureq::Error::Status(status, response)) => {
394 let rate_remaining = response.header("X-RateLimit-Remaining").map(str::to_string);
395 let body = response.into_string().unwrap_or_default();
396 Err(github_api_status_error(
397 status,
398 rate_remaining.as_deref(),
399 source_name,
400 &body,
401 ))
402 }
403 Err(ureq::Error::Transport(err)) => Err(GitClosureError::Parse(format!(
404 "github-api: request failed for {source_name}: {err}",
405 ))),
406 }
407}
408
409fn github_api_status_error(
410 status: u16,
411 rate_remaining: Option<&str>,
412 source_name: &str,
413 body: &str,
414) -> GitClosureError {
415 let body_summary = body.trim();
416 let suffix = if body_summary.is_empty() {
417 String::new()
418 } else {
419 format!(": {body_summary}")
420 };
421
422 match status {
423 401 => GitClosureError::Parse(format!(
424 "github-api: authentication failed for {source_name} (HTTP 401). Set {GITHUB_TOKEN_ENV}."
425 )),
426 403 if rate_remaining == Some("0") => GitClosureError::Parse(format!(
427 "github-api: rate limit exceeded while downloading {source_name}. Set {GITHUB_TOKEN_ENV} for higher limits."
428 )),
429 404 => GitClosureError::Parse(format!(
430 "github-api: repository or reference not found: {source_name}"
431 )),
432 _ => GitClosureError::Parse(format!(
433 "github-api: request failed for {source_name} with HTTP {status}{suffix}"
434 )),
435 }
436}
437
438fn extract_github_tarball(bytes: &[u8], destination: &Path) -> Result<()> {
439 let decoder = GzDecoder::new(bytes);
440 let mut archive = tar::Archive::new(decoder);
441 let mut top_level: Option<std::ffi::OsString> = None;
442
443 for entry_result in archive.entries().map_err(|err| {
444 GitClosureError::Parse(format!("github-api: failed to read tar entries: {err}"))
445 })? {
446 let mut entry = entry_result.map_err(|err| {
447 GitClosureError::Parse(format!("github-api: invalid tar entry: {err}"))
448 })?;
449 let entry_path = entry.path().map_err(|err| {
450 GitClosureError::Parse(format!("github-api: invalid tar path entry: {err}"))
451 })?;
452 let relative = strip_github_archive_prefix(entry_path.as_ref(), &mut top_level)?;
453 let Some(relative) = relative else {
454 continue;
455 };
456
457 let output_path = destination.join(&relative);
458 if let Some(parent) = output_path.parent() {
459 ensure_no_symlink_ancestors(destination, parent)?;
460 fs::create_dir_all(parent)?;
461 }
462
463 let entry_type = entry.header().entry_type();
464 if entry_type.is_dir() {
465 reject_if_symlink(&output_path)?;
466 fs::create_dir_all(&output_path)?;
467 continue;
468 }
469
470 if entry_type.is_file() {
471 reject_if_symlink(&output_path)?;
472 if output_path.exists() {
473 return Err(GitClosureError::Parse(format!(
474 "github-api: duplicate file entry path in archive: {}",
475 relative.display()
476 )));
477 }
478 entry.unpack(&output_path).map_err(|err| {
479 GitClosureError::Parse(format!(
480 "github-api: failed to unpack file {}: {err}",
481 output_path.display()
482 ))
483 })?;
484 continue;
485 }
486
487 if entry_type.is_symlink() {
488 reject_if_symlink(&output_path)?;
489 if output_path.exists() {
490 return Err(GitClosureError::Parse(format!(
491 "github-api: duplicate symlink entry path in archive: {}",
492 relative.display()
493 )));
494 }
495 let target = entry.link_name().map_err(|err| {
496 GitClosureError::Parse(format!("github-api: invalid symlink entry target: {err}"))
497 })?;
498 let target = target.ok_or_else(|| {
499 GitClosureError::Parse("github-api: symlink entry missing target".to_string())
500 })?;
501 let target_path = target.as_ref();
502 let effective_target = if target_path.is_absolute() {
503 target_path.to_path_buf()
504 } else {
505 output_path
506 .parent()
507 .unwrap_or(destination)
508 .join(target_path)
509 };
510 let normalized = lexical_normalize(&effective_target)?;
511 if !normalized.starts_with(destination) {
512 return Err(GitClosureError::UnsafePath(format!(
513 "github-api: symlink target escapes destination: {}",
514 relative.display()
515 )));
516 }
517 #[cfg(unix)]
518 {
519 std::os::unix::fs::symlink(target_path, &output_path)?;
520 }
521 #[cfg(not(unix))]
522 {
523 let _ = target_path;
524 return Err(GitClosureError::Parse(
525 "github-api: symlink extraction is unsupported on this platform".to_string(),
526 ));
527 }
528 continue;
529 }
530
531 return Err(GitClosureError::Parse(format!(
532 "github-api: unsupported tar entry type for {}",
533 relative.display()
534 )));
535 }
536
537 if top_level.is_none() {
538 return Err(GitClosureError::Parse(
539 "github-api: archive contained no entries".to_string(),
540 ));
541 }
542
543 Ok(())
544}
545
546fn strip_github_archive_prefix(
547 path: &Path,
548 top_level: &mut Option<std::ffi::OsString>,
549) -> Result<Option<PathBuf>> {
550 let mut components = path.components();
551 let first = match components.next() {
552 Some(Component::Normal(name)) => name.to_os_string(),
553 _ => {
554 return Err(GitClosureError::UnsafePath(path.display().to_string()));
555 }
556 };
557
558 match top_level {
559 Some(existing) if existing != &first => {
560 return Err(GitClosureError::Parse(format!(
561 "github-api: archive has multiple top-level directories: {} and {}",
562 existing.to_string_lossy(),
563 first.to_string_lossy(),
564 )));
565 }
566 Some(_) => {}
567 None => {
568 *top_level = Some(first);
569 }
570 }
571
572 let mut relative = PathBuf::new();
573 for component in components {
574 match component {
575 Component::Normal(part) => relative.push(part),
576 _ => {
577 return Err(GitClosureError::UnsafePath(path.display().to_string()));
578 }
579 }
580 }
581
582 if relative.as_os_str().is_empty() {
583 return Ok(None);
584 }
585
586 Ok(Some(relative))
587}
588
589fn parse_git_source(source: &str) -> Result<ParsedGitSource> {
590 if let Some(rest) = source.strip_prefix("gh:") {
591 let (repo, reference) = split_repo_ref(rest);
592 return Ok(ParsedGitSource {
593 url: format!("https://github.com/{repo}.git"),
594 reference,
595 });
596 }
597
598 if let Some(rest) = source.strip_prefix("gl:") {
599 let (repo, reference) = split_repo_ref(rest);
600 return Ok(ParsedGitSource {
601 url: format!("https://gitlab.com/{repo}.git"),
602 reference,
603 });
604 }
605
606 Ok(ParsedGitSource {
607 url: source.to_string(),
608 reference: None,
609 })
610}
611
612fn split_repo_ref(input: &str) -> (&str, Option<String>) {
613 if let Some((repo, reference)) = input.rsplit_once('@') {
614 if !repo.is_empty() && !reference.is_empty() {
615 return (repo, Some(reference.to_string()));
616 }
617 }
618 (input, None)
619}
620
621fn looks_like_nix_flake_ref(source: &str) -> bool {
622 source.starts_with("nix:")
623 || source.starts_with("github:")
624 || source.starts_with("gitlab:")
625 || source.starts_with("sourcehut:")
626 || source.starts_with("git+")
627 || source.starts_with("path:")
628 || source.starts_with("tarball+")
629 || source.starts_with("file+")
630}
631
632fn parse_hosted_repo(
633 source: &str,
634 host: &str,
635 allow_nested_group: bool,
636) -> Result<(String, String, Option<String>)> {
637 let (repo_part, reference) = split_repo_ref(source);
638 let repo_part = repo_part.trim_end_matches(".git");
639 let mut segments = repo_part.split('/').collect::<Vec<_>>();
640 if segments.len() < 2 {
641 return Err(GitClosureError::Parse(format!(
642 "invalid {host} source, expected <owner>/<repo>: {source}"
643 )));
644 }
645 if !allow_nested_group && segments.len() != 2 {
646 return Err(GitClosureError::Parse(format!(
647 "invalid {host} source, expected <owner>/<repo>: {source}"
648 )));
649 }
650
651 let repo = segments.pop().unwrap().to_string();
652 let owner_or_group = segments.join("/");
653 if owner_or_group.is_empty() || repo.is_empty() {
654 return Err(GitClosureError::Parse(format!(
655 "invalid {host} source, expected <owner>/<repo>: {source}"
656 )));
657 }
658
659 Ok((owner_or_group, repo, reference))
660}
661
662fn parse_nix_metadata_path(output: &[u8]) -> Result<PathBuf> {
663 let metadata: NixFlakeMetadata = serde_json::from_slice(output).map_err(|err| {
664 GitClosureError::Parse(format!("failed to parse nix flake metadata JSON: {err}"))
665 })?;
666 Ok(PathBuf::from(metadata.path))
667}
668
669pub(crate) fn run_command_output(
670 command: &'static str,
671 args: &[&str],
672 current_dir: Option<&Path>,
673) -> Result<std::process::Output> {
674 let mut cmd = Command::new(command);
675 cmd.args(args);
676 if let Some(dir) = current_dir {
677 cmd.current_dir(dir);
678 }
679 cmd.output()
680 .map_err(|source| GitClosureError::CommandSpawnFailed { command, source })
681}
682
683#[cfg(test)]
687pub(crate) fn run_command_status(
688 command: &'static str,
689 args: &[&str],
690 current_dir: Option<&Path>,
691) -> Result<std::process::ExitStatus> {
692 let mut cmd = Command::new(command);
693 cmd.args(args);
694 if let Some(dir) = current_dir {
695 cmd.current_dir(dir);
696 }
697 cmd.status()
698 .map_err(|source| GitClosureError::CommandSpawnFailed { command, source })
699}
700
701#[cfg(test)]
702mod tests {
703 use super::{
704 choose_provider, fetch_source, github_api_status_error, parse_git_source,
705 parse_github_api_source, parse_nix_metadata_path, run_command_output, run_command_status,
706 split_repo_ref, strip_github_archive_prefix, GitCloneProvider, NixProvider,
707 ParsedGithubApiSource, Provider, ProviderKind, SourceSpec,
708 };
709 use crate::error::GitClosureError;
710 use crate::utils::truncate_stderr;
711 use flate2::write::GzEncoder;
712 use flate2::Compression;
713 use std::io::ErrorKind;
714 use std::io::{Read, Write};
715 use std::net::TcpListener;
716 use std::path::Path;
717 use std::time::Duration;
718
719 fn make_gzipped_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
720 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
721 {
722 let mut builder = tar::Builder::new(&mut gz);
723 for (path, bytes) in entries {
724 let mut header = tar::Header::new_gnu();
725 header.set_size(bytes.len() as u64);
726 header.set_mode(0o644);
727 header.set_cksum();
728 builder
729 .append_data(&mut header, *path, *bytes)
730 .expect("append tar file entry");
731 }
732 builder.finish().expect("finish tar builder");
733 }
734 gz.finish().expect("finish gzip stream")
735 }
736
737 #[test]
738 fn split_repo_ref_parses_optional_reference() {
739 assert_eq!(split_repo_ref("owner/repo"), ("owner/repo", None));
740 assert_eq!(
741 split_repo_ref("owner/repo@main"),
742 ("owner/repo", Some("main".to_string()))
743 );
744 }
745
746 #[test]
747 fn source_spec_parse_documented_examples() {
748 let gh = SourceSpec::parse("gh:owner/repo@main").expect("parse gh");
749 assert!(matches!(
750 gh,
751 SourceSpec::GitHubRepo {
752 owner,
753 repo,
754 reference: Some(reference)
755 } if owner == "owner" && repo == "repo" && reference == "main"
756 ));
757
758 let gl = SourceSpec::parse("gl:group/project").expect("parse gl");
759 assert!(matches!(
760 gl,
761 SourceSpec::GitLabRepo {
762 group,
763 repo,
764 reference: None
765 } if group == "group" && repo == "project"
766 ));
767
768 let nix = SourceSpec::parse("nix:github:NixOS/nixpkgs/nixos-unstable").expect("parse nix");
769 assert!(matches!(nix, SourceSpec::NixFlakeRef(_)));
770
771 let github_flake = SourceSpec::parse("github:owner/repo").expect("parse github flake ref");
772 assert!(matches!(github_flake, SourceSpec::NixFlakeRef(_)));
773
774 let https = SourceSpec::parse("https://github.com/owner/repo").expect("parse github https");
775 assert!(matches!(https, SourceSpec::GitHubRepo { .. }));
776
777 let archive = SourceSpec::parse("https://github.com/owner/repo/archive/main.tar.gz")
778 .expect("parse github archive URL as unsupported");
779 assert!(matches!(archive, SourceSpec::Unknown(_)));
780 }
781
782 #[test]
783 fn choose_provider_auto_from_source_spec() {
784 let local = SourceSpec::LocalPath(std::path::PathBuf::from("."));
785 assert_eq!(
786 choose_provider(&local, ProviderKind::Auto).expect("choose local"),
787 ProviderKind::Local
788 );
789
790 let gh = SourceSpec::GitHubRepo {
791 owner: "owner".to_string(),
792 repo: "repo".to_string(),
793 reference: None,
794 };
795 assert_eq!(
796 choose_provider(&gh, ProviderKind::Auto).expect("choose git clone for github"),
797 ProviderKind::GithubApi
798 );
799
800 let nix = SourceSpec::NixFlakeRef("github:owner/repo".to_string());
801 assert_eq!(
802 choose_provider(&nix, ProviderKind::Auto).expect("choose nix for flake refs"),
803 ProviderKind::Nix
804 );
805
806 let unknown = SourceSpec::Unknown("wat://unknown".to_string());
807 let err = choose_provider(&unknown, ProviderKind::Auto)
808 .expect_err("unknown auto source should fail before subprocess");
809 assert!(matches!(err, GitClosureError::Parse(_)));
810 }
811
812 #[test]
813 fn parse_git_source_supports_gh_and_gl_shortcuts() {
814 let gh = parse_git_source("gh:foo/bar@main").expect("parse gh source");
815 assert_eq!(gh.url, "https://github.com/foo/bar.git");
816 assert_eq!(gh.reference.as_deref(), Some("main"));
817
818 let gl = parse_git_source("gl:foo/bar").expect("parse gl source");
819 assert_eq!(gl.url, "https://gitlab.com/foo/bar.git");
820 assert!(gl.reference.is_none());
821 }
822
823 #[test]
824 fn parse_nix_metadata_extracts_store_path() {
825 let json = br#"{ "path": "/nix/store/abc123-source", "locked": { "rev": "deadbeef" } }"#;
826 let path = parse_nix_metadata_path(json).expect("parse nix metadata JSON");
827 assert_eq!(path, std::path::PathBuf::from("/nix/store/abc123-source"));
828 }
829
830 #[test]
831 fn missing_binary_maps_to_command_spawn_failed() {
832 let err = run_command_status("__nonexistent_binary_for_testing__", &[], None)
833 .expect_err("missing binary should produce spawn error");
834
835 match err {
836 GitClosureError::CommandSpawnFailed { command, source } => {
837 assert_eq!(command, "__nonexistent_binary_for_testing__");
838 assert_eq!(source.kind(), ErrorKind::NotFound);
839 }
840 other => panic!("expected CommandSpawnFailed, got {other:?}"),
841 }
842 }
843
844 #[test]
845 fn missing_binary_with_current_dir_maps_to_command_spawn_failed() {
846 let dir = std::env::temp_dir();
847 let err = run_command_status("__nonexistent_binary_for_testing__", &[], Some(&dir))
848 .expect_err("missing binary should fail");
849 assert!(
850 matches!(
851 err,
852 GitClosureError::CommandSpawnFailed {
853 command: "__nonexistent_binary_for_testing__",
854 ..
855 }
856 ),
857 "expected CommandSpawnFailed, got {err:?}"
858 );
859 }
860
861 #[test]
862 fn git_clone_failure_maps_to_command_exit_failure() {
863 let provider = GitCloneProvider;
864 let err = match provider.fetch("::::") {
865 Ok(_) => panic!("invalid git source should fail clone"),
866 Err(err) => err,
867 };
868
869 match err {
870 GitClosureError::CommandExitFailure {
871 command, stderr, ..
872 } => {
873 assert_eq!(command, "git");
874 assert!(!stderr.is_empty(), "stderr payload should be captured");
875 }
876 other => panic!("expected CommandExitFailure, got {other:?}"),
877 }
878 }
879
880 #[test]
881 fn command_exit_failure_display_includes_stderr() {
882 let output = run_command_output(
883 "git",
884 &["rev-parse", "--verify", "nonexistent-ref-xyz-abc"],
885 None,
886 )
887 .expect("git command should execute");
888 assert!(
889 !output.status.success(),
890 "rev-parse on nonexistent ref should fail"
891 );
892
893 let err = GitClosureError::CommandExitFailure {
894 command: "git",
895 status: output.status.to_string(),
896 stderr: truncate_stderr(&output.stderr),
897 };
898
899 let display = err.to_string();
900 assert!(
901 display.contains("nonexistent-ref")
902 || display.contains("fatal")
903 || display.contains("unknown"),
904 "error display must include stderr context, got: {display:?}"
905 );
906 }
907
908 #[test]
909 fn nix_provider_exit_failure_maps_to_command_exit_failure() {
910 let provider = NixProvider;
911 let err = match provider.fetch("path:/definitely/not/here") {
912 Ok(_) => panic!("invalid local flake path should fail"),
913 Err(err) => err,
914 };
915
916 match err {
922 GitClosureError::CommandExitFailure {
923 command, stderr, ..
924 } => {
925 assert_eq!(command, "nix");
926 assert!(
927 !stderr.is_empty(),
928 "stderr should be captured for nix exit failure"
929 );
930 let lowered = stderr.to_lowercase();
931 assert!(
932 lowered.contains("does not exist")
933 || lowered.contains("while fetching the input")
934 || lowered.contains("nix"),
935 "stderr should include actionable nix context, got: {stderr:?}"
936 );
937 }
938 GitClosureError::CommandSpawnFailed { command, .. } => {
939 assert_eq!(command, "nix");
941 }
942 other => panic!("expected CommandExitFailure or CommandSpawnFailed, got {other:?}"),
943 }
944 }
945
946 #[test]
947 fn parse_github_api_source_accepts_gh_and_https_syntax() {
948 let gh = parse_github_api_source("gh:owner/repo@main").expect("parse gh syntax");
949 assert_eq!(
950 gh,
951 ParsedGithubApiSource {
952 owner: "owner".to_string(),
953 repo: "repo".to_string(),
954 reference: Some("main".to_string())
955 }
956 );
957
958 let https =
959 parse_github_api_source("https://github.com/owner/repo").expect("parse https syntax");
960 assert_eq!(
961 https,
962 ParsedGithubApiSource {
963 owner: "owner".to_string(),
964 repo: "repo".to_string(),
965 reference: None
966 }
967 );
968 }
969
970 #[test]
971 fn parse_github_api_source_rejects_non_github_inputs() {
972 let err = parse_github_api_source("gl:group/repo").expect_err("gl source must fail");
973 assert!(
974 matches!(err, GitClosureError::Parse(_)),
975 "expected parse error for non-github source"
976 );
977 }
978
979 #[test]
980 fn github_api_download_follows_redirects() {
981 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
982 let addr = listener.local_addr().expect("listener addr");
983 let payload = b"redirect-ok".to_vec();
984 let payload_for_server = payload.clone();
985
986 let server = std::thread::spawn(move || {
987 let mut seen_redirect = false;
988 for _ in 0..2 {
989 let (mut stream, _) = listener.accept().expect("accept connection");
990 stream
991 .set_read_timeout(Some(Duration::from_secs(2)))
992 .expect("set read timeout");
993 let mut req_buf = [0u8; 2048];
994 let n = stream.read(&mut req_buf).expect("read request");
995 let request = String::from_utf8_lossy(&req_buf[..n]);
996
997 if request.starts_with("GET /redirect ") {
998 let response = format!(
999 "HTTP/1.1 302 Found\r\nLocation: http://{addr}/tarball\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1000 );
1001 stream
1002 .write_all(response.as_bytes())
1003 .expect("write redirect response");
1004 seen_redirect = true;
1005 } else if request.starts_with("GET /tarball ") {
1006 let headers = format!(
1007 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1008 payload_for_server.len()
1009 );
1010 stream
1011 .write_all(headers.as_bytes())
1012 .expect("write ok headers");
1013 stream
1014 .write_all(&payload_for_server)
1015 .expect("write payload");
1016 return seen_redirect;
1017 }
1018 }
1019 false
1020 });
1021
1022 let bytes = super::download_tarball_url(
1023 &format!("http://{addr}/redirect"),
1024 "owner/repo@HEAD",
1025 None,
1026 )
1027 .expect("redirected download should succeed");
1028
1029 assert_eq!(bytes, payload);
1030 assert!(
1031 server.join().expect("join test server"),
1032 "server should observe redirect then tarball request"
1033 );
1034 }
1035
1036 #[test]
1037 fn github_api_status_error_maps_auth_and_rate_limit_cases() {
1038 let auth = github_api_status_error(401, None, "owner/repo@HEAD", "");
1039 assert!(
1040 auth.to_string().contains("authentication failed")
1041 && auth.to_string().contains("GCL_GITHUB_TOKEN"),
1042 "401 must mention authentication and token env var"
1043 );
1044
1045 let rate = github_api_status_error(403, Some("0"), "owner/repo@HEAD", "rate limited");
1046 assert!(
1047 rate.to_string().contains("rate limit")
1048 && rate.to_string().contains("GCL_GITHUB_TOKEN"),
1049 "rate-limit errors must be actionable"
1050 );
1051
1052 let missing = github_api_status_error(404, None, "owner/repo@badref", "");
1053 assert!(
1054 missing.to_string().contains("not found")
1055 && missing.to_string().contains("owner/repo@badref"),
1056 "404 must mention missing repo/ref"
1057 );
1058 }
1059
1060 #[test]
1061 fn strip_github_archive_prefix_rejects_parent_traversal() {
1062 let mut top = None;
1063 let err = strip_github_archive_prefix(Path::new("repo-abc/../../evil.txt"), &mut top)
1064 .expect_err("path traversal in archive must be rejected");
1065 assert!(matches!(err, GitClosureError::UnsafePath(_)));
1066 }
1067
1068 #[test]
1069 fn split_github_archive_prefix_strips_top_level_directory() {
1070 let mut top = None;
1071 let rel = strip_github_archive_prefix(Path::new("repo-abc/src/lib.rs"), &mut top)
1072 .expect("valid github archive entry path")
1073 .expect("non-root entry must remain after stripping");
1074 assert_eq!(rel, std::path::PathBuf::from("src/lib.rs"));
1075 }
1076
1077 #[test]
1078 fn github_archive_extraction_strips_prefix_and_writes_files() {
1079 let tarball = make_gzipped_tar(&[
1080 ("repo-abc/README.md", b"hello\n"),
1081 ("repo-abc/src/lib.rs", b"pub fn x() {}\n"),
1082 ]);
1083 let tmp = tempfile::TempDir::new().expect("create tempdir");
1084 let dest = tmp.path().join("out");
1085 std::fs::create_dir_all(&dest).expect("create destination dir");
1086
1087 super::extract_github_tarball(&tarball, &dest).expect("extract archive");
1088 let readme = std::fs::read_to_string(dest.join("README.md")).expect("read README");
1089 let lib = std::fs::read_to_string(dest.join("src/lib.rs")).expect("read src/lib.rs");
1090 assert_eq!(readme, "hello\n");
1091 assert_eq!(lib, "pub fn x() {}\n");
1092 }
1093
1094 #[cfg(unix)]
1095 #[test]
1096 fn github_archive_extraction_preserves_symlink_entries() {
1097 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
1098 {
1099 let mut builder = tar::Builder::new(&mut gz);
1100
1101 let mut file_header = tar::Header::new_gnu();
1102 let file_bytes = b"target\n";
1103 file_header.set_size(file_bytes.len() as u64);
1104 file_header.set_mode(0o644);
1105 file_header.set_cksum();
1106 builder
1107 .append_data(&mut file_header, "repo-abc/target.txt", &file_bytes[..])
1108 .expect("append regular file");
1109
1110 let mut link_header = tar::Header::new_gnu();
1111 link_header.set_entry_type(tar::EntryType::Symlink);
1112 link_header.set_size(0);
1113 link_header.set_mode(0o777);
1114 link_header
1115 .set_link_name("target.txt")
1116 .expect("set symlink target");
1117 link_header.set_cksum();
1118 builder
1119 .append_data(&mut link_header, "repo-abc/link", std::io::empty())
1120 .expect("append symlink entry");
1121
1122 builder.finish().expect("finish tar builder");
1123 }
1124 let tarball = gz.finish().expect("finish gzip stream");
1125
1126 let tmp = tempfile::TempDir::new().expect("create tempdir");
1127 let dest = tmp.path().join("out");
1128 std::fs::create_dir_all(&dest).expect("create destination dir");
1129
1130 super::extract_github_tarball(&tarball, &dest).expect("extract archive");
1131 let target = std::fs::read_link(dest.join("link")).expect("read extracted symlink");
1132 assert_eq!(target, std::path::PathBuf::from("target.txt"));
1133 }
1134
1135 #[cfg(unix)]
1136 #[test]
1137 fn github_archive_extraction_rejects_absolute_symlink_target_escape() {
1138 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
1139 {
1140 let mut builder = tar::Builder::new(&mut gz);
1141
1142 let mut link_header = tar::Header::new_gnu();
1143 link_header.set_entry_type(tar::EntryType::Symlink);
1144 link_header.set_size(0);
1145 link_header.set_mode(0o777);
1146 link_header
1147 .set_link_name("/etc")
1148 .expect("set absolute target");
1149 link_header.set_cksum();
1150 builder
1151 .append_data(&mut link_header, "repo-abc/link", std::io::empty())
1152 .expect("append symlink entry");
1153
1154 builder.finish().expect("finish tar builder");
1155 }
1156 let tarball = gz.finish().expect("finish gzip stream");
1157
1158 let tmp = tempfile::TempDir::new().expect("create tempdir");
1159 let dest = tmp.path().join("out");
1160 std::fs::create_dir_all(&dest).expect("create destination dir");
1161
1162 let err = super::extract_github_tarball(&tarball, &dest)
1163 .expect_err("absolute symlink target must be rejected");
1164 assert!(matches!(err, GitClosureError::UnsafePath(_)));
1165 }
1166
1167 #[cfg(unix)]
1168 #[test]
1169 fn github_archive_extraction_rejects_relative_symlink_target_escape() {
1170 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
1171 {
1172 let mut builder = tar::Builder::new(&mut gz);
1173
1174 let mut link_header = tar::Header::new_gnu();
1175 link_header.set_entry_type(tar::EntryType::Symlink);
1176 link_header.set_size(0);
1177 link_header.set_mode(0o777);
1178 link_header
1179 .set_link_name("../../escape")
1180 .expect("set traversal target");
1181 link_header.set_cksum();
1182 builder
1183 .append_data(&mut link_header, "repo-abc/sub/link", std::io::empty())
1184 .expect("append symlink entry");
1185
1186 builder.finish().expect("finish tar builder");
1187 }
1188 let tarball = gz.finish().expect("finish gzip stream");
1189
1190 let tmp = tempfile::TempDir::new().expect("create tempdir");
1191 let dest = tmp.path().join("out");
1192 std::fs::create_dir_all(&dest).expect("create destination dir");
1193
1194 let err = super::extract_github_tarball(&tarball, &dest)
1195 .expect_err("relative symlink escape target must be rejected");
1196 assert!(matches!(err, GitClosureError::UnsafePath(_)));
1197 }
1198
1199 #[cfg(unix)]
1200 #[test]
1201 fn github_archive_extraction_allows_safe_relative_symlink_target() {
1202 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
1203 {
1204 let mut builder = tar::Builder::new(&mut gz);
1205
1206 let mut file_header = tar::Header::new_gnu();
1207 let file_bytes = b"ok\n";
1208 file_header.set_size(file_bytes.len() as u64);
1209 file_header.set_mode(0o644);
1210 file_header.set_cksum();
1211 builder
1212 .append_data(&mut file_header, "repo-abc/sub/sibling", &file_bytes[..])
1213 .expect("append sibling file");
1214
1215 let mut link_header = tar::Header::new_gnu();
1216 link_header.set_entry_type(tar::EntryType::Symlink);
1217 link_header.set_size(0);
1218 link_header.set_mode(0o777);
1219 link_header
1220 .set_link_name("./sibling")
1221 .expect("set safe target");
1222 link_header.set_cksum();
1223 builder
1224 .append_data(&mut link_header, "repo-abc/sub/link", std::io::empty())
1225 .expect("append symlink entry");
1226
1227 builder.finish().expect("finish tar builder");
1228 }
1229 let tarball = gz.finish().expect("finish gzip stream");
1230
1231 let tmp = tempfile::TempDir::new().expect("create tempdir");
1232 let dest = tmp.path().join("out");
1233 std::fs::create_dir_all(&dest).expect("create destination dir");
1234
1235 super::extract_github_tarball(&tarball, &dest).expect("safe symlink should extract");
1236 let target = std::fs::read_link(dest.join("sub/link")).expect("read extracted symlink");
1237 assert_eq!(target, std::path::PathBuf::from("./sibling"));
1238 }
1239
1240 #[cfg(unix)]
1241 #[test]
1242 fn github_archive_extraction_rejects_symlink_parent_escape() {
1243 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
1244 {
1245 let mut builder = tar::Builder::new(&mut gz);
1246
1247 let mut dir_link_header = tar::Header::new_gnu();
1248 dir_link_header.set_entry_type(tar::EntryType::Symlink);
1249 dir_link_header.set_size(0);
1250 dir_link_header.set_mode(0o777);
1251 dir_link_header
1252 .set_link_name("../escape")
1253 .expect("set symlink target");
1254 dir_link_header.set_cksum();
1255 builder
1256 .append_data(&mut dir_link_header, "repo-abc/dir", std::io::empty())
1257 .expect("append symlinked directory entry");
1258
1259 let mut file_header = tar::Header::new_gnu();
1260 let payload = b"owned\n";
1261 file_header.set_size(payload.len() as u64);
1262 file_header.set_mode(0o644);
1263 file_header.set_cksum();
1264 builder
1265 .append_data(&mut file_header, "repo-abc/dir/payload.txt", &payload[..])
1266 .expect("append nested file");
1267
1268 builder.finish().expect("finish tar builder");
1269 }
1270 let tarball = gz.finish().expect("finish gzip stream");
1271
1272 let tmp = tempfile::TempDir::new().expect("create tempdir");
1273 let dest = tmp.path().join("out");
1274 std::fs::create_dir_all(&dest).expect("create destination dir");
1275 let escape = tmp.path().join("escape");
1276 std::fs::create_dir_all(&escape).expect("create would-be escape dir");
1277
1278 let err = super::extract_github_tarball(&tarball, &dest)
1279 .expect_err("archive writing through symlink parent must be rejected");
1280 assert!(
1281 matches!(err, GitClosureError::UnsafePath(_)),
1282 "expected UnsafePath, got {err:?}"
1283 );
1284 assert!(
1285 !escape.join("payload.txt").exists(),
1286 "extraction must not write outside destination root"
1287 );
1288 }
1289
1290 #[test]
1291 fn github_archive_extraction_rejects_duplicate_file_entries() {
1292 let tarball = make_gzipped_tar(&[
1293 ("repo-abc/dup.txt", b"first\n"),
1294 ("repo-abc/dup.txt", b"second\n"),
1295 ]);
1296 let tmp = tempfile::TempDir::new().expect("create tempdir");
1297 let dest = tmp.path().join("out");
1298 std::fs::create_dir_all(&dest).expect("create destination dir");
1299
1300 let err = super::extract_github_tarball(&tarball, &dest)
1301 .expect_err("duplicate file entries must be rejected");
1302 assert!(
1303 matches!(err, GitClosureError::Parse(_)),
1304 "expected Parse error, got {err:?}"
1305 );
1306 assert!(
1307 err.to_string().contains("duplicate file entry path"),
1308 "error must mention duplicate file entry path: {err}"
1309 );
1310 }
1311
1312 #[test]
1313 fn github_api_provider_rejects_non_github_source() {
1314 use super::GithubApiProvider;
1315 let provider = GithubApiProvider;
1316 let err = match provider.fetch("gl:group/repo") {
1317 Ok(_) => panic!("github-api provider must reject non-github source syntax"),
1318 Err(e) => e,
1319 };
1320 assert!(
1321 matches!(err, GitClosureError::Parse(_)),
1322 "expected Parse error, got {err:?}"
1323 );
1324 let msg = err.to_string();
1325 assert!(
1326 msg.contains("github-api") || msg.contains("GitHub"),
1327 "error message must mention github-api source requirement, got: {msg:?}"
1328 );
1329 }
1330
1331 #[test]
1332 fn auto_provider_github_repo_routes_to_github_api() {
1333 let gh = SourceSpec::parse("gh:owner/repo").expect("parse gh source");
1334 assert_eq!(
1335 choose_provider(&gh, ProviderKind::Auto).expect("choose provider"),
1336 ProviderKind::GithubApi
1337 );
1338 }
1339
1340 #[test]
1341 fn auto_provider_github_https_routes_to_github_api() {
1342 let gh = SourceSpec::parse("https://github.com/owner/repo").expect("parse github https");
1343 assert_eq!(
1344 choose_provider(&gh, ProviderKind::Auto).expect("choose provider"),
1345 ProviderKind::GithubApi
1346 );
1347 }
1348
1349 #[test]
1350 fn auto_provider_github_prefix_is_still_treated_as_nix_flake_ref() {
1351 let err = match fetch_source("github:owner/repo", ProviderKind::Auto) {
1352 Ok(_) => return,
1353 Err(err) => err,
1354 };
1355 match err {
1356 GitClosureError::CommandExitFailure { command, .. }
1357 | GitClosureError::CommandSpawnFailed { command, .. } => {
1358 assert_eq!(command, "nix");
1359 }
1360 other => panic!("expected nix-command failure path for github: refs, got {other:?}"),
1361 }
1362 }
1363}