use anyhow::{format_err, Context, Result};
use std::io::Read;
use strum::IntoEnumIterator;
mod npm;
#[derive(Clone, Debug)]
pub struct JsExtension {
name_: String,
registry_host_names_: Vec<String>,
registry_human_url_template_: String,
}
impl thirdpass_core::extension::FromLib for JsExtension {
fn new() -> Self {
Self {
name_: "js".to_string(),
registry_host_names_: vec!["npmjs.com".to_owned()],
registry_human_url_template_:
"https://www.npmjs.com/package/{{package_name}}/v/{{package_version}}".to_string(),
}
}
}
impl thirdpass_core::extension::Extension for JsExtension {
fn name(&self) -> String {
self.name_.clone()
}
fn registries(&self) -> Vec<String> {
self.registry_host_names_.clone()
}
fn identify_package_dependencies(
&self,
package_name: &str,
package_version: &Option<&str>,
_extension_args: &[String],
) -> Result<Vec<thirdpass_core::extension::PackageDependencies>> {
let tmp_dir = tempdir::TempDir::new("thirdpass_js_identify_package_dependencies")?;
let tmp_directory_path = tmp_dir.path().to_path_buf();
let package = if let Some(package_version) = package_version {
format!(
"{name}@{version}",
name = package_name,
version = package_version
)
} else {
package_name.to_string()
};
let args = vec!["install", package.as_str(), "--package-lock-only"];
std::process::Command::new("npm")
.args(args)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.current_dir(&tmp_directory_path)
.output()?;
let package_lock_path = tmp_directory_path.join("package-lock.json");
let dependencies = npm::get_dependencies(&package_lock_path, false)?;
let package_version = if let Some(package_version) = package_version {
thirdpass_core::extension::VersionParseResult::Ok(package_version.to_string())
} else {
let mut target_package_instances: Vec<_> = dependencies
.iter()
.filter(|d| d.name == package_name)
.cloned()
.collect();
target_package_instances.sort();
target_package_instances.reverse();
let target_package_instance = target_package_instances.first().ok_or(format_err!(
"Failed to find target package in dependencies list."
))?;
target_package_instance.version.clone()
};
let dependencies = dependencies
.into_iter()
.filter(|d| d.name != package_name && d.version != package_version)
.collect();
Ok(vec![thirdpass_core::extension::PackageDependencies {
package_version,
registry_host_name: npm::get_registry_host_name(),
dependencies,
}])
}
fn identify_file_defined_dependencies(
&self,
working_directory: &std::path::Path,
extension_args: &[String],
) -> Result<Vec<thirdpass_core::extension::FileDefinedDependencies>> {
let include_dev_dependencies = extension_args.iter().any(|v| v == "--dev");
let dependency_files = match identify_dependency_files(working_directory) {
Some(v) => v,
None => return Ok(Vec::new()),
};
let mut all_dependency_specs = Vec::new();
for dependency_file in dependency_files {
let (dependencies, registry_host_name) = match dependency_file.r#type {
DependencyFileType::Npm => (
npm::get_dependencies(&dependency_file.path, include_dev_dependencies)?,
npm::get_registry_host_name(),
),
};
all_dependency_specs.push(thirdpass_core::extension::FileDefinedDependencies {
path: dependency_file.path,
registry_host_name,
dependencies,
});
}
Ok(all_dependency_specs)
}
fn registries_package_metadata(
&self,
package_name: &str,
package_version: &Option<&str>,
) -> Result<Vec<thirdpass_core::extension::RegistryPackageMetadata>> {
let package_version = match package_version {
Some(v) => Some(v.to_string()),
None => get_latest_version(package_name)?,
}
.ok_or(format_err!("Failed to find package version."))?;
let human_url = get_registry_human_url(self, package_name, &package_version)?;
let registry_host_name = self
.registries()
.first()
.ok_or(format_err!(
"Code error: vector of registry host names is empty."
))?
.clone();
let entry_json = get_registry_entry_json(package_name)?;
let artifact_url = get_archive_url(&entry_json, &package_version)?;
Ok(vec![thirdpass_core::extension::RegistryPackageMetadata {
registry_host_name,
human_url: human_url.to_string(),
artifact_url: artifact_url.to_string(),
is_primary: true,
package_version,
}])
}
}
fn get_latest_version(package_name: &str) -> Result<Option<String>> {
let json = get_registry_entry_json(package_name)?;
let versions = json["versions"]
.as_object()
.ok_or(format_err!("Failed to find versions JSON section."))?;
let latest_version = versions.keys().next_back();
Ok(latest_version.cloned())
}
fn get_registry_human_url(
extension: &JsExtension,
package_name: &str,
package_version: &str,
) -> Result<url::Url> {
let handlebars_registry = handlebars::Handlebars::new();
let url = handlebars_registry.render_template(
&extension.registry_human_url_template_,
&maplit::btreemap! {
"package_name" => package_name,
"package_version" => package_version,
},
)?;
Ok(url::Url::parse(url.as_str())?)
}
fn get_registry_entry_json(package_name: &str) -> Result<serde_json::Value> {
let handlebars_registry = handlebars::Handlebars::new();
let json_url = handlebars_registry.render_template(
"https://registry.npmjs.com/{{package_name}}",
&maplit::btreemap! {"package_name" => package_name},
)?;
let mut result = reqwest::blocking::get(&json_url.to_string())?;
let mut body = String::new();
result.read_to_string(&mut body)?;
serde_json::from_str(&body).context(format!("JSON was not well-formatted:\n{}", body))
}
fn get_archive_url(
registry_entry_json: &serde_json::Value,
package_version: &str,
) -> Result<url::Url> {
Ok(url::Url::parse(
registry_entry_json["versions"][package_version]["dist"]["tarball"]
.as_str()
.ok_or(format_err!("Failed to parse package archive URL."))?,
)?)
}
#[derive(Debug, Copy, Clone, strum_macros::EnumIter)]
enum DependencyFileType {
Npm,
}
impl DependencyFileType {
pub fn file_name(&self) -> std::path::PathBuf {
match self {
Self::Npm => std::path::PathBuf::from("package-lock.json"),
}
}
}
#[derive(Debug, Clone)]
struct DependencyFile {
r#type: DependencyFileType,
path: std::path::PathBuf,
}
fn identify_dependency_files(working_directory: &std::path::Path) -> Option<Vec<DependencyFile>> {
assert!(working_directory.is_absolute());
let mut working_directory = working_directory.to_path_buf();
loop {
let mut found_dependency_file = false;
let mut dependency_files: Vec<DependencyFile> = Vec::new();
for dependency_file_type in DependencyFileType::iter() {
let target_absolute_path = working_directory.join(dependency_file_type.file_name());
if target_absolute_path.is_file() {
found_dependency_file = true;
dependency_files.push(DependencyFile {
r#type: dependency_file_type,
path: target_absolute_path,
})
}
}
if found_dependency_file {
return Some(dependency_files);
}
if working_directory == std::path::Path::new("/") {
break;
}
working_directory.pop();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use thirdpass_core::extension::{Dependency, Extension, FromLib};
#[test]
fn file_defined_dependencies_parse_package_lock_from_child_directory() -> Result<()> {
let tmp_dir = tempdir::TempDir::new("thirdpass_js_file_defined_dependencies")?;
let project_root = tmp_dir.path();
let nested = project_root.join("packages").join("app");
std::fs::create_dir_all(&nested)?;
let package_lock_path = project_root.join("package-lock.json");
std::fs::write(
&package_lock_path,
serde_json::to_string_pretty(&serde_json::json!({
"name": "fixture-project",
"lockfileVersion": 1,
"dependencies": {
"left-pad": {
"version": "1.3.0"
},
"parent-package": {
"version": "2.0.0",
"dependencies": {
"child-package": {
"version": "3.0.0"
}
}
},
"dev-only": {
"version": "0.1.0",
"dev": true
}
}
}))?,
)?;
let extension = JsExtension::new();
let extension_args = Vec::new();
let groups = extension.identify_file_defined_dependencies(&nested, &extension_args)?;
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].path, package_lock_path);
assert_eq!(groups[0].registry_host_name, "npmjs.com");
assert_dependency(&groups[0].dependencies, "left-pad", "1.3.0");
assert_dependency(&groups[0].dependencies, "parent-package", "2.0.0");
assert_dependency(&groups[0].dependencies, "child-package", "3.0.0");
assert!(!has_dependency(
&groups[0].dependencies,
"dev-only",
"0.1.0"
));
let extension_args = vec!["--dev".to_string()];
let groups = extension.identify_file_defined_dependencies(&nested, &extension_args)?;
assert_eq!(groups.len(), 1);
assert_dependency(&groups[0].dependencies, "dev-only", "0.1.0");
Ok(())
}
fn assert_dependency(dependencies: &[Dependency], name: &str, version: &str) {
assert!(
has_dependency(dependencies, name, version),
"expected dependency {}@{} in {:?}",
name,
version,
dependencies
);
}
fn has_dependency(dependencies: &[Dependency], name: &str, version: &str) -> bool {
dependencies
.iter()
.any(|dependency| dependency.name == name && dependency.version == Ok(version.into()))
}
}