Skip to main content

bougie_backend/
nodejs_org.rs

1//! `NodejsOrgBackend` — Node.js interpreter source for all hosts.
2//!
3//! PHP projects routinely need node/npm for frontend assets (Vite,
4//! Laravel Mix, Magento static-content deploy). This backend provisions
5//! Node from the official distribution at <https://nodejs.org/dist>,
6//! which publishes a machine-readable version index and per-release
7//! signed checksums:
8//!
9//! - `https://nodejs.org/dist/index.json` — every release, newest-first,
10//!   each with a `version` (`"v20.11.0"`), an `lts` field (`false` or a
11//!   codename string like `"Iron"`), and a `files` token list.
12//! - `https://nodejs.org/dist/v<ver>/SHASUMS256.txt` — `<sha256>␠␠<file>`
13//!   lines for that release. This is the trust anchor (TLS +
14//!   sha256-from-SHASUMS); we don't verify the detached GPG signature.
15//!
16//! Unlike the PHP backends this is **not** a [`super::Backend`] impl:
17//! that trait is PHP-shaped (flavors, extensions, index closures). Node
18//! is just resolve-version → one-blob → extract, so this module is
19//! standalone and only reuses [`super::BlobRef`] + the shared fetch
20//! pipeline.
21//!
22//! ## Portability
23//!
24//! Official node binaries are already relocatable and statically bundle
25//! V8/OpenSSL/zlib, so there's no node-build-standalone analog the way
26//! there is for PHP. The one external dependency on Linux is glibc;
27//! official builds (Node 18+) require glibc ≥2.28. We deliberately do
28//! **not** consume `nodejs/unofficial-builds` (musl / glibc-217), so
29//! musl hosts get a clear up-front error rather than a cryptic
30//! exec-time `GLIBC_2.28 not found`.
31
32use super::{BlobRef, build_http_client};
33use bougie_errors::{BougieError, error_chain};
34use bougie_fetch::ArchiveKind;
35use bougie_paths::Paths;
36use bougie_platform::target::{Arch, Env, Os, Triple};
37use eyre::{Result, WrapErr, eyre};
38use serde::Deserialize;
39use std::path::{Path, PathBuf};
40
41const INDEX_URL: &str = "https://nodejs.org/dist/index.json";
42const DIST_BASE: &str = "https://nodejs.org/dist";
43const CACHE_HOST_DIR: &str = "nodejs.org";
44
45/// A user-facing `bougie node install <request>` spec.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum NodeRequest {
48    /// Highest published version (`latest`, the default).
49    Latest,
50    /// Highest version flagged as an LTS line (`lts`).
51    Lts,
52    /// Highest version in a major line (`20`).
53    Major(u32),
54    /// Highest patch in a minor line (`20.11`).
55    MajorMinor(u32, u32),
56    /// One exact release (`20.11.0`).
57    Exact(NodeVersion),
58}
59
60impl std::str::FromStr for NodeRequest {
61    type Err = eyre::Report;
62
63    fn from_str(s: &str) -> Result<Self> {
64        let s = s.trim();
65        match s.to_ascii_lowercase().as_str() {
66            "" | "latest" | "*" => return Ok(Self::Latest),
67            "lts" => return Ok(Self::Lts),
68            _ => {}
69        }
70        // Tolerate a leading `v` (`v20.11.0`).
71        let body = s.strip_prefix(['v', 'V']).unwrap_or(s);
72        let parts: Vec<&str> = body.split('.').collect();
73        let parse = |p: &str| -> Result<u32> {
74            p.parse()
75                .wrap_err_with(|| format!("`{p}` in `{s}` is not a version number"))
76        };
77        match parts.as_slice() {
78            [maj] => Ok(Self::Major(parse(maj)?)),
79            [maj, min] => Ok(Self::MajorMinor(parse(maj)?, parse(min)?)),
80            [maj, min, pat] => Ok(Self::Exact(NodeVersion {
81                major: parse(maj)?,
82                minor: parse(min)?,
83                patch: parse(pat)?,
84            })),
85            _ => Err(eyre!(
86                "`{s}` is not a Node.js version request \
87                 (expected `latest`, `lts`, `20`, `20.11`, or `20.11.0`)"
88            )),
89        }
90    }
91}
92
93/// A concrete `major.minor.patch` Node.js version. Node versions are
94/// plain three-segment integers (no pre-release tags on stable
95/// releases), so a dedicated tuple is simpler than pulling in the
96/// Composer-flavored [`bougie_semver::Version`].
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
98pub struct NodeVersion {
99    pub major: u32,
100    pub minor: u32,
101    pub patch: u32,
102}
103
104impl std::fmt::Display for NodeVersion {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
107    }
108}
109
110impl std::str::FromStr for NodeVersion {
111    type Err = eyre::Report;
112
113    /// Parse a `dist/index.json` version string (`"v20.11.0"`) or a bare
114    /// `20.11.0`.
115    fn from_str(s: &str) -> Result<Self> {
116        let body = s.trim().strip_prefix(['v', 'V']).unwrap_or(s.trim());
117        let mut it = body.split('.');
118        let mut next = |what: &str| -> Result<u32> {
119            it.next()
120                .ok_or_else(|| eyre!("`{s}` is missing its {what} component"))?
121                .parse()
122                .wrap_err_with(|| format!("`{s}` has a non-numeric {what} component"))
123        };
124        let v = Self {
125            major: next("major")?,
126            minor: next("minor")?,
127            patch: next("patch")?,
128        };
129        if it.next().is_some() {
130            return Err(eyre!("`{s}` has more than three version components"));
131        }
132        Ok(v)
133    }
134}
135
136/// A resolved Node.js release, ready to install: the concrete version
137/// plus the single blob to fetch and extract. Mirrors [`super::PhpRecipe`]
138/// but without the PHP-only `flavor` / `frozen_warning` fields.
139#[derive(Debug, Clone)]
140pub struct NodeRecipe {
141    pub version: NodeVersion,
142    pub blob: BlobRef,
143}
144
145#[derive(Debug)]
146pub struct NodejsOrgBackend {
147    client: reqwest::blocking::Client,
148    cache_root: PathBuf,
149    target: Triple,
150}
151
152impl NodejsOrgBackend {
153    pub fn new(paths: &Paths, target: &Triple) -> Result<Self> {
154        let client = build_http_client("nodejs.org")?;
155        let cache_root = paths.cache_index(CACHE_HOST_DIR);
156        Ok(Self {
157            client,
158            cache_root,
159            target: target.clone(),
160        })
161    }
162
163    /// Borrow the backend's HTTP client so the install command can drive
164    /// [`bougie_fetch::fetch_blob`] without rebuilding one.
165    pub fn client(&self) -> &reqwest::blocking::Client {
166        &self.client
167    }
168
169    /// Resolve a request against the live index, then look up the
170    /// checksum for the host's platform file. Network I/O happens here
171    /// (index.json + SHASUMS256.txt fetch); no filesystem state under
172    /// `$BOUGIE_HOME` is mutated.
173    pub fn resolve(&self, req: &NodeRequest) -> Result<NodeRecipe> {
174        let plat = self.platform_token()?;
175        let index = fetch_index(&self.client, &self.cache_root)?;
176        let version = select_version(&index, req)?;
177
178        let filename = format!("node-v{version}-{plat}.{}", plat.ext());
179        let strip_prefix = format!("node-v{version}-{plat}");
180        let url = format!("{DIST_BASE}/v{version}/{filename}");
181
182        let sha256 = fetch_shasum(&self.client, &self.cache_root, version, &filename)?;
183
184        Ok(NodeRecipe {
185            version,
186            blob: BlobRef {
187                url,
188                sha256,
189                // nodejs.org publishes no per-file size (neither in
190                // index.json nor SHASUMS256.txt), so the progress bar
191                // ticks bytes received but can't fill — same fallback as
192                // a pre-`size` bougie-index publisher.
193                size: 0,
194                archive: plat.archive(),
195                strip_prefix,
196            },
197        })
198    }
199
200    /// The `<os>-<arch>` token nodejs.org uses in its filenames for the
201    /// host, e.g. `linux-x64`, `darwin-arm64`, `win-x64`. Rejects musl
202    /// (official builds are glibc-only — see module docs).
203    fn platform_token(&self) -> Result<PlatformToken> {
204        if matches!(self.target.env, Some(Env::Musl)) {
205            return Err(BougieError::UnknownTarget {
206                triple: self.target.to_string(),
207                hint: "official Node.js binaries are built against glibc and do not run on \
208                       musl/Alpine. Install Node from your distro's package manager, or run \
209                       bougie on a glibc-based image."
210                    .into(),
211            }
212            .into());
213        }
214        let arch = match self.target.arch {
215            Arch::X86_64 => "x64",
216            Arch::Aarch64 => "arm64",
217        };
218        let (os, kind) = match self.target.os {
219            Os::Linux => ("linux", PlatformKind::TarGz),
220            Os::Darwin => ("darwin", PlatformKind::TarGz),
221            Os::Windows => ("win", PlatformKind::Zip),
222        };
223        Ok(PlatformToken {
224            token: format!("{os}-{arch}"),
225            kind,
226        })
227    }
228}
229
230/// Resolved `<os>-<arch>` token plus how its artifact is packaged.
231#[derive(Debug)]
232struct PlatformToken {
233    token: String,
234    kind: PlatformKind,
235}
236
237#[derive(Debug, Clone, Copy)]
238enum PlatformKind {
239    /// `.tar.gz` — Linux and macOS.
240    TarGz,
241    /// `.zip` — Windows.
242    Zip,
243}
244
245impl PlatformToken {
246    fn ext(&self) -> &'static str {
247        match self.kind {
248            PlatformKind::TarGz => "tar.gz",
249            PlatformKind::Zip => "zip",
250        }
251    }
252    fn archive(&self) -> ArchiveKind {
253        match self.kind {
254            PlatformKind::TarGz => ArchiveKind::TarGz,
255            PlatformKind::Zip => ArchiveKind::Zip,
256        }
257    }
258}
259
260impl std::fmt::Display for PlatformToken {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        f.write_str(&self.token)
263    }
264}
265
266/// One release row in `dist/index.json`. Only the fields bougie needs;
267/// the rest (`date`, `npm`, `v8`, `security`, …) are ignored.
268#[derive(Debug, Clone, Deserialize)]
269struct IndexEntry {
270    /// `"v20.11.0"`.
271    version: String,
272    /// `false` for non-LTS, or the line codename string (`"Iron"`) for
273    /// LTS releases. Deserialized untyped because serde can't model a
274    /// `bool | string` union directly.
275    #[serde(default)]
276    lts: serde_json::Value,
277}
278
279impl IndexEntry {
280    fn parsed(&self) -> Option<NodeVersion> {
281        self.version.parse().ok()
282    }
283    fn is_lts(&self) -> bool {
284        self.lts.as_str().is_some()
285    }
286}
287
288/// Pick the concrete version a request resolves to. The index is
289/// published newest-first, but we don't rely on ordering: every arm
290/// takes the `max` of the matching candidates so a re-sorted or
291/// out-of-order index can't mis-resolve.
292fn select_version(index: &[IndexEntry], req: &NodeRequest) -> Result<NodeVersion> {
293    let pick = |filter: &dyn Fn(&IndexEntry, NodeVersion) -> bool| -> Option<NodeVersion> {
294        index
295            .iter()
296            .filter_map(|e| e.parsed().map(|v| (e, v)))
297            .filter(|(e, v)| filter(e, *v))
298            .map(|(_, v)| v)
299            .max()
300    };
301    let chosen = match req {
302        NodeRequest::Latest => pick(&|_, _| true),
303        NodeRequest::Lts => pick(&|e, _| e.is_lts()),
304        NodeRequest::Major(maj) => pick(&|_, v| v.major == *maj),
305        NodeRequest::MajorMinor(maj, min) => pick(&|_, v| v.major == *maj && v.minor == *min),
306        NodeRequest::Exact(want) => pick(&|_, v| v == *want),
307    };
308    chosen.ok_or_else(|| {
309        BougieError::Resolution {
310            kind: "node interpreter".into(),
311            detail: format!("nodejs.org has no release matching `{}`", describe(req)),
312        }
313        .into()
314    })
315}
316
317fn describe(req: &NodeRequest) -> String {
318    match req {
319        NodeRequest::Latest => "latest".into(),
320        NodeRequest::Lts => "lts".into(),
321        NodeRequest::Major(m) => m.to_string(),
322        NodeRequest::MajorMinor(m, n) => format!("{m}.{n}"),
323        NodeRequest::Exact(v) => v.to_string(),
324    }
325}
326
327/// Fetch (or revalidate) `dist/index.json`, caching the body + `ETag`.
328/// Same conditional-GET dance as the windows.php.net backend; the trust
329/// story is TLS + the per-release `SHASUMS256.txt` checked at fetch time,
330/// so there's no signature to verify on the index itself.
331fn fetch_index(client: &reqwest::blocking::Client, cache_root: &Path) -> Result<Vec<IndexEntry>> {
332    std::fs::create_dir_all(cache_root)
333        .wrap_err_with(|| format!("creating {}", cache_root.display()))?;
334    let body_path = cache_root.join("index.json");
335    let etag_path = cache_root.join("index.json.etag");
336    let cached_etag = std::fs::read_to_string(&etag_path).ok();
337
338    let mut req = client.get(INDEX_URL);
339    if let Some(etag) = cached_etag.as_deref().filter(|s| !s.is_empty()) {
340        req = req.header(reqwest::header::IF_NONE_MATCH, etag.trim());
341    }
342    let resp = req.send().map_err(|e| BougieError::Network {
343        operation: format!("fetching {INDEX_URL}"),
344        detail: error_chain(&e),
345    })?;
346
347    if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
348        let bytes = std::fs::read(&body_path)
349            .wrap_err_with(|| format!("reading cached {}", body_path.display()))?;
350        return serde_json::from_slice(&bytes).wrap_err("parsing cached index.json");
351    }
352    if !resp.status().is_success() {
353        return Err(BougieError::Network {
354            operation: format!("GET {INDEX_URL}"),
355            detail: format!("server returned HTTP {}", resp.status()),
356        }
357        .into());
358    }
359    let new_etag = resp
360        .headers()
361        .get(reqwest::header::ETAG)
362        .and_then(|v| v.to_str().ok())
363        .map(str::to_owned);
364    let body = resp.bytes().map_err(|e| BougieError::Network {
365        operation: format!("reading body of {INDEX_URL}"),
366        detail: error_chain(&e),
367    })?;
368    std::fs::write(&body_path, &body)
369        .wrap_err_with(|| format!("writing {}", body_path.display()))?;
370    if let Some(etag) = new_etag.as_deref() {
371        let _ = std::fs::write(&etag_path, etag);
372    }
373    serde_json::from_slice(&body).wrap_err("parsing fetched index.json")
374}
375
376/// Fetch `v<version>/SHASUMS256.txt` and return the sha256 for `filename`.
377/// Cached per-version under the index cache root (checksums for a
378/// released version are immutable, so no revalidation is needed).
379fn fetch_shasum(
380    client: &reqwest::blocking::Client,
381    cache_root: &Path,
382    version: NodeVersion,
383    filename: &str,
384) -> Result<String> {
385    let dir = cache_root.join("shasums");
386    std::fs::create_dir_all(&dir).wrap_err_with(|| format!("creating {}", dir.display()))?;
387    let cache_path = dir.join(format!("SHASUMS256-{version}.txt"));
388
389    let body = if let Ok(cached) = std::fs::read_to_string(&cache_path) {
390        cached
391    } else {
392        let url = format!("{DIST_BASE}/v{version}/SHASUMS256.txt");
393        let resp = client.get(&url).send().map_err(|e| BougieError::Network {
394            operation: format!("fetching {url}"),
395            detail: error_chain(&e),
396        })?;
397        if !resp.status().is_success() {
398            return Err(BougieError::Network {
399                operation: format!("GET {url}"),
400                detail: format!("server returned HTTP {}", resp.status()),
401            }
402            .into());
403        }
404        let text = resp.text().map_err(|e| BougieError::Network {
405            operation: format!("reading body of {url}"),
406            detail: error_chain(&e),
407        })?;
408        let _ = std::fs::write(&cache_path, &text);
409        text
410    };
411
412    parse_shasum(&body, filename).ok_or_else(|| {
413        BougieError::Resolution {
414            kind: "node interpreter".into(),
415            detail: format!(
416                "nodejs.org's SHASUMS256.txt for v{version} has no entry for `{filename}` \
417                 (this platform may not be published for that release)"
418            ),
419        }
420        .into()
421    })
422}
423
424/// Parse a `SHASUMS256.txt` body for `filename`'s sha256. Lines are
425/// `<64-hex>␠␠<path>`; node lists bare filenames, but tolerate a leading
426/// `./` just in case.
427fn parse_shasum(body: &str, filename: &str) -> Option<String> {
428    for line in body.lines() {
429        let mut parts = line.split_whitespace();
430        let sha = parts.next()?;
431        let name = parts.next()?;
432        let name = name.strip_prefix("./").unwrap_or(name);
433        if name == filename && sha.len() == 64 && sha.chars().all(|c| c.is_ascii_hexdigit()) {
434            return Some(sha.to_ascii_lowercase());
435        }
436    }
437    None
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use bougie_platform::target::{Arch, Os, Triple, Vendor};
444
445    fn linux_x64() -> Triple {
446        Triple {
447            arch: Arch::X86_64,
448            vendor: Vendor::Unknown,
449            os: Os::Linux,
450            env: Some(Env::Gnu),
451        }
452    }
453
454    #[test]
455    fn parses_node_requests() {
456        use NodeRequest::*;
457        assert_eq!("latest".parse::<NodeRequest>().unwrap(), Latest);
458        assert_eq!("".parse::<NodeRequest>().unwrap(), Latest);
459        assert_eq!("LTS".parse::<NodeRequest>().unwrap(), Lts);
460        assert_eq!("20".parse::<NodeRequest>().unwrap(), Major(20));
461        assert_eq!("20.11".parse::<NodeRequest>().unwrap(), MajorMinor(20, 11));
462        assert_eq!(
463            "v20.11.0".parse::<NodeRequest>().unwrap(),
464            Exact(NodeVersion {
465                major: 20,
466                minor: 11,
467                patch: 0
468            })
469        );
470        assert!("20.11.0.1".parse::<NodeRequest>().is_err());
471        assert!("twenty".parse::<NodeRequest>().is_err());
472    }
473
474    fn idx(version: &str, lts: serde_json::Value) -> IndexEntry {
475        IndexEntry {
476            version: version.into(),
477            lts,
478        }
479    }
480
481    fn sample_index() -> Vec<IndexEntry> {
482        use serde_json::json;
483        vec![
484            idx("v22.3.0", json!(false)),
485            idx("v22.2.0", json!(false)),
486            idx("v20.14.0", json!("Iron")),
487            idx("v20.13.1", json!("Iron")),
488            idx("v18.20.3", json!("Hydrogen")),
489        ]
490    }
491
492    #[test]
493    fn select_version_resolves_each_request_kind() {
494        let i = sample_index();
495        let v = |s: &str| s.parse::<NodeVersion>().unwrap();
496        assert_eq!(
497            select_version(&i, &NodeRequest::Latest).unwrap(),
498            v("22.3.0")
499        );
500        // lts → highest version whose `lts` is a codename, not the
501        // overall newest (which is non-LTS).
502        assert_eq!(select_version(&i, &NodeRequest::Lts).unwrap(), v("20.14.0"));
503        assert_eq!(
504            select_version(&i, &NodeRequest::Major(20)).unwrap(),
505            v("20.14.0")
506        );
507        assert_eq!(
508            select_version(&i, &NodeRequest::MajorMinor(22, 2)).unwrap(),
509            v("22.2.0")
510        );
511        assert_eq!(
512            select_version(&i, &NodeRequest::Exact(v("18.20.3"))).unwrap(),
513            v("18.20.3")
514        );
515    }
516
517    #[test]
518    fn select_version_errors_on_no_match() {
519        let i = sample_index();
520        assert!(select_version(&i, &NodeRequest::Major(19)).is_err());
521    }
522
523    #[test]
524    fn select_version_takes_max_regardless_of_index_order() {
525        // Out-of-order index: max must still win.
526        use serde_json::json;
527        let i = vec![
528            idx("v20.1.0", json!("Iron")),
529            idx("v20.14.0", json!("Iron")),
530            idx("v20.9.0", json!("Iron")),
531        ];
532        assert_eq!(
533            select_version(&i, &NodeRequest::Major(20)).unwrap(),
534            "20.14.0".parse::<NodeVersion>().unwrap()
535        );
536    }
537
538    #[test]
539    fn platform_token_maps_each_os_and_arch() {
540        let td = tempfile::TempDir::new().unwrap();
541        let paths = Paths::new(td.path().into(), td.path().join("cache"));
542
543        let mk = |arch, os, env| {
544            let t = Triple {
545                arch,
546                vendor: Vendor::Unknown,
547                os,
548                env,
549            };
550            NodejsOrgBackend::new(&paths, &t).unwrap().platform_token()
551        };
552        let lx = mk(Arch::X86_64, Os::Linux, Some(Env::Gnu)).unwrap();
553        assert_eq!(lx.token, "linux-x64");
554        assert_eq!(lx.ext(), "tar.gz");
555        assert!(matches!(lx.archive(), ArchiveKind::TarGz));
556
557        let mac = mk(Arch::Aarch64, Os::Darwin, None).unwrap();
558        assert_eq!(mac.token, "darwin-arm64");
559        assert_eq!(mac.ext(), "tar.gz");
560
561        let win = mk(Arch::X86_64, Os::Windows, Some(Env::Msvc)).unwrap();
562        assert_eq!(win.token, "win-x64");
563        assert_eq!(win.ext(), "zip");
564        assert!(matches!(win.archive(), ArchiveKind::Zip));
565    }
566
567    #[test]
568    fn platform_token_rejects_musl() {
569        let td = tempfile::TempDir::new().unwrap();
570        let paths = Paths::new(td.path().into(), td.path().join("cache"));
571        let t = Triple {
572            arch: Arch::X86_64,
573            vendor: Vendor::Unknown,
574            os: Os::Linux,
575            env: Some(Env::Musl),
576        };
577        let err = NodejsOrgBackend::new(&paths, &t)
578            .unwrap()
579            .platform_token()
580            .unwrap_err();
581        assert!(err.to_string().contains("musl"), "got: {err}");
582    }
583
584    #[test]
585    fn parse_shasum_finds_the_right_file() {
586        let body = "\
587aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111  node-v20.11.0-linux-arm64.tar.gz
588bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222  node-v20.11.0-linux-x64.tar.gz
589cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333  node-v20.11.0-win-x64.zip
590";
591        assert_eq!(
592            parse_shasum(body, "node-v20.11.0-linux-x64.tar.gz").as_deref(),
593            Some("bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222")
594        );
595        assert!(parse_shasum(body, "node-v20.11.0-darwin-x64.tar.gz").is_none());
596    }
597
598    #[test]
599    fn node_version_round_trips() {
600        let v: NodeVersion = "v20.11.0".parse().unwrap();
601        assert_eq!(v.to_string(), "20.11.0");
602        assert!("20.11".parse::<NodeVersion>().is_err());
603        assert!("20.11.0.0".parse::<NodeVersion>().is_err());
604    }
605
606    /// The backend constructs without network access and exposes a
607    /// glibc-friendly token on a stock Linux triple.
608    #[test]
609    fn backend_constructs_on_linux() {
610        let td = tempfile::TempDir::new().unwrap();
611        let paths = Paths::new(td.path().into(), td.path().join("cache"));
612        let backend = NodejsOrgBackend::new(&paths, &linux_x64()).unwrap();
613        assert_eq!(backend.platform_token().unwrap().token, "linux-x64");
614    }
615}