use std::path::{Path, PathBuf};
use crate::tools::path::{expand, resolve_to_cwd};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
Npm {
name: String,
version: Option<String>,
pinned: bool,
},
Git {
repo: String,
host: String,
path: String,
reference: Option<String>,
},
Local {
path: String,
},
}
const REMOTE_PREFIXES: [&str; 7] = [
"npm:", "git:", "github:", "http:", "https:", "ssh:", "builtin:",
];
pub fn parse(source: &str) -> Source {
if let Some(spec) = source.strip_prefix("npm:") {
return npm(spec.trim());
}
if let Some(git) = git(source) {
return git;
}
Source::Local {
path: source.to_owned(),
}
}
fn npm(spec: &str) -> Source {
let (name, version) = split_npm_spec(spec);
let pinned = version
.as_deref()
.is_some_and(|version| semver::Version::parse(version).is_ok());
Source::Npm {
name,
version,
pinned,
}
}
pub(crate) fn split_npm_spec(spec: &str) -> (String, Option<String>) {
let (scope, rest) = match spec.strip_prefix('@') {
Some(rest) => ("@", rest),
None => ("", spec),
};
let (name, version) = match rest.split_once('@') {
Some((name, version)) if !version.is_empty() => (name, Some(version.to_owned())),
_ => (rest, None),
};
(format!("{scope}{name}"), version)
}
fn split_reference(path: &str) -> (&str, Option<String>) {
if let Some((path, reference)) = path.rsplit_once('#') {
return (path, Some(reference.to_owned()));
}
let last = path.rfind('/').map_or(0, |index| index + 1);
match path[last..].find('@') {
Some(at) => (&path[..last + at], Some(path[last + at + 1..].to_owned())),
None => (path, None),
}
}
fn clean_path(path: &str) -> Option<String> {
let path = path.trim_start_matches('/');
let path = path.strip_suffix(".git").unwrap_or(path);
let path = path.trim_end_matches('/');
let segments: Vec<&str> = path.split('/').collect();
let valid = segments.len() >= 2
&& segments
.iter()
.all(|segment| !segment.is_empty() && *segment != "..")
&& !path.contains('\\')
&& !path.contains('\0');
valid.then(|| path.to_owned())
}
fn safe_host(host: &str) -> bool {
!host.contains(['/', '\\', '\0']) && host != ".." && !host.starts_with('-')
}
fn safe_reference(reference: Option<&str>) -> bool {
!reference.is_some_and(|reference| reference.starts_with('-'))
}
fn git(source: &str) -> Option<Source> {
let (explicit, rest) = match source.strip_prefix("git:") {
Some(rest) => (true, rest.trim()),
None => (false, source.trim()),
};
let has_scheme = ["https://", "http://", "ssh://", "git://"]
.iter()
.any(|scheme| rest.starts_with(scheme));
if !explicit && !has_scheme {
return None;
}
if has_scheme {
let parsed = url::Url::parse(rest).ok()?;
let host = parsed.host_str().filter(|host| safe_host(host))?.to_owned();
let (path, reference) = split_reference(parsed.path());
let fragment = parsed.fragment().filter(|fragment| !fragment.is_empty());
let reference = reference.or_else(|| fragment.map(str::to_owned));
if !safe_reference(reference.as_deref()) {
return None;
}
let path = clean_path(path)?;
let mut repo = parsed.clone();
repo.set_path(&format!("/{path}"));
repo.set_fragment(None);
let repo = repo.to_string();
return Some(Source::Git {
repo: repo.strip_suffix('/').unwrap_or(&repo).to_owned(),
host,
path,
reference,
});
}
let (repo_prefix, host, path) = if let Some((user_host, path)) = rest.split_once(':') {
let host = user_host.rsplit('@').next()?.to_owned();
(Some(user_host.to_owned()), host, path)
} else {
let (host, path) = rest.split_once('/')?;
(None, host.to_owned(), path)
};
if !(host.contains('.') || host == "localhost") || !safe_host(&host) {
return None;
}
let (path, reference) = split_reference(path);
if !safe_reference(reference.as_deref()) {
return None;
}
let path = clean_path(path)?;
let repo = match repo_prefix {
Some(user_host) => format!("{user_host}:{path}"),
None => format!("https://{host}/{path}"),
};
Some(Source::Git {
repo,
host,
path,
reference,
})
}
pub fn is_local(source: &str) -> bool {
!REMOTE_PREFIXES
.iter()
.any(|prefix| source.starts_with(prefix))
&& matches!(parse(source), Source::Local { .. })
}
pub fn local_path(path: &str, base: &Path) -> PathBuf {
let path = path.strip_prefix("file://").unwrap_or(path);
resolve_to_cwd(&expand(path), base)
}
impl Source {
pub fn identity(&self, base: &Path) -> String {
match self {
Source::Npm { name, .. } => format!("npm:{name}"),
Source::Git { host, path, .. } => format!("git:{host}/{path}"),
Source::Local { path } => format!("local:{}", local_path(path, base).display()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_npm_specs() {
assert_eq!(
parse("npm:@scope/pkg@1.2.3"),
Source::Npm {
name: "@scope/pkg".into(),
version: Some("1.2.3".into()),
pinned: true
}
);
assert_eq!(
parse("npm:pkg@^1"),
Source::Npm {
name: "pkg".into(),
version: Some("^1".into()),
pinned: false
}
);
assert_eq!(
parse("npm: pkg "),
Source::Npm {
name: "pkg".into(),
version: None,
pinned: false
}
);
}
#[test]
fn parses_git_forms() {
let expected = |repo: &str, reference: Option<&str>| Source::Git {
repo: repo.into(),
host: "github.com".into(),
path: "user/repo".into(),
reference: reference.map(str::to_owned),
};
assert_eq!(
parse("git:github.com/user/repo@v1"),
expected("https://github.com/user/repo", Some("v1"))
);
assert_eq!(
parse("git:git@github.com:user/repo.git"),
expected("git@github.com:user/repo", None)
);
assert_eq!(
parse("https://github.com/user/repo.git"),
expected("https://github.com/user/repo", None)
);
assert_eq!(
parse("ssh://git@github.com/user/repo@main"),
expected("ssh://git@github.com/user/repo", Some("main"))
);
assert_eq!(
parse("https://github.com/user/repo#v1"),
expected("https://github.com/user/repo", Some("v1"))
);
assert_eq!(
parse("ssh://git@github.com/user/repo.git#main"),
expected("ssh://git@github.com/user/repo", Some("main"))
);
assert_eq!(
parse("https://github.com/user/repo#"),
expected("https://github.com/user/repo", None)
);
for source in [
"git:../../..:Projects/app",
"git:..:user/repo",
"git:a\\b.com:user/repo",
"git:-ucmd.com/user/repo",
"git:-ucmd.com:user/repo",
"git:github.com/user/repo@--upload-pack=touch x",
"https://github.com/user/repo#--upload-pack=touch x",
] {
assert!(matches!(parse(source), Source::Local { .. }), "{source}");
}
assert!(matches!(
parse("github.com/user/repo"),
Source::Local { .. }
));
assert!(matches!(parse("git:github.com/repo"), Source::Local { .. }));
assert!(matches!(parse("github:user/repo"), Source::Local { .. }));
}
#[test]
fn identities_ignore_versions_and_forms() {
let base = Path::new("/base");
assert_eq!(
parse("npm:pkg@1.0.0").identity(base),
parse("npm:pkg").identity(base)
);
assert_eq!(
parse("git:git@github.com:user/repo").identity(base),
parse("https://github.com/user/repo@v2").identity(base)
);
assert_eq!(parse("./pkg").identity(base), "local:/base/pkg");
}
}