use super::{BlobRef, build_http_client};
use bougie_errors::{BougieError, error_chain};
use bougie_fetch::ArchiveKind;
use bougie_paths::Paths;
use bougie_platform::target::{Arch, Env, Os, Triple};
use eyre::{Result, WrapErr, eyre};
use serde::Deserialize;
use std::path::{Path, PathBuf};
const INDEX_URL: &str = "https://nodejs.org/dist/index.json";
const DIST_BASE: &str = "https://nodejs.org/dist";
const CACHE_HOST_DIR: &str = "nodejs.org";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeRequest {
Latest,
Lts,
Major(u32),
MajorMinor(u32, u32),
Exact(NodeVersion),
}
impl std::str::FromStr for NodeRequest {
type Err = eyre::Report;
fn from_str(s: &str) -> Result<Self> {
let s = s.trim();
match s.to_ascii_lowercase().as_str() {
"" | "latest" | "*" => return Ok(Self::Latest),
"lts" => return Ok(Self::Lts),
_ => {}
}
let body = s.strip_prefix(['v', 'V']).unwrap_or(s);
let parts: Vec<&str> = body.split('.').collect();
let parse = |p: &str| -> Result<u32> {
p.parse()
.wrap_err_with(|| format!("`{p}` in `{s}` is not a version number"))
};
match parts.as_slice() {
[maj] => Ok(Self::Major(parse(maj)?)),
[maj, min] => Ok(Self::MajorMinor(parse(maj)?, parse(min)?)),
[maj, min, pat] => Ok(Self::Exact(NodeVersion {
major: parse(maj)?,
minor: parse(min)?,
patch: parse(pat)?,
})),
_ => Err(eyre!(
"`{s}` is not a Node.js version request \
(expected `latest`, `lts`, `20`, `20.11`, or `20.11.0`)"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct NodeVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl std::fmt::Display for NodeVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl std::str::FromStr for NodeVersion {
type Err = eyre::Report;
fn from_str(s: &str) -> Result<Self> {
let body = s.trim().strip_prefix(['v', 'V']).unwrap_or(s.trim());
let mut it = body.split('.');
let mut next = |what: &str| -> Result<u32> {
it.next()
.ok_or_else(|| eyre!("`{s}` is missing its {what} component"))?
.parse()
.wrap_err_with(|| format!("`{s}` has a non-numeric {what} component"))
};
let v = Self {
major: next("major")?,
minor: next("minor")?,
patch: next("patch")?,
};
if it.next().is_some() {
return Err(eyre!("`{s}` has more than three version components"));
}
Ok(v)
}
}
#[derive(Debug, Clone)]
pub struct NodeRecipe {
pub version: NodeVersion,
pub blob: BlobRef,
}
#[derive(Debug)]
pub struct NodejsOrgBackend {
client: reqwest::blocking::Client,
cache_root: PathBuf,
target: Triple,
}
impl NodejsOrgBackend {
pub fn new(paths: &Paths, target: &Triple) -> Result<Self> {
let client = build_http_client("nodejs.org")?;
let cache_root = paths.cache_index(CACHE_HOST_DIR);
Ok(Self {
client,
cache_root,
target: target.clone(),
})
}
pub fn client(&self) -> &reqwest::blocking::Client {
&self.client
}
pub fn resolve(&self, req: &NodeRequest) -> Result<NodeRecipe> {
let plat = self.platform_token()?;
let index = fetch_index(&self.client, &self.cache_root)?;
let version = select_version(&index, req)?;
let filename = format!("node-v{version}-{plat}.{}", plat.ext());
let strip_prefix = format!("node-v{version}-{plat}");
let url = format!("{DIST_BASE}/v{version}/{filename}");
let sha256 = fetch_shasum(&self.client, &self.cache_root, version, &filename)?;
Ok(NodeRecipe {
version,
blob: BlobRef {
url,
sha256,
size: 0,
archive: plat.archive(),
strip_prefix,
},
})
}
fn platform_token(&self) -> Result<PlatformToken> {
if matches!(self.target.env, Some(Env::Musl)) {
return Err(BougieError::UnknownTarget {
triple: self.target.to_string(),
hint: "official Node.js binaries are built against glibc and do not run on \
musl/Alpine. Install Node from your distro's package manager, or run \
bougie on a glibc-based image."
.into(),
}
.into());
}
let arch = match self.target.arch {
Arch::X86_64 => "x64",
Arch::Aarch64 => "arm64",
};
let (os, kind) = match self.target.os {
Os::Linux => ("linux", PlatformKind::TarGz),
Os::Darwin => ("darwin", PlatformKind::TarGz),
Os::Windows => ("win", PlatformKind::Zip),
};
Ok(PlatformToken {
token: format!("{os}-{arch}"),
kind,
})
}
}
#[derive(Debug)]
struct PlatformToken {
token: String,
kind: PlatformKind,
}
#[derive(Debug, Clone, Copy)]
enum PlatformKind {
TarGz,
Zip,
}
impl PlatformToken {
fn ext(&self) -> &'static str {
match self.kind {
PlatformKind::TarGz => "tar.gz",
PlatformKind::Zip => "zip",
}
}
fn archive(&self) -> ArchiveKind {
match self.kind {
PlatformKind::TarGz => ArchiveKind::TarGz,
PlatformKind::Zip => ArchiveKind::Zip,
}
}
}
impl std::fmt::Display for PlatformToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.token)
}
}
#[derive(Debug, Clone, Deserialize)]
struct IndexEntry {
version: String,
#[serde(default)]
lts: serde_json::Value,
}
impl IndexEntry {
fn parsed(&self) -> Option<NodeVersion> {
self.version.parse().ok()
}
fn is_lts(&self) -> bool {
self.lts.as_str().is_some()
}
}
fn select_version(index: &[IndexEntry], req: &NodeRequest) -> Result<NodeVersion> {
let pick = |filter: &dyn Fn(&IndexEntry, NodeVersion) -> bool| -> Option<NodeVersion> {
index
.iter()
.filter_map(|e| e.parsed().map(|v| (e, v)))
.filter(|(e, v)| filter(e, *v))
.map(|(_, v)| v)
.max()
};
let chosen = match req {
NodeRequest::Latest => pick(&|_, _| true),
NodeRequest::Lts => pick(&|e, _| e.is_lts()),
NodeRequest::Major(maj) => pick(&|_, v| v.major == *maj),
NodeRequest::MajorMinor(maj, min) => pick(&|_, v| v.major == *maj && v.minor == *min),
NodeRequest::Exact(want) => pick(&|_, v| v == *want),
};
chosen.ok_or_else(|| {
BougieError::Resolution {
kind: "node interpreter".into(),
detail: format!("nodejs.org has no release matching `{}`", describe(req)),
}
.into()
})
}
fn describe(req: &NodeRequest) -> String {
match req {
NodeRequest::Latest => "latest".into(),
NodeRequest::Lts => "lts".into(),
NodeRequest::Major(m) => m.to_string(),
NodeRequest::MajorMinor(m, n) => format!("{m}.{n}"),
NodeRequest::Exact(v) => v.to_string(),
}
}
fn fetch_index(client: &reqwest::blocking::Client, cache_root: &Path) -> Result<Vec<IndexEntry>> {
std::fs::create_dir_all(cache_root)
.wrap_err_with(|| format!("creating {}", cache_root.display()))?;
let body_path = cache_root.join("index.json");
let etag_path = cache_root.join("index.json.etag");
let cached_etag = std::fs::read_to_string(&etag_path).ok();
let mut req = client.get(INDEX_URL);
if let Some(etag) = cached_etag.as_deref().filter(|s| !s.is_empty()) {
req = req.header(reqwest::header::IF_NONE_MATCH, etag.trim());
}
let resp = req.send().map_err(|e| BougieError::Network {
operation: format!("fetching {INDEX_URL}"),
detail: error_chain(&e),
})?;
if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
let bytes = std::fs::read(&body_path)
.wrap_err_with(|| format!("reading cached {}", body_path.display()))?;
return serde_json::from_slice(&bytes).wrap_err("parsing cached index.json");
}
if !resp.status().is_success() {
return Err(BougieError::Network {
operation: format!("GET {INDEX_URL}"),
detail: format!("server returned HTTP {}", resp.status()),
}
.into());
}
let new_etag = resp
.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let body = resp.bytes().map_err(|e| BougieError::Network {
operation: format!("reading body of {INDEX_URL}"),
detail: error_chain(&e),
})?;
std::fs::write(&body_path, &body)
.wrap_err_with(|| format!("writing {}", body_path.display()))?;
if let Some(etag) = new_etag.as_deref() {
let _ = std::fs::write(&etag_path, etag);
}
serde_json::from_slice(&body).wrap_err("parsing fetched index.json")
}
fn fetch_shasum(
client: &reqwest::blocking::Client,
cache_root: &Path,
version: NodeVersion,
filename: &str,
) -> Result<String> {
let dir = cache_root.join("shasums");
std::fs::create_dir_all(&dir).wrap_err_with(|| format!("creating {}", dir.display()))?;
let cache_path = dir.join(format!("SHASUMS256-{version}.txt"));
let body = if let Ok(cached) = std::fs::read_to_string(&cache_path) {
cached
} else {
let url = format!("{DIST_BASE}/v{version}/SHASUMS256.txt");
let resp = client.get(&url).send().map_err(|e| BougieError::Network {
operation: format!("fetching {url}"),
detail: error_chain(&e),
})?;
if !resp.status().is_success() {
return Err(BougieError::Network {
operation: format!("GET {url}"),
detail: format!("server returned HTTP {}", resp.status()),
}
.into());
}
let text = resp.text().map_err(|e| BougieError::Network {
operation: format!("reading body of {url}"),
detail: error_chain(&e),
})?;
let _ = std::fs::write(&cache_path, &text);
text
};
parse_shasum(&body, filename).ok_or_else(|| {
BougieError::Resolution {
kind: "node interpreter".into(),
detail: format!(
"nodejs.org's SHASUMS256.txt for v{version} has no entry for `{filename}` \
(this platform may not be published for that release)"
),
}
.into()
})
}
fn parse_shasum(body: &str, filename: &str) -> Option<String> {
for line in body.lines() {
let mut parts = line.split_whitespace();
let sha = parts.next()?;
let name = parts.next()?;
let name = name.strip_prefix("./").unwrap_or(name);
if name == filename && sha.len() == 64 && sha.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(sha.to_ascii_lowercase());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use bougie_platform::target::{Arch, Os, Triple, Vendor};
fn linux_x64() -> Triple {
Triple {
arch: Arch::X86_64,
vendor: Vendor::Unknown,
os: Os::Linux,
env: Some(Env::Gnu),
}
}
#[test]
fn parses_node_requests() {
use NodeRequest::*;
assert_eq!("latest".parse::<NodeRequest>().unwrap(), Latest);
assert_eq!("".parse::<NodeRequest>().unwrap(), Latest);
assert_eq!("LTS".parse::<NodeRequest>().unwrap(), Lts);
assert_eq!("20".parse::<NodeRequest>().unwrap(), Major(20));
assert_eq!("20.11".parse::<NodeRequest>().unwrap(), MajorMinor(20, 11));
assert_eq!(
"v20.11.0".parse::<NodeRequest>().unwrap(),
Exact(NodeVersion {
major: 20,
minor: 11,
patch: 0
})
);
assert!("20.11.0.1".parse::<NodeRequest>().is_err());
assert!("twenty".parse::<NodeRequest>().is_err());
}
fn idx(version: &str, lts: serde_json::Value) -> IndexEntry {
IndexEntry {
version: version.into(),
lts,
}
}
fn sample_index() -> Vec<IndexEntry> {
use serde_json::json;
vec![
idx("v22.3.0", json!(false)),
idx("v22.2.0", json!(false)),
idx("v20.14.0", json!("Iron")),
idx("v20.13.1", json!("Iron")),
idx("v18.20.3", json!("Hydrogen")),
]
}
#[test]
fn select_version_resolves_each_request_kind() {
let i = sample_index();
let v = |s: &str| s.parse::<NodeVersion>().unwrap();
assert_eq!(
select_version(&i, &NodeRequest::Latest).unwrap(),
v("22.3.0")
);
assert_eq!(select_version(&i, &NodeRequest::Lts).unwrap(), v("20.14.0"));
assert_eq!(
select_version(&i, &NodeRequest::Major(20)).unwrap(),
v("20.14.0")
);
assert_eq!(
select_version(&i, &NodeRequest::MajorMinor(22, 2)).unwrap(),
v("22.2.0")
);
assert_eq!(
select_version(&i, &NodeRequest::Exact(v("18.20.3"))).unwrap(),
v("18.20.3")
);
}
#[test]
fn select_version_errors_on_no_match() {
let i = sample_index();
assert!(select_version(&i, &NodeRequest::Major(19)).is_err());
}
#[test]
fn select_version_takes_max_regardless_of_index_order() {
use serde_json::json;
let i = vec![
idx("v20.1.0", json!("Iron")),
idx("v20.14.0", json!("Iron")),
idx("v20.9.0", json!("Iron")),
];
assert_eq!(
select_version(&i, &NodeRequest::Major(20)).unwrap(),
"20.14.0".parse::<NodeVersion>().unwrap()
);
}
#[test]
fn platform_token_maps_each_os_and_arch() {
let td = tempfile::TempDir::new().unwrap();
let paths = Paths::new(td.path().into(), td.path().join("cache"));
let mk = |arch, os, env| {
let t = Triple {
arch,
vendor: Vendor::Unknown,
os,
env,
};
NodejsOrgBackend::new(&paths, &t).unwrap().platform_token()
};
let lx = mk(Arch::X86_64, Os::Linux, Some(Env::Gnu)).unwrap();
assert_eq!(lx.token, "linux-x64");
assert_eq!(lx.ext(), "tar.gz");
assert!(matches!(lx.archive(), ArchiveKind::TarGz));
let mac = mk(Arch::Aarch64, Os::Darwin, None).unwrap();
assert_eq!(mac.token, "darwin-arm64");
assert_eq!(mac.ext(), "tar.gz");
let win = mk(Arch::X86_64, Os::Windows, Some(Env::Msvc)).unwrap();
assert_eq!(win.token, "win-x64");
assert_eq!(win.ext(), "zip");
assert!(matches!(win.archive(), ArchiveKind::Zip));
}
#[test]
fn platform_token_rejects_musl() {
let td = tempfile::TempDir::new().unwrap();
let paths = Paths::new(td.path().into(), td.path().join("cache"));
let t = Triple {
arch: Arch::X86_64,
vendor: Vendor::Unknown,
os: Os::Linux,
env: Some(Env::Musl),
};
let err = NodejsOrgBackend::new(&paths, &t)
.unwrap()
.platform_token()
.unwrap_err();
assert!(err.to_string().contains("musl"), "got: {err}");
}
#[test]
fn parse_shasum_finds_the_right_file() {
let body = "\
aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111 node-v20.11.0-linux-arm64.tar.gz
bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222 node-v20.11.0-linux-x64.tar.gz
cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333 node-v20.11.0-win-x64.zip
";
assert_eq!(
parse_shasum(body, "node-v20.11.0-linux-x64.tar.gz").as_deref(),
Some("bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222")
);
assert!(parse_shasum(body, "node-v20.11.0-darwin-x64.tar.gz").is_none());
}
#[test]
fn node_version_round_trips() {
let v: NodeVersion = "v20.11.0".parse().unwrap();
assert_eq!(v.to_string(), "20.11.0");
assert!("20.11".parse::<NodeVersion>().is_err());
assert!("20.11.0.0".parse::<NodeVersion>().is_err());
}
#[test]
fn backend_constructs_on_linux() {
let td = tempfile::TempDir::new().unwrap();
let paths = Paths::new(td.path().into(), td.path().join("cache"));
let backend = NodejsOrgBackend::new(&paths, &linux_x64()).unwrap();
assert_eq!(backend.platform_token().unwrap().token, "linux-x64");
}
}