1use 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#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum NodeRequest {
48 Latest,
50 Lts,
52 Major(u32),
54 MajorMinor(u32, u32),
56 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 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#[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 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#[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 pub fn client(&self) -> &reqwest::blocking::Client {
166 &self.client
167 }
168
169 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 size: 0,
194 archive: plat.archive(),
195 strip_prefix,
196 },
197 })
198 }
199
200 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#[derive(Debug)]
232struct PlatformToken {
233 token: String,
234 kind: PlatformKind,
235}
236
237#[derive(Debug, Clone, Copy)]
238enum PlatformKind {
239 TarGz,
241 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#[derive(Debug, Clone, Deserialize)]
269struct IndexEntry {
270 version: String,
272 #[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
288fn 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
327fn 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
376fn 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
424fn 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 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 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 #[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}