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(':') {
155 let host = user_host.rsplit('@').next()?.to_owned();
156 (Some(user_host.to_owned()), host, path)
157 } else {
158 let (host, path) = rest.split_once('/')?;
159 (None, host.to_owned(), path)
160 };
161 let absolute = repo_prefix.is_some() && path.starts_with('/');
162 if !(host.contains('.') || host == "localhost") || !safe_host(&host) || absolute {
163 return None;
164 }
165 let (path, reference) = split_reference(path);
166 if !safe_reference(reference.as_deref()) {
167 return None;
168 }
169 let path = clean_path(path)?;
170 let repo = match repo_prefix {
171 Some(user_host) => format!("{user_host}:{path}"),
172 None => format!("https://{host}/{path}"),
173 };
174 Some(Source::Git {
175 repo,
176 host,
177 path,
178 reference,
179 })
180}
181
182pub fn is_local(source: &str) -> bool {
184 !REMOTE_PREFIXES
185 .iter()
186 .any(|prefix| source.starts_with(prefix))
187 && matches!(parse(source), Source::Local { .. })
188}
189
190pub fn local_path(path: &str, base: &Path) -> PathBuf {
193 let path = path.strip_prefix("file://").unwrap_or(path);
194 resolve_to_cwd(&expand(path), base)
195}
196
197impl Source {
198 pub fn identity(&self, base: &Path) -> String {
201 match self {
202 Source::Npm { name, .. } => format!("npm:{name}"),
203 Source::Git { host, path, .. } => format!("git:{host}/{path}"),
204 Source::Local { path } => format!("local:{}", local_path(path, base).display()),
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn parses_npm_specs() {
215 assert_eq!(
216 parse("npm:@scope/pkg@1.2.3"),
217 Source::Npm {
218 name: "@scope/pkg".into(),
219 version: Some("1.2.3".into()),
220 pinned: true
221 }
222 );
223 assert_eq!(
224 parse("npm:pkg@^1"),
225 Source::Npm {
226 name: "pkg".into(),
227 version: Some("^1".into()),
228 pinned: false
229 }
230 );
231 assert_eq!(
232 parse("npm: pkg "),
233 Source::Npm {
234 name: "pkg".into(),
235 version: None,
236 pinned: false
237 }
238 );
239 }
240
241 #[test]
242 fn parses_git_forms() {
243 let expected = |repo: &str, reference: Option<&str>| Source::Git {
244 repo: repo.into(),
245 host: "github.com".into(),
246 path: "user/repo".into(),
247 reference: reference.map(str::to_owned),
248 };
249 assert_eq!(
250 parse("git:github.com/user/repo@v1"),
251 expected("https://github.com/user/repo", Some("v1"))
252 );
253 assert_eq!(
254 parse("git:git@github.com:user/repo.git"),
255 expected("git@github.com:user/repo", None)
256 );
257 assert_eq!(
258 parse("https://github.com/user/repo.git"),
259 expected("https://github.com/user/repo", None)
260 );
261 assert_eq!(
262 parse("ssh://git@github.com/user/repo@main"),
263 expected("ssh://git@github.com/user/repo", Some("main"))
264 );
265 assert_eq!(
266 parse("https://github.com/user/repo#v1"),
267 expected("https://github.com/user/repo", Some("v1"))
268 );
269 assert_eq!(
270 parse("ssh://git@github.com/user/repo.git#main"),
271 expected("ssh://git@github.com/user/repo", Some("main"))
272 );
273 assert_eq!(
274 parse("https://github.com/user/repo#"),
275 expected("https://github.com/user/repo", None)
276 );
277 for source in [
280 "git:../../..:Projects/app",
281 "git:..:user/repo",
282 "git:a\\b.com:user/repo",
283 "git:-ucmd.com/user/repo",
284 "git:-ucmd.com:user/repo",
285 "git:github.com/user/repo@--upload-pack=touch x",
286 "https://github.com/user/repo#--upload-pack=touch x",
287 "git:git@github.com:/user/repo",
288 ] {
289 assert!(matches!(parse(source), Source::Local { .. }), "{source}");
290 }
291 assert!(matches!(
293 parse("github.com/user/repo"),
294 Source::Local { .. }
295 ));
296 assert!(matches!(parse("git:github.com/repo"), Source::Local { .. }));
297 assert!(matches!(parse("github:user/repo"), Source::Local { .. }));
298 }
299
300 #[test]
301 fn identities_ignore_versions_and_forms() {
302 let base = Path::new("/base");
303 assert_eq!(
304 parse("npm:pkg@1.0.0").identity(base),
305 parse("npm:pkg").identity(base)
306 );
307 assert_eq!(
308 parse("git:git@github.com:user/repo").identity(base),
309 parse("https://github.com/user/repo@v2").identity(base)
310 );
311 assert_eq!(parse("./pkg").identity(base), "local:/base/pkg");
312 }
313}