thirdpass-js 0.4.0

Javascript ecosystem extension for the Thirdpass package review system.
Documentation
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()
    }

    /// Returns a list of dependencies for the given package.
    ///
    /// Returns one package dependencies structure per registry.
    fn identify_package_dependencies(
        &self,
        package_name: &str,
        package_version: &Option<&str>,
        _extension_args: &[String],
    ) -> Result<Vec<thirdpass_core::extension::PackageDependencies>> {
        // npm install is-even@1.0.0 --package-lock-only
        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 {
            // Extract target package version from dependencies so as to remove from the dependencies vector.
            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");

        // Identify all dependency definition files.
        let dependency_files = match identify_dependency_files(working_directory) {
            Some(v) => v,
            None => return Ok(Vec::new()),
        };

        // Read all dependencies definitions files.
        let mut all_dependency_specs = Vec::new();
        for dependency_file in dependency_files {
            // TODO: Add support for parsing all definition file types.
            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."))?;

        // Query remote package registry for given package.
        let human_url = get_registry_human_url(self, package_name, &package_version)?;

        // Currently, only one registry is supported. Therefore simply extract.
        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,
        }])
    }
}

/// Given package name, return latest 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> {
    // Example return value: https://www.npmjs.com/package/d3/v/6.5.0
    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."))?,
    )?)
}

/// Package dependency file types.
#[derive(Debug, Copy, Clone, strum_macros::EnumIter)]
enum DependencyFileType {
    Npm,
}

impl DependencyFileType {
    /// Return file name associated with dependency type.
    pub fn file_name(&self) -> std::path::PathBuf {
        match self {
            Self::Npm => std::path::PathBuf::from("package-lock.json"),
        }
    }
}

/// Package dependency file type and file path.
#[derive(Debug, Clone)]
struct DependencyFile {
    r#type: DependencyFileType,
    path: std::path::PathBuf,
}

/// Returns a vector of identified package dependency definition files.
///
/// Walks up the directory tree directory tree until the first positive result is found.
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 {
        // If at least one target is found, assume package is present.
        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);
        }

        // No need to move further up the directory tree after this loop.
        if working_directory == std::path::Path::new("/") {
            break;
        }

        // Move further up the directory tree.
        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()))
    }
}