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: &[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,
93            registry_host_name: npm::get_registry_host_name(),
94            dependencies,
95        }])
96    }
97
98    fn identify_file_defined_dependencies(
99        &self,
100        working_directory: &std::path::Path,
101        extension_args: &[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,
124                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,
159            human_url: human_url.to_string(),
160            artifact_url: artifact_url.to_string(),
161            is_primary: true,
162            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().next_back();
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    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(working_directory: &std::path::Path) -> Option<Vec<DependencyFile>> {
245    assert!(working_directory.is_absolute());
246    let mut working_directory = working_directory.to_path_buf();
247
248    loop {
249        // If at least one target is found, assume package is present.
250        let mut found_dependency_file = false;
251
252        let mut dependency_files: Vec<DependencyFile> = Vec::new();
253        for dependency_file_type in DependencyFileType::iter() {
254            let target_absolute_path = working_directory.join(dependency_file_type.file_name());
255            if target_absolute_path.is_file() {
256                found_dependency_file = true;
257                dependency_files.push(DependencyFile {
258                    r#type: dependency_file_type,
259                    path: target_absolute_path,
260                })
261            }
262        }
263        if found_dependency_file {
264            return Some(dependency_files);
265        }
266
267        // No need to move further up the directory tree after this loop.
268        if working_directory == std::path::Path::new("/") {
269            break;
270        }
271
272        // Move further up the directory tree.
273        working_directory.pop();
274    }
275    None
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use thirdpass_core::extension::{Dependency, Extension, FromLib};
282
283    #[test]
284    fn file_defined_dependencies_parse_package_lock_from_child_directory() -> Result<()> {
285        let tmp_dir = tempdir::TempDir::new("thirdpass_js_file_defined_dependencies")?;
286        let project_root = tmp_dir.path();
287        let nested = project_root.join("packages").join("app");
288        std::fs::create_dir_all(&nested)?;
289
290        let package_lock_path = project_root.join("package-lock.json");
291        std::fs::write(
292            &package_lock_path,
293            serde_json::to_string_pretty(&serde_json::json!({
294                "name": "fixture-project",
295                "lockfileVersion": 1,
296                "dependencies": {
297                    "left-pad": {
298                        "version": "1.3.0"
299                    },
300                    "parent-package": {
301                        "version": "2.0.0",
302                        "dependencies": {
303                            "child-package": {
304                                "version": "3.0.0"
305                            }
306                        }
307                    },
308                    "dev-only": {
309                        "version": "0.1.0",
310                        "dev": true
311                    }
312                }
313            }))?,
314        )?;
315
316        let extension = JsExtension::new();
317        let extension_args = Vec::new();
318        let groups = extension.identify_file_defined_dependencies(&nested, &extension_args)?;
319
320        assert_eq!(groups.len(), 1);
321        assert_eq!(groups[0].path, package_lock_path);
322        assert_eq!(groups[0].registry_host_name, "npmjs.com");
323        assert_dependency(&groups[0].dependencies, "left-pad", "1.3.0");
324        assert_dependency(&groups[0].dependencies, "parent-package", "2.0.0");
325        assert_dependency(&groups[0].dependencies, "child-package", "3.0.0");
326        assert!(!has_dependency(
327            &groups[0].dependencies,
328            "dev-only",
329            "0.1.0"
330        ));
331
332        let extension_args = vec!["--dev".to_string()];
333        let groups = extension.identify_file_defined_dependencies(&nested, &extension_args)?;
334
335        assert_eq!(groups.len(), 1);
336        assert_dependency(&groups[0].dependencies, "dev-only", "0.1.0");
337        Ok(())
338    }
339
340    fn assert_dependency(dependencies: &[Dependency], name: &str, version: &str) {
341        assert!(
342            has_dependency(dependencies, name, version),
343            "expected dependency {}@{} in {:?}",
344            name,
345            version,
346            dependencies
347        );
348    }
349
350    fn has_dependency(dependencies: &[Dependency], name: &str, version: &str) -> bool {
351        dependencies
352            .iter()
353            .any(|dependency| dependency.name == name && dependency.version == Ok(version.into()))
354    }
355}