use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use jsonc_parser::JsonObject;
use crate::environment::CanonicalizedPathBuf;
use crate::environment::Environment;
use crate::plugins::FetchNpmLatestInfo;
use crate::plugins::PluginSourceReference;
use crate::plugins::fetch_npm_latest_info;
use crate::utils::DependencyAgeCutoff;
use crate::utils::NpmSpecifier;
use crate::utils::PathSource;
use crate::utils::PluginKind;
use crate::utils::parse_npm_specifier;
use crate::utils::validate_plugin_extension;
use crate::utils::validate_safe_version;
const NPM_PROCESS_PLUGIN_FILE: &str = "plugin.json";
const NPM_WASM_PLUGIN_FILE: &str = "plugin.wasm";
#[derive(PartialEq, Eq, Debug, Clone)]
pub struct PluginNpmInfo {
pub name: String,
pub path: Option<String>,
}
#[derive(Clone)]
pub struct ResolveNpmLatestOptions {
pub force_checksum: bool,
pub base_dir: Option<CanonicalizedPathBuf>,
pub minimum_dependency_age: Option<DependencyAgeCutoff>,
}
impl PluginNpmInfo {
pub fn parse(mut obj: JsonObject) -> Option<PluginNpmInfo> {
let name = obj.take_string("name")?.into_owned();
if name.is_empty() {
return None;
}
Some(PluginNpmInfo {
name,
path: obj.take_string("path").map(|path| path.into_owned()).filter(|path| !path.is_empty()),
})
}
pub async fn resolve_latest(&self, environment: &impl Environment, plugin_kind: PluginKind, options: ResolveNpmLatestOptions) -> Result<ResolvedNpmPlugin> {
let ResolveNpmLatestOptions {
force_checksum,
base_dir,
minimum_dependency_age,
} = options;
let unversioned = self.unversioned_specifier(plugin_kind)?;
let latest = fetch_npm_latest_info(
FetchNpmLatestInfo {
specifier: &unversioned,
start_dir: base_dir.as_ref().map(|dir| dir.as_ref()),
want_tarball_sha: force_checksum,
minimum_dependency_age: minimum_dependency_age.as_ref(),
},
environment,
)
.await?;
validate_safe_version(&latest.version, &format!("npm:{}@{}", self.name, latest.version))?;
Ok(ResolvedNpmPlugin {
reference: PluginSourceReference {
path_source: PathSource::new_npm(
NpmSpecifier {
version: Some(latest.version.clone()),
..unversioned
},
base_dir,
),
checksum: latest.tarball_sha256,
},
version: latest.version,
})
}
fn unversioned_specifier(&self, plugin_kind: PluginKind) -> Result<NpmSpecifier> {
let path = self.path.clone().unwrap_or_else(|| {
match plugin_kind {
PluginKind::Wasm => NPM_WASM_PLUGIN_FILE,
PluginKind::Process => NPM_PROCESS_PLUGIN_FILE,
}
.to_string()
});
let text = format!("npm:{}/{}", self.name, path);
let parsed = parse_npm_specifier(&text).with_context(|| format!("Invalid npm package for plugin: {}", text))?;
if parsed.specifier.name != self.name || parsed.specifier.version.is_some() {
bail!("Invalid npm package name for plugin: '{}'", self.name);
}
validate_plugin_extension(&parsed.specifier, &text)?;
Ok(parsed.specifier)
}
}
pub struct ResolvedNpmPlugin {
pub version: String,
reference: PluginSourceReference,
}
impl ResolvedNpmPlugin {
pub fn config_file_entry(&self) -> String {
self.reference.to_full_string()
}
pub fn as_source_reference(&self) -> PluginSourceReference {
self.reference.clone()
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::environment::TestEnvironment;
use jsonc_parser::JsonValue;
use jsonc_parser::parse_to_value;
use pretty_assertions::assert_eq;
fn parse(text: &str) -> Option<PluginNpmInfo> {
match parse_to_value(text, &Default::default()) {
Ok(Some(JsonValue::Object(obj))) => PluginNpmInfo::parse(obj),
_ => panic!("expected an object"),
}
}
#[test]
fn parses_package_name_and_path() {
assert_eq!(
parse(r#"{ "name": "@dprint/json" }"#),
Some(PluginNpmInfo {
name: "@dprint/json".to_string(),
path: None,
})
);
assert_eq!(
parse(r#"{ "name": "@dprint/example", "path": "json/plugin.wasm" }"#),
Some(PluginNpmInfo {
name: "@dprint/example".to_string(),
path: Some("json/plugin.wasm".to_string()),
})
);
assert_eq!(parse(r#"{ "path": "json/plugin.wasm" }"#), None);
assert_eq!(parse(r#"{ "name": "" }"#), None);
assert_eq!(parse(r#"{ "name": "a", "path": "" }"#).unwrap().path, None);
}
#[test]
fn rejects_a_package_that_isnt_a_plugin_file_or_escapes_the_package() {
let resolve = |name: &str, path: Option<&str>| {
let npm = PluginNpmInfo {
name: name.to_string(),
path: path.map(ToString::to_string),
};
npm.unversioned_specifier(PluginKind::Wasm).err().map(|err| format!("{err:#}"))
};
assert!(resolve("pkg", Some("../evil.wasm")).unwrap().contains("must not contain '.' or '..' segments"));
assert!(resolve("pkg", Some("/etc/evil.wasm")).unwrap().contains("must be relative"));
assert!(resolve("pkg", Some("..\\evil.wasm")).unwrap().contains("must not contain backslashes"));
assert!(resolve("pkg", Some("readme.md")).unwrap().contains("Unsupported plugin file extension"));
assert!(resolve("pkg@1.0.0", None).unwrap().contains("Invalid npm package name for plugin"));
assert!(resolve("../pkg", None).unwrap().contains("must not contain '.' or '..' segments"));
assert_eq!(resolve("@scope/pkg", Some("json/plugin.wasm")), None);
assert_eq!(resolve("pkg", Some("nested/dir/plugin.json")), None);
assert_eq!(resolve("@scope/pkg", None), None);
}
#[test]
fn resolves_the_latest_version_from_the_registry() {
let environment = TestEnvironment::new();
let tarball = crate::test_helpers::create_test_npm_tarball(&[("package/plugin.wasm", crate::test_helpers::WASM_PLUGIN_BYTES)]);
let tarball_checksum = crate::utils::get_sha256_checksum(&tarball);
environment.add_remote_file_bytes(
"https://registry.npmjs.org/@dprint/json",
serde_json::json!({
"dist-tags": { "latest": "1.2.3" },
"versions": { "1.2.3": { "dist": { "tarball": "https://registry.npmjs.org/@dprint/json/-/json-1.2.3.tgz" } } }
})
.to_string()
.into_bytes(),
);
environment.add_remote_file_bytes("https://registry.npmjs.org/@dprint/json/-/json-1.2.3.tgz", tarball);
let npm = PluginNpmInfo {
name: "@dprint/json".to_string(),
path: None,
};
environment.clone().run_in_runtime(async move {
let options = |force_checksum| ResolveNpmLatestOptions {
minimum_dependency_age: None,
force_checksum,
base_dir: None,
};
let resolved = npm.resolve_latest(&environment, PluginKind::Wasm, options(false)).await.unwrap();
assert_eq!(resolved.version, "1.2.3");
assert_eq!(resolved.config_file_entry(), "npm:@dprint/json@1.2.3");
let resolved = npm.resolve_latest(&environment, PluginKind::Wasm, options(true)).await.unwrap();
assert_eq!(resolved.config_file_entry(), format!("npm:@dprint/json@1.2.3@{}", tarball_checksum));
let resolved = npm.resolve_latest(&environment, PluginKind::Process, options(false)).await.unwrap();
assert_eq!(resolved.config_file_entry(), format!("npm:@dprint/json@1.2.3/plugin.json@{}", tarball_checksum));
assert_eq!(resolved.as_source_reference().to_full_string(), resolved.config_file_entry());
let with_path = |path: &str| PluginNpmInfo {
name: "@dprint/json".to_string(),
path: Some(path.to_string()),
};
let resolved = with_path("json/plugin.wasm")
.resolve_latest(&environment, PluginKind::Process, options(false))
.await
.unwrap();
assert_eq!(resolved.config_file_entry(), "npm:@dprint/json@1.2.3/json/plugin.wasm");
let resolved = with_path("exec/plugin.json")
.resolve_latest(&environment, PluginKind::Wasm, options(false))
.await
.unwrap();
assert_eq!(
resolved.config_file_entry(),
format!("npm:@dprint/json@1.2.3/exec/plugin.json@{}", tarball_checksum)
);
});
}
}