1use std::path::{Path, PathBuf};
16
17use semver::VersionReq;
18use serde_json::Value;
19
20use crate::registry::{version_req, Registry, Resolved};
21use crate::{cache, download, extract};
22
23pub fn node_modules(
31 package_json: &Path,
32 dest: &Path,
33) -> Result<Vec<Resolved>, Box<dyn std::error::Error>> {
34 let roots = root_requirements(package_json)?;
35 let resolved = Registry::npm().resolve_tree(&roots)?;
36
37 let node_modules = dest.join("node_modules");
38 let lock = dest.join(".node_modules.lock");
39 let marker = dest.join(".node_modules.marker");
40 let want = resolved
41 .iter()
42 .map(|r| format!("{}@{}", r.name, r.version))
43 .collect::<Vec<_>>()
44 .join("\n");
45
46 cache::with_lock(&lock)(|| -> Result<(), Box<dyn std::error::Error>> {
47 if cache::dir_has_content(&node_modules) && cache::marker_matches(&marker, &want) {
48 return Ok(()); }
50 cache::clear_directory(&node_modules)?;
51 for pkg in &resolved {
52 let bytes = download::fetch(&pkg.tarball_url)?;
53 let dir = package_dir(&node_modules, &pkg.name)?;
54 extract::tar_gz(&bytes, &dir, Some("package/"), extract::Select::All)?;
55 }
56 cache::write_marker(&marker, &want)?;
57 Ok(())
58 })?;
59
60 Ok(resolved)
61}
62
63fn root_requirements(
66 package_json: &Path,
67) -> Result<Vec<(String, VersionReq)>, Box<dyn std::error::Error>> {
68 let json: Value = serde_json::from_str(&std::fs::read_to_string(package_json)?)?;
69 let deps = json
70 .get("dependencies")
71 .and_then(Value::as_object)
72 .ok_or("no dependencies section in package.json")?;
73 let mut out = Vec::new();
74 for (name, value) in deps {
75 let Some(spec) = value.as_str() else { continue };
76 let req = version_req(spec)
77 .map_err(|e| format!("dependency `{name}`: unsupported version {spec:?}: {e}"))?;
78 out.push((name.clone(), req));
79 }
80 Ok(out)
81}
82
83fn package_dir(node_modules: &Path, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
86 let mut dir = node_modules.to_path_buf();
87 for segment in name.split('/') {
88 if segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\') {
89 return Err(format!("unsafe package name {name:?}").into());
90 }
91 dir.push(segment);
92 }
93 Ok(dir)
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use flate2::write::GzEncoder;
100 use flate2::Compression;
101 use std::io::Cursor;
102 use tempfile::tempdir;
103
104 #[test]
105 fn package_dir_handles_scoped_and_rejects_escapes() {
106 let nm = Path::new("/tmp/nm");
107 assert_eq!(package_dir(nm, "react").unwrap(), nm.join("react"));
108 assert_eq!(
109 package_dir(nm, "@preact/signals").unwrap(),
110 nm.join("@preact").join("signals")
111 );
112 assert!(package_dir(nm, "../escape").is_err());
113 assert!(package_dir(nm, "a/../b").is_err());
114 assert!(package_dir(nm, "/abs").is_err());
115 }
116
117 fn tiny_tgz(files: &[(&str, &[u8])]) -> Vec<u8> {
118 let mut b = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
119 for (path, contents) in files {
120 let mut h = tar::Header::new_gnu();
121 h.set_size(contents.len() as u64);
122 h.set_mode(0o644);
123 h.set_entry_type(tar::EntryType::Regular);
124 b.append_data(&mut h, *path, Cursor::new(*contents))
125 .unwrap();
126 }
127 b.finish().unwrap();
128 b.into_inner().unwrap().finish().unwrap()
129 }
130
131 #[test]
132 fn extracts_a_package_into_the_node_modules_layout() {
133 let tmp = tempdir().unwrap();
136 let nm = tmp.path().join("node_modules");
137 let tgz = tiny_tgz(&[
138 (
139 "package/package.json",
140 br#"{"name":"@scope/pkg","version":"1.0.0"}"#,
141 ),
142 ("package/index.js", b"export default 1;"),
143 ]);
144 let dir = package_dir(&nm, "@scope/pkg").unwrap();
145 extract::tar_gz(&tgz, &dir, Some("package/"), extract::Select::All).unwrap();
146 assert!(nm.join("@scope/pkg/package.json").is_file());
147 assert!(nm.join("@scope/pkg/index.js").is_file());
148 }
149
150 #[test]
151 #[ignore = "network: hits the npm registry"]
152 fn installs_react_with_transitive_scheduler() {
153 let tmp = tempdir().unwrap();
156 let pkg = tmp.path().join("package.json");
157 std::fs::write(
158 &pkg,
159 r#"{ "dependencies": { "react": "^19", "react-dom": "^19" } }"#,
160 )
161 .unwrap();
162
163 let resolved = node_modules(&pkg, tmp.path()).unwrap();
164 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
165 assert!(names.contains(&"react"), "got {names:?}");
166 assert!(names.contains(&"react-dom"), "got {names:?}");
167 assert!(
168 names.contains(&"scheduler"),
169 "transitive dep missing: {names:?}"
170 );
171
172 let nm = tmp.path().join("node_modules");
173 for p in ["react", "react-dom", "scheduler"] {
174 assert!(
175 nm.join(p).join("package.json").is_file(),
176 "node_modules/{p}/package.json missing"
177 );
178 }
179 }
180
181 #[test]
182 #[ignore = "network: hits the npm registry"]
183 fn downloads_and_extracts_a_commonjs_package() {
184 use crate::package_json::{PackageJson, PackageType};
185 let tmp = tempdir().unwrap();
189 let pkg = tmp.path().join("package.json");
190 std::fs::write(&pkg, r#"{ "dependencies": { "ms": "^2" } }"#).unwrap();
191
192 let resolved = node_modules(&pkg, tmp.path()).unwrap();
193 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
194 assert_eq!(names, ["ms"], "ms has no runtime dependencies");
195
196 let ms = tmp.path().join("node_modules/ms");
197 let manifest = PackageJson::from_path(&ms.join("package.json")).unwrap();
198 assert_eq!(manifest.name(), Some("ms"));
199 assert_eq!(
200 manifest.package_type(),
201 PackageType::CommonJs,
202 "ms ships CommonJS"
203 );
204 let entry = ms.join("index.js");
207 let source = std::fs::read_to_string(&entry).unwrap();
208 assert!(
209 source.contains("module.exports"),
210 "extracted entry {entry:?} is CommonJS source"
211 );
212 }
213}