Skip to main content

yapi_core/packages/
source.rs

1//! Package sources: `npm:` specs, git repositories and local paths, as pi's
2//! `parseSource` reads them (`core/package-manager.ts`, `utils/git.ts`).
3
4use std::path::{Path, PathBuf};
5
6use crate::tools::path::{expand, resolve_to_cwd};
7
8/// Where a package comes from.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum Source {
11    /// An npm package: `npm:<name>[@<version or range>]`.
12    Npm {
13        /// The package name.
14        name: String,
15        /// The requested version or range.
16        version: Option<String>,
17        /// Whether the version is exact, so updates skip it.
18        pinned: bool,
19    },
20    /// A git repository.
21    Git {
22        /// What `git clone` gets.
23        repo: String,
24        /// Host name, such as `github.com`.
25        host: String,
26        /// Repository path without `.git`, such as `user/repo`.
27        path: String,
28        /// The branch, tag or commit to check out.
29        reference: Option<String>,
30    },
31    /// A file or directory.
32    Local {
33        /// The path as written.
34        path: String,
35    },
36}
37
38/// Prefixes that make a source something other than a local path.
39const REMOTE_PREFIXES: [&str; 7] = [
40    "npm:", "git:", "github:", "http:", "https:", "ssh:", "builtin:",
41];
42
43/// Parses `source` as pi does: `npm:`, then git forms, then a local path.
44pub 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
68/// Splits `<name>[@<version or range>]` into the name and the version.
69pub(crate) fn split_npm_spec(spec: &str) -> (String, Option<String>) {
70    // `^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$`: a scoped name keeps its leading `@`.
71    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
82/// Splits `@ref` (or `#ref`) off the end of a repository path.
83fn 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
108/// Whether `host` is safe as a folder name under `git/`, as pi's
109/// `hasUnsafeGitInstallPart` judges it, and cannot pass for a git option.
110fn safe_host(host: &str) -> bool {
111    !host.contains(['/', '\\', '\0']) && host != ".." && !host.starts_with('-')
112}
113
114/// Whether `reference` cannot pass for an option of `git checkout` or `git
115/// fetch`, such as `--upload-pack=<command>`.
116fn 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    // `git@host:user/repo` or `host/user/repo`.
153    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
180/// Whether `source` names a local path.
181pub 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
188/// A local source as an absolute path: `~` expands, `file://` URLs convert,
189/// and relative paths resolve against `base`.
190pub 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    /// The identity settings entries are matched by: the npm name, the git
197    /// host and path, or the absolute local path resolved against `base`.
198    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        // Hosts that would leave `git/`, and hosts or refs that would pass
276        // for a git option.
277        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        // Without `git:`, only URLs are git sources.
289        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}