1use std::path::Path;
13
14use crate::{cache, download, extract, integrity};
15
16mod lockfile;
17mod node_modules;
18
19pub use lockfile::from_lockfile;
20pub(crate) use lockfile::from_lockfile_observed;
21pub use node_modules::node_modules;
22#[cfg(feature = "cli")]
25pub(crate) use node_modules::node_modules_observed;
26
27pub(crate) enum InstallEvent<'a> {
31 Fetch {
32 #[allow(dead_code)]
35 index: usize,
36 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
37 total: usize,
38 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
39 name: &'a str,
40 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
41 version: &'a str,
42 },
43}
44
45fn run_install(
51 dest: &Path,
52 marker_input: &str,
53 populate: impl FnOnce(&Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>>,
54) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
55 let node_modules = dest.join("node_modules");
56 let lock = dest.join(".node_modules.lock");
57 let marker = dest.join(".node_modules.marker");
58 cache::with_lock(&lock)(|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
59 if cache::dir_has_content(&node_modules) && cache::marker_matches(&marker, marker_input) {
60 return Ok(()); }
62 cache::clear_directory(&node_modules)?;
63 populate(&node_modules)?;
64 cache::write_marker(&marker, marker_input)?;
65 Ok(())
66 })
67}
68
69fn fetch_verify_extract(
72 name: &str,
73 tarball_url: &str,
74 integrity_sri: Option<&str>,
75 dir: &Path,
76) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
77 let bytes = download::fetch(tarball_url)?;
78 integrity::verify(name, &bytes, integrity_sri.unwrap_or(""))?;
79 extract::tar_gz(&bytes, dir, None, extract::Select::Matching(&strip_top_dir))?;
83 Ok(())
84}
85
86fn strip_top_dir(rel: &str) -> Option<String> {
89 rel.split_once('/')
90 .map(|(_, rest)| rest.to_string())
91 .filter(|rest| !rest.is_empty())
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::path_safety::safe_join;
98 use flate2::write::GzEncoder;
99 use flate2::Compression;
100 use std::io::Cursor;
101 use tempfile::tempdir;
102
103 fn tiny_tgz(files: &[(&str, &[u8])]) -> Vec<u8> {
104 let mut b = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
105 for (path, contents) in files {
106 let mut h = tar::Header::new_gnu();
107 h.set_size(contents.len() as u64);
108 h.set_mode(0o644);
109 h.set_entry_type(tar::EntryType::Regular);
110 b.append_data(&mut h, *path, Cursor::new(*contents))
111 .unwrap();
112 }
113 b.finish().unwrap();
114 b.into_inner().unwrap().finish().unwrap()
115 }
116
117 #[test]
118 fn strip_top_dir_drops_first_component_regardless_of_name() {
119 assert_eq!(
120 strip_top_dir("package/index.js").as_deref(),
121 Some("index.js")
122 );
123 assert_eq!(
125 strip_top_dir("react v18.3/index.d.ts").as_deref(),
126 Some("index.d.ts")
127 );
128 assert_eq!(
129 strip_top_dir("root/sub/file.d.ts").as_deref(),
130 Some("sub/file.d.ts")
131 );
132 assert_eq!(strip_top_dir("toplevel"), None); }
134
135 #[test]
136 fn extracts_a_package_into_the_node_modules_layout() {
137 let tmp = tempdir().unwrap();
140 let nm = tmp.path().join("node_modules");
141 let tgz = tiny_tgz(&[
142 (
143 "package/package.json",
144 br#"{"name":"@scope/pkg","version":"1.0.0"}"#,
145 ),
146 ("package/index.js", b"export default 1;"),
147 ]);
148 let dir = safe_join(&nm, "@scope/pkg").unwrap();
149 extract::tar_gz(&tgz, &dir, Some("package/"), extract::Select::All).unwrap();
150 assert!(nm.join("@scope/pkg/package.json").is_file());
151 assert!(nm.join("@scope/pkg/index.js").is_file());
152 }
153
154 #[test]
155 fn extracts_tarballs_whose_root_is_not_named_package() {
156 let tmp = tempdir().unwrap();
160 let dir = tmp.path().join("@types/react");
161 let tgz = tiny_tgz(&[
162 ("react v18.3/index.d.ts", b"export {};"),
163 ("react v18.3/package.json", br#"{"name":"@types/react"}"#),
164 ]);
165 extract::tar_gz(&tgz, &dir, None, extract::Select::Matching(&strip_top_dir)).unwrap();
166 assert!(
167 dir.join("index.d.ts").is_file(),
168 "top dir stripped by position"
169 );
170 assert!(dir.join("package.json").is_file());
171 assert!(
172 !dir.join("react v18.3").exists(),
173 "no stray top-level dir remains"
174 );
175 }
176}