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 pub git_hosted: bool,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct GitSource {
58 pub url: String,
59 pub committish: Option<String>,
60 pub resolved: String,
61 pub integrity: Option<String>,
65 pub subpath: Option<String>,
71}
72
73pub fn git_commits_match(left: &str, right: &str) -> bool {
74 if left.eq_ignore_ascii_case(right) {
75 return true;
76 }
77 let left = left.trim();
78 let right = right.trim();
79 if left.len().min(right.len()) < 7
80 || !left.bytes().all(|b| b.is_ascii_hexdigit())
81 || !right.bytes().all(|b| b.is_ascii_hexdigit())
82 {
83 return false;
84 }
85 let left = left.to_ascii_lowercase();
86 let right = right.to_ascii_lowercase();
87 (left.len() == 40 && right.len() < 40 && left.starts_with(&right))
88 || (right.len() == 40 && left.len() < 40 && right.starts_with(&left))
89}
90
91impl LocalSource {
92 pub fn path(&self) -> Option<&Path> {
95 match self {
96 LocalSource::Directory(p)
97 | LocalSource::Tarball(p)
98 | LocalSource::Link(p)
99 | LocalSource::Portal(p)
100 | LocalSource::Exec(p) => Some(p),
101 LocalSource::Git(_) | LocalSource::RemoteTarball(_) => None,
102 }
103 }
104
105 pub fn kind_str(&self) -> &'static str {
107 match self {
108 LocalSource::Directory(_) | LocalSource::Tarball(_) => "file",
109 LocalSource::Link(_) => "link",
110 LocalSource::Portal(_) => "portal",
111 LocalSource::Exec(_) => "exec",
112 LocalSource::Git(_) => "git",
113 LocalSource::RemoteTarball(_) => "url",
114 }
115 }
116
117 pub fn is_globally_shareable(&self) -> bool {
138 matches!(self, LocalSource::Git(_) | LocalSource::RemoteTarball(_))
139 }
140
141 pub fn path_posix(&self) -> String {
150 self.path()
151 .map(|p| p.to_string_lossy().replace('\\', "/"))
152 .unwrap_or_default()
153 }
154
155 pub fn specifier(&self) -> String {
163 match self {
164 LocalSource::Git(g) => match &g.subpath {
165 Some(sub) => format!("{}#{}&path:/{}", g.url, g.resolved, sub),
166 None => format!("{}#{}", g.url, g.resolved),
167 },
168 LocalSource::RemoteTarball(t) => t.url.clone(),
169 _ => format!("{}:{}", self.kind_str(), self.path_posix()),
170 }
171 }
172
173 pub fn dep_path(&self, name: &str) -> String {
189 use sha2::{Digest, Sha256};
190 let mut hasher = Sha256::new();
191 match self {
192 LocalSource::Git(g) => {
193 hasher.update(g.url.as_bytes());
194 hasher.update(b"#");
195 hasher.update(g.resolved.as_bytes());
196 if let Some(sub) = &g.subpath {
197 hasher.update(b"&path:/");
198 hasher.update(sub.as_bytes());
199 }
200 }
201 LocalSource::RemoteTarball(t) => {
202 hasher.update(t.url.as_bytes());
203 }
204 _ => hasher.update(self.path_posix().as_bytes()),
205 }
206 let digest = hasher.finalize();
207 let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
208 format!("{name}@{}+{short}", self.kind_str())
209 }
210
211 pub fn parse(spec: &str, project_root: &Path) -> Option<Self> {
217 if let Some((url, committish, subpath)) = parse_git_spec(spec) {
221 return Some(LocalSource::Git(GitSource {
226 url,
227 committish,
228 resolved: String::new(),
229 integrity: None,
230 subpath,
231 }));
232 }
233 if Self::looks_like_remote_tarball_url(spec) {
239 return Some(LocalSource::RemoteTarball(RemoteTarballSource {
240 url: spec.to_string(),
241 integrity: String::new(),
242 git_hosted: false,
243 }));
244 }
245 let (kind, rest) = if let Some(r) = spec.strip_prefix("file:") {
246 ("file", r)
247 } else if let Some(r) = spec.strip_prefix("link:") {
248 ("link", r)
249 } else if let Some(r) = spec.strip_prefix("portal:") {
250 ("portal", r)
251 } else if let Some(r) = spec.strip_prefix("exec:") {
252 return Some(LocalSource::Exec(PathBuf::from(r)));
253 } else {
254 return None;
255 };
256 let rel = PathBuf::from(rest);
257 let abs = project_root.join(&rel);
258 if kind == "link" {
259 return Some(LocalSource::Link(rel));
260 }
261 if kind == "portal" {
262 return Some(LocalSource::Portal(rel));
263 }
264 if abs.is_file() && Self::path_looks_like_tarball(&rel) {
265 return Some(LocalSource::Tarball(rel));
266 }
267 Some(LocalSource::Directory(rel))
268 }
269
270 pub fn looks_like_remote_tarball_url(spec: &str) -> bool {
279 spec.starts_with("https://") || spec.starts_with("http://")
280 }
281
282 pub fn path_looks_like_tarball(path: &Path) -> bool {
283 let name = match path.file_name().and_then(|n| n.to_str()) {
284 Some(n) => n,
285 None => return false,
286 };
287 let lower = name.to_ascii_lowercase();
288 lower.ends_with(".tgz") || lower.ends_with(".tar.gz")
289 }
290}
291
292pub fn shared_local_dep_path(dep_name: &str, dep_value: &str) -> Option<String> {
318 let classify = dep_value.split('(').next().unwrap_or(dep_value);
331 match LocalSource::parse(classify, Path::new("")) {
332 Some(LocalSource::Git(mut git)) => {
333 if git.resolved.is_empty() {
339 git.resolved = git.committish.take()?;
340 }
341 Some(LocalSource::Git(git).dep_path(dep_name))
342 }
343 Some(tarball @ LocalSource::RemoteTarball(_)) => Some(tarball.dep_path(dep_name)),
344 _ => None,
345 }
346}
347
348pub fn parse_git_spec(spec: &str) -> Option<(String, Option<String>, Option<String>)> {
367 let (body, committish, subpath) = match spec.find('#') {
368 Some(idx) => {
369 let (c, s) = parse_git_fragment(&spec[idx + 1..]);
370 (&spec[..idx], c, s)
371 }
372 None => (spec, None, None),
373 };
374 let is_bare_transport = body.starts_with("https://")
375 || body.starts_with("http://")
376 || body.starts_with("ssh://")
377 || body.starts_with("file://");
378 let url = if let Some(rest) = body.strip_prefix("git+") {
379 rest.to_string()
382 } else if body.starts_with("git://") {
383 body.to_string()
384 } else if let Some(scp) = parse_scp_url(body) {
385 scp
386 } else if let Some(path) = body.strip_prefix("github:") {
387 format!("https://github.com/{path}.git")
388 } else if let Some(path) = body.strip_prefix("gitlab:") {
389 format!("https://gitlab.com/{path}.git")
390 } else if let Some(path) = body.strip_prefix("bitbucket:") {
391 format!("https://bitbucket.org/{path}.git")
392 } else if is_bare_transport && body.ends_with(".git") {
393 body.to_string()
394 } else if is_bare_transport
395 && committish
396 .as_deref()
397 .is_some_and(|c| c.len() == 40 && c.chars().all(|ch| ch.is_ascii_hexdigit()))
398 {
399 body.to_string()
404 } else if is_bare_github_shorthand(body) {
405 format!("https://github.com/{body}.git")
409 } else {
410 return None;
411 };
412 Some((url, committish, subpath))
413}
414
415fn is_bare_github_shorthand(body: &str) -> bool {
421 let Some((owner, repo)) = body.split_once('/') else {
422 return false;
423 };
424 !owner.is_empty()
425 && !owner.starts_with('.')
426 && !repo.is_empty()
427 && !repo.contains('/')
428 && owner
429 .bytes()
430 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
431 && repo
432 .bytes()
433 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
434}
435
436#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct HostedGit {
446 pub host: HostedGitHost,
447 pub owner: String,
448 pub repo: String,
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum HostedGitHost {
453 GitHub,
454 GitLab,
455 Bitbucket,
456}
457
458impl HostedGit {
459 pub fn https_url(&self) -> String {
464 let host = self.host.host_domain();
465 format!("https://{host}/{}/{}.git", self.owner, self.repo)
466 }
467
468 pub fn tarball_url(&self, committish: &str) -> Option<String> {
475 if committish.len() != 40 || !committish.chars().all(|c| c.is_ascii_hexdigit()) {
476 return None;
477 }
478 let sha = committish.to_ascii_lowercase();
479 Some(match self.host {
480 HostedGitHost::GitHub => format!(
481 "https://codeload.github.com/{}/{}/tar.gz/{sha}",
482 self.owner, self.repo
483 ),
484 HostedGitHost::GitLab => format!(
485 "https://gitlab.com/{}/{}/-/archive/{sha}/{}-{sha}.tar.gz",
486 self.owner, self.repo, self.repo
487 ),
488 HostedGitHost::Bitbucket => format!(
489 "https://bitbucket.org/{}/{}/get/{sha}.tar.gz",
490 self.owner, self.repo
491 ),
492 })
493 }
494}
495
496impl HostedGitHost {
497 fn from_domain(domain: &str) -> Option<Self> {
498 match domain {
499 "github.com" => Some(HostedGitHost::GitHub),
500 "gitlab.com" => Some(HostedGitHost::GitLab),
501 "bitbucket.org" => Some(HostedGitHost::Bitbucket),
502 _ => None,
503 }
504 }
505
506 pub fn host_domain(self) -> &'static str {
507 match self {
508 HostedGitHost::GitHub => "github.com",
509 HostedGitHost::GitLab => "gitlab.com",
510 HostedGitHost::Bitbucket => "bitbucket.org",
511 }
512 }
513}
514
515pub fn parse_hosted_git(url: &str) -> Option<HostedGit> {
532 let body = url.strip_prefix("git+").unwrap_or(url);
533 let after_scheme = if let Some(rest) = body.strip_prefix("https://") {
534 rest
535 } else if let Some(rest) = body.strip_prefix("http://") {
536 rest
537 } else if let Some(rest) = body.strip_prefix("ssh://") {
538 rest
539 } else if let Some(rest) = body.strip_prefix("git://") {
540 rest
541 } else {
542 let scp_path = parse_scp_url(body)?;
546 return parse_hosted_git(&scp_path);
547 };
548 let host_and_path = match after_scheme.split_once('@') {
550 Some((_, rest)) => rest,
551 None => after_scheme,
552 };
553 let (host, path) = host_and_path.split_once('/')?;
554 let host = HostedGitHost::from_domain(host)?;
555 let mut segs = path.splitn(3, '/');
560 let owner = segs.next()?;
561 let repo = segs.next()?;
562 if owner.is_empty() || repo.is_empty() || segs.next().is_some() {
563 return None;
564 }
565 let repo = repo
566 .strip_suffix(".git")
567 .unwrap_or(repo)
568 .trim_end_matches('/');
569 if repo.is_empty() {
570 return None;
571 }
572 Some(HostedGit {
573 host,
574 owner: owner.to_string(),
575 repo: repo.to_string(),
576 })
577}
578
579fn parse_scp_url(body: &str) -> Option<String> {
580 if body.contains("://") {
581 return None;
582 }
583 let colon = body.find(':')?;
584 let before = &body[..colon];
585 let path = &body[colon + 1..];
586 if before.is_empty() || path.is_empty() {
587 return None;
588 }
589 if path.starts_with('/') {
590 return None;
591 }
592 let at = before.find('@')?;
593 let user = &before[..at];
594 let host = &before[at + 1..];
595 if user.is_empty() || host.is_empty() || host.contains('/') || host.contains('@') {
596 return None;
597 }
598 if !matches!(host, "github.com" | "gitlab.com" | "bitbucket.org") {
602 return None;
603 }
604 Some(format!("ssh://{user}@{host}/{path}"))
605}
606
607pub(crate) fn normalize_git_fragment(fragment: &str) -> Option<String> {
615 parse_git_fragment(fragment).0
616}
617
618pub(crate) fn parse_git_fragment(fragment: &str) -> (Option<String>, Option<String>) {
626 if fragment.is_empty() {
627 return (None, None);
628 }
629
630 let mut fallback: Option<&str> = None;
631 let mut preferred: Option<&str> = None;
632 let mut subpath: Option<String> = None;
633 for part in fragment.split('&') {
634 if part.is_empty() {
635 continue;
636 }
637 let split = part.split_once('=').or_else(|| {
643 part.split_once(':')
644 .filter(|(k, _)| matches!(*k, "commit" | "tag" | "head" | "branch" | "path"))
645 });
646 let (key, value) = split.unwrap_or(("", part));
647 if value.is_empty() {
648 continue;
649 }
650 match key {
651 "commit" => {
652 preferred.get_or_insert(value);
653 }
654 "tag" | "head" | "branch" => {
655 fallback.get_or_insert(value);
656 }
657 "path" => {
658 if subpath.is_some() {
664 continue;
666 }
667 let trimmed = value.trim_start_matches('/');
668 if trimmed.is_empty() {
669 continue;
670 }
671 if trimmed
672 .split('/')
673 .any(|c| c.is_empty() || c == "." || c == "..")
674 {
675 continue;
676 }
677 subpath = Some(trimmed.to_string());
678 }
679 "" => {
680 fallback.get_or_insert(value);
681 }
682 _ => {}
683 }
684 }
685
686 (preferred.or(fallback).map(ToString::to_string), subpath)
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn matches_https_tgz() {
695 assert!(LocalSource::looks_like_remote_tarball_url(
696 "https://example.com/pkg-1.0.0.tgz"
697 ));
698 }
699
700 #[test]
701 fn matches_http_tar_gz() {
702 assert!(LocalSource::looks_like_remote_tarball_url(
703 "http://example.com/pkg-1.0.0.tar.gz"
704 ));
705 }
706
707 #[test]
708 fn strips_fragment_before_suffix_check() {
709 assert!(LocalSource::looks_like_remote_tarball_url(
710 "https://example.com/pkg-1.0.0.tgz#sha512-abc"
711 ));
712 }
713
714 #[test]
715 fn strips_query_string_before_suffix_check() {
716 assert!(LocalSource::looks_like_remote_tarball_url(
720 "https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
721 ));
722 assert!(LocalSource::looks_like_remote_tarball_url(
723 "https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
724 ));
725 }
726
727 #[test]
728 fn matches_bare_http_url_without_tarball_suffix() {
729 assert!(LocalSource::looks_like_remote_tarball_url(
733 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
734 ));
735 assert!(LocalSource::looks_like_remote_tarball_url(
736 "https://codeload.github.com/user/repo/tar.gz/main"
737 ));
738 }
739
740 #[test]
741 fn git_commits_match_only_allows_full_sha_prefix_pairs() {
742 let full = "abcdef0123456789abcdef0123456789abcdef01";
743 assert!(git_commits_match(full, "abcdef0"));
744 assert!(git_commits_match("abcdef0", full));
745 assert!(git_commits_match(full, full));
746 assert!(!git_commits_match("abcdef0", "abcdef012"));
747 assert!(!git_commits_match(full, "abcdef1"));
748 assert!(!git_commits_match("main", full));
749 }
750
751 #[test]
752 fn rejects_non_http_schemes() {
753 assert!(!LocalSource::looks_like_remote_tarball_url(
754 "ftp://example.com/pkg.tgz"
755 ));
756 assert!(!LocalSource::looks_like_remote_tarball_url(
757 "git://example.com/repo.git"
758 ));
759 }
760
761 #[test]
762 fn parse_classifies_bare_http_url_as_remote_tarball() {
763 use std::path::Path;
764 let parsed = LocalSource::parse(
765 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
766 Path::new(""),
767 );
768 assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
769 }
770
771 #[test]
772 fn parse_prefers_git_over_tarball_for_dot_git_url() {
773 use std::path::Path;
774 let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
775 assert!(matches!(parsed, Some(LocalSource::Git(_))));
776 }
777
778 #[test]
779 fn parse_classifies_exec_as_local_source() {
780 let parsed = LocalSource::parse("exec:./scripts/generate.js", Path::new(""));
781 assert_eq!(
782 parsed,
783 Some(LocalSource::Exec(PathBuf::from("./scripts/generate.js")))
784 );
785 }
786
787 #[test]
788 fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
789 let (url, committish, subpath) = parse_git_spec("git+https://host/user/repo").unwrap();
791 assert_eq!(url, "https://host/user/repo");
792 assert_eq!(committish, None);
793 assert_eq!(subpath, None);
794
795 let sha = "abcdef0123456789abcdef0123456789abcdef01";
798 let source = LocalSource::Git(GitSource {
799 url: url.clone(),
800 committish: None,
801 resolved: sha.to_string(),
802 integrity: None,
803 subpath: None,
804 });
805 let lockfile_version = source.specifier();
806 assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
807
808 let (round_url, round_committish, round_subpath) =
811 parse_git_spec(&lockfile_version).unwrap();
812 assert_eq!(round_url, "https://host/user/repo");
813 assert_eq!(round_committish.as_deref(), Some(sha));
814 assert_eq!(round_subpath, None);
815 }
816
817 #[test]
818 fn bare_https_without_dot_git_and_no_committish_is_not_git() {
819 assert!(parse_git_spec("https://example.com/pkg").is_none());
822 }
823
824 #[test]
825 fn github_shorthand_expands_and_roundtrips() {
826 let (url, _, _) = parse_git_spec("github:user/repo").unwrap();
827 assert_eq!(url, "https://github.com/user/repo.git");
828 }
829
830 #[test]
831 fn bare_user_repo_expands_to_github() {
832 let (url, committish, subpath) = parse_git_spec("kevva/is-negative").unwrap();
833 assert_eq!(url, "https://github.com/kevva/is-negative.git");
834 assert!(committish.is_none());
835 assert!(subpath.is_none());
836 }
837
838 #[test]
839 fn bare_user_repo_with_committish_preserved() {
840 let (url, committish, _) = parse_git_spec("kevva/is-negative#v1.0.0").unwrap();
841 assert_eq!(url, "https://github.com/kevva/is-negative.git");
842 assert_eq!(committish.as_deref(), Some("v1.0.0"));
843 }
844
845 #[test]
846 fn bare_scope_pkg_is_not_git_shorthand() {
847 assert!(parse_git_spec("@types/node").is_none());
849 }
850
851 #[test]
852 fn bare_relative_path_is_not_git_shorthand() {
853 assert!(parse_git_spec("./repo").is_none());
856 assert!(parse_git_spec("../repo").is_none());
857 assert!(parse_git_spec("./local/path").is_none());
860 assert!(parse_git_spec("../local/path").is_none());
861 }
862
863 #[test]
864 fn bare_path_with_extra_slashes_is_not_git_shorthand() {
865 assert!(parse_git_spec("path/with/slashes/extra").is_none());
868 }
869
870 #[test]
871 fn bare_scp_form_unknown_host_is_not_github_shorthand() {
872 assert!(parse_git_spec("user@host:repo.git").is_none());
875 }
876
877 #[test]
878 fn scp_form_recognized() {
879 let (url, committish, _) =
880 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git").unwrap();
881 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
882 assert!(committish.is_none());
883 }
884
885 #[test]
886 fn scp_form_with_ref_recognized() {
887 let (url, committish, _) =
888 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git#0.1.5").unwrap();
889 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
890 assert_eq!(committish.as_deref(), Some("0.1.5"));
891 }
892
893 #[test]
894 fn scp_form_bitbucket_recognized() {
895 let (url, _, _) = parse_git_spec("git@bitbucket.org:pnpmjs/git-resolver.git").unwrap();
896 assert_eq!(url, "ssh://git@bitbucket.org/pnpmjs/git-resolver.git");
897 }
898
899 #[test]
900 fn scp_form_unknown_host_rejected() {
901 assert!(parse_git_spec("git@example.com:org/repo.git").is_none());
903 assert!(parse_git_spec("alice@host.example.com:org/repo.git").is_none());
904 }
905
906 #[test]
907 fn scp_form_without_user_rejected() {
908 assert!(parse_git_spec("github.com:user/repo.git").is_none());
910 }
911
912 #[test]
913 fn commit_selector_fragment_normalizes_to_sha() {
914 let sha = "abcdef0123456789abcdef0123456789abcdef01";
915 let (url, committish, _) =
916 parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
917 assert_eq!(url, "https://host/user/repo.git");
918 assert_eq!(committish.as_deref(), Some(sha));
919 }
920
921 #[test]
922 fn named_selector_fragment_normalizes_to_ref() {
923 let (url, committish, _) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
924 assert_eq!(url, "https://host/user/repo");
925 assert_eq!(committish.as_deref(), Some("v1.2.3"));
926 }
927
928 #[test]
929 fn pnpm_path_subpath_extracted_from_fragment() {
930 let (url, committish, subpath) =
933 parse_git_spec("github:org/dep#v0.1.4&path:/packages/special").unwrap();
934 assert_eq!(url, "https://github.com/org/dep.git");
935 assert_eq!(committish.as_deref(), Some("v0.1.4"));
936 assert_eq!(subpath.as_deref(), Some("packages/special"));
937 }
938
939 #[test]
940 fn path_subpath_roundtrips_via_specifier() {
941 let sha = "abcdef0123456789abcdef0123456789abcdef01";
942 let source = LocalSource::Git(GitSource {
943 url: "https://github.com/org/dep.git".to_string(),
944 committish: None,
945 resolved: sha.to_string(),
946 integrity: None,
947 subpath: Some("packages/special".to_string()),
948 });
949 let spec = source.specifier();
950 assert_eq!(
951 spec,
952 format!("https://github.com/org/dep.git#{sha}&path:/packages/special")
953 );
954 let (url, committish, subpath) = parse_git_spec(&spec).unwrap();
955 assert_eq!(url, "https://github.com/org/dep.git");
956 assert_eq!(committish.as_deref(), Some(sha));
957 assert_eq!(subpath.as_deref(), Some("packages/special"));
958 }
959
960 #[test]
961 fn parse_hosted_git_recognizes_canonical_forms() {
962 let canonical = HostedGit {
966 host: HostedGitHost::GitHub,
967 owner: "owner".to_string(),
968 repo: "repo".to_string(),
969 };
970 for spec in [
971 "https://github.com/owner/repo.git",
972 "https://github.com/owner/repo",
973 "http://github.com/owner/repo.git",
974 "git+https://github.com/owner/repo.git",
975 "git+https://github.com/owner/repo",
976 "git://github.com/owner/repo.git",
977 "ssh://git@github.com/owner/repo.git",
978 "git+ssh://git@github.com/owner/repo.git",
979 "git@github.com:owner/repo.git",
980 ] {
981 assert_eq!(
982 parse_hosted_git(spec).as_ref(),
983 Some(&canonical),
984 "spec {spec} should map to canonical HostedGit",
985 );
986 }
987 }
988
989 #[test]
990 fn parse_hosted_git_returns_none_for_non_hosted() {
991 for spec in [
994 "https://example.com/owner/repo.git",
995 "ssh://git@gitea.internal/owner/repo.git",
996 "git+ssh://git@gitlab.example.com/group/sub/repo.git",
997 "https://github.com/owner/repo/sub",
998 "https://github.com/owner",
999 ] {
1000 assert!(
1001 parse_hosted_git(spec).is_none(),
1002 "spec {spec} must not match a hosted provider",
1003 );
1004 }
1005 }
1006
1007 #[test]
1008 fn hosted_tarball_url_only_for_full_sha() {
1009 let g = HostedGit {
1010 host: HostedGitHost::GitHub,
1011 owner: "o".to_string(),
1012 repo: "r".to_string(),
1013 };
1014 let sha = "abcdef0123456789abcdef0123456789abcdef01";
1015 assert_eq!(
1016 g.tarball_url(sha).as_deref(),
1017 Some("https://codeload.github.com/o/r/tar.gz/abcdef0123456789abcdef0123456789abcdef01"),
1018 );
1019 assert!(g.tarball_url("main").is_none());
1023 assert!(g.tarball_url("v1.2.3").is_none());
1024 assert!(g.tarball_url("abcdef0").is_none());
1025 }
1026
1027 #[test]
1028 fn hosted_tarball_url_per_provider() {
1029 let sha = "abcdef0123456789abcdef0123456789abcdef01";
1030 let gitlab = HostedGit {
1031 host: HostedGitHost::GitLab,
1032 owner: "g".to_string(),
1033 repo: "r".to_string(),
1034 }
1035 .tarball_url(sha)
1036 .unwrap();
1037 assert!(gitlab.starts_with("https://gitlab.com/g/r/-/archive/"));
1038 assert!(gitlab.ends_with("/r-abcdef0123456789abcdef0123456789abcdef01.tar.gz"));
1039 let bitbucket = HostedGit {
1040 host: HostedGitHost::Bitbucket,
1041 owner: "g".to_string(),
1042 repo: "r".to_string(),
1043 }
1044 .tarball_url(sha)
1045 .unwrap();
1046 assert_eq!(
1047 bitbucket,
1048 "https://bitbucket.org/g/r/get/abcdef0123456789abcdef0123456789abcdef01.tar.gz",
1049 );
1050 }
1051
1052 #[test]
1053 fn hosted_https_url_normalizes() {
1054 let g = parse_hosted_git("git+ssh://git@github.com/owner/repo.git").unwrap();
1055 assert_eq!(g.https_url(), "https://github.com/owner/repo.git");
1056 }
1057
1058 #[test]
1059 fn path_traversal_components_in_subpath_are_rejected() {
1060 let cases = [
1064 "github:org/dep#main&path:/../../etc",
1065 "github:org/dep#main&path:/packages/../../../etc",
1066 "github:org/dep#main&path:/./packages/foo",
1067 "github:org/dep#main&path:/packages//foo",
1068 ];
1069 for spec in cases {
1070 let (_, _, subpath) = parse_git_spec(spec).unwrap();
1071 assert_eq!(subpath, None, "spec should drop subpath: {spec}");
1072 }
1073 }
1074
1075 #[test]
1076 fn dep_path_distinguishes_subpaths_under_same_commit() {
1077 let sha = "abcdef0123456789abcdef0123456789abcdef01";
1081 let a = LocalSource::Git(GitSource {
1082 url: "https://example.com/r.git".to_string(),
1083 committish: None,
1084 resolved: sha.to_string(),
1085 integrity: None,
1086 subpath: Some("packages/a".to_string()),
1087 });
1088 let b = LocalSource::Git(GitSource {
1089 url: "https://example.com/r.git".to_string(),
1090 committish: None,
1091 resolved: sha.to_string(),
1092 integrity: None,
1093 subpath: Some("packages/b".to_string()),
1094 });
1095 assert_ne!(a.dep_path("dep"), b.dep_path("dep"));
1096 }
1097
1098 const SHARED_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
1099
1100 fn git_key(url: &str, resolved: &str) -> String {
1103 LocalSource::Git(GitSource {
1104 url: url.to_string(),
1105 committish: None,
1106 resolved: resolved.to_string(),
1107 integrity: None,
1108 subpath: None,
1109 })
1110 .dep_path("request")
1111 }
1112
1113 fn tarball_key(url: &str) -> String {
1116 LocalSource::RemoteTarball(RemoteTarballSource {
1117 url: url.to_string(),
1118 integrity: String::new(),
1119 git_hosted: false,
1120 })
1121 .dep_path("request")
1122 }
1123
1124 #[test]
1125 fn shared_github_shorthand_maps_to_git_dep_path() {
1126 let got = shared_local_dep_path("request", &format!("github:request/request#{SHARED_SHA}"))
1131 .expect("github: spec is a shareable local source");
1132 assert_eq!(
1133 got,
1134 git_key("https://github.com/request/request.git", SHARED_SHA)
1135 );
1136 assert!(got.starts_with("request@git+"), "unexpected key: {got}");
1137 }
1138
1139 #[test]
1140 fn shared_git_url_and_shorthand_converge() {
1141 let from_shorthand =
1144 shared_local_dep_path("request", &format!("github:request/request#{SHARED_SHA}"))
1145 .unwrap();
1146 let from_url = shared_local_dep_path(
1147 "request",
1148 &format!("https://github.com/request/request.git#{SHARED_SHA}"),
1149 )
1150 .unwrap();
1151 assert_eq!(from_shorthand, from_url);
1152 }
1153
1154 #[test]
1155 fn shared_missing_resolved_is_promoted_from_committish() {
1156 let got = shared_local_dep_path(
1160 "request",
1161 &format!("https://github.com/request/request.git#{SHARED_SHA}"),
1162 )
1163 .unwrap();
1164 assert_eq!(
1165 got,
1166 git_key("https://github.com/request/request.git", SHARED_SHA)
1167 );
1168 }
1169
1170 #[test]
1171 fn shared_codeload_tarball_maps_to_url_dep_path() {
1172 let url = format!("https://codeload.github.com/request/request/tar.gz/{SHARED_SHA}");
1176 let got = shared_local_dep_path("request", &url).unwrap();
1177 assert_eq!(got, tarball_key(&url));
1178 assert!(got.starts_with("request@url+"), "unexpected key: {got}");
1179 }
1180
1181 #[test]
1182 fn shared_strips_peer_suffix_before_classifying() {
1183 let url = format!("https://codeload.github.com/request/request/tar.gz/{SHARED_SHA}");
1184 let with_peer = format!("{url}(typescript@5.8.3)");
1185 assert_eq!(
1186 shared_local_dep_path("request", &with_peer),
1187 shared_local_dep_path("request", &url),
1188 );
1189 }
1190
1191 #[test]
1192 fn shared_returns_none_for_non_shareable_specs() {
1193 for value in [
1194 "4.18.1",
1195 "^1.2.3",
1196 "link:../sibling",
1197 "file:./vendor/x",
1198 "npm:lodash@4.18.1",
1199 ] {
1200 assert!(
1201 shared_local_dep_path("dep", value).is_none(),
1202 "{value:?} must not be treated as a shareable local source",
1203 );
1204 }
1205 }
1206}