Skip to main content

npm_utils/install/
node_modules.rs

1//! `node_modules()` — resolve a `package.json`'s transitive `dependencies` against the registry
2//! and install the flat tree.
3
4use std::path::Path;
5
6use crate::package_json::spec::{version_req, Spec};
7use semver::VersionReq;
8use serde_json::Value;
9
10use crate::path_safety::safe_join;
11use crate::registry::{Registry, Resolved};
12
13/// Resolve `package_json`'s dependencies transitively, verify each tarball's registry
14/// `dist.integrity` (sha512), and extract the flat tree into `<dest>/node_modules/`. Returns the
15/// resolved set (sorted by name). A package whose registry metadata advertises no sha512 is
16/// refused rather than installed unverified. Skips all work when the resolved set is unchanged.
17pub fn node_modules(
18    package_json: &Path,
19    dest: &Path,
20) -> Result<Vec<Resolved>, Box<dyn std::error::Error>> {
21    let roots = root_requirements(package_json)?;
22    let resolved = Registry::npm().resolve_tree(&roots)?;
23    let want = resolved
24        .iter()
25        .map(|r| format!("{}@{}", r.name, r.version))
26        .collect::<Vec<_>>()
27        .join("\n");
28
29    super::run_install(dest, &want, |node_modules| {
30        for pkg in &resolved {
31            let dir = safe_join(node_modules, &pkg.name)?;
32            super::fetch_verify_extract(
33                &pkg.name,
34                &pkg.tarball_url,
35                pkg.integrity.as_deref(),
36                &dir,
37            )?;
38        }
39        Ok(())
40    })?;
41
42    Ok(resolved)
43}
44
45/// The root requirements: each `dependencies` entry as `(name, VersionReq)`. Specs are classified
46/// via [`Spec`]; a non-registry spec (git, remote tarball, local path, alias-to-non-registry)
47/// can't be fetched as a registry tarball and is a clear error here.
48fn root_requirements(
49    package_json: &Path,
50) -> Result<Vec<(String, VersionReq)>, Box<dyn std::error::Error>> {
51    let json: Value = serde_json::from_str(&std::fs::read_to_string(package_json)?)?;
52    let deps = json
53        .get("dependencies")
54        .and_then(Value::as_object)
55        .ok_or("no dependencies section in package.json")?;
56    let mut out = Vec::new();
57    for (name, value) in deps {
58        let Some(spec) = value.as_str() else { continue };
59        if !Spec::parse(spec).is_registry() {
60            return Err(format!(
61                "dependency `{name}`: {spec:?} is not a registry spec — git/tarball/local specs \
62                 aren't installable from the registry"
63            )
64            .into());
65        }
66        let req = version_req(spec)
67            .map_err(|e| format!("dependency `{name}`: unsupported version {spec:?}: {e}"))?;
68        out.push((name.clone(), req));
69    }
70    Ok(out)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use tempfile::tempdir;
77
78    #[test]
79    fn root_requirements_classifies_via_spec() {
80        let tmp = tempdir().unwrap();
81        let pkg = tmp.path().join("package.json");
82
83        // A git spec is not a registry install → clear error.
84        std::fs::write(&pkg, r#"{"dependencies":{"x":"github:owner/repo#abc"}}"#).unwrap();
85        assert!(root_requirements(&pkg).is_err());
86
87        // A registry range resolves to a (name, VersionReq).
88        std::fs::write(&pkg, r#"{"dependencies":{"lit":"^3"}}"#).unwrap();
89        let reqs = root_requirements(&pkg).unwrap();
90        assert_eq!(reqs.len(), 1);
91        assert_eq!(reqs[0].0, "lit");
92    }
93
94    #[test]
95    #[ignore = "network: hits the npm registry"]
96    fn installs_react_with_transitive_scheduler() {
97        // Real install of the React-showcase deps. react-dom depends on scheduler, so a
98        // correct transitive resolve produces all three under node_modules/. Each tarball's
99        // registry sha512 integrity is also verified end-to-end here — a mismatch would fail
100        // the install. (Tamper-rejection itself is covered offline by
101        // `crate::integrity::tests::verify_checks_sha512_and_rejects_tampering`.)
102        let tmp = tempdir().unwrap();
103        let pkg = tmp.path().join("package.json");
104        std::fs::write(
105            &pkg,
106            r#"{ "dependencies": { "react": "^19", "react-dom": "^19" } }"#,
107        )
108        .unwrap();
109
110        let resolved = node_modules(&pkg, tmp.path()).unwrap();
111        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
112        assert!(names.contains(&"react"), "got {names:?}");
113        assert!(names.contains(&"react-dom"), "got {names:?}");
114        assert!(
115            names.contains(&"scheduler"),
116            "transitive dep missing: {names:?}"
117        );
118
119        let nm = tmp.path().join("node_modules");
120        for p in ["react", "react-dom", "scheduler"] {
121            assert!(
122                nm.join(p).join("package.json").is_file(),
123                "node_modules/{p}/package.json missing"
124            );
125        }
126    }
127
128    #[test]
129    #[ignore = "network: hits the npm registry"]
130    fn downloads_and_extracts_a_commonjs_package() {
131        use crate::package_json::{PackageJson, PackageType};
132        // `ms` is a tiny, dependency-free, long-frozen CommonJS package — a focused check that
133        // we download + extract a real CJS package *intact*. CommonJS is exactly the case a
134        // buildless ESM tree can't serve directly, which is why node_modules/ exists.
135        let tmp = tempdir().unwrap();
136        let pkg = tmp.path().join("package.json");
137        std::fs::write(&pkg, r#"{ "dependencies": { "ms": "^2" } }"#).unwrap();
138
139        let resolved = node_modules(&pkg, tmp.path()).unwrap();
140        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
141        assert_eq!(names, ["ms"], "ms has no runtime dependencies");
142
143        let ms = tmp.path().join("node_modules/ms");
144        let manifest = PackageJson::from_path(&ms.join("package.json")).unwrap();
145        assert_eq!(manifest.name(), Some("ms"));
146        assert_eq!(
147            manifest.package_type(),
148            PackageType::CommonJs,
149            "ms ships CommonJS"
150        );
151        let entry = ms.join("index.js");
152        let source = std::fs::read_to_string(&entry).unwrap();
153        assert!(
154            source.contains("module.exports"),
155            "extracted entry {entry:?} is CommonJS source"
156        );
157    }
158}