Skip to main content

git_closure/providers/
mod.rs

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 does not implement Debug; keep field private and suppress the
119    // derive — Debug is only needed for test assertions on the Err branch.
120    _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/// `run_command_status` is only used in tests (spawn/exit-code assertions).
684/// Keeping it test-only avoids a `#[allow(dead_code)]` annotation on a
685/// `pub(crate)` function that has no production call site.
686#[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        // On systems without the `nix` binary the error is CommandSpawnFailed
917        // (ENOENT).  On systems with `nix`, the path does not exist so it
918        // exits non-zero → CommandExitFailure.  Both are acceptable outcomes
919        // for this test; what we assert is that the error correctly identifies
920        // the `nix` command and does not silently succeed.
921        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                // nix binary is not installed; spawn failure is the expected path.
940                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}