1use 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#[derive(Debug, Clone, Default)]
18pub struct FetchOptions {
19 pub preferred_commit: Option<CommitHash>,
22}
23
24pub fn url_to_dirname(url: &str) -> String {
35 let mut s = url.to_string();
36
37 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 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 if let Some(rest) = s.strip_suffix(".git") {
58 s = rest.to_string();
59 }
60
61 if let Some(rest) = s.strip_suffix('/') {
63 s = rest.to_string();
64 }
65
66 s.replace('/', "_")
68}
69
70fn 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
133pub 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 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
170pub 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 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 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 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
500pub 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 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 #[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 #[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 #[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}