use crate::discover::{self, InstallOrigin, InstalledNode};
use crate::error::Error;
use crate::http::Http;
use crate::index;
use crate::installer::{self, DownloadSpec};
use crate::mise;
use crate::platform::{Platform, artifact_filename, artifact_top_dir};
use crate::progress::DownloadProgress;
use crate::shasums::{self, sha256_from_sri, sri_sha256};
use crate::spec::{NodeRequest, NodeSpec};
use crate::{InstallerMode, PinnedNode, PinnedVariant, RuntimeConfig};
use aube_manifest::OnFail;
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedFrom {
PathEnv,
Installed(InstallOrigin),
FreshInstall(InstallOrigin),
}
#[derive(Debug, Clone)]
pub struct Resolution {
pub version: node_semver::Version,
pub bin_dir: Option<PathBuf>,
pub node_bin: PathBuf,
pub from: ResolvedFrom,
pub fresh_pin: Option<PinnedNode>,
}
pub struct NodeRuntime {
pub(crate) cfg: RuntimeConfig,
pub(crate) http: Http,
memo: tokio::sync::Mutex<BTreeMap<String, Option<Resolution>>>,
}
impl NodeRuntime {
pub fn new(cfg: RuntimeConfig) -> Self {
let http = Http::new(cfg.retries);
NodeRuntime {
cfg,
http,
memo: tokio::sync::Mutex::new(BTreeMap::new()),
}
}
pub async fn resolve(
&self,
req: &NodeRequest,
pinned: Option<&PinnedNode>,
progress: &dyn DownloadProgress,
) -> Result<Option<Resolution>, Error> {
let memo_key = match pinned {
Some(p) => format!("pin:{}", p.version),
None => format!("spec:{}", req.raw),
};
if let Some(hit) = self.memo.lock().await.get(&memo_key) {
return Ok(hit.clone());
}
let result = self.resolve_uncached(req, pinned, progress).await?;
self.memo.lock().await.insert(memo_key, result.clone());
Ok(result)
}
async fn resolve_uncached(
&self,
req: &NodeRequest,
pinned: Option<&PinnedNode>,
progress: &dyn DownloadProgress,
) -> Result<Option<Resolution>, Error> {
let target = match pinned {
Some(p) => NodeSpec::Exact(p.version.clone()),
None => req.spec.clone(),
};
if let Some(resolution) = local_resolution(&target) {
return Ok(Some(resolution));
}
let locally_decidable = matches!(target, NodeSpec::Exact(_) | NodeSpec::Range(_));
if locally_decidable {
match req.on_fail {
OnFail::Ignore => return Ok(None),
OnFail::Warn => {
warn_version_mismatch(req);
return Ok(None);
}
OnFail::Error => return Err(self.unsatisfied(req)),
OnFail::Download => {}
}
}
progress.on_phase(None, crate::progress::InstallPhase::Resolving);
let platform = Platform::current()?;
let (version, fresh_pin) = match (pinned, &target) {
(Some(p), _) => (p.version.clone(), None),
(None, NodeSpec::Exact(version)) => (version.clone(), None),
(None, _) => {
let selected = match index::load_index(&self.http, &self.cfg).await {
Ok(entries) => index::select(&entries, &target, &platform)
.map(|e| e.version.clone())
.ok_or_else(|| Error::NoMatchingVersion {
requested: req.raw.clone(),
platform_note: format!(" with a build for {}", platform.label()),
}),
Err(e) => Err(e),
};
match selected {
Ok(v) => (v, None),
Err(_) if req.on_fail == OnFail::Ignore => return Ok(None),
Err(e) if req.on_fail == OnFail::Warn => {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH,
requested = %req.raw,
source = req.source.label(),
error = %e,
"could not verify the project's runtime requirement; continuing on the active Node.js"
);
return Ok(None);
}
Err(e) => return Err(e),
}
}
};
let exact = NodeSpec::Exact(version.clone());
if let Some(resolution) = local_resolution(&exact) {
return Ok(Some(resolution));
}
match req.on_fail {
OnFail::Ignore => return Ok(None),
OnFail::Warn => {
warn_version_mismatch(req);
return Ok(None);
}
OnFail::Error => return Err(self.unsatisfied(req)),
OnFail::Download => {}
}
let artifact_base = self.cfg.artifact_base(&platform);
let pinned_variant = pinned
.and_then(|p| p.variant_for(&platform.os, &platform.cpu, platform.libc.as_deref()));
let (download, fresh_pin) = match pinned_variant {
Some(v) => {
let expected =
sha256_from_sri(&v.integrity_sri).ok_or_else(|| Error::ChecksumMismatch {
url: v.url.clone(),
expected: v.integrity_sri.clone(),
actual: "<unparseable lockfile integrity>".to_string(),
})?;
(
DownloadSpec {
url: v.url.clone(),
expected_sha256: expected,
zip: v.archive == "zip",
},
fresh_pin,
)
}
None => {
if pinned.is_some() {
tracing::warn!(
version = %version,
platform = %platform.label(),
"lockfile runtime pin has no variant for this platform; using live checksums"
);
}
let sums =
shasums::load_shasums(&self.http, &self.cfg, &artifact_base, &version).await?;
let filename = artifact_filename(&version, &platform);
let digest = sums.for_file(&filename).copied().ok_or_else(|| {
Error::UnsupportedPlatform {
platform: platform.label(),
}
})?;
let pin = self.build_full_pin(&version).await.unwrap_or_else(|e| {
tracing::debug!(error = %e, "could not build full runtime pin");
PinnedNode {
version: version.clone(),
variants: Vec::new(),
}
});
(
DownloadSpec {
url: format!("{artifact_base}/v{version}/{filename}"),
expected_sha256: digest,
zip: platform.os == "win32",
},
Some(pin),
)
}
};
let installed = self.install(&version, &download, progress).await?;
Ok(Some(Resolution {
version: installed.version.clone(),
bin_dir: Some(installed.bin_dir.clone()),
node_bin: installed.node_bin.clone(),
from: ResolvedFrom::FreshInstall(installed.origin),
fresh_pin,
}))
}
async fn install(
&self,
version: &node_semver::Version,
download: &DownloadSpec,
progress: &dyn DownloadProgress,
) -> Result<InstalledNode, Error> {
match self.cfg.installer {
InstallerMode::Aube => {
installer::install(&self.http, version, download, progress).await
}
InstallerMode::Mise => {
let Some(mise_bin) = mise::mise_on_path() else {
return Err(Error::MiseInstallFailed {
version: format!("node@{version}"),
reason: "runtimeInstaller=mise but mise is not on PATH".to_string(),
});
};
mise::install_via_mise(&mise_bin, version, progress).await
}
InstallerMode::Auto => match mise::mise_on_path() {
Some(mise_bin) => {
match mise::install_via_mise(&mise_bin, version, progress).await {
Ok(node) => Ok(node),
Err(e) => {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK,
error = %e,
"mise failed to install the runtime; falling back to aube's own download"
);
installer::install(&self.http, version, download, progress).await
}
}
}
None => installer::install(&self.http, version, download, progress).await,
},
}
}
fn unsatisfied(&self, req: &NodeRequest) -> Error {
let current = discover::probe_path_node()
.map(|(v, _)| format!(" (PATH provides {v})"))
.unwrap_or_else(|| " (no node on PATH)".to_string());
Error::VersionUnsatisfied {
requested: req.raw.clone(),
hint: format!(
"{current}; required by {} at {}",
req.source.label(),
req.origin.display()
),
}
}
pub async fn resolve_for_lockfile(&self, spec: &NodeSpec) -> Result<PinnedNode, Error> {
let platform = Platform::current()?;
let version = match spec {
NodeSpec::Exact(version) => version.clone(),
_ => {
let entries = index::load_index(&self.http, &self.cfg).await?;
index::select(&entries, spec, &platform)
.ok_or_else(|| Error::NoMatchingVersion {
requested: spec.display(),
platform_note: String::new(),
})?
.version
.clone()
}
};
let pin = self.build_full_pin(&version).await?;
if requires_official_host_variant(&platform)
&& pin
.variant_for(&platform.os, &platform.cpu, platform.libc.as_deref())
.is_none()
{
return Err(Error::UnsupportedPlatform {
platform: platform.label(),
});
}
Ok(pin)
}
async fn build_full_pin(&self, version: &node_semver::Version) -> Result<PinnedNode, Error> {
let base = self.cfg.mirror_base();
let sums = shasums::load_shasums(&self.http, &self.cfg, &base, version).await?;
let mut variants = variants_from_shasums(&base, version, sums.iter());
if self.cfg.mirror.is_none() {
let musl_base = crate::UNOFFICIAL_BASE;
match shasums::load_shasums(&self.http, &self.cfg, musl_base, version).await {
Ok(musl_sums) => {
variants.extend(
variants_from_shasums(musl_base, version, musl_sums.iter())
.into_iter()
.filter(|v| v.libc.as_deref() == Some("musl")),
);
}
Err(e) => {
tracing::debug!(error = %e, "no musl builds recorded for v{version}");
}
}
}
Ok(PinnedNode {
version: version.clone(),
variants,
})
}
}
fn requires_official_host_variant(platform: &Platform) -> bool {
platform.os != "freebsd" && platform.libc.as_deref() != Some("musl")
}
fn warn_version_mismatch(req: &NodeRequest) {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH,
requested = %req.raw,
source = req.source.label(),
"the active Node.js does not satisfy the project's runtime requirement"
);
}
fn local_resolution(target: &NodeSpec) -> Option<Resolution> {
if let Some((version, node_bin)) = discover::probe_path_node()
&& target.satisfied_by(&version) == Some(true)
{
return Some(Resolution {
version,
bin_dir: None,
node_bin,
from: ResolvedFrom::PathEnv,
fresh_pin: None,
});
}
let best = discover::list_installed()
.into_iter()
.filter(|n| target.satisfied_by(&n.version) == Some(true))
.max_by(|a, b| a.version.cmp(&b.version))?;
Some(Resolution {
version: best.version.clone(),
bin_dir: Some(best.bin_dir.clone()),
node_bin: best.node_bin.clone(),
from: ResolvedFrom::Installed(best.origin),
fresh_pin: None,
})
}
fn variants_from_shasums<'a>(
base: &str,
version: &node_semver::Version,
entries: impl Iterator<Item = (&'a String, &'a [u8; 32])>,
) -> Vec<PinnedVariant> {
let prefix = format!("node-v{version}-");
let mut out = Vec::new();
for (filename, digest) in entries {
let Some(rest) = filename.strip_prefix(&prefix) else {
continue;
};
let (slug, ext) = if let Some(s) = rest.strip_suffix(".tar.gz") {
(s, "tar.gz")
} else if let Some(s) = rest.strip_suffix(".zip") {
(s, "zip")
} else {
continue;
};
let (slug, musl) = match slug.strip_suffix("-musl") {
Some(s) => (s, true),
None => (slug, false),
};
let Some((os_raw, cpu)) = slug.split_once('-') else {
continue;
};
if cpu.contains('-') {
continue;
}
let os = match os_raw {
"win" => "win32",
"osx" | "darwin" => "darwin",
"linux" => "linux",
"aix" => "aix",
_ => continue,
};
let bin: BTreeMap<String, String> = if os == "win32" {
[("node".to_string(), "node.exe".to_string())].into()
} else {
[("node".to_string(), "bin/node".to_string())].into()
};
out.push(PinnedVariant {
os: os.to_string(),
cpu: cpu.to_string(),
libc: musl.then(|| "musl".to_string()),
archive: if ext == "zip" { "zip" } else { "tarball" }.to_string(),
url: format!("{base}/v{version}/{filename}"),
integrity_sri: sri_sha256(digest),
bin,
prefix: (ext == "zip").then(|| {
let plat = Platform {
os: os.to_string(),
cpu: cpu.to_string(),
libc: musl.then(|| "musl".to_string()),
};
artifact_top_dir(version, &plat)
}),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_variant_validation_skips_non_official_distributions() {
let platform = |os: &str, libc: Option<&str>| Platform {
os: os.to_string(),
cpu: "x64".to_string(),
libc: libc.map(str::to_string),
};
assert!(requires_official_host_variant(&platform("linux", None)));
assert!(requires_official_host_variant(&platform("darwin", None)));
assert!(!requires_official_host_variant(&platform(
"linux",
Some("musl")
)));
assert!(!requires_official_host_variant(&platform("freebsd", None)));
}
#[tokio::test]
async fn exact_lockfile_pin_skips_release_index() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let mirror = format!("http://{address}");
let version = node_semver::Version::parse("22.23.2").unwrap();
let cache_path = crate::paths::shasums_cache_path(&mirror, &version);
if let Some(path) = cache_path.as_deref() {
let _ = std::fs::remove_file(path);
}
struct CacheCleanup(Option<std::path::PathBuf>);
impl Drop for CacheCleanup {
fn drop(&mut self) {
if let Some(path) = self.0.as_deref() {
let _ = std::fs::remove_file(path);
}
}
}
let _cache_cleanup = CacheCleanup(cache_path);
let platform = Platform::current().unwrap();
let filename = artifact_filename(&version, &platform);
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 2048];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]);
let path = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap()
.to_string();
let body = format!(
"{} {filename}\n",
"0000000000000000000000000000000000000000000000000000000000000000"
);
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
path
});
let runtime = NodeRuntime::new(RuntimeConfig {
mirror: Some(mirror),
retries: 0,
..RuntimeConfig::default()
});
let pin = runtime
.resolve_for_lockfile(&NodeSpec::Exact(version.clone()))
.await
.unwrap();
assert_eq!(pin.version, version);
assert_eq!(server.join().unwrap(), "/v22.23.2/SHASUMS256.txt");
}
#[test]
fn shasums_variant_mapping() {
let version: node_semver::Version = "24.4.1".parse().unwrap();
let entries: Vec<(String, [u8; 32])> = vec![
("node-v24.4.1-darwin-arm64.tar.gz".into(), [1; 32]),
("node-v24.4.1-linux-x64.tar.gz".into(), [2; 32]),
("node-v24.4.1-linux-x64-musl.tar.gz".into(), [3; 32]),
("node-v24.4.1-win-x64.zip".into(), [4; 32]),
("node-v24.4.1-headers.tar.gz".into(), [5; 32]),
("node-v24.4.1.pkg".into(), [6; 32]),
("node-v24.4.1-win-x64.7z".into(), [7; 32]),
("node-v24.4.1-darwin-arm64.tar.xz".into(), [8; 32]),
];
let variants = variants_from_shasums(
"https://nodejs.org/download/release",
&version,
entries.iter().map(|(k, v)| (k, v)),
);
let labels: Vec<String> = variants
.iter()
.map(|v| {
format!(
"{}-{}{}",
v.os,
v.cpu,
v.libc
.as_deref()
.map(|l| format!("-{l}"))
.unwrap_or_default()
)
})
.collect();
assert_eq!(
labels,
vec!["darwin-arm64", "linux-x64", "linux-x64-musl", "win32-x64"]
);
let win = variants.iter().find(|v| v.os == "win32").unwrap();
assert_eq!(win.archive, "zip");
assert_eq!(win.prefix.as_deref(), Some("node-v24.4.1-win-x64"));
assert_eq!(win.bin.get("node").map(String::as_str), Some("node.exe"));
assert!(win.url.ends_with("/v24.4.1/node-v24.4.1-win-x64.zip"));
let mac = variants.iter().find(|v| v.os == "darwin").unwrap();
assert_eq!(mac.archive, "tarball");
assert_eq!(mac.prefix, None);
assert!(mac.integrity_sri.starts_with("sha256-"));
}
}