Skip to main content

mars_agents/source/
git.rs

1//! Git source adapter primitives.
2
3use std::fs;
4use std::io::{self, Cursor};
5use std::path::{Component, Path, PathBuf};
6use std::process::Command;
7use std::time::Duration;
8
9use crate::error::MarsError;
10use crate::source::parse::extract_hostname;
11use crate::source::{AvailableVersion, GlobalCache, ResolvedRef};
12use crate::types::CommitHash;
13use flate2::read::GzDecoder;
14use tar::Archive;
15
16/// Options controlling git fetch behavior.
17#[derive(Debug, Clone, Default)]
18pub struct FetchOptions {
19    /// Preferred commit SHA to checkout before resolving tags/versions.
20    /// Used for lock replay to guarantee reproducible content.
21    pub preferred_commit: Option<CommitHash>,
22}
23
24/// Normalize a git URL to a filesystem-safe directory name.
25///
26/// Strips protocol prefixes and replaces `/` and `:` with `_`.
27/// Strips trailing `.git` suffix.
28///
29/// Examples:
30/// - `https://github.com/foo/bar` -> `github.com_foo_bar`
31/// - `github.com/foo/bar` -> `github.com_foo_bar`
32/// - `git@github.com:foo/bar.git` -> `github.com_foo_bar`
33/// - `ssh://git@github.com/foo/bar` -> `github.com_foo_bar`
34pub fn url_to_dirname(url: &str) -> String {
35    let mut s = url.to_string();
36
37    // Strip common protocol prefixes
38    for prefix in &["https://", "http://", "ssh://", "git://"] {
39        if let Some(rest) = s.strip_prefix(prefix) {
40            s = rest.to_string();
41            break;
42        }
43    }
44
45    // Handle SSH shorthand: git@github.com:foo/bar -> github.com/foo/bar
46    if let Some(rest) = s.strip_prefix("git@") {
47        s = rest.to_string();
48        if let Some(colon_pos) = s.find(':') {
49            let after_colon = &s[colon_pos + 1..];
50            if !after_colon.starts_with("//") {
51                s.replace_range(colon_pos..colon_pos + 1, "/");
52            }
53        }
54    }
55
56    // Strip trailing .git
57    if let Some(rest) = s.strip_suffix(".git") {
58        s = rest.to_string();
59    }
60
61    // Strip trailing slash
62    if let Some(rest) = s.strip_suffix('/') {
63        s = rest.to_string();
64    }
65
66    // Replace `/` with `_`
67    s.replace('/', "_")
68}
69
70/// Parse a tag name as a semver version tag.
71///
72/// Accepts: `v1.0.0`, `v0.5.2`, `1.0.0`
73/// Rejects: `latest`, `nightly-2024`, or any non-semver tag.
74fn parse_semver_tag(tag: &str) -> Option<semver::Version> {
75    let version_str = tag.strip_prefix('v').unwrap_or(tag);
76    semver::Version::parse(version_str).ok()
77}
78
79#[derive(Debug, Clone)]
80struct ResolvedVersion {
81    tag: Option<String>,
82    version: Option<semver::Version>,
83    sha: String,
84}
85
86fn run_command(command: &mut Command, display: String) -> Result<String, MarsError> {
87    let output = command.output().map_err(|err| MarsError::GitCli {
88        command: display.clone(),
89        message: err.to_string(),
90    })?;
91
92    if !output.status.success() {
93        let stderr = String::from_utf8_lossy(&output.stderr);
94        let stdout = String::from_utf8_lossy(&output.stdout);
95        let message = if !stderr.trim().is_empty() {
96            stderr.trim().to_string()
97        } else if !stdout.trim().is_empty() {
98            stdout.trim().to_string()
99        } else {
100            format!("command exited with status {}", output.status)
101        };
102
103        return Err(MarsError::GitCli {
104            command: display,
105            message,
106        });
107    }
108
109    Ok(String::from_utf8_lossy(&output.stdout).to_string())
110}
111
112fn ls_remote_ref(url: &str, reference: &str) -> Result<String, MarsError> {
113    let mut command = Command::new("git");
114    command.arg("ls-remote").arg(url).arg(reference);
115
116    let command_display = format!("git ls-remote {url} {reference}");
117    let output = run_command(&mut command, command_display.clone())?;
118
119    for line in output.lines() {
120        if let Some((sha, _)) = line.split_once('\t')
121            && !sha.trim().is_empty()
122        {
123            return Ok(sha.trim().to_string());
124        }
125    }
126
127    Err(MarsError::GitCli {
128        command: command_display,
129        message: format!("reference `{reference}` not found"),
130    })
131}
132
133/// Run `git ls-remote --tags <url>` and parse semver tags.
134pub fn ls_remote_tags(url: &str) -> Result<Vec<AvailableVersion>, MarsError> {
135    let mut command = Command::new("git");
136    command.arg("ls-remote").arg("--tags").arg(url);
137
138    let output = run_command(&mut command, format!("git ls-remote --tags {url}"))?;
139    let mut versions = Vec::new();
140
141    for line in output.lines() {
142        let Some((sha, reference)) = line.split_once('\t') else {
143            continue;
144        };
145        let Some(tag) = reference.strip_prefix("refs/tags/") else {
146            continue;
147        };
148
149        // Annotated tags show up twice (`tag` and peeled `tag^{}`).
150        // Keep only the non-peeled entry to avoid duplicates.
151        if tag.ends_with("^{}") {
152            continue;
153        }
154
155        let Some(version) = parse_semver_tag(tag) else {
156            continue;
157        };
158
159        versions.push(AvailableVersion {
160            tag: tag.to_string(),
161            version,
162            commit_id: sha.trim().to_string(),
163        });
164    }
165
166    versions.sort_by(|a, b| a.version.cmp(&b.version));
167    Ok(versions)
168}
169
170/// Run `git ls-remote <url> HEAD` and return the default-branch SHA.
171pub fn ls_remote_head(url: &str) -> Result<String, MarsError> {
172    ls_remote_ref(url, "HEAD")
173}
174
175fn resolve_version(url: &str, version_req: Option<&str>) -> Result<ResolvedVersion, MarsError> {
176    if let Some(version_req) = version_req {
177        if let Some(requested_version) = parse_semver_tag(version_req) {
178            let tags = ls_remote_tags(url)?;
179            let selected = tags
180                .into_iter()
181                .find(|tag| tag.tag == version_req || tag.version == requested_version)
182                .ok_or_else(|| MarsError::Source {
183                    source_name: url.to_string(),
184                    message: format!("version tag `{version_req}` not found"),
185                })?;
186
187            return Ok(ResolvedVersion {
188                tag: Some(selected.tag),
189                version: Some(selected.version),
190                sha: selected.commit_id,
191            });
192        }
193
194        let sha = ls_remote_ref(url, version_req)?;
195        return Ok(ResolvedVersion {
196            tag: None,
197            version: None,
198            sha,
199        });
200    }
201
202    let tags = ls_remote_tags(url)?;
203    if let Some(selected) = tags.last() {
204        return Ok(ResolvedVersion {
205            tag: Some(selected.tag.clone()),
206            version: Some(selected.version.clone()),
207            sha: selected.commit_id.clone(),
208        });
209    }
210
211    eprintln!("warning: no releases found for {url}, using latest commit from default branch");
212    let sha = ls_remote_head(url)?;
213    Ok(ResolvedVersion {
214        tag: None,
215        version: None,
216        sha,
217    })
218}
219
220fn github_owner_repo(url: &str) -> Option<(String, String)> {
221    let (_, tail) = url.split_once("github.com/")?;
222    let mut segments = tail.split('/');
223    let owner = segments.next()?.trim();
224    let repo = segments.next()?.trim();
225    if owner.is_empty() || repo.is_empty() {
226        return None;
227    }
228    let repo = repo.strip_suffix(".git").unwrap_or(repo);
229    Some((owner.to_string(), repo.to_string()))
230}
231
232fn download_archive_bytes(archive_url: &str) -> Result<Vec<u8>, MarsError> {
233    const MAX_ATTEMPTS: usize = 3;
234
235    for attempt in 1..=MAX_ATTEMPTS {
236        match ureq::get(archive_url).call() {
237            Ok(mut response) => {
238                return response
239                    .body_mut()
240                    .with_config()
241                    .limit(200 * 1024 * 1024)
242                    .read_to_vec()
243                    .map_err(|err| MarsError::Http {
244                        url: archive_url.to_string(),
245                        status: 0,
246                        message: err.to_string(),
247                    });
248            }
249            Err(ureq::Error::StatusCode(status)) => {
250                if status == 429 && attempt < MAX_ATTEMPTS {
251                    std::thread::sleep(Duration::from_millis(150 * attempt as u64));
252                    continue;
253                }
254                return Err(MarsError::Http {
255                    url: archive_url.to_string(),
256                    status,
257                    message: format!("request failed with HTTP status {status}"),
258                });
259            }
260            Err(err) => {
261                return Err(MarsError::Http {
262                    url: archive_url.to_string(),
263                    status: 0,
264                    message: err.to_string(),
265                });
266            }
267        }
268    }
269
270    Err(MarsError::Http {
271        url: archive_url.to_string(),
272        status: 429,
273        message: "request failed after retrying HTTP 429".to_string(),
274    })
275}
276
277fn extract_and_strip_archive(archive_bytes: &[u8], dest: &Path) -> Result<(), MarsError> {
278    let decoder = GzDecoder::new(Cursor::new(archive_bytes));
279    let mut archive = Archive::new(decoder);
280
281    for entry in archive.entries()? {
282        let mut entry = entry?;
283        let entry_type = entry.header().entry_type();
284
285        if entry_type.is_symlink() || entry_type.is_hard_link() {
286            continue;
287        }
288
289        let entry_path = entry.path()?;
290        if entry_path.is_absolute() {
291            return Err(MarsError::InvalidRequest {
292                message: format!(
293                    "archive entry contains absolute path: {}",
294                    entry_path.display()
295                ),
296            });
297        }
298
299        let mut components = entry_path.components();
300        // Strip the top-level `{repo}-{sha}/` directory.
301        components.next();
302
303        let mut relative_path = PathBuf::new();
304        for component in components {
305            match component {
306                Component::Normal(seg) => relative_path.push(seg),
307                Component::CurDir => {}
308                Component::ParentDir => {
309                    return Err(MarsError::InvalidRequest {
310                        message: format!(
311                            "archive entry attempts parent traversal: {}",
312                            entry_path.display()
313                        ),
314                    });
315                }
316                Component::RootDir | Component::Prefix(_) => {
317                    return Err(MarsError::InvalidRequest {
318                        message: format!(
319                            "archive entry has invalid path: {}",
320                            entry_path.display()
321                        ),
322                    });
323                }
324            }
325        }
326
327        if relative_path.as_os_str().is_empty() {
328            continue;
329        }
330
331        let target_path = dest.join(&relative_path);
332
333        if entry_type.is_dir() {
334            fs::create_dir_all(&target_path)?;
335            continue;
336        }
337
338        if !entry_type.is_file() {
339            continue;
340        }
341
342        if let Some(parent) = target_path.parent() {
343            fs::create_dir_all(parent)?;
344        }
345
346        let mut output = fs::File::create(&target_path)?;
347        io::copy(&mut entry, &mut output)?;
348    }
349
350    Ok(())
351}
352
353fn fetch_archive(url: &str, sha: &str, cache: &GlobalCache) -> Result<PathBuf, MarsError> {
354    let (owner, repo) = github_owner_repo(url).ok_or_else(|| MarsError::Source {
355        source_name: url.to_string(),
356        message: "expected GitHub URL in the form https://github.com/owner/repo".to_string(),
357    })?;
358
359    let archive_url = format!("https://github.com/{owner}/{repo}/archive/{sha}.tar.gz");
360    let cache_path = cache
361        .archives_dir()
362        .join(format!("{}_{}", url_to_dirname(url), sha));
363
364    if cache_path.exists() {
365        return Ok(cache_path);
366    }
367
368    let archive_bytes = download_archive_bytes(&archive_url)?;
369    let temp_path = PathBuf::from(format!(
370        "{}.tmp.{}",
371        cache_path.to_string_lossy(),
372        std::process::id()
373    ));
374
375    if temp_path.exists() {
376        let _ = fs::remove_dir_all(&temp_path);
377    }
378    fs::create_dir_all(&temp_path)?;
379
380    let extract_result = extract_and_strip_archive(&archive_bytes, &temp_path);
381    if let Err(err) = extract_result {
382        let _ = fs::remove_dir_all(&temp_path);
383        return Err(err);
384    }
385
386    match fs::rename(&temp_path, &cache_path) {
387        Ok(()) => Ok(cache_path),
388        Err(err) => {
389            // Another process may have won the race and already created the cache path.
390            if cache_path.exists() {
391                let _ = fs::remove_dir_all(&temp_path);
392                Ok(cache_path)
393            } else {
394                let _ = fs::remove_dir_all(&temp_path);
395                Err(err.into())
396            }
397        }
398    }
399}
400
401fn fetch_git_clone(
402    url: &str,
403    tag: Option<&str>,
404    sha: Option<&str>,
405    cache: &GlobalCache,
406) -> Result<PathBuf, MarsError> {
407    let cache_path = cache.git_dir().join(url_to_dirname(url));
408
409    // Acquire per-entry lock to prevent cross-repo races on the same cache entry.
410    // Held through fetch + checkout, released when _lock drops at function return.
411    let lock_path = cache_path.with_extension("lock");
412    let _lock = crate::fs::FileLock::acquire(&lock_path)?;
413
414    let cache_path_display = cache_path.to_string_lossy().to_string();
415    let was_cached = cache_path.exists();
416
417    if !was_cached {
418        let mut command = Command::new("git");
419        command.arg("clone").arg("--depth").arg("1");
420        if let Some(tag) = tag {
421            command.arg("--branch").arg(tag);
422        }
423        command.arg(url).arg(&cache_path);
424
425        let mut display = String::from("git clone --depth 1");
426        if let Some(tag) = tag {
427            display.push_str(&format!(" --branch {tag}"));
428        }
429        display.push_str(&format!(" {url} {cache_path_display}"));
430
431        run_command(&mut command, display)?;
432    } else {
433        let mut fetch_cmd = Command::new("git");
434        fetch_cmd
435            .arg("-C")
436            .arg(&cache_path)
437            .arg("fetch")
438            .arg("--depth")
439            .arg("1")
440            .arg("origin");
441        run_command(
442            &mut fetch_cmd,
443            format!("git -C {cache_path_display} fetch --depth 1 origin"),
444        )?;
445    }
446
447    if was_cached {
448        if let Some(tag) = tag {
449            let mut checkout_tag = Command::new("git");
450            checkout_tag
451                .arg("-C")
452                .arg(&cache_path)
453                .arg("checkout")
454                .arg(tag);
455            run_command(
456                &mut checkout_tag,
457                format!("git -C {cache_path_display} checkout {tag}"),
458            )?;
459        }
460
461        if let Some(sha) = sha {
462            let mut checkout_sha = Command::new("git");
463            checkout_sha
464                .arg("-C")
465                .arg(&cache_path)
466                .arg("checkout")
467                .arg(sha);
468            run_command(
469                &mut checkout_sha,
470                format!("git -C {cache_path_display} checkout {sha}"),
471            )?;
472        } else if tag.is_none() {
473            let mut checkout_head = Command::new("git");
474            checkout_head
475                .arg("-C")
476                .arg(&cache_path)
477                .arg("checkout")
478                .arg("origin/HEAD");
479            run_command(
480                &mut checkout_head,
481                format!("git -C {cache_path_display} checkout origin/HEAD"),
482            )?;
483        }
484    } else if let Some(sha) = sha {
485        let mut checkout_sha = Command::new("git");
486        checkout_sha
487            .arg("-C")
488            .arg(&cache_path)
489            .arg("checkout")
490            .arg(sha);
491        run_command(
492            &mut checkout_sha,
493            format!("git -C {cache_path_display} checkout {sha}"),
494        )?;
495    }
496
497    Ok(cache_path)
498}
499
500/// Return true when the URL host resolves to github.com.
501pub fn is_github_host(url: &str) -> bool {
502    extract_hostname(url)
503        .map(|host| host.eq_ignore_ascii_case("github.com"))
504        .unwrap_or(false)
505}
506
507fn should_use_github_archive(url: &str) -> bool {
508    let trimmed = url.trim();
509    if trimmed.starts_with("git@") || trimmed.starts_with("ssh://") {
510        return false;
511    }
512
513    trimmed.starts_with("https://") && is_github_host(trimmed)
514}
515
516pub fn list_versions(url: &str, _cache: &GlobalCache) -> Result<Vec<AvailableVersion>, MarsError> {
517    ls_remote_tags(url)
518}
519
520pub fn fetch(
521    url: &str,
522    version_req: Option<&str>,
523    source_name: &str,
524    cache: &GlobalCache,
525    options: &FetchOptions,
526) -> Result<ResolvedRef, MarsError> {
527    let mut resolved = resolve_version(url, version_req)?;
528    if let Some(preferred_commit) = options.preferred_commit.as_ref() {
529        resolved.sha = preferred_commit.to_string();
530    }
531
532    let tree_path = if should_use_github_archive(url) {
533        match fetch_archive(url, &resolved.sha, cache) {
534            Ok(path) => path,
535            Err(MarsError::Http { status: 404, .. }) if options.preferred_commit.is_some() => {
536                return Err(MarsError::LockedCommitUnreachable {
537                    commit: resolved.sha.clone(),
538                    url: url.to_string(),
539                });
540            }
541            Err(err) => return Err(err),
542        }
543    } else {
544        // For git clone path, prefer exact SHA checkout when replaying a locked commit,
545        // or when resolving branch/default-HEAD refs (non-tag fetches).
546        let checkout_sha = if options.preferred_commit.is_some() || resolved.tag.is_none() {
547            Some(resolved.sha.as_str())
548        } else {
549            None
550        };
551
552        match fetch_git_clone(url, resolved.tag.as_deref(), checkout_sha, cache) {
553            Ok(path) => path,
554            Err(MarsError::GitCli { .. }) if options.preferred_commit.is_some() => {
555                return Err(MarsError::LockedCommitUnreachable {
556                    commit: resolved.sha.clone(),
557                    url: url.to_string(),
558                });
559            }
560            Err(err) => return Err(err),
561        }
562    };
563
564    Ok(ResolvedRef {
565        source_name: source_name.into(),
566        version: resolved.version,
567        version_tag: resolved.tag,
568        commit: Some(CommitHash::from(resolved.sha)),
569        tree_path,
570    })
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use flate2::Compression;
577    use flate2::write::GzEncoder;
578    use semver::Version;
579    use std::ffi::OsStr;
580    use std::io::Cursor;
581    use std::io::Write;
582    use std::path::Path;
583    use tar::Builder;
584    use tempfile::TempDir;
585
586    fn run_git<I, S>(cwd: &Path, args: I) -> String
587    where
588        I: IntoIterator<Item = S>,
589        S: AsRef<OsStr>,
590    {
591        let output = Command::new("git")
592            .current_dir(cwd)
593            .args(args)
594            .output()
595            .unwrap();
596        if !output.status.success() {
597            panic!(
598                "git command failed: {}\nstdout:\n{}\nstderr:\n{}",
599                output.status,
600                String::from_utf8_lossy(&output.stdout),
601                String::from_utf8_lossy(&output.stderr)
602            );
603        }
604        String::from_utf8_lossy(&output.stdout).trim().to_string()
605    }
606
607    fn init_repo() -> TempDir {
608        let repo = TempDir::new().unwrap();
609        run_git(repo.path(), ["init", "."]);
610        run_git(repo.path(), ["config", "user.name", "Mars Test"]);
611        run_git(repo.path(), ["config", "user.email", "mars@example.com"]);
612
613        fs::write(repo.path().join("README.md"), "initial\n").unwrap();
614        run_git(repo.path(), ["add", "."]);
615        run_git(repo.path(), ["commit", "-m", "initial commit"]);
616
617        repo
618    }
619
620    fn commit_file(repo: &Path, filename: &str, contents: &str, message: &str) -> String {
621        fs::write(repo.join(filename), contents).unwrap();
622        run_git(repo, ["add", filename]);
623        run_git(repo, ["commit", "-m", message]);
624        run_git(repo, ["rev-parse", "HEAD"])
625    }
626
627    fn build_tar_gz(files: &[(&str, &[u8])]) -> Vec<u8> {
628        let encoder = GzEncoder::new(Vec::new(), Compression::default());
629        let mut builder = Builder::new(encoder);
630
631        for (path, contents) in files {
632            let mut header = tar::Header::new_gnu();
633            header.set_mode(0o644);
634            header.set_size(contents.len() as u64);
635            header.set_cksum();
636            builder
637                .append_data(&mut header, *path, Cursor::new(*contents))
638                .unwrap();
639        }
640
641        let encoder = builder.into_inner().unwrap();
642        encoder.finish().unwrap()
643    }
644
645    fn build_tar_gz_with_symlink() -> Vec<u8> {
646        let encoder = GzEncoder::new(Vec::new(), Compression::default());
647        let mut builder = Builder::new(encoder);
648
649        let file_contents = b"safe\n";
650        let mut file_header = tar::Header::new_gnu();
651        file_header.set_mode(0o644);
652        file_header.set_size(file_contents.len() as u64);
653        file_header.set_cksum();
654        builder
655            .append_data(
656                &mut file_header,
657                "repo-abc/agents/coder.md",
658                Cursor::new(file_contents),
659            )
660            .unwrap();
661
662        let mut symlink_header = tar::Header::new_gnu();
663        symlink_header.set_entry_type(tar::EntryType::Symlink);
664        symlink_header.set_mode(0o777);
665        symlink_header.set_size(0);
666        symlink_header.set_cksum();
667        builder
668            .append_link(&mut symlink_header, "repo-abc/agents/link.md", "coder.md")
669            .unwrap();
670
671        let encoder = builder.into_inner().unwrap();
672        encoder.finish().unwrap()
673    }
674
675    fn write_tar_field(dst: &mut [u8], value: &[u8]) {
676        let len = value.len().min(dst.len());
677        dst[..len].copy_from_slice(&value[..len]);
678    }
679
680    fn write_tar_octal(dst: &mut [u8], value: u64) {
681        let width = dst.len().saturating_sub(1);
682        let octal = format!("{value:0width$o}");
683        let bytes = octal.as_bytes();
684        let copy_len = bytes.len().min(width);
685        dst[..copy_len].copy_from_slice(&bytes[..copy_len]);
686        dst[dst.len() - 1] = 0;
687    }
688
689    fn build_raw_tar_gz_single_file(path: &str, contents: &[u8]) -> Vec<u8> {
690        let mut header = [0_u8; 512];
691        write_tar_field(&mut header[0..100], path.as_bytes());
692        write_tar_octal(&mut header[100..108], 0o644);
693        write_tar_octal(&mut header[108..116], 0);
694        write_tar_octal(&mut header[116..124], 0);
695        write_tar_octal(&mut header[124..136], contents.len() as u64);
696        write_tar_octal(&mut header[136..148], 0);
697        header[156] = b'0';
698        write_tar_field(&mut header[257..263], b"ustar\0");
699        write_tar_field(&mut header[263..265], b"00");
700
701        for b in &mut header[148..156] {
702            *b = b' ';
703        }
704        let checksum: u32 = header.iter().map(|b| *b as u32).sum();
705        let checksum_field = format!("{checksum:06o}\0 ");
706        write_tar_field(&mut header[148..156], checksum_field.as_bytes());
707
708        let mut tar = Vec::new();
709        tar.extend_from_slice(&header);
710        tar.extend_from_slice(contents);
711        let padding = (512 - (contents.len() % 512)) % 512;
712        tar.extend(vec![0_u8; padding]);
713        tar.extend(vec![0_u8; 1024]);
714
715        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
716        encoder.write_all(&tar).unwrap();
717        encoder.finish().unwrap()
718    }
719
720    // ==================== url_to_dirname tests ====================
721
722    #[test]
723    fn url_to_dirname_https() {
724        assert_eq!(
725            url_to_dirname("https://github.com/foo/bar"),
726            "github.com_foo_bar"
727        );
728    }
729
730    #[test]
731    fn url_to_dirname_bare_domain() {
732        assert_eq!(
733            url_to_dirname("github.com/haowjy/meridian-base"),
734            "github.com_haowjy_meridian-base"
735        );
736    }
737
738    #[test]
739    fn url_to_dirname_ssh() {
740        assert_eq!(
741            url_to_dirname("git@github.com:foo/bar.git"),
742            "github.com_foo_bar"
743        );
744    }
745
746    #[test]
747    fn url_to_dirname_https_with_git_suffix() {
748        assert_eq!(
749            url_to_dirname("https://github.com/foo/bar.git"),
750            "github.com_foo_bar"
751        );
752    }
753
754    #[test]
755    fn url_to_dirname_ssh_protocol() {
756        assert_eq!(
757            url_to_dirname("ssh://git@github.com/foo/bar"),
758            "github.com_foo_bar"
759        );
760    }
761
762    #[test]
763    fn url_to_dirname_http() {
764        assert_eq!(
765            url_to_dirname("http://gitlab.com/org/repo"),
766            "gitlab.com_org_repo"
767        );
768    }
769
770    #[test]
771    fn url_to_dirname_trailing_slash() {
772        assert_eq!(
773            url_to_dirname("https://github.com/foo/bar/"),
774            "github.com_foo_bar"
775        );
776    }
777
778    // ==================== parse_semver_tag tests ====================
779
780    #[test]
781    fn parse_semver_v_prefixed() {
782        let v = parse_semver_tag("v1.2.3").unwrap();
783        assert_eq!(v, semver::Version::new(1, 2, 3));
784    }
785
786    #[test]
787    fn parse_semver_no_prefix() {
788        let v = parse_semver_tag("0.5.2").unwrap();
789        assert_eq!(v, semver::Version::new(0, 5, 2));
790    }
791
792    #[test]
793    fn ls_remote_tags_filters_sorts_and_skips_peeled_refs() {
794        let repo = init_repo();
795        run_git(repo.path(), ["tag", "v1.0.0"]);
796
797        commit_file(repo.path(), "README.md", "second\n", "second commit");
798        run_git(repo.path(), ["tag", "-a", "v1.2.0", "-m", "v1.2.0"]);
799        run_git(repo.path(), ["tag", "not-a-version"]);
800
801        commit_file(repo.path(), "README.md", "third\n", "third commit");
802        run_git(repo.path(), ["tag", "v1.10.0"]);
803
804        let versions = ls_remote_tags(repo.path().to_str().unwrap()).unwrap();
805        let tags: Vec<String> = versions.iter().map(|v| v.tag.clone()).collect();
806        assert_eq!(tags, vec!["v1.0.0", "v1.2.0", "v1.10.0"]);
807
808        for version in versions {
809            assert_eq!(version.commit_id.len(), 40);
810            assert!(version.commit_id.chars().all(|c| c.is_ascii_hexdigit()));
811        }
812    }
813
814    #[test]
815    fn extract_and_strip_archive_flattens_top_level_directory() {
816        let tarball = build_tar_gz(&[
817            ("repo-abc/agents/coder.md", b"agent"),
818            ("repo-abc/skills/review/SKILL.md", b"skill"),
819        ]);
820        let out = TempDir::new().unwrap();
821
822        extract_and_strip_archive(&tarball, out.path()).unwrap();
823
824        assert_eq!(
825            fs::read_to_string(out.path().join("agents/coder.md")).unwrap(),
826            "agent"
827        );
828        assert_eq!(
829            fs::read_to_string(out.path().join("skills/review/SKILL.md")).unwrap(),
830            "skill"
831        );
832    }
833
834    #[test]
835    fn extract_and_strip_archive_rejects_parent_traversal() {
836        let tarball = build_raw_tar_gz_single_file("repo-abc/../escape.txt", b"bad");
837        let out = TempDir::new().unwrap();
838
839        let err = extract_and_strip_archive(&tarball, out.path()).unwrap_err();
840        assert!(matches!(err, MarsError::InvalidRequest { .. }));
841        assert!(!out.path().join("escape.txt").exists());
842    }
843
844    #[test]
845    fn extract_and_strip_archive_skips_symlinks() {
846        let tarball = build_tar_gz_with_symlink();
847        let out = TempDir::new().unwrap();
848
849        extract_and_strip_archive(&tarball, out.path()).unwrap();
850
851        assert!(out.path().join("agents/coder.md").exists());
852        assert!(!out.path().join("agents/link.md").exists());
853    }
854
855    #[test]
856    fn fetch_local_git_repo_uses_latest_semver_tag() {
857        let remote = init_repo();
858        run_git(remote.path(), ["tag", "v0.1.0"]);
859
860        let v020_commit = commit_file(remote.path(), "README.md", "v0.2.0\n", "release v0.2.0");
861        run_git(remote.path(), ["tag", "v0.2.0"]);
862
863        let cache_root = TempDir::new().unwrap();
864        let cache = GlobalCache {
865            root: cache_root.path().join("cache"),
866        };
867        fs::create_dir_all(cache.archives_dir()).unwrap();
868        fs::create_dir_all(cache.git_dir()).unwrap();
869
870        let url = format!("file://{}", remote.path().display());
871        let resolved = fetch(&url, None, "local-source", &cache, &FetchOptions::default()).unwrap();
872
873        assert_eq!(resolved.source_name.as_ref(), "local-source");
874        assert_eq!(resolved.version, Some(Version::new(0, 2, 0)));
875        assert_eq!(resolved.version_tag.as_deref(), Some("v0.2.0"));
876        assert_eq!(resolved.commit.as_deref(), Some(v020_commit.as_str()));
877        assert!(resolved.tree_path.join("README.md").exists());
878
879        let checked_out = run_git(&resolved.tree_path, ["rev-parse", "HEAD"]);
880        assert_eq!(checked_out, v020_commit);
881    }
882
883    // ==================== is_github_host tests ====================
884
885    #[test]
886    fn is_github_host_accepts_supported_formats() {
887        assert!(is_github_host("https://github.com/org/repo"));
888        assert!(is_github_host("github.com/org/repo"));
889        assert!(is_github_host("git@github.com:org/repo.git"));
890        assert!(is_github_host("https://git@github.com:8443/org/repo"));
891    }
892
893    #[test]
894    fn is_github_host_rejects_other_hosts() {
895        assert!(!is_github_host("https://gitlab.com/org/repo"));
896        assert!(!is_github_host("git@source.example.com:org/repo.git"));
897    }
898
899    #[test]
900    fn github_archive_only_for_https_github_urls() {
901        assert!(should_use_github_archive("https://github.com/org/repo"));
902        assert!(!should_use_github_archive("http://github.com/org/repo"));
903        assert!(!should_use_github_archive("github.com/org/repo"));
904        assert!(!should_use_github_archive("git@github.com:org/repo.git"));
905        assert!(!should_use_github_archive("ssh://git@github.com/org/repo"));
906    }
907}