1use std::path::{Path, PathBuf};
5
6use crate::tools::path::{expand, resolve_to_cwd};
7
8#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum Source {
11 Npm {
13 name: String,
15 version: Option<String>,
17 pinned: bool,
19 },
20 Git {
22 repo: String,
24 host: String,
26 path: String,
28 reference: Option<String>,
30 },
31 Local {
33 path: String,
35 },
36}
37
38const REMOTE_PREFIXES: [&str; 7] = [
40 "npm:", "git:", "github:", "http:", "https:", "ssh:", "builtin:",
41];
42
43pub fn parse(source: &str) -> Source {
45 if let Some(spec) = source.strip_prefix("npm:") {
46 return npm(spec.trim());
47 }
48 if let Some(git) = git(source) {
49 return git;
50 }
51 Source::Local {
52 path: source.to_owned(),
53 }
54}
55
56fn npm(spec: &str) -> Source {
57 let (name, version) = split_npm_spec(spec);
58 let pinned = version
59 .as_deref()
60 .is_some_and(|version| semver::Version::parse(version).is_ok());
61 Source::Npm {
62 name,
63 version,
64 pinned,
65 }
66}
67
68pub(crate) fn split_npm_spec(spec: &str) -> (String, Option<String>) {
70 let (scope, rest) = match spec.strip_prefix('@') {
72 Some(rest) => ("@", rest),
73 None => ("", spec),
74 };
75 let (name, version) = match rest.split_once('@') {
76 Some((name, version)) if !version.is_empty() => (name, Some(version.to_owned())),
77 _ => (rest, None),
78 };
79 (format!("{scope}{name}"), version)
80}
81
82fn split_reference(path: &str) -> (&str, Option<String>) {
84 if let Some((path, reference)) = path.rsplit_once('#') {
85 return (path, Some(reference.to_owned()));
86 }
87 let last = path.rfind('/').map_or(0, |index| index + 1);
88 match path[last..].find('@') {
89 Some(at) => (&path[..last + at], Some(path[last + at + 1..].to_owned())),
90 None => (path, None),
91 }
92}
93
94fn clean_path(path: &str) -> Option<String> {
95 let path = path.trim_start_matches('/');
96 let path = path.strip_suffix(".git").unwrap_or(path);
97 let path = path.trim_end_matches('/');
98 let segments: Vec<&str> = path.split('/').collect();
99 let valid = segments.len() >= 2
100 && segments
101 .iter()
102 .all(|segment| !segment.is_empty() && *segment != "..")
103 && !path.contains('\\')
104 && !path.contains('\0');
105 valid.then(|| path.to_owned())
106}
107
108fn safe_host(host: &str) -> bool {
111 !host.contains(['/', '\\', '\0']) && host != ".." && !host.starts_with('-')
112}
113
114fn safe_reference(reference: Option<&str>) -> bool {
117 !reference.is_some_and(|reference| reference.starts_with('-'))
118}
119
120fn git(source: &str) -> Option<Source> {
121 let (explicit, rest) = match source.strip_prefix("git:") {
122 Some(rest) => (true, rest.trim()),
123 None => (false, source.trim()),
124 };
125 let has_scheme = ["https://", "http://", "ssh://", "git://"]
126 .iter()
127 .any(|scheme| rest.starts_with(scheme));
128 if !explicit && !has_scheme {
129 return None;
130 }
131 if has_scheme {
132 let parsed = url::Url::parse(rest).ok()?;
133 let host = parsed.host_str().filter(|host| safe_host(host))?.to_owned();
134 let (path, reference) = split_reference(parsed.path());
135 let fragment = parsed.fragment().filter(|fragment| !fragment.is_empty());
136 let reference = reference.or_else(|| fragment.map(str::to_owned));
137 if !safe_reference(reference.as_deref()) {
138 return None;
139 }
140 let path = clean_path(path)?;
141 let mut repo = parsed.clone();
142 repo.set_path(&format!("/{path}"));
143 repo.set_fragment(None);
144 let repo = repo.to_string();
145 return Some(Source::Git {
146 repo: repo.strip_suffix('/').unwrap_or(&repo).to_owned(),
147 host,
148 path,
149 reference,
150 });
151 }
152 let (repo_prefix, host, path) = if let Some((user_host, path)) = rest.split_once(':') {
154 let host = user_host.rsplit('@').next()?.to_owned();
155 (Some(user_host.to_owned()), host, path)
156 } else {
157 let (host, path) = rest.split_once('/')?;
158 (None, host.to_owned(), path)
159 };
160 if !(host.contains('.') || host == "localhost") || !safe_host(&host) {
161 return None;
162 }
163 let (path, reference) = split_reference(path);
164 if !safe_reference(reference.as_deref()) {
165 return None;
166 }
167 let path = clean_path(path)?;
168 let repo = match repo_prefix {
169 Some(user_host) => format!("{user_host}:{path}"),
170 None => format!("https://{host}/{path}"),
171 };
172 Some(Source::Git {
173 repo,
174 host,
175 path,
176 reference,
177 })
178}
179
180pub fn is_local(source: &str) -> bool {
182 !REMOTE_PREFIXES
183 .iter()
184 .any(|prefix| source.starts_with(prefix))
185 && matches!(parse(source), Source::Local { .. })
186}
187
188pub fn local_path(path: &str, base: &Path) -> PathBuf {
191 let path = path.strip_prefix("file://").unwrap_or(path);
192 resolve_to_cwd(&expand(path), base)
193}
194
195impl Source {
196 pub fn identity(&self, base: &Path) -> String {
199 match self {
200 Source::Npm { name, .. } => format!("npm:{name}"),
201 Source::Git { host, path, .. } => format!("git:{host}/{path}"),
202 Source::Local { path } => format!("local:{}", local_path(path, base).display()),
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn parses_npm_specs() {
213 assert_eq!(
214 parse("npm:@scope/pkg@1.2.3"),
215 Source::Npm {
216 name: "@scope/pkg".into(),
217 version: Some("1.2.3".into()),
218 pinned: true
219 }
220 );
221 assert_eq!(
222 parse("npm:pkg@^1"),
223 Source::Npm {
224 name: "pkg".into(),
225 version: Some("^1".into()),
226 pinned: false
227 }
228 );
229 assert_eq!(
230 parse("npm: pkg "),
231 Source::Npm {
232 name: "pkg".into(),
233 version: None,
234 pinned: false
235 }
236 );
237 }
238
239 #[test]
240 fn parses_git_forms() {
241 let expected = |repo: &str, reference: Option<&str>| Source::Git {
242 repo: repo.into(),
243 host: "github.com".into(),
244 path: "user/repo".into(),
245 reference: reference.map(str::to_owned),
246 };
247 assert_eq!(
248 parse("git:github.com/user/repo@v1"),
249 expected("https://github.com/user/repo", Some("v1"))
250 );
251 assert_eq!(
252 parse("git:git@github.com:user/repo.git"),
253 expected("git@github.com:user/repo", None)
254 );
255 assert_eq!(
256 parse("https://github.com/user/repo.git"),
257 expected("https://github.com/user/repo", None)
258 );
259 assert_eq!(
260 parse("ssh://git@github.com/user/repo@main"),
261 expected("ssh://git@github.com/user/repo", Some("main"))
262 );
263 assert_eq!(
264 parse("https://github.com/user/repo#v1"),
265 expected("https://github.com/user/repo", Some("v1"))
266 );
267 assert_eq!(
268 parse("ssh://git@github.com/user/repo.git#main"),
269 expected("ssh://git@github.com/user/repo", Some("main"))
270 );
271 assert_eq!(
272 parse("https://github.com/user/repo#"),
273 expected("https://github.com/user/repo", None)
274 );
275 for source in [
278 "git:../../..:Projects/app",
279 "git:..:user/repo",
280 "git:a\\b.com:user/repo",
281 "git:-ucmd.com/user/repo",
282 "git:-ucmd.com:user/repo",
283 "git:github.com/user/repo@--upload-pack=touch x",
284 "https://github.com/user/repo#--upload-pack=touch x",
285 ] {
286 assert!(matches!(parse(source), Source::Local { .. }), "{source}");
287 }
288 assert!(matches!(
290 parse("github.com/user/repo"),
291 Source::Local { .. }
292 ));
293 assert!(matches!(parse("git:github.com/repo"), Source::Local { .. }));
294 assert!(matches!(parse("github:user/repo"), Source::Local { .. }));
295 }
296
297 #[test]
298 fn identities_ignore_versions_and_forms() {
299 let base = Path::new("/base");
300 assert_eq!(
301 parse("npm:pkg@1.0.0").identity(base),
302 parse("npm:pkg").identity(base)
303 );
304 assert_eq!(
305 parse("git:git@github.com:user/repo").identity(base),
306 parse("https://github.com/user/repo@v2").identity(base)
307 );
308 assert_eq!(parse("./pkg").identity(base), "local:/base/pkg");
309 }
310}