1use std::path::Path;
13
14use crate::{cache, download, extract, integrity};
15
16mod lockfile;
17mod node_modules;
18
19pub use lockfile::from_lockfile;
20pub use node_modules::node_modules;
21
22fn run_install(
28 dest: &Path,
29 marker_input: &str,
30 populate: impl FnOnce(&Path) -> Result<(), Box<dyn std::error::Error>>,
31) -> Result<(), Box<dyn std::error::Error>> {
32 let node_modules = dest.join("node_modules");
33 let lock = dest.join(".node_modules.lock");
34 let marker = dest.join(".node_modules.marker");
35 cache::with_lock(&lock)(|| -> Result<(), Box<dyn std::error::Error>> {
36 if cache::dir_has_content(&node_modules) && cache::marker_matches(&marker, marker_input) {
37 return Ok(()); }
39 cache::clear_directory(&node_modules)?;
40 populate(&node_modules)?;
41 cache::write_marker(&marker, marker_input)?;
42 Ok(())
43 })
44}
45
46fn fetch_verify_extract(
49 name: &str,
50 tarball_url: &str,
51 integrity_sri: Option<&str>,
52 dir: &Path,
53) -> Result<(), Box<dyn std::error::Error>> {
54 let bytes = download::fetch(tarball_url)?;
55 integrity::verify(name, &bytes, integrity_sri.unwrap_or(""))?;
56 extract::tar_gz(&bytes, dir, None, extract::Select::Matching(&strip_top_dir))?;
60 Ok(())
61}
62
63fn strip_top_dir(rel: &str) -> Option<String> {
66 rel.split_once('/')
67 .map(|(_, rest)| rest.to_string())
68 .filter(|rest| !rest.is_empty())
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74 use crate::path_safety::safe_join;
75 use flate2::write::GzEncoder;
76 use flate2::Compression;
77 use std::io::Cursor;
78 use tempfile::tempdir;
79
80 fn tiny_tgz(files: &[(&str, &[u8])]) -> Vec<u8> {
81 let mut b = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
82 for (path, contents) in files {
83 let mut h = tar::Header::new_gnu();
84 h.set_size(contents.len() as u64);
85 h.set_mode(0o644);
86 h.set_entry_type(tar::EntryType::Regular);
87 b.append_data(&mut h, *path, Cursor::new(*contents))
88 .unwrap();
89 }
90 b.finish().unwrap();
91 b.into_inner().unwrap().finish().unwrap()
92 }
93
94 #[test]
95 fn strip_top_dir_drops_first_component_regardless_of_name() {
96 assert_eq!(
97 strip_top_dir("package/index.js").as_deref(),
98 Some("index.js")
99 );
100 assert_eq!(
102 strip_top_dir("react v18.3/index.d.ts").as_deref(),
103 Some("index.d.ts")
104 );
105 assert_eq!(
106 strip_top_dir("root/sub/file.d.ts").as_deref(),
107 Some("sub/file.d.ts")
108 );
109 assert_eq!(strip_top_dir("toplevel"), None); }
111
112 #[test]
113 fn extracts_a_package_into_the_node_modules_layout() {
114 let tmp = tempdir().unwrap();
117 let nm = tmp.path().join("node_modules");
118 let tgz = tiny_tgz(&[
119 (
120 "package/package.json",
121 br#"{"name":"@scope/pkg","version":"1.0.0"}"#,
122 ),
123 ("package/index.js", b"export default 1;"),
124 ]);
125 let dir = safe_join(&nm, "@scope/pkg").unwrap();
126 extract::tar_gz(&tgz, &dir, Some("package/"), extract::Select::All).unwrap();
127 assert!(nm.join("@scope/pkg/package.json").is_file());
128 assert!(nm.join("@scope/pkg/index.js").is_file());
129 }
130
131 #[test]
132 fn extracts_tarballs_whose_root_is_not_named_package() {
133 let tmp = tempdir().unwrap();
137 let dir = tmp.path().join("@types/react");
138 let tgz = tiny_tgz(&[
139 ("react v18.3/index.d.ts", b"export {};"),
140 ("react v18.3/package.json", br#"{"name":"@types/react"}"#),
141 ]);
142 extract::tar_gz(&tgz, &dir, None, extract::Select::Matching(&strip_top_dir)).unwrap();
143 assert!(
144 dir.join("index.d.ts").is_file(),
145 "top dir stripped by position"
146 );
147 assert!(dir.join("package.json").is_file());
148 assert!(
149 !dir.join("react v18.3").exists(),
150 "no stray top-level dir remains"
151 );
152 }
153}