Skip to main content

thirdpass_js_lib/
lib.rs

1use anyhow::{format_err, Context, Result};
2use std::io::Read;
3use strum::IntoEnumIterator;
4
5mod npm;
6
7#[derive(Clone, Debug)]
8pub struct JsExtension {
9    name_: String,
10    registry_host_names_: Vec<String>,
11    registry_human_url_template_: String,
12}
13
14impl thirdpass_core::extension::FromLib for JsExtension {
15    fn new() -> Self {
16        Self {
17            name_: "js".to_string(),
18            registry_host_names_: vec!["npmjs.com".to_owned()],
19            registry_human_url_template_:
20                "https://www.npmjs.com/package/{{package_name}}/v/{{package_version}}".to_string(),
21        }
22    }
23}
24
25impl thirdpass_core::extension::Extension for JsExtension {
26    fn name(&self) -> String {
27        self.name_.clone()
28    }
29
30    fn registries(&self) -> Vec<String> {
31        self.registry_host_names_.clone()
32    }
33
34    /// Returns a list of dependencies for the given package.
35    ///
36    /// Returns one package dependencies structure per registry.
37    fn identify_package_dependencies(
38        &self,
39        package_name: &str,
40        package_version: &Option<&str>,
41        _extension_args: &Vec<String>,
42    ) -> Result<Vec<thirdpass_core::extension::PackageDependencies>> {
43        // npm install is-even@1.0.0 --package-lock-only
44        let tmp_dir = tempdir::TempDir::new("thirdpass_js_identify_package_dependencies")?;
45        let tmp_directory_path = tmp_dir.path().to_path_buf();
46
47        let package = if let Some(package_version) = package_version {
48            format!(
49                "{name}@{version}",
50                name = package_name,
51                version = package_version
52            )
53        } else {
54            package_name.to_string()
55        };
56        let args = vec!["install", package.as_str(), "--package-lock-only"];
57
58        std::process::Command::new("npm")
59            .args(args)
60            .stdin(std::process::Stdio::null())
61            .stderr(std::process::Stdio::piped())
62            .stdout(std::process::Stdio::piped())
63            .current_dir(&tmp_directory_path)
64            .output()?;
65
66        let package_lock_path = tmp_directory_path.join("package-lock.json");
67        let dependencies = npm::get_dependencies(&package_lock_path, false)?;
68
69        let package_version = if let Some(package_version) = package_version {
70            thirdpass_core::extension::VersionParseResult::Ok(package_version.to_string())
71        } else {
72            // Extract target package version from dependencies so as to remove from the dependencies vector.
73            let mut target_package_instances: Vec<_> = dependencies
74                .iter()
75                .filter(|d| d.name == package_name)
76                .cloned()
77                .collect();
78            target_package_instances.sort();
79            target_package_instances.reverse();
80            let target_package_instance = target_package_instances.first().ok_or(format_err!(
81                "Failed to find target package in dependencies list."
82            ))?;
83            target_package_instance.version.clone()
84        };
85
86        let dependencies = dependencies
87            .into_iter()
88            .filter(|d| d.name != package_name && d.version != package_version)
89            .collect();
90
91        Ok(vec![thirdpass_core::extension::PackageDependencies {
92            package_version: package_version,
93            registry_host_name: npm::get_registry_host_name(),
94            dependencies: dependencies,
95        }])
96    }
97
98    fn identify_file_defined_dependencies(
99        &self,
100        working_directory: &std::path::PathBuf,
101        extension_args: &Vec<String>,
102    ) -> Result<Vec<thirdpass_core::extension::FileDefinedDependencies>> {
103        let include_dev_dependencies = extension_args.iter().any(|v| v == "--dev");
104
105        // Identify all dependency definition files.
106        let dependency_files = match identify_dependency_files(&working_directory) {
107            Some(v) => v,
108            None => return Ok(Vec::new()),
109        };
110
111        // Read all dependencies definitions files.
112        let mut all_dependency_specs = Vec::new();
113        for dependency_file in dependency_files {
114            // TODO: Add support for parsing all definition file types.
115            let (dependencies, registry_host_name) = match dependency_file.r#type {
116                DependencyFileType::Npm => (
117                    npm::get_dependencies(&dependency_file.path, include_dev_dependencies)?,
118                    npm::get_registry_host_name(),
119                ),
120            };
121            all_dependency_specs.push(thirdpass_core::extension::FileDefinedDependencies {
122                path: dependency_file.path,
123                registry_host_name: registry_host_name,
124                dependencies: dependencies,
125            });
126        }
127
128        Ok(all_dependency_specs)
129    }
130
131    fn registries_package_metadata(
132        &self,
133        package_name: &str,
134        package_version: &Option<&str>,
135    ) -> Result<Vec<thirdpass_core::extension::RegistryPackageMetadata>> {
136        let package_version = match package_version {
137            Some(v) => Some(v.to_string()),
138            None => get_latest_version(&package_name)?,
139        }
140        .ok_or(format_err!("Failed to find package version."))?;
141
142        // Query remote package registry for given package.
143        let human_url = get_registry_human_url(&self, &package_name, &package_version)?;
144
145        // Currently, only one registry is supported. Therefore simply extract.
146        let registry_host_name = self
147            .registries()
148            .first()
149            .ok_or(format_err!(
150                "Code error: vector of registry host names is empty."
151            ))?
152            .clone();
153
154        let entry_json = get_registry_entry_json(&package_name)?;
155        let artifact_url = get_archive_url(&entry_json, &package_version)?;
156
157        Ok(vec![thirdpass_core::extension::RegistryPackageMetadata {
158            registry_host_name: registry_host_name,
159            human_url: human_url.to_string(),
160            artifact_url: artifact_url.to_string(),
161            is_primary: true,
162            package_version: package_version,
163        }])
164    }
165}
166
167/// Given package name, return latest version.
168fn get_latest_version(package_name: &str) -> Result<Option<String>> {
169    let json = get_registry_entry_json(&package_name)?;
170    let versions = json["versions"]
171        .as_object()
172        .ok_or(format_err!("Failed to find versions JSON section."))?;
173    let latest_version = versions.keys().last();
174    Ok(latest_version.cloned())
175}
176
177fn get_registry_human_url(
178    extension: &JsExtension,
179    package_name: &str,
180    package_version: &str,
181) -> Result<url::Url> {
182    // Example return value: https://www.npmjs.com/package/d3/v/6.5.0
183    let handlebars_registry = handlebars::Handlebars::new();
184    let url = handlebars_registry.render_template(
185        &extension.registry_human_url_template_,
186        &maplit::btreemap! {
187            "package_name" => package_name,
188            "package_version" => package_version,
189        },
190    )?;
191    Ok(url::Url::parse(url.as_str())?)
192}
193
194fn get_registry_entry_json(package_name: &str) -> Result<serde_json::Value> {
195    let handlebars_registry = handlebars::Handlebars::new();
196    let json_url = handlebars_registry.render_template(
197        "https://registry.npmjs.com/{{package_name}}",
198        &maplit::btreemap! {"package_name" => package_name},
199    )?;
200
201    let mut result = reqwest::blocking::get(&json_url.to_string())?;
202    let mut body = String::new();
203    result.read_to_string(&mut body)?;
204
205    Ok(serde_json::from_str(&body).context(format!("JSON was not well-formatted:\n{}", body))?)
206}
207
208fn get_archive_url(
209    registry_entry_json: &serde_json::Value,
210    package_version: &str,
211) -> Result<url::Url> {
212    Ok(url::Url::parse(
213        registry_entry_json["versions"][package_version]["dist"]["tarball"]
214            .as_str()
215            .ok_or(format_err!("Failed to parse package archive URL."))?,
216    )?)
217}
218
219/// Package dependency file types.
220#[derive(Debug, Copy, Clone, strum_macros::EnumIter)]
221enum DependencyFileType {
222    Npm,
223}
224
225impl DependencyFileType {
226    /// Return file name associated with dependency type.
227    pub fn file_name(&self) -> std::path::PathBuf {
228        match self {
229            Self::Npm => std::path::PathBuf::from("package-lock.json"),
230        }
231    }
232}
233
234/// Package dependency file type and file path.
235#[derive(Debug, Clone)]
236struct DependencyFile {
237    r#type: DependencyFileType,
238    path: std::path::PathBuf,
239}
240
241/// Returns a vector of identified package dependency definition files.
242///
243/// Walks up the directory tree directory tree until the first positive result is found.
244fn identify_dependency_files(
245    working_directory: &std::path::PathBuf,
246) -> Option<Vec<DependencyFile>> {
247    assert!(working_directory.is_absolute());
248    let mut working_directory = working_directory.clone();
249
250    loop {
251        // If at least one target is found, assume package is present.
252        let mut found_dependency_file = false;
253
254        let mut dependency_files: Vec<DependencyFile> = Vec::new();
255        for dependency_file_type in DependencyFileType::iter() {
256            let target_absolute_path = working_directory.join(dependency_file_type.file_name());
257            if target_absolute_path.is_file() {
258                found_dependency_file = true;
259                dependency_files.push(DependencyFile {
260                    r#type: dependency_file_type,
261                    path: target_absolute_path,
262                })
263            }
264        }
265        if found_dependency_file {
266            return Some(dependency_files);
267        }
268
269        // No need to move further up the directory tree after this loop.
270        if working_directory == std::path::PathBuf::from("/") {
271            break;
272        }
273
274        // Move further up the directory tree.
275        working_directory.pop();
276    }
277    None
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use thirdpass_core::extension::{Dependency, Extension, FromLib};
284
285    #[test]
286    fn file_defined_dependencies_parse_package_lock_from_child_directory() -> Result<()> {
287        let tmp_dir = tempdir::TempDir::new("thirdpass_js_file_defined_dependencies")?;
288        let project_root = tmp_dir.path();
289        let nested = project_root.join("packages").join("app");
290        std::fs::create_dir_all(&nested)?;
291
292        let package_lock_path = project_root.join("package-lock.json");
293        std::fs::write(
294            &package_lock_path,
295            serde_json::to_string_pretty(&serde_json::json!({
296                "name": "fixture-project",
297                "lockfileVersion": 1,
298                "dependencies": {
299                    "left-pad": {
300                        "version": "1.3.0"
301                    },
302                    "parent-package": {
303                        "version": "2.0.0",
304                        "dependencies": {
305                            "child-package": {
306                                "version": "3.0.0"
307                            }
308                        }
309                    },
310                    "dev-only": {
311                        "version": "0.1.0",
312                        "dev": true
313                    }
314                }
315            }))?,
316        )?;
317
318        let extension = JsExtension::new();
319        let extension_args = Vec::new();
320        let groups = extension.identify_file_defined_dependencies(&nested, &extension_args)?;
321
322        assert_eq!(groups.len(), 1);
323        assert_eq!(groups[0].path, package_lock_path);
324        assert_eq!(groups[0].registry_host_name, "npmjs.com");
325        assert_dependency(&groups[0].dependencies, "left-pad", "1.3.0");
326        assert_dependency(&groups[0].dependencies, "parent-package", "2.0.0");
327        assert_dependency(&groups[0].dependencies, "child-package", "3.0.0");
328        assert!(!has_dependency(
329            &groups[0].dependencies,
330            "dev-only",
331            "0.1.0"
332        ));
333
334        let extension_args = vec!["--dev".to_string()];
335        let groups = extension.identify_file_defined_dependencies(&nested, &extension_args)?;
336
337        assert_eq!(groups.len(), 1);
338        assert_dependency(&groups[0].dependencies, "dev-only", "0.1.0");
339        Ok(())
340    }
341
342    fn assert_dependency(dependencies: &[Dependency], name: &str, version: &str) {
343        assert!(
344            has_dependency(dependencies, name, version),
345            "expected dependency {}@{} in {:?}",
346            name,
347            version,
348            dependencies
349        );
350    }
351
352    fn has_dependency(dependencies: &[Dependency], name: &str, version: &str) -> bool {
353        dependencies
354            .iter()
355            .any(|dependency| dependency.name == name && dependency.version == Ok(version.into()))
356    }
357}