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 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 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 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 let dependency_files = match identify_dependency_files(&working_directory) {
107 Some(v) => v,
108 None => return Ok(Vec::new()),
109 };
110
111 let mut all_dependency_specs = Vec::new();
113 for dependency_file in dependency_files {
114 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 let human_url = get_registry_human_url(&self, &package_name, &package_version)?;
144
145 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
167fn 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 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#[derive(Debug, Copy, Clone, strum_macros::EnumIter)]
221enum DependencyFileType {
222 Npm,
223}
224
225impl DependencyFileType {
226 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#[derive(Debug, Clone)]
236struct DependencyFile {
237 r#type: DependencyFileType,
238 path: std::path::PathBuf,
239}
240
241fn 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 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 if working_directory == std::path::PathBuf::from("/") {
271 break;
272 }
273
274 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}