Skip to main content

aube_runtime/
resolver.rs

1//! The resolution state machine. Hot-path guarantee: when a
2//! satisfying Node is already on PATH or installed (aube or mise),
3//! resolution touches the network never and spawns at most one
4//! memoized `node --version`.
5
6use 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/// How a resolution was satisfied.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ResolvedFrom {
24    /// The `node` already on PATH satisfies the request — no PATH
25    /// manipulation needed.
26    PathEnv,
27    /// An existing install (aube's runtime dir or mise's installs).
28    Installed(InstallOrigin),
29    /// Installed during this resolution.
30    FreshInstall(InstallOrigin),
31}
32
33/// A successfully resolved runtime.
34#[derive(Debug, Clone)]
35pub struct Resolution {
36    pub version: node_semver::Version,
37    /// Directory to prepend to PATH. `None` for [`ResolvedFrom::PathEnv`].
38    pub bin_dir: Option<PathBuf>,
39    pub node_bin: PathBuf,
40    pub from: ResolvedFrom,
41    /// Populated when this resolution hit the network index — lets the
42    /// caller record/refresh the lockfile pin without a second
43    /// SHASUMS round-trip.
44    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    /// Resolve `req`, preferring the lockfile `pinned` version when
64    /// present.
65    ///
66    /// `Ok(None)` means "leave the environment alone": the request
67    /// couldn't be satisfied locally and the `onFail` policy
68    /// (`ignore`/`warn`) says to keep running on whatever node PATH
69    /// provides. `Ok(Some(_))` is a concrete runtime to put on PATH
70    /// (or, for [`ResolvedFrom::PathEnv`], to leave as-is).
71    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        // The lockfile pin wins over the range: reproducibility.
96        let target = match pinned {
97            Some(p) => NodeSpec::Exact(p.version.clone()),
98            None => req.spec.clone(),
99        };
100
101        // Zero-network fast paths. Lts/Latest/codename targets skip
102        // them — satisfaction is unknowable without the index.
103        if let Some(resolution) = local_resolution(&target) {
104            return Ok(Some(resolution));
105        }
106
107        // Locally unsatisfiable: apply policy before touching the
108        // network — but only for specs whose satisfaction is locally
109        // decidable. Alias specs (`lts`, `latest`, codenames) need the
110        // index first under *every* policy: the installed node may
111        // well BE the latest LTS, and warning or erroring without
112        // checking would be a false positive. Policy gates runtime
113        // downloads, not metadata fetches.
114        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        // Network: pin the spec to an exact version.
128        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            // An exact version can go straight to the immutable
133            // per-release SHASUMS file. Besides saving the index
134            // round-trip, this lets an exact request resolve when a
135            // mirror serves release artifacts but no global index.
136            (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                    // Under warn/ignore the requirement is advisory —
150                    // an unreachable index must not block the command.
151                    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        // The exact version may already be present even though the
168        // range check above couldn't run (alias specs) or the pin
169        // differs from what PATH carries.
170        let exact = NodeSpec::Exact(version.clone());
171        if let Some(resolution) = local_resolution(&exact) {
172            return Ok(Some(resolution));
173        }
174        // Alias specs reach their policy here, after the index turned
175        // them into a concrete version (a confirmed mismatch, not a
176        // guess).
177        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        // Build the download spec: lockfile variant when available,
188        // live SHASUMS otherwise.
189        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                    // Lockfile written before this platform was
212                    // supported — verify against live SHASUMS instead
213                    // and let the caller refresh the pin.
214                    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        // Install, honoring the delegation mode.
247        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    /// Resolve `spec` to an exact version plus the full per-platform
310    /// artifact set — the lockfile-pin path. Always network-backed
311    /// (through the disk caches).
312    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        // The release index is only a coarse availability gate. Validate the
329        // selected release against its checksums as well, while leaving
330        // FreeBSD and best-effort musl pins usable: those hosts intentionally
331        // rely on system/mise Node or a later live-checksum lookup.
332        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    /// Build a full pin (all platforms) from SHASUMS data: the
345    /// configured mirror's checksums, plus — when running against the
346    /// default official mirror — unofficial-builds' musl checksums,
347    /// best-effort (older releases have no musl builds).
348    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
387/// Zero-network resolution: PATH probe, then installed scan. Only
388/// meaningful for `Exact` / `Range` targets.
389fn 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
414/// Map SHASUMS entries (`<hex>  node-v{V}-{os}-{arch}[-musl].{ext}`)
415/// onto lockfile variants, mirroring pnpm's `readNodeAssetsFromMirror`:
416/// `win` → `win32`, bin paths per OS, `prefix` set for zips.
417fn 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        // Only the canonical platform pairs; skip exotic artifacts
443        // (headers, pkg, 7z multi-dash names fall out naturally via
444        // the extension filter, `win-x64-7z` via the split shape).
445        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}