use anyhow::Result;
use anyhow::bail;
use super::PluginKind;
const DEFAULT_NPM_PLUGIN_FILE: &str = "plugin.wasm";
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct NpmSpecifier {
pub name: String,
pub version: Option<String>,
pub path: String,
}
#[derive(Debug)]
pub struct ParsedNpmSpecifier {
pub specifier: NpmSpecifier,
pub checksum: Option<String>,
pub path_was_explicit: bool,
}
impl NpmSpecifier {
pub fn plugin_kind(&self) -> PluginKind {
plugin_kind_from_extension(&self.path).unwrap_or(PluginKind::Wasm)
}
pub fn display(&self) -> String {
let path_suffix = if self.path == DEFAULT_NPM_PLUGIN_FILE {
String::new()
} else {
format!("/{}", self.path)
};
match &self.version {
Some(version) => format!("npm:{}@{}{}", self.name, version, path_suffix),
None => format!("npm:{}{}", self.name, path_suffix),
}
}
}
pub fn parse_npm_specifier(text: &str) -> Result<ParsedNpmSpecifier> {
let Some(rest) = text.strip_prefix("npm:") else {
bail!("Expected npm specifier to start with 'npm:': {}", text);
};
if rest.is_empty() {
bail!("Expected a package name after 'npm:': {}", text);
}
let (name, after_name) = parse_package_name(rest, text)?;
validate_safe_package_name(&name, text)?;
if after_name.is_empty() {
return Ok(ParsedNpmSpecifier {
specifier: NpmSpecifier {
name,
version: None,
path: DEFAULT_NPM_PLUGIN_FILE.to_string(),
},
checksum: None,
path_was_explicit: false,
});
}
if let Some(rest) = after_name.strip_prefix('/') {
let (path, checksum) = parse_path_and_checksum(rest, text)?;
return Ok(ParsedNpmSpecifier {
specifier: NpmSpecifier { name, version: None, path },
checksum,
path_was_explicit: true,
});
}
let after_at = &after_name[1..];
let (version, remainder) = split_version(after_at);
if version.is_empty() {
bail!("Expected a version after '@' in npm specifier: {}", text);
}
validate_safe_version(version, text)?;
if remainder.is_empty() {
return Ok(ParsedNpmSpecifier {
specifier: NpmSpecifier {
name,
version: Some(version.to_string()),
path: DEFAULT_NPM_PLUGIN_FILE.to_string(),
},
checksum: None,
path_was_explicit: false,
});
}
if let Some(rest) = remainder.strip_prefix('/') {
let (path, checksum) = parse_path_and_checksum(rest, text)?;
return Ok(ParsedNpmSpecifier {
specifier: NpmSpecifier {
name,
version: Some(version.to_string()),
path,
},
checksum,
path_was_explicit: true,
});
}
if let Some(checksum) = remainder.strip_prefix('@') {
if checksum.is_empty() {
bail!("Expected a checksum after '@' in npm specifier: {}", text);
}
return Ok(ParsedNpmSpecifier {
specifier: NpmSpecifier {
name,
version: Some(version.to_string()),
path: DEFAULT_NPM_PLUGIN_FILE.to_string(),
},
checksum: Some(checksum.to_string()),
path_was_explicit: false,
});
}
bail!("Unexpected characters after version in npm specifier: {}", text);
}
fn split_version(s: &str) -> (&str, &str) {
for (i, c) in s.char_indices() {
if c == '/' || c == '@' {
return (&s[..i], &s[i..]);
}
}
(s, "")
}
fn parse_path_and_checksum(s: &str, original: &str) -> Result<(String, Option<String>)> {
if s.is_empty() {
bail!("Expected a plugin filename after '/' in npm specifier: {}", original);
}
let (path, checksum) = if let Some(at_idx) = s.find('@') {
let path = &s[..at_idx];
let checksum = &s[at_idx + 1..];
if path.is_empty() {
bail!("Expected a plugin filename after '/' in npm specifier: {}", original);
}
if checksum.is_empty() {
bail!("Expected a checksum after '@' in npm specifier: {}", original);
}
(path, Some(checksum.to_string()))
} else {
(s, None)
};
validate_safe_sub_path(path, original)?;
Ok((path.to_string(), checksum))
}
fn validate_safe_sub_path(path: &str, original: &str) -> Result<()> {
if path.starts_with('/') {
bail!("Plugin path in npm specifier must be relative (got '{}'): {}", path, original);
}
if path.contains('\\') {
bail!("Plugin path in npm specifier must not contain backslashes (got '{}'): {}", path, original);
}
for segment in path.split('/') {
if segment.is_empty() {
bail!("Plugin path in npm specifier must not contain empty segments (got '{}'): {}", path, original);
}
if segment == "." || segment == ".." {
bail!(
"Plugin path in npm specifier must not contain '.' or '..' segments (got '{}'): {}",
path,
original
);
}
}
Ok(())
}
fn validate_safe_version(version: &str, original: &str) -> Result<()> {
if version == "." || version == ".." {
bail!("Version in npm specifier must not be '.' or '..' (got '{}'): {}", version, original);
}
if !version.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '_' | '~')) {
bail!("Version in npm specifier contains invalid characters (got '{}'): {}", version, original);
}
Ok(())
}
fn validate_safe_package_name(name: &str, original: &str) -> Result<()> {
if name.contains('\\') {
bail!("Package name in npm specifier must not contain backslashes (got '{}'): {}", name, original);
}
for segment in name.split('/') {
if segment.is_empty() {
bail!("Package name in npm specifier must not contain empty segments (got '{}'): {}", name, original);
}
if segment == "." || segment == ".." {
bail!(
"Package name in npm specifier must not contain '.' or '..' segments (got '{}'): {}",
name,
original
);
}
}
Ok(())
}
pub fn validate_plugin_extension(specifier: &NpmSpecifier, original: &str) -> Result<()> {
if plugin_kind_from_extension(&specifier.path).is_none() {
bail!(
"Unsupported plugin file extension in npm specifier '{}': '{}'. Expected '.wasm' or '.json'.",
original,
specifier.path,
);
}
Ok(())
}
fn plugin_kind_from_extension(path: &str) -> Option<PluginKind> {
let (_, ext) = path.rsplit_once('.')?;
if ext.eq_ignore_ascii_case("wasm") {
Some(PluginKind::Wasm)
} else if ext.eq_ignore_ascii_case("json") {
Some(PluginKind::Process)
} else {
None
}
}
fn parse_package_name<'a>(rest: &'a str, original: &str) -> Result<(String, &'a str)> {
if rest.starts_with('@') {
let Some(slash_idx) = rest.find('/') else {
bail!("Expected '/' after scope in npm specifier: {}", original);
};
let after_slash = &rest[slash_idx + 1..];
if after_slash.is_empty() {
bail!("Expected a package name after '/' in npm specifier: {}", original);
}
match after_slash.find(['@', '/']) {
Some(idx) => {
let name_end = slash_idx + 1 + idx;
Ok((rest[..name_end].to_string(), &rest[name_end..]))
}
None => Ok((rest.to_string(), "")),
}
} else {
match rest.find(['@', '/']) {
Some(idx) => Ok((rest[..idx].to_string(), &rest[idx..])),
None => Ok((rest.to_string(), "")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_scoped_with_version() {
let result = parse_npm_specifier("npm:@dprint/typescript@0.23.0").unwrap();
assert_eq!(result.specifier.name, "@dprint/typescript");
assert_eq!(result.specifier.version, Some("0.23.0".to_string()));
assert_eq!(result.specifier.path, "plugin.wasm");
assert_eq!(result.specifier.plugin_kind(), PluginKind::Wasm);
assert_eq!(result.checksum, None);
}
#[test]
fn parse_scoped_with_version_and_checksum() {
let result = parse_npm_specifier("npm:@dprint/prettier@0.23.0@abc123def").unwrap();
assert_eq!(result.specifier.name, "@dprint/prettier");
assert_eq!(result.specifier.version, Some("0.23.0".to_string()));
assert_eq!(result.specifier.path, "plugin.wasm");
assert_eq!(result.checksum, Some("abc123def".to_string()));
}
#[test]
fn parse_scoped_without_version() {
let result = parse_npm_specifier("npm:@dprint/typescript").unwrap();
assert_eq!(result.specifier.name, "@dprint/typescript");
assert_eq!(result.specifier.version, None);
assert_eq!(result.specifier.path, "plugin.wasm");
assert_eq!(result.checksum, None);
}
#[test]
fn parse_with_plugin_json_path() {
let result = parse_npm_specifier("npm:@dprint/prettier@0.23.0/plugin.json").unwrap();
assert_eq!(result.specifier.name, "@dprint/prettier");
assert_eq!(result.specifier.version, Some("0.23.0".to_string()));
assert_eq!(result.specifier.path, "plugin.json");
assert_eq!(result.specifier.plugin_kind(), PluginKind::Process);
assert_eq!(result.checksum, None);
}
#[test]
fn parse_with_plugin_json_path_and_checksum() {
let result = parse_npm_specifier("npm:@dprint/prettier@0.23.0/plugin.json@abc123").unwrap();
assert_eq!(result.specifier.name, "@dprint/prettier");
assert_eq!(result.specifier.version, Some("0.23.0".to_string()));
assert_eq!(result.specifier.path, "plugin.json");
assert_eq!(result.checksum, Some("abc123".to_string()));
}
#[test]
fn parse_with_explicit_plugin_wasm() {
let result = parse_npm_specifier("npm:@dprint/typescript@0.23.0/plugin.wasm").unwrap();
assert_eq!(result.specifier.name, "@dprint/typescript");
assert_eq!(result.specifier.version, Some("0.23.0".to_string()));
assert_eq!(result.specifier.path, "plugin.wasm");
assert_eq!(result.specifier.plugin_kind(), PluginKind::Wasm);
assert_eq!(result.checksum, None);
}
#[test]
fn parse_no_version_with_path() {
let result = parse_npm_specifier("npm:@dprint/prettier/plugin.json").unwrap();
assert_eq!(result.specifier.name, "@dprint/prettier");
assert_eq!(result.specifier.version, None);
assert_eq!(result.specifier.path, "plugin.json");
assert_eq!(result.specifier.plugin_kind(), PluginKind::Process);
}
#[test]
fn parse_tracks_whether_path_was_explicit() {
for defaulted in [
"npm:@dprint/typescript",
"npm:@dprint/typescript@0.23.0",
"npm:@dprint/typescript@0.23.0@abc123",
"npm:dprint-plugin-foo",
] {
assert!(
!parse_npm_specifier(defaulted).unwrap().path_was_explicit,
"expected defaulted path for {defaulted}"
);
}
for explicit in [
"npm:@dprint/prettier/plugin.json",
"npm:@dprint/prettier@0.23.0/plugin.json",
"npm:@dprint/prettier@0.23.0/plugin.json@abc123",
"npm:@dprint/typescript@0.23.0/plugin.wasm",
] {
assert!(
parse_npm_specifier(explicit).unwrap().path_was_explicit,
"expected explicit path for {explicit}"
);
}
}
#[test]
fn parse_unscoped_with_version() {
let result = parse_npm_specifier("npm:dprint-plugin-foo@1.0.0").unwrap();
assert_eq!(result.specifier.name, "dprint-plugin-foo");
assert_eq!(result.specifier.version, Some("1.0.0".to_string()));
assert_eq!(result.specifier.path, "plugin.wasm");
assert_eq!(result.checksum, None);
}
#[test]
fn parse_unscoped_without_version() {
let result = parse_npm_specifier("npm:dprint-plugin-foo").unwrap();
assert_eq!(result.specifier.name, "dprint-plugin-foo");
assert_eq!(result.specifier.version, None);
assert_eq!(result.checksum, None);
}
#[test]
fn parse_unscoped_with_path() {
let result = parse_npm_specifier("npm:dprint-plugin-foo@1.0.0/plugin.json@sha256hash").unwrap();
assert_eq!(result.specifier.name, "dprint-plugin-foo");
assert_eq!(result.specifier.version, Some("1.0.0".to_string()));
assert_eq!(result.specifier.path, "plugin.json");
assert_eq!(result.checksum, Some("sha256hash".to_string()));
}
#[test]
fn display_default_path_omitted() {
let result = parse_npm_specifier("npm:@dprint/typescript@0.23.0").unwrap();
assert_eq!(result.specifier.display(), "npm:@dprint/typescript@0.23.0");
}
#[test]
fn display_explicit_json_path() {
let result = parse_npm_specifier("npm:@dprint/prettier@0.23.0/plugin.json").unwrap();
assert_eq!(result.specifier.display(), "npm:@dprint/prettier@0.23.0/plugin.json");
}
#[test]
fn display_without_version() {
let result = parse_npm_specifier("npm:@dprint/typescript").unwrap();
assert_eq!(result.specifier.display(), "npm:@dprint/typescript");
}
#[test]
fn display_without_version_with_path() {
let result = parse_npm_specifier("npm:@dprint/prettier/plugin.json").unwrap();
assert_eq!(result.specifier.display(), "npm:@dprint/prettier/plugin.json");
}
#[test]
fn error_no_prefix() {
assert!(parse_npm_specifier("@dprint/typescript@0.23.0").is_err());
}
#[test]
fn error_empty_after_npm() {
assert!(parse_npm_specifier("npm:").is_err());
}
#[test]
fn error_no_slash_in_scope() {
assert!(parse_npm_specifier("npm:@dprint").is_err());
}
#[test]
fn error_empty_version() {
assert!(parse_npm_specifier("npm:@dprint/typescript@").is_err());
}
#[test]
fn error_empty_checksum() {
assert!(parse_npm_specifier("npm:@dprint/typescript@0.23.0@").is_err());
}
#[test]
fn error_empty_path() {
assert!(parse_npm_specifier("npm:@dprint/typescript@0.23.0/").is_err());
}
#[test]
fn error_empty_checksum_after_path() {
assert!(parse_npm_specifier("npm:@dprint/typescript@0.23.0/plugin.json@").is_err());
}
#[test]
fn validate_plugin_extension_rejects_non_plugin_paths() {
let parsed = parse_npm_specifier("npm:foo@1.0.0/plugin.txt").unwrap();
let err = validate_plugin_extension(&parsed.specifier, "npm:foo@1.0.0/plugin.txt").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unsupported plugin file extension"), "got: {msg}");
assert!(msg.contains("plugin.txt"), "got: {msg}");
let parsed = parse_npm_specifier("npm:foo@1.0.0/plugin").unwrap();
assert!(validate_plugin_extension(&parsed.specifier, "npm:foo@1.0.0/plugin").is_err());
}
#[test]
fn validate_plugin_extension_accepts_arbitrary_paths_only_through_parse_not_validate() {
let parsed = parse_npm_specifier("npm:foo-bin@1.0.0/foo").unwrap();
assert_eq!(parsed.specifier.path, "foo");
assert!(validate_plugin_extension(&parsed.specifier, "npm:foo-bin@1.0.0/foo").is_err());
let parsed = parse_npm_specifier("npm:foo-bin@1.0.0/foo.exe").unwrap();
assert_eq!(parsed.specifier.path, "foo.exe");
assert!(validate_plugin_extension(&parsed.specifier, "npm:foo-bin@1.0.0/foo.exe").is_err());
}
#[test]
fn parse_rejects_parent_dir_segments() {
let cases = [
"npm:foo@1.0.0/../escape",
"npm:foo@1.0.0/sub/../escape",
"npm:foo@1.0.0/..",
"npm:foo@1.0.0/./plugin.wasm",
"npm:foo@1.0.0/.",
];
for input in cases {
let err = parse_npm_specifier(input).unwrap_err();
assert!(
err.to_string().contains("'.' or '..' segments"),
"expected '.'/'..' rejection for {input}, got: {err}",
);
}
}
#[test]
fn parse_rejects_parent_dir_package_names() {
let cases = ["npm:..@1.0.0", "npm:..", "npm:@scope/..@1.0.0", "npm:@scope/..", "npm:.@1.0.0"];
for input in cases {
let err = parse_npm_specifier(input).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("'.' or '..' segments") || msg.contains("empty segments"),
"expected name rejection for {input}, got: {msg}",
);
}
}
#[test]
fn parse_rejects_invalid_versions() {
let cases = ["npm:foo@..", "npm:foo@.", "npm:foo@ 1.0.0", "npm:foo@1.0.0 ", "npm:foo@1,0,0"];
for input in cases {
assert!(parse_npm_specifier(input).is_err(), "expected version rejection for {input}");
}
}
#[test]
fn parse_accepts_semver_versions() {
for input in ["npm:foo@1.0.0-beta.1", "npm:foo@1.0.0+build.5", "npm:foo@1.0.0-rc_1"] {
assert!(parse_npm_specifier(input).is_ok(), "expected {input} to parse");
}
}
#[test]
fn parse_rejects_backslash_in_package_name() {
let err = parse_npm_specifier("npm:foo\\bar@1.0.0").unwrap_err();
assert!(err.to_string().contains("backslashes"), "got: {err}");
}
#[test]
fn parse_rejects_absolute_paths() {
let err = parse_npm_specifier("npm:foo@1.0.0//etc/passwd").unwrap_err();
assert!(
err.to_string().contains("must be relative") || err.to_string().contains("empty segments"),
"got: {err}",
);
}
#[test]
fn parse_rejects_backslashes() {
let err = parse_npm_specifier("npm:foo@1.0.0/sub\\plugin.wasm").unwrap_err();
assert!(err.to_string().contains("backslashes"), "got: {err}");
}
#[test]
fn parse_extension_case_insensitive() {
let result = parse_npm_specifier("npm:foo@1.0.0/plugin.JSON").unwrap();
assert_eq!(result.specifier.path, "plugin.JSON");
assert_eq!(result.specifier.plugin_kind(), PluginKind::Process);
assert!(validate_plugin_extension(&result.specifier, "npm:foo@1.0.0/plugin.JSON").is_ok());
let result = parse_npm_specifier("npm:foo@1.0.0/PLUGIN.WASM").unwrap();
assert_eq!(result.specifier.path, "PLUGIN.WASM");
assert_eq!(result.specifier.plugin_kind(), PluginKind::Wasm);
assert!(validate_plugin_extension(&result.specifier, "npm:foo@1.0.0/PLUGIN.WASM").is_ok());
}
}