1use std::path::{Path, PathBuf};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum LocalSource {
11 Directory(PathBuf),
15 Tarball(PathBuf),
18 Link(PathBuf),
22 Portal(PathBuf),
26 Exec(PathBuf),
30 Git(GitSource),
39 RemoteTarball(RemoteTarballSource),
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct RemoteTarballSource {
50 pub url: String,
51 pub integrity: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct GitSource {
57 pub url: String,
58 pub committish: Option<String>,
59 pub resolved: String,
60 pub subpath: Option<String>,
66}
67
68impl LocalSource {
69 pub fn path(&self) -> Option<&Path> {
72 match self {
73 LocalSource::Directory(p)
74 | LocalSource::Tarball(p)
75 | LocalSource::Link(p)
76 | LocalSource::Portal(p)
77 | LocalSource::Exec(p) => Some(p),
78 LocalSource::Git(_) | LocalSource::RemoteTarball(_) => None,
79 }
80 }
81
82 pub fn kind_str(&self) -> &'static str {
84 match self {
85 LocalSource::Directory(_) | LocalSource::Tarball(_) => "file",
86 LocalSource::Link(_) => "link",
87 LocalSource::Portal(_) => "portal",
88 LocalSource::Exec(_) => "exec",
89 LocalSource::Git(_) => "git",
90 LocalSource::RemoteTarball(_) => "url",
91 }
92 }
93
94 pub fn path_posix(&self) -> String {
103 self.path()
104 .map(|p| p.to_string_lossy().replace('\\', "/"))
105 .unwrap_or_default()
106 }
107
108 pub fn specifier(&self) -> String {
116 match self {
117 LocalSource::Git(g) => match &g.subpath {
118 Some(sub) => format!("{}#{}&path:/{}", g.url, g.resolved, sub),
119 None => format!("{}#{}", g.url, g.resolved),
120 },
121 LocalSource::RemoteTarball(t) => t.url.clone(),
122 _ => format!("{}:{}", self.kind_str(), self.path_posix()),
123 }
124 }
125
126 pub fn dep_path(&self, name: &str) -> String {
142 use sha2::{Digest, Sha256};
143 let mut hasher = Sha256::new();
144 match self {
145 LocalSource::Git(g) => {
146 hasher.update(g.url.as_bytes());
147 hasher.update(b"#");
148 hasher.update(g.resolved.as_bytes());
149 if let Some(sub) = &g.subpath {
150 hasher.update(b"&path:/");
151 hasher.update(sub.as_bytes());
152 }
153 }
154 LocalSource::RemoteTarball(t) => {
155 hasher.update(t.url.as_bytes());
156 }
157 _ => hasher.update(self.path_posix().as_bytes()),
158 }
159 let digest = hasher.finalize();
160 let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
161 format!("{name}@{}+{short}", self.kind_str())
162 }
163
164 pub fn parse(spec: &str, project_root: &Path) -> Option<Self> {
170 if let Some((url, committish, subpath)) = parse_git_spec(spec) {
174 return Some(LocalSource::Git(GitSource {
179 url,
180 committish,
181 resolved: String::new(),
182 subpath,
183 }));
184 }
185 if Self::looks_like_remote_tarball_url(spec) {
191 return Some(LocalSource::RemoteTarball(RemoteTarballSource {
192 url: spec.to_string(),
193 integrity: String::new(),
194 }));
195 }
196 let (kind, rest) = if let Some(r) = spec.strip_prefix("file:") {
197 ("file", r)
198 } else if let Some(r) = spec.strip_prefix("link:") {
199 ("link", r)
200 } else if let Some(r) = spec.strip_prefix("portal:") {
201 ("portal", r)
202 } else if let Some(r) = spec.strip_prefix("exec:") {
203 return Some(LocalSource::Exec(PathBuf::from(r)));
204 } else {
205 return None;
206 };
207 let rel = PathBuf::from(rest);
208 let abs = project_root.join(&rel);
209 if kind == "link" {
210 return Some(LocalSource::Link(rel));
211 }
212 if kind == "portal" {
213 return Some(LocalSource::Portal(rel));
214 }
215 if abs.is_file() && Self::path_looks_like_tarball(&rel) {
216 return Some(LocalSource::Tarball(rel));
217 }
218 Some(LocalSource::Directory(rel))
219 }
220
221 pub fn looks_like_remote_tarball_url(spec: &str) -> bool {
230 spec.starts_with("https://") || spec.starts_with("http://")
231 }
232
233 pub fn path_looks_like_tarball(path: &Path) -> bool {
234 let name = match path.file_name().and_then(|n| n.to_str()) {
235 Some(n) => n,
236 None => return false,
237 };
238 let lower = name.to_ascii_lowercase();
239 lower.ends_with(".tgz") || lower.ends_with(".tar.gz")
240 }
241}
242
243pub fn parse_git_spec(spec: &str) -> Option<(String, Option<String>, Option<String>)> {
262 let (body, committish, subpath) = match spec.find('#') {
263 Some(idx) => {
264 let (c, s) = parse_git_fragment(&spec[idx + 1..]);
265 (&spec[..idx], c, s)
266 }
267 None => (spec, None, None),
268 };
269 let is_bare_transport = body.starts_with("https://")
270 || body.starts_with("http://")
271 || body.starts_with("ssh://")
272 || body.starts_with("file://");
273 let url = if let Some(rest) = body.strip_prefix("git+") {
274 rest.to_string()
277 } else if body.starts_with("git://") {
278 body.to_string()
279 } else if let Some(scp) = parse_scp_url(body) {
280 scp
281 } else if let Some(path) = body.strip_prefix("github:") {
282 format!("https://github.com/{path}.git")
283 } else if let Some(path) = body.strip_prefix("gitlab:") {
284 format!("https://gitlab.com/{path}.git")
285 } else if let Some(path) = body.strip_prefix("bitbucket:") {
286 format!("https://bitbucket.org/{path}.git")
287 } else if is_bare_transport && body.ends_with(".git") {
288 body.to_string()
289 } else if is_bare_transport
290 && committish
291 .as_deref()
292 .is_some_and(|c| c.len() == 40 && c.chars().all(|ch| ch.is_ascii_hexdigit()))
293 {
294 body.to_string()
299 } else if is_bare_github_shorthand(body) {
300 format!("https://github.com/{body}.git")
304 } else {
305 return None;
306 };
307 Some((url, committish, subpath))
308}
309
310fn is_bare_github_shorthand(body: &str) -> bool {
316 let Some((owner, repo)) = body.split_once('/') else {
317 return false;
318 };
319 !owner.is_empty()
320 && !owner.starts_with('.')
321 && !repo.is_empty()
322 && !repo.contains('/')
323 && owner
324 .bytes()
325 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
326 && repo
327 .bytes()
328 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
329}
330
331#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct HostedGit {
341 pub host: HostedGitHost,
342 pub owner: String,
343 pub repo: String,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum HostedGitHost {
348 GitHub,
349 GitLab,
350 Bitbucket,
351}
352
353impl HostedGit {
354 pub fn https_url(&self) -> String {
359 let host = self.host.host_domain();
360 format!("https://{host}/{}/{}.git", self.owner, self.repo)
361 }
362
363 pub fn tarball_url(&self, committish: &str) -> Option<String> {
370 if committish.len() != 40 || !committish.chars().all(|c| c.is_ascii_hexdigit()) {
371 return None;
372 }
373 let sha = committish.to_ascii_lowercase();
374 Some(match self.host {
375 HostedGitHost::GitHub => format!(
376 "https://codeload.github.com/{}/{}/tar.gz/{sha}",
377 self.owner, self.repo
378 ),
379 HostedGitHost::GitLab => format!(
380 "https://gitlab.com/{}/{}/-/archive/{sha}/{}-{sha}.tar.gz",
381 self.owner, self.repo, self.repo
382 ),
383 HostedGitHost::Bitbucket => format!(
384 "https://bitbucket.org/{}/{}/get/{sha}.tar.gz",
385 self.owner, self.repo
386 ),
387 })
388 }
389}
390
391impl HostedGitHost {
392 fn from_domain(domain: &str) -> Option<Self> {
393 match domain {
394 "github.com" => Some(HostedGitHost::GitHub),
395 "gitlab.com" => Some(HostedGitHost::GitLab),
396 "bitbucket.org" => Some(HostedGitHost::Bitbucket),
397 _ => None,
398 }
399 }
400
401 pub fn host_domain(self) -> &'static str {
402 match self {
403 HostedGitHost::GitHub => "github.com",
404 HostedGitHost::GitLab => "gitlab.com",
405 HostedGitHost::Bitbucket => "bitbucket.org",
406 }
407 }
408}
409
410pub fn parse_hosted_git(url: &str) -> Option<HostedGit> {
427 let body = url.strip_prefix("git+").unwrap_or(url);
428 let after_scheme = if let Some(rest) = body.strip_prefix("https://") {
429 rest
430 } else if let Some(rest) = body.strip_prefix("http://") {
431 rest
432 } else if let Some(rest) = body.strip_prefix("ssh://") {
433 rest
434 } else if let Some(rest) = body.strip_prefix("git://") {
435 rest
436 } else {
437 let scp_path = parse_scp_url(body)?;
441 return parse_hosted_git(&scp_path);
442 };
443 let host_and_path = match after_scheme.split_once('@') {
445 Some((_, rest)) => rest,
446 None => after_scheme,
447 };
448 let (host, path) = host_and_path.split_once('/')?;
449 let host = HostedGitHost::from_domain(host)?;
450 let mut segs = path.splitn(3, '/');
455 let owner = segs.next()?;
456 let repo = segs.next()?;
457 if owner.is_empty() || repo.is_empty() || segs.next().is_some() {
458 return None;
459 }
460 let repo = repo
461 .strip_suffix(".git")
462 .unwrap_or(repo)
463 .trim_end_matches('/');
464 if repo.is_empty() {
465 return None;
466 }
467 Some(HostedGit {
468 host,
469 owner: owner.to_string(),
470 repo: repo.to_string(),
471 })
472}
473
474fn parse_scp_url(body: &str) -> Option<String> {
475 if body.contains("://") {
476 return None;
477 }
478 let colon = body.find(':')?;
479 let before = &body[..colon];
480 let path = &body[colon + 1..];
481 if before.is_empty() || path.is_empty() {
482 return None;
483 }
484 if path.starts_with('/') {
485 return None;
486 }
487 let at = before.find('@')?;
488 let user = &before[..at];
489 let host = &before[at + 1..];
490 if user.is_empty() || host.is_empty() || host.contains('/') || host.contains('@') {
491 return None;
492 }
493 if !matches!(host, "github.com" | "gitlab.com" | "bitbucket.org") {
497 return None;
498 }
499 Some(format!("ssh://{user}@{host}/{path}"))
500}
501
502pub(crate) fn normalize_git_fragment(fragment: &str) -> Option<String> {
510 parse_git_fragment(fragment).0
511}
512
513pub(crate) fn parse_git_fragment(fragment: &str) -> (Option<String>, Option<String>) {
521 if fragment.is_empty() {
522 return (None, None);
523 }
524
525 let mut fallback: Option<&str> = None;
526 let mut preferred: Option<&str> = None;
527 let mut subpath: Option<String> = None;
528 for part in fragment.split('&') {
529 if part.is_empty() {
530 continue;
531 }
532 let split = part.split_once('=').or_else(|| {
538 part.split_once(':')
539 .filter(|(k, _)| matches!(*k, "commit" | "tag" | "head" | "branch" | "path"))
540 });
541 let (key, value) = split.unwrap_or(("", part));
542 if value.is_empty() {
543 continue;
544 }
545 match key {
546 "commit" => {
547 preferred.get_or_insert(value);
548 }
549 "tag" | "head" | "branch" => {
550 fallback.get_or_insert(value);
551 }
552 "path" => {
553 if subpath.is_some() {
559 continue;
561 }
562 let trimmed = value.trim_start_matches('/');
563 if trimmed.is_empty() {
564 continue;
565 }
566 if trimmed
567 .split('/')
568 .any(|c| c.is_empty() || c == "." || c == "..")
569 {
570 continue;
571 }
572 subpath = Some(trimmed.to_string());
573 }
574 "" => {
575 fallback.get_or_insert(value);
576 }
577 _ => {}
578 }
579 }
580
581 (preferred.or(fallback).map(ToString::to_string), subpath)
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn matches_https_tgz() {
590 assert!(LocalSource::looks_like_remote_tarball_url(
591 "https://example.com/pkg-1.0.0.tgz"
592 ));
593 }
594
595 #[test]
596 fn matches_http_tar_gz() {
597 assert!(LocalSource::looks_like_remote_tarball_url(
598 "http://example.com/pkg-1.0.0.tar.gz"
599 ));
600 }
601
602 #[test]
603 fn strips_fragment_before_suffix_check() {
604 assert!(LocalSource::looks_like_remote_tarball_url(
605 "https://example.com/pkg-1.0.0.tgz#sha512-abc"
606 ));
607 }
608
609 #[test]
610 fn strips_query_string_before_suffix_check() {
611 assert!(LocalSource::looks_like_remote_tarball_url(
615 "https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
616 ));
617 assert!(LocalSource::looks_like_remote_tarball_url(
618 "https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
619 ));
620 }
621
622 #[test]
623 fn matches_bare_http_url_without_tarball_suffix() {
624 assert!(LocalSource::looks_like_remote_tarball_url(
628 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
629 ));
630 assert!(LocalSource::looks_like_remote_tarball_url(
631 "https://codeload.github.com/user/repo/tar.gz/main"
632 ));
633 }
634
635 #[test]
636 fn rejects_non_http_schemes() {
637 assert!(!LocalSource::looks_like_remote_tarball_url(
638 "ftp://example.com/pkg.tgz"
639 ));
640 assert!(!LocalSource::looks_like_remote_tarball_url(
641 "git://example.com/repo.git"
642 ));
643 }
644
645 #[test]
646 fn parse_classifies_bare_http_url_as_remote_tarball() {
647 use std::path::Path;
648 let parsed = LocalSource::parse(
649 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
650 Path::new(""),
651 );
652 assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
653 }
654
655 #[test]
656 fn parse_prefers_git_over_tarball_for_dot_git_url() {
657 use std::path::Path;
658 let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
659 assert!(matches!(parsed, Some(LocalSource::Git(_))));
660 }
661
662 #[test]
663 fn parse_classifies_exec_as_local_source() {
664 let parsed = LocalSource::parse("exec:./scripts/generate.js", Path::new(""));
665 assert_eq!(
666 parsed,
667 Some(LocalSource::Exec(PathBuf::from("./scripts/generate.js")))
668 );
669 }
670
671 #[test]
672 fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
673 let (url, committish, subpath) = parse_git_spec("git+https://host/user/repo").unwrap();
675 assert_eq!(url, "https://host/user/repo");
676 assert_eq!(committish, None);
677 assert_eq!(subpath, None);
678
679 let sha = "abcdef0123456789abcdef0123456789abcdef01";
682 let source = LocalSource::Git(GitSource {
683 url: url.clone(),
684 committish: None,
685 resolved: sha.to_string(),
686 subpath: None,
687 });
688 let lockfile_version = source.specifier();
689 assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
690
691 let (round_url, round_committish, round_subpath) =
694 parse_git_spec(&lockfile_version).unwrap();
695 assert_eq!(round_url, "https://host/user/repo");
696 assert_eq!(round_committish.as_deref(), Some(sha));
697 assert_eq!(round_subpath, None);
698 }
699
700 #[test]
701 fn bare_https_without_dot_git_and_no_committish_is_not_git() {
702 assert!(parse_git_spec("https://example.com/pkg").is_none());
705 }
706
707 #[test]
708 fn github_shorthand_expands_and_roundtrips() {
709 let (url, _, _) = parse_git_spec("github:user/repo").unwrap();
710 assert_eq!(url, "https://github.com/user/repo.git");
711 }
712
713 #[test]
714 fn bare_user_repo_expands_to_github() {
715 let (url, committish, subpath) = parse_git_spec("kevva/is-negative").unwrap();
716 assert_eq!(url, "https://github.com/kevva/is-negative.git");
717 assert!(committish.is_none());
718 assert!(subpath.is_none());
719 }
720
721 #[test]
722 fn bare_user_repo_with_committish_preserved() {
723 let (url, committish, _) = parse_git_spec("kevva/is-negative#v1.0.0").unwrap();
724 assert_eq!(url, "https://github.com/kevva/is-negative.git");
725 assert_eq!(committish.as_deref(), Some("v1.0.0"));
726 }
727
728 #[test]
729 fn bare_scope_pkg_is_not_git_shorthand() {
730 assert!(parse_git_spec("@types/node").is_none());
732 }
733
734 #[test]
735 fn bare_relative_path_is_not_git_shorthand() {
736 assert!(parse_git_spec("./repo").is_none());
739 assert!(parse_git_spec("../repo").is_none());
740 assert!(parse_git_spec("./local/path").is_none());
743 assert!(parse_git_spec("../local/path").is_none());
744 }
745
746 #[test]
747 fn bare_path_with_extra_slashes_is_not_git_shorthand() {
748 assert!(parse_git_spec("path/with/slashes/extra").is_none());
751 }
752
753 #[test]
754 fn bare_scp_form_unknown_host_is_not_github_shorthand() {
755 assert!(parse_git_spec("user@host:repo.git").is_none());
758 }
759
760 #[test]
761 fn scp_form_recognized() {
762 let (url, committish, _) =
763 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git").unwrap();
764 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
765 assert!(committish.is_none());
766 }
767
768 #[test]
769 fn scp_form_with_ref_recognized() {
770 let (url, committish, _) =
771 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git#0.1.5").unwrap();
772 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
773 assert_eq!(committish.as_deref(), Some("0.1.5"));
774 }
775
776 #[test]
777 fn scp_form_bitbucket_recognized() {
778 let (url, _, _) = parse_git_spec("git@bitbucket.org:pnpmjs/git-resolver.git").unwrap();
779 assert_eq!(url, "ssh://git@bitbucket.org/pnpmjs/git-resolver.git");
780 }
781
782 #[test]
783 fn scp_form_unknown_host_rejected() {
784 assert!(parse_git_spec("git@example.com:org/repo.git").is_none());
786 assert!(parse_git_spec("alice@host.example.com:org/repo.git").is_none());
787 }
788
789 #[test]
790 fn scp_form_without_user_rejected() {
791 assert!(parse_git_spec("github.com:user/repo.git").is_none());
793 }
794
795 #[test]
796 fn commit_selector_fragment_normalizes_to_sha() {
797 let sha = "abcdef0123456789abcdef0123456789abcdef01";
798 let (url, committish, _) =
799 parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
800 assert_eq!(url, "https://host/user/repo.git");
801 assert_eq!(committish.as_deref(), Some(sha));
802 }
803
804 #[test]
805 fn named_selector_fragment_normalizes_to_ref() {
806 let (url, committish, _) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
807 assert_eq!(url, "https://host/user/repo");
808 assert_eq!(committish.as_deref(), Some("v1.2.3"));
809 }
810
811 #[test]
812 fn pnpm_path_subpath_extracted_from_fragment() {
813 let (url, committish, subpath) =
816 parse_git_spec("github:org/dep#v0.1.4&path:/packages/special").unwrap();
817 assert_eq!(url, "https://github.com/org/dep.git");
818 assert_eq!(committish.as_deref(), Some("v0.1.4"));
819 assert_eq!(subpath.as_deref(), Some("packages/special"));
820 }
821
822 #[test]
823 fn path_subpath_roundtrips_via_specifier() {
824 let sha = "abcdef0123456789abcdef0123456789abcdef01";
825 let source = LocalSource::Git(GitSource {
826 url: "https://github.com/org/dep.git".to_string(),
827 committish: None,
828 resolved: sha.to_string(),
829 subpath: Some("packages/special".to_string()),
830 });
831 let spec = source.specifier();
832 assert_eq!(
833 spec,
834 format!("https://github.com/org/dep.git#{sha}&path:/packages/special")
835 );
836 let (url, committish, subpath) = parse_git_spec(&spec).unwrap();
837 assert_eq!(url, "https://github.com/org/dep.git");
838 assert_eq!(committish.as_deref(), Some(sha));
839 assert_eq!(subpath.as_deref(), Some("packages/special"));
840 }
841
842 #[test]
843 fn parse_hosted_git_recognizes_canonical_forms() {
844 let canonical = HostedGit {
848 host: HostedGitHost::GitHub,
849 owner: "owner".to_string(),
850 repo: "repo".to_string(),
851 };
852 for spec in [
853 "https://github.com/owner/repo.git",
854 "https://github.com/owner/repo",
855 "http://github.com/owner/repo.git",
856 "git+https://github.com/owner/repo.git",
857 "git+https://github.com/owner/repo",
858 "git://github.com/owner/repo.git",
859 "ssh://git@github.com/owner/repo.git",
860 "git+ssh://git@github.com/owner/repo.git",
861 "git@github.com:owner/repo.git",
862 ] {
863 assert_eq!(
864 parse_hosted_git(spec).as_ref(),
865 Some(&canonical),
866 "spec {spec} should map to canonical HostedGit",
867 );
868 }
869 }
870
871 #[test]
872 fn parse_hosted_git_returns_none_for_non_hosted() {
873 for spec in [
876 "https://example.com/owner/repo.git",
877 "ssh://git@gitea.internal/owner/repo.git",
878 "git+ssh://git@gitlab.example.com/group/sub/repo.git",
879 "https://github.com/owner/repo/sub",
880 "https://github.com/owner",
881 ] {
882 assert!(
883 parse_hosted_git(spec).is_none(),
884 "spec {spec} must not match a hosted provider",
885 );
886 }
887 }
888
889 #[test]
890 fn hosted_tarball_url_only_for_full_sha() {
891 let g = HostedGit {
892 host: HostedGitHost::GitHub,
893 owner: "o".to_string(),
894 repo: "r".to_string(),
895 };
896 let sha = "abcdef0123456789abcdef0123456789abcdef01";
897 assert_eq!(
898 g.tarball_url(sha).as_deref(),
899 Some("https://codeload.github.com/o/r/tar.gz/abcdef0123456789abcdef0123456789abcdef01"),
900 );
901 assert!(g.tarball_url("main").is_none());
905 assert!(g.tarball_url("v1.2.3").is_none());
906 assert!(g.tarball_url("abcdef0").is_none());
907 }
908
909 #[test]
910 fn hosted_tarball_url_per_provider() {
911 let sha = "abcdef0123456789abcdef0123456789abcdef01";
912 let gitlab = HostedGit {
913 host: HostedGitHost::GitLab,
914 owner: "g".to_string(),
915 repo: "r".to_string(),
916 }
917 .tarball_url(sha)
918 .unwrap();
919 assert!(gitlab.starts_with("https://gitlab.com/g/r/-/archive/"));
920 assert!(gitlab.ends_with("/r-abcdef0123456789abcdef0123456789abcdef01.tar.gz"));
921 let bitbucket = HostedGit {
922 host: HostedGitHost::Bitbucket,
923 owner: "g".to_string(),
924 repo: "r".to_string(),
925 }
926 .tarball_url(sha)
927 .unwrap();
928 assert_eq!(
929 bitbucket,
930 "https://bitbucket.org/g/r/get/abcdef0123456789abcdef0123456789abcdef01.tar.gz",
931 );
932 }
933
934 #[test]
935 fn hosted_https_url_normalizes() {
936 let g = parse_hosted_git("git+ssh://git@github.com/owner/repo.git").unwrap();
937 assert_eq!(g.https_url(), "https://github.com/owner/repo.git");
938 }
939
940 #[test]
941 fn path_traversal_components_in_subpath_are_rejected() {
942 let cases = [
946 "github:org/dep#main&path:/../../etc",
947 "github:org/dep#main&path:/packages/../../../etc",
948 "github:org/dep#main&path:/./packages/foo",
949 "github:org/dep#main&path:/packages//foo",
950 ];
951 for spec in cases {
952 let (_, _, subpath) = parse_git_spec(spec).unwrap();
953 assert_eq!(subpath, None, "spec should drop subpath: {spec}");
954 }
955 }
956
957 #[test]
958 fn dep_path_distinguishes_subpaths_under_same_commit() {
959 let sha = "abcdef0123456789abcdef0123456789abcdef01";
963 let a = LocalSource::Git(GitSource {
964 url: "https://example.com/r.git".to_string(),
965 committish: None,
966 resolved: sha.to_string(),
967 subpath: Some("packages/a".to_string()),
968 });
969 let b = LocalSource::Git(GitSource {
970 url: "https://example.com/r.git".to_string(),
971 committish: None,
972 resolved: sha.to_string(),
973 subpath: Some("packages/b".to_string()),
974 });
975 assert_ne!(a.dep_path("dep"), b.dep_path("dep"));
976 }
977}