1use serde::{Deserialize, Serialize};
9use std::sync::LazyLock;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ParsedSource {
15 Npm {
17 spec: String,
19 name: String,
21 pinned: bool,
23 },
24 Git {
26 repo: String,
28 host: String,
30 path: String,
32 ref_: Option<String>,
34 },
35 Local {
37 path: String,
39 },
40 Url {
42 url: String,
44 },
45}
46
47impl ParsedSource {
48 pub fn parse(source: &str) -> Self {
50 if let Some(rest) = source.strip_prefix("npm:") {
51 let spec = rest.trim();
52 let (name, pinned) = parse_npm_spec(spec);
53 return ParsedSource::Npm {
54 spec: spec.to_string(),
55 name,
56 pinned,
57 };
58 }
59
60 if let Some(rest) = source.strip_prefix("github:") {
61 let parts: Vec<&str> = rest.splitn(2, '/').collect();
62 if parts.len() == 2 {
63 let (path, ref_) = split_git_path_ref(rest);
64 return ParsedSource::Git {
65 repo: format!("https://github.com/{}.git", path),
66 host: "github.com".to_string(),
67 path,
68 ref_,
69 };
70 }
71 }
72
73 if source.starts_with("git+") || source.starts_with("git://") || source.starts_with("git@")
74 {
75 return parse_git_source(source);
76 }
77
78 if source.starts_with("https://") || source.starts_with("http://") {
79 if source.ends_with(".git")
81 || source.contains("github.com")
82 || source.contains("gitlab.com")
83 {
84 return parse_git_source(source);
85 }
86 if source.ends_with(".tar.gz")
88 || source.ends_with(".tgz")
89 || source.ends_with(".zip")
90 || source.ends_with(".tar.bz2")
91 {
92 return ParsedSource::Url {
93 url: source.to_string(),
94 };
95 }
96 return parse_git_source(source);
98 }
99
100 ParsedSource::Local {
102 path: source.to_string(),
103 }
104 }
105
106 pub fn identity(&self) -> String {
108 match self {
109 ParsedSource::Npm { name, .. } => format!("npm:{}", name),
110 ParsedSource::Git { host, path, .. } => format!("git:{}/{}", host, path),
111 ParsedSource::Local { path } => format!("local:{}", path),
112 ParsedSource::Url { url } => format!("url:{}", url),
113 }
114 }
115
116 pub fn display_name(&self) -> String {
118 match self {
119 ParsedSource::Npm { name, .. } => name.clone(),
120 ParsedSource::Git { host, path, .. } => format!("{}/{}", host, path),
121 ParsedSource::Local { path } => path.clone(),
122 ParsedSource::Url { url } => url.clone(),
123 }
124 }
125}
126
127pub(super) static NPM_SPEC_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
129 regex::Regex::new(r"^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$").expect("valid static regex")
130});
131
132pub(super) fn parse_npm_spec(spec: &str) -> (String, bool) {
134 if let Some(caps) = NPM_SPEC_RE.captures(spec) {
136 let name = caps.get(1).map(|m| m.as_str()).unwrap_or(spec);
137 let has_version = caps.get(2).is_some();
138 return (name.to_string(), has_version);
139 }
140 (spec.to_string(), false)
141}
142
143pub(super) fn split_git_path_ref(input: &str) -> (String, Option<String>) {
145 if let Some(at_pos) = input.rfind('@') {
146 if input[..at_pos].contains('/') {
148 return (
149 input[..at_pos].to_string(),
150 Some(input[at_pos + 1..].to_string()),
151 );
152 }
153 }
154 (input.to_string(), None)
155}
156
157pub(super) fn parse_git_source(source: &str) -> ParsedSource {
159 if let Some(rest) = source.strip_prefix("git@") {
161 let colon_pos = rest.find(':').unwrap_or(rest.len());
162 let host = &rest[..colon_pos];
163 let path_part = rest.get(colon_pos + 1..).unwrap_or("");
164 let (path, ref_) = if let Some(hash_pos) = path_part.rfind('#') {
165 (
166 path_part[..hash_pos].to_string(),
167 Some(path_part[hash_pos + 1..].to_string()),
168 )
169 } else {
170 split_git_path_ref(path_part)
171 };
172 let repo = format!("git@{}:{}", host, path_part);
173 let host = host.to_string();
174 return ParsedSource::Git {
175 repo,
176 host,
177 path: path.trim_end_matches(".git").to_string(),
178 ref_,
179 };
180 }
181
182 let url_str = source
184 .strip_prefix("git+")
185 .unwrap_or(source)
186 .strip_prefix("git://")
187 .map(|s| format!("https://{}", s))
188 .unwrap_or_else(|| source.strip_prefix("git+").unwrap_or(source).to_string());
189
190 let url = match url::Url::parse(&url_str) {
192 Ok(u) => u,
193 Err(_) => {
194 return ParsedSource::Local {
195 path: source.to_string(),
196 };
197 }
198 };
199
200 let host = url.host_str().unwrap_or("unknown").to_string();
201 let full_path = url.path().trim_start_matches('/').to_string();
202
203 let fragment = url.fragment().map(|f| f.to_string());
205
206 let (path, ref_) = if let Some(frag) = fragment {
207 (full_path.trim_end_matches(".git").to_string(), Some(frag))
208 } else {
209 let (p, r) = split_git_path_ref(&full_path);
210 (p.trim_end_matches(".git").to_string(), r)
211 };
212
213 let repo = url_str.clone();
214
215 ParsedSource::Git {
216 repo,
217 host,
218 path,
219 ref_,
220 }
221}
222
223const NPM_SAFE_CHARS: &str = r"@a-zA-Z0-9._/+=~";
238const GIT_SAFE_CHARS: &str = r"a-zA-Z0-9._:/=+@#-~";
239
240pub(crate) fn validate_npm_spec(spec: &str) -> Result<(), String> {
243 let trimmed = spec.trim();
244 if trimmed.is_empty() {
245 return Err("npm spec must not be empty".to_string());
246 }
247 if trimmed.chars().any(|c| !is_safe_npm_char(c)) {
248 return Err(format!(
249 "npm spec '{trimmed}' contains shell metacharacters; \
250 only {NPM_SAFE_CHARS} are permitted"
251 ));
252 }
253 if trimmed.starts_with('.') {
254 return Err(format!("npm spec '{trimmed}' must not start with '.'"));
255 }
256 Ok(())
257}
258
259pub(crate) fn validate_git_source(
261 repo: &str,
262 host: &str,
263 path: &str,
264 ref_: Option<&str>,
265) -> Result<(), String> {
266 fn check(field: &str, label: &str) -> Result<(), String> {
267 if field.is_empty() {
268 return Err(format!("git {label} must not be empty"));
269 }
270 if field.chars().any(|c| !is_safe_git_char(c)) {
271 return Err(format!(
272 "git {label} '{field}' contains shell metacharacters; \
273 only {GIT_SAFE_CHARS} are permitted"
274 ));
275 }
276 Ok(())
277 }
278 check(repo, "repo")?;
279 check(host, "host")?;
280 check(path, "path")?;
281 if let Some(r) = ref_ {
282 check(r, "ref")?;
283 }
284 Ok(())
285}
286
287pub(crate) fn validate_parsed_source(parsed: &ParsedSource) -> Result<(), String> {
292 match parsed {
293 ParsedSource::Npm { spec, name, .. } => {
294 validate_npm_spec(spec)?;
295 validate_npm_spec(name)?;
296 }
297 ParsedSource::Git {
298 repo,
299 host,
300 path,
301 ref_,
302 } => {
303 validate_git_source(repo, host, path, ref_.as_deref())?;
304 }
305 ParsedSource::Local { .. } | ParsedSource::Url { .. } => {}
306 }
307 Ok(())
308}
309
310fn is_safe_npm_char(c: char) -> bool {
311 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | '@' | '+' | '=' | '~')
312}
313
314fn is_safe_git_char(c: char) -> bool {
315 c.is_ascii_alphanumeric()
316 || matches!(c, '.' | '_' | '-' | ':' | '/' | '+' | '=' | '@' | '#' | '~')
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn accepts_safe_npm_specs() {
325 for s in [
326 "express",
327 "express@4.18.0",
328 "@scope/pkg",
329 "@scope/pkg@1.2.3",
330 "lodash-es@4.17.21+esm",
331 ] {
332 validate_npm_spec(s).unwrap_or_else(|e| panic!("{s}: {e}"));
333 }
334 }
335
336 #[test]
337 fn rejects_npm_specs_with_metachars() {
338 for s in [
339 "ex press",
340 "$(rm -rf)",
341 ";ls",
342 "ex&press",
343 "ex|press",
344 "`ls`",
345 "ex\npress",
346 ".hidden-start",
347 "@scope/pkg@1; rm",
348 "npm:foo", ] {
350 assert!(validate_npm_spec(s).is_err(), "{s} should be rejected");
351 }
352 }
353
354 #[test]
355 fn accepts_safe_git_fields() {
356 validate_git_source(
357 "https://github.com/org/repo.git",
358 "github.com",
359 "org/repo",
360 Some("v1.2.3"),
361 )
362 .unwrap();
363 validate_git_source(
364 "https://github.com/org/repo.git",
365 "github.com",
366 "org/repo",
367 None,
368 )
369 .unwrap();
370 }
371
372 #[test]
373 fn rejects_git_fields_with_metachars() {
374 assert!(
375 validate_git_source(
376 "https://github.com/org/repo;rm.git",
377 "github.com",
378 "org/repo",
379 None
380 )
381 .is_err()
382 );
383 assert!(
384 validate_git_source(
385 "https://github.com/org/repo.git",
386 "github.com|xx",
387 "org/repo",
388 None
389 )
390 .is_err()
391 );
392 assert!(
393 validate_git_source(
394 "https://github.com/org/repo.git",
395 "github.com",
396 "org/$(rm)repo",
397 None
398 )
399 .is_err()
400 );
401 assert!(
402 validate_git_source(
403 "https://github.com/org/repo.git",
404 "github.com",
405 "org/repo",
406 Some("v1; ls"),
407 )
408 .is_err()
409 );
410 }
411
412 #[test]
413 fn parsed_source_validation_picks_correct_branch() {
414 validate_parsed_source(&ParsedSource::Npm {
415 spec: "express".into(),
416 name: "express".into(),
417 pinned: false,
418 })
419 .unwrap();
420 assert!(
421 validate_parsed_source(&ParsedSource::Npm {
422 spec: "ex;rm".into(),
423 name: "express".into(),
424 pinned: false,
425 })
426 .is_err()
427 );
428
429 validate_parsed_source(&ParsedSource::Git {
430 repo: "https://github.com/o/r.git".into(),
431 host: "github.com".into(),
432 path: "o/r".into(),
433 ref_: None,
434 })
435 .unwrap();
436 assert!(
437 validate_parsed_source(&ParsedSource::Git {
438 repo: "https://github.com/o/r.git".into(),
439 host: "github.com".into(),
440 path: "o/r;ls".into(),
441 ref_: None,
442 })
443 .is_err()
444 );
445
446 validate_parsed_source(&ParsedSource::Url {
448 url: "https://example.com/x.tar.gz?query=`whoami`".into(),
449 })
450 .unwrap();
451 validate_parsed_source(&ParsedSource::Local {
452 path: "/tmp/pkg;rm".into(),
453 })
454 .unwrap();
455 }
456}