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`. As pi's `buildGitSource`,
153    // `git@host:/srv/repo`, absolute on the host, is not a git source.
154    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
182/// Whether `source` names a local path.
183pub 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
190/// A local source as an absolute path: `~` expands, `file://` URLs convert,
191/// and relative paths resolve against `base`.
192pub 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    /// The identity settings entries are matched by: the npm name, the git
199    /// host and path, or the absolute local path resolved against `base`.
200    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        // Hosts that would leave `git/`, hosts or refs that would pass for
278        // a git option, and scp-like paths that are absolute on the host.
279        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        // Without `git:`, only URLs are git sources.
292        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}