Skip to main content

oxi/storage/packages/
source.rs

1//! Source-spec parsing for the package system.
2//!
3//! Every package source string flows through `ParsedSource::parse`,
4//! which recognises npm specs, GitHub shorthand, raw git URLs, archive
5//! URLs, and local paths. Helpers (`parse_npm_spec`,
6//! `split_git_path_ref`, `parse_git_source`) are pure functions so they
7
8use serde::{Deserialize, Serialize};
9use std::sync::LazyLock;
10
11/// Parsed package source
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ParsedSource {
15    /// Variant.
16    Npm {
17        /// Full spec (e.g. "express@4.18.0")
18        spec: String,
19        /// Package name without version
20        name: String,
21        /// Whether a version was pinned
22        pinned: bool,
23    },
24    /// Variant.
25    Git {
26        /// Full repository URL
27        repo: String,
28        /// Host (e.g. "github.com")
29        host: String,
30        /// Path on host (e.g. "org/repo")
31        path: String,
32        /// Optional ref (branch / tag / commit)
33        ref_: Option<String>,
34    },
35    /// Variant.
36    Local {
37        /// Local path
38        path: String,
39    },
40    /// Variant.
41    Url {
42        /// URL to archive
43        url: String,
44    },
45}
46
47impl ParsedSource {
48    /// Parse a source string into a ParsedSource
49    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            // Distinguish git URLs from plain archive URLs
80            if source.ends_with(".git")
81                || source.contains("github.com")
82                || source.contains("gitlab.com")
83            {
84                return parse_git_source(source);
85            }
86            // Archive URL (.tar.gz, .zip, .tgz)
87            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            // Default to git for http(s) URLs that look like repos
97            return parse_git_source(source);
98        }
99
100        // Local path
101        ParsedSource::Local {
102            path: source.to_string(),
103        }
104    }
105
106    /// Get a unique identity key for this source (ignoring version/ref)
107    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    /// Get a display-friendly name
117    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
127/// Cached regex for parsing npm package specs
128pub(super) static NPM_SPEC_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
129    regex::Regex::new(r"^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$").expect("valid static regex")
130});
131
132/// Parse an npm spec into (name, pinned)
133pub(super) fn parse_npm_spec(spec: &str) -> (String, bool) {
134    // Handle scoped packages like @scope/name@version
135    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
143/// Split a git path like "org/repo@ref" into ("org/repo", Some("ref"))
144pub(super) fn split_git_path_ref(input: &str) -> (String, Option<String>) {
145    if let Some(at_pos) = input.rfind('@') {
146        // Make sure it's not part of an email (don't split if there's no / before @)
147        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
157/// Parse a git source string
158pub(super) fn parse_git_source(source: &str) -> ParsedSource {
159    // Handle git@host:path format (SSH)
160    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    // Handle git+ssh://, git+https://, git:// prefixes
183    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    // Parse URL to extract host and path
191    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    // Check for #ref fragment
204    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
223/// Shell metacharacter rejection. The package system spawns external
224/// processes (`npm pack`, `git clone`) with fields from `ParsedSource`
225/// as arguments. Anything outside the strict whitelist below can become
226/// a shell injection vector through arg-splitting differences and
227/// packaging-tool escaping bugs.
228///
229/// Policy:
230/// - npm `name`/`spec`: letters, digits, `.`, `_`, `-`, `/`, `@`, `~`,
231///   `=`, `+`, leading dot, internal spaces are NOT allowed.
232/// - git `repo`/`host`/`path`/`ref_`: alphanumerics, `.`, `_`, `-`, `:`,
233///   `/`, `+`, `=`, `@`, `#`, `~` (no spaces, no backticks, no $).
234/// - URL: any URL is treated as opaque — we never spawn `Command::new`
235///   on it directly (the HTTP client takes the URL as a single
236///   structured argument), so URLs are exempt from the same rule.
237const NPM_SAFE_CHARS: &str = r"@a-zA-Z0-9._/+=~";
238const GIT_SAFE_CHARS: &str = r"a-zA-Z0-9._:/=+@#-~";
239
240/// Validate a shell-safe npm package spec string (the `name` or full
241/// `spec` portion after stripping `npm:`).
242pub(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
259/// Validate shell-safe fields on a git source.
260pub(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
287/// Reject a `ParsedSource` outright if any of its component fields that
288/// are about to be passed to `Command::new` (or to `git_clone`'s args)
289/// contain shell metacharacters. URLs are exempt because they always go
290/// through `reqwest` which takes the URL as one structured argument.
291pub(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", // prefix is stripped before validate; just name
349        ] {
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        // URL & Local short-circuit (no spawn-time fields).
447        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}