1use crate::discover::{self, InstallOrigin, InstalledNode};
7use crate::error::Error;
8use crate::http::Http;
9use crate::index;
10use crate::installer::{self, DownloadSpec};
11use crate::mise;
12use crate::platform::{Platform, artifact_filename, artifact_top_dir};
13use crate::progress::DownloadProgress;
14use crate::shasums::{self, sha256_from_sri, sri_sha256};
15use crate::spec::{NodeRequest, NodeSpec};
16use crate::{InstallerMode, PinnedNode, PinnedVariant, RuntimeConfig};
17use aube_manifest::OnFail;
18use std::collections::BTreeMap;
19use std::path::PathBuf;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ResolvedFrom {
24 PathEnv,
27 Installed(InstallOrigin),
29 FreshInstall(InstallOrigin),
31}
32
33#[derive(Debug, Clone)]
35pub struct Resolution {
36 pub version: node_semver::Version,
37 pub bin_dir: Option<PathBuf>,
39 pub node_bin: PathBuf,
40 pub from: ResolvedFrom,
41 pub fresh_pin: Option<PinnedNode>,
45}
46
47pub struct NodeRuntime {
48 pub(crate) cfg: RuntimeConfig,
49 pub(crate) http: Http,
50 memo: tokio::sync::Mutex<BTreeMap<String, Option<Resolution>>>,
51}
52
53impl NodeRuntime {
54 pub fn new(cfg: RuntimeConfig) -> Self {
55 let http = Http::new(cfg.retries);
56 NodeRuntime {
57 cfg,
58 http,
59 memo: tokio::sync::Mutex::new(BTreeMap::new()),
60 }
61 }
62
63 pub async fn resolve(
72 &self,
73 req: &NodeRequest,
74 pinned: Option<&PinnedNode>,
75 progress: &dyn DownloadProgress,
76 ) -> Result<Option<Resolution>, Error> {
77 let memo_key = match pinned {
78 Some(p) => format!("pin:{}", p.version),
79 None => format!("spec:{}", req.raw),
80 };
81 if let Some(hit) = self.memo.lock().await.get(&memo_key) {
82 return Ok(hit.clone());
83 }
84 let result = self.resolve_uncached(req, pinned, progress).await?;
85 self.memo.lock().await.insert(memo_key, result.clone());
86 Ok(result)
87 }
88
89 async fn resolve_uncached(
90 &self,
91 req: &NodeRequest,
92 pinned: Option<&PinnedNode>,
93 progress: &dyn DownloadProgress,
94 ) -> Result<Option<Resolution>, Error> {
95 let target = match pinned {
97 Some(p) => NodeSpec::Exact(p.version.clone()),
98 None => req.spec.clone(),
99 };
100
101 if let Some(resolution) = local_resolution(&target) {
104 return Ok(Some(resolution));
105 }
106
107 let locally_decidable = matches!(target, NodeSpec::Exact(_) | NodeSpec::Range(_));
115 if locally_decidable {
116 match req.on_fail {
117 OnFail::Ignore => return Ok(None),
118 OnFail::Warn => {
119 warn_version_mismatch(req);
120 return Ok(None);
121 }
122 OnFail::Error => return Err(self.unsatisfied(req)),
123 OnFail::Download => {}
124 }
125 }
126
127 progress.on_phase(None, crate::progress::InstallPhase::Resolving);
129 let platform = Platform::current()?;
130 let (version, fresh_pin) = match (pinned, &target) {
131 (Some(p), _) => (p.version.clone(), None),
132 (None, NodeSpec::Exact(version)) => (version.clone(), None),
137 (None, _) => {
138 let selected = match index::load_index(&self.http, &self.cfg).await {
139 Ok(entries) => index::select(&entries, &target, &platform)
140 .map(|e| e.version.clone())
141 .ok_or_else(|| Error::NoMatchingVersion {
142 requested: req.raw.clone(),
143 platform_note: format!(" with a build for {}", platform.label()),
144 }),
145 Err(e) => Err(e),
146 };
147 match selected {
148 Ok(v) => (v, None),
149 Err(_) if req.on_fail == OnFail::Ignore => return Ok(None),
152 Err(e) if req.on_fail == OnFail::Warn => {
153 tracing::warn!(
154 code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH,
155 requested = %req.raw,
156 source = req.source.label(),
157 error = %e,
158 "could not verify the project's runtime requirement; continuing on the active Node.js"
159 );
160 return Ok(None);
161 }
162 Err(e) => return Err(e),
163 }
164 }
165 };
166
167 let exact = NodeSpec::Exact(version.clone());
171 if let Some(resolution) = local_resolution(&exact) {
172 return Ok(Some(resolution));
173 }
174 match req.on_fail {
178 OnFail::Ignore => return Ok(None),
179 OnFail::Warn => {
180 warn_version_mismatch(req);
181 return Ok(None);
182 }
183 OnFail::Error => return Err(self.unsatisfied(req)),
184 OnFail::Download => {}
185 }
186
187 let artifact_base = self.cfg.artifact_base(&platform);
190 let pinned_variant = pinned
191 .and_then(|p| p.variant_for(&platform.os, &platform.cpu, platform.libc.as_deref()));
192 let (download, fresh_pin) = match pinned_variant {
193 Some(v) => {
194 let expected =
195 sha256_from_sri(&v.integrity_sri).ok_or_else(|| Error::ChecksumMismatch {
196 url: v.url.clone(),
197 expected: v.integrity_sri.clone(),
198 actual: "<unparseable lockfile integrity>".to_string(),
199 })?;
200 (
201 DownloadSpec {
202 url: v.url.clone(),
203 expected_sha256: expected,
204 zip: v.archive == "zip",
205 },
206 fresh_pin,
207 )
208 }
209 None => {
210 if pinned.is_some() {
211 tracing::warn!(
215 version = %version,
216 platform = %platform.label(),
217 "lockfile runtime pin has no variant for this platform; using live checksums"
218 );
219 }
220 let sums =
221 shasums::load_shasums(&self.http, &self.cfg, &artifact_base, &version).await?;
222 let filename = artifact_filename(&version, &platform);
223 let digest = sums.for_file(&filename).copied().ok_or_else(|| {
224 Error::UnsupportedPlatform {
225 platform: platform.label(),
226 }
227 })?;
228 let pin = self.build_full_pin(&version).await.unwrap_or_else(|e| {
229 tracing::debug!(error = %e, "could not build full runtime pin");
230 PinnedNode {
231 version: version.clone(),
232 variants: Vec::new(),
233 }
234 });
235 (
236 DownloadSpec {
237 url: format!("{artifact_base}/v{version}/{filename}"),
238 expected_sha256: digest,
239 zip: platform.os == "win32",
240 },
241 Some(pin),
242 )
243 }
244 };
245
246 let installed = self.install(&version, &download, progress).await?;
248 Ok(Some(Resolution {
249 version: installed.version.clone(),
250 bin_dir: Some(installed.bin_dir.clone()),
251 node_bin: installed.node_bin.clone(),
252 from: ResolvedFrom::FreshInstall(installed.origin),
253 fresh_pin,
254 }))
255 }
256
257 async fn install(
258 &self,
259 version: &node_semver::Version,
260 download: &DownloadSpec,
261 progress: &dyn DownloadProgress,
262 ) -> Result<InstalledNode, Error> {
263 match self.cfg.installer {
264 InstallerMode::Aube => {
265 installer::install(&self.http, version, download, progress).await
266 }
267 InstallerMode::Mise => {
268 let Some(mise_bin) = mise::mise_on_path() else {
269 return Err(Error::MiseInstallFailed {
270 version: format!("node@{version}"),
271 reason: "runtimeInstaller=mise but mise is not on PATH".to_string(),
272 });
273 };
274 mise::install_via_mise(&mise_bin, version, progress).await
275 }
276 InstallerMode::Auto => match mise::mise_on_path() {
277 Some(mise_bin) => {
278 match mise::install_via_mise(&mise_bin, version, progress).await {
279 Ok(node) => Ok(node),
280 Err(e) => {
281 tracing::warn!(
282 code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK,
283 error = %e,
284 "mise failed to install the runtime; falling back to aube's own download"
285 );
286 installer::install(&self.http, version, download, progress).await
287 }
288 }
289 }
290 None => installer::install(&self.http, version, download, progress).await,
291 },
292 }
293 }
294
295 fn unsatisfied(&self, req: &NodeRequest) -> Error {
296 let current = discover::probe_path_node()
297 .map(|(v, _)| format!(" (PATH provides {v})"))
298 .unwrap_or_else(|| " (no node on PATH)".to_string());
299 Error::VersionUnsatisfied {
300 requested: req.raw.clone(),
301 hint: format!(
302 "{current}; required by {} at {}",
303 req.source.label(),
304 req.origin.display()
305 ),
306 }
307 }
308
309 pub async fn resolve_for_lockfile(&self, spec: &NodeSpec) -> Result<PinnedNode, Error> {
313 let platform = Platform::current()?;
314 let version = match spec {
315 NodeSpec::Exact(version) => version.clone(),
316 _ => {
317 let entries = index::load_index(&self.http, &self.cfg).await?;
318 index::select(&entries, spec, &platform)
319 .ok_or_else(|| Error::NoMatchingVersion {
320 requested: spec.display(),
321 platform_note: String::new(),
322 })?
323 .version
324 .clone()
325 }
326 };
327 let pin = self.build_full_pin(&version).await?;
328 if requires_official_host_variant(&platform)
333 && pin
334 .variant_for(&platform.os, &platform.cpu, platform.libc.as_deref())
335 .is_none()
336 {
337 return Err(Error::UnsupportedPlatform {
338 platform: platform.label(),
339 });
340 }
341 Ok(pin)
342 }
343
344 async fn build_full_pin(&self, version: &node_semver::Version) -> Result<PinnedNode, Error> {
349 let base = self.cfg.mirror_base();
350 let sums = shasums::load_shasums(&self.http, &self.cfg, &base, version).await?;
351 let mut variants = variants_from_shasums(&base, version, sums.iter());
352 if self.cfg.mirror.is_none() {
353 let musl_base = crate::UNOFFICIAL_BASE;
354 match shasums::load_shasums(&self.http, &self.cfg, musl_base, version).await {
355 Ok(musl_sums) => {
356 variants.extend(
357 variants_from_shasums(musl_base, version, musl_sums.iter())
358 .into_iter()
359 .filter(|v| v.libc.as_deref() == Some("musl")),
360 );
361 }
362 Err(e) => {
363 tracing::debug!(error = %e, "no musl builds recorded for v{version}");
364 }
365 }
366 }
367 Ok(PinnedNode {
368 version: version.clone(),
369 variants,
370 })
371 }
372}
373
374fn requires_official_host_variant(platform: &Platform) -> bool {
375 platform.os != "freebsd" && platform.libc.as_deref() != Some("musl")
376}
377
378fn warn_version_mismatch(req: &NodeRequest) {
379 tracing::warn!(
380 code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH,
381 requested = %req.raw,
382 source = req.source.label(),
383 "the active Node.js does not satisfy the project's runtime requirement"
384 );
385}
386
387fn local_resolution(target: &NodeSpec) -> Option<Resolution> {
390 if let Some((version, node_bin)) = discover::probe_path_node()
391 && target.satisfied_by(&version) == Some(true)
392 {
393 return Some(Resolution {
394 version,
395 bin_dir: None,
396 node_bin,
397 from: ResolvedFrom::PathEnv,
398 fresh_pin: None,
399 });
400 }
401 let best = discover::list_installed()
402 .into_iter()
403 .filter(|n| target.satisfied_by(&n.version) == Some(true))
404 .max_by(|a, b| a.version.cmp(&b.version))?;
405 Some(Resolution {
406 version: best.version.clone(),
407 bin_dir: Some(best.bin_dir.clone()),
408 node_bin: best.node_bin.clone(),
409 from: ResolvedFrom::Installed(best.origin),
410 fresh_pin: None,
411 })
412}
413
414fn variants_from_shasums<'a>(
418 base: &str,
419 version: &node_semver::Version,
420 entries: impl Iterator<Item = (&'a String, &'a [u8; 32])>,
421) -> Vec<PinnedVariant> {
422 let prefix = format!("node-v{version}-");
423 let mut out = Vec::new();
424 for (filename, digest) in entries {
425 let Some(rest) = filename.strip_prefix(&prefix) else {
426 continue;
427 };
428 let (slug, ext) = if let Some(s) = rest.strip_suffix(".tar.gz") {
429 (s, "tar.gz")
430 } else if let Some(s) = rest.strip_suffix(".zip") {
431 (s, "zip")
432 } else {
433 continue;
434 };
435 let (slug, musl) = match slug.strip_suffix("-musl") {
436 Some(s) => (s, true),
437 None => (slug, false),
438 };
439 let Some((os_raw, cpu)) = slug.split_once('-') else {
440 continue;
441 };
442 if cpu.contains('-') {
446 continue;
447 }
448 let os = match os_raw {
449 "win" => "win32",
450 "osx" | "darwin" => "darwin",
451 "linux" => "linux",
452 "aix" => "aix",
453 _ => continue,
454 };
455 let bin: BTreeMap<String, String> = if os == "win32" {
456 [("node".to_string(), "node.exe".to_string())].into()
457 } else {
458 [("node".to_string(), "bin/node".to_string())].into()
459 };
460 out.push(PinnedVariant {
461 os: os.to_string(),
462 cpu: cpu.to_string(),
463 libc: musl.then(|| "musl".to_string()),
464 archive: if ext == "zip" { "zip" } else { "tarball" }.to_string(),
465 url: format!("{base}/v{version}/{filename}"),
466 integrity_sri: sri_sha256(digest),
467 bin,
468 prefix: (ext == "zip").then(|| {
469 let plat = Platform {
470 os: os.to_string(),
471 cpu: cpu.to_string(),
472 libc: musl.then(|| "musl".to_string()),
473 };
474 artifact_top_dir(version, &plat)
475 }),
476 });
477 }
478 out
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn host_variant_validation_skips_non_official_distributions() {
487 let platform = |os: &str, libc: Option<&str>| Platform {
488 os: os.to_string(),
489 cpu: "x64".to_string(),
490 libc: libc.map(str::to_string),
491 };
492
493 assert!(requires_official_host_variant(&platform("linux", None)));
494 assert!(requires_official_host_variant(&platform("darwin", None)));
495 assert!(!requires_official_host_variant(&platform(
496 "linux",
497 Some("musl")
498 )));
499 assert!(!requires_official_host_variant(&platform("freebsd", None)));
500 }
501
502 #[tokio::test]
503 async fn exact_lockfile_pin_skips_release_index() {
504 use std::io::{Read, Write};
505
506 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
507 let address = listener.local_addr().unwrap();
508 let mirror = format!("http://{address}");
509 let version = node_semver::Version::parse("22.23.2").unwrap();
510 let cache_path = crate::paths::shasums_cache_path(&mirror, &version);
511 if let Some(path) = cache_path.as_deref() {
512 let _ = std::fs::remove_file(path);
513 }
514 struct CacheCleanup(Option<std::path::PathBuf>);
515 impl Drop for CacheCleanup {
516 fn drop(&mut self) {
517 if let Some(path) = self.0.as_deref() {
518 let _ = std::fs::remove_file(path);
519 }
520 }
521 }
522 let _cache_cleanup = CacheCleanup(cache_path);
523 let platform = Platform::current().unwrap();
524 let filename = artifact_filename(&version, &platform);
525 let server = std::thread::spawn(move || {
526 let (mut stream, _) = listener.accept().unwrap();
527 let mut request = [0_u8; 2048];
528 let read = stream.read(&mut request).unwrap();
529 let request = String::from_utf8_lossy(&request[..read]);
530 let path = request
531 .lines()
532 .next()
533 .and_then(|line| line.split_whitespace().nth(1))
534 .unwrap()
535 .to_string();
536 let body = format!(
537 "{} {filename}\n",
538 "0000000000000000000000000000000000000000000000000000000000000000"
539 );
540 write!(
541 stream,
542 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
543 body.len()
544 )
545 .unwrap();
546 path
547 });
548
549 let runtime = NodeRuntime::new(RuntimeConfig {
550 mirror: Some(mirror),
551 retries: 0,
552 ..RuntimeConfig::default()
553 });
554 let pin = runtime
555 .resolve_for_lockfile(&NodeSpec::Exact(version.clone()))
556 .await
557 .unwrap();
558
559 assert_eq!(pin.version, version);
560 assert_eq!(server.join().unwrap(), "/v22.23.2/SHASUMS256.txt");
561 }
562
563 #[test]
564 fn shasums_variant_mapping() {
565 let version: node_semver::Version = "24.4.1".parse().unwrap();
566 let entries: Vec<(String, [u8; 32])> = vec![
567 ("node-v24.4.1-darwin-arm64.tar.gz".into(), [1; 32]),
568 ("node-v24.4.1-linux-x64.tar.gz".into(), [2; 32]),
569 ("node-v24.4.1-linux-x64-musl.tar.gz".into(), [3; 32]),
570 ("node-v24.4.1-win-x64.zip".into(), [4; 32]),
571 ("node-v24.4.1-headers.tar.gz".into(), [5; 32]),
572 ("node-v24.4.1.pkg".into(), [6; 32]),
573 ("node-v24.4.1-win-x64.7z".into(), [7; 32]),
574 ("node-v24.4.1-darwin-arm64.tar.xz".into(), [8; 32]),
575 ];
576 let variants = variants_from_shasums(
577 "https://nodejs.org/download/release",
578 &version,
579 entries.iter().map(|(k, v)| (k, v)),
580 );
581 let labels: Vec<String> = variants
582 .iter()
583 .map(|v| {
584 format!(
585 "{}-{}{}",
586 v.os,
587 v.cpu,
588 v.libc
589 .as_deref()
590 .map(|l| format!("-{l}"))
591 .unwrap_or_default()
592 )
593 })
594 .collect();
595 assert_eq!(
596 labels,
597 vec!["darwin-arm64", "linux-x64", "linux-x64-musl", "win32-x64"]
598 );
599 let win = variants.iter().find(|v| v.os == "win32").unwrap();
600 assert_eq!(win.archive, "zip");
601 assert_eq!(win.prefix.as_deref(), Some("node-v24.4.1-win-x64"));
602 assert_eq!(win.bin.get("node").map(String::as_str), Some("node.exe"));
603 assert!(win.url.ends_with("/v24.4.1/node-v24.4.1-win-x64.zip"));
604 let mac = variants.iter().find(|v| v.os == "darwin").unwrap();
605 assert_eq!(mac.archive, "tarball");
606 assert_eq!(mac.prefix, None);
607 assert!(mac.integrity_sri.starts_with("sha256-"));
608 }
609}