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 {
131            Some(p) => (p.version.clone(), None),
132            None => {
133                let selected = match index::load_index(&self.http, &self.cfg).await {
134                    Ok(entries) => index::select(&entries, &target, &platform)
135                        .map(|e| e.version.clone())
136                        .ok_or_else(|| Error::NoMatchingVersion {
137                            requested: req.raw.clone(),
138                            platform_note: format!(" with a build for {}", platform.label()),
139                        }),
140                    Err(e) => Err(e),
141                };
142                match selected {
143                    Ok(v) => (v, None),
144                    // Under warn/ignore the requirement is advisory —
145                    // an unreachable index must not block the command.
146                    Err(_) if req.on_fail == OnFail::Ignore => return Ok(None),
147                    Err(e) if req.on_fail == OnFail::Warn => {
148                        tracing::warn!(
149                            code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH,
150                            requested = %req.raw,
151                            source = req.source.label(),
152                            error = %e,
153                            "could not verify the project's runtime requirement; continuing on the active Node.js"
154                        );
155                        return Ok(None);
156                    }
157                    Err(e) => return Err(e),
158                }
159            }
160        };
161
162        // The exact version may already be present even though the
163        // range check above couldn't run (alias specs) or the pin
164        // differs from what PATH carries.
165        let exact = NodeSpec::Exact(version.clone());
166        if let Some(resolution) = local_resolution(&exact) {
167            return Ok(Some(resolution));
168        }
169        // Alias specs reach their policy here, after the index turned
170        // them into a concrete version (a confirmed mismatch, not a
171        // guess).
172        match req.on_fail {
173            OnFail::Ignore => return Ok(None),
174            OnFail::Warn => {
175                warn_version_mismatch(req);
176                return Ok(None);
177            }
178            OnFail::Error => return Err(self.unsatisfied(req)),
179            OnFail::Download => {}
180        }
181
182        // Build the download spec: lockfile variant when available,
183        // live SHASUMS otherwise.
184        let artifact_base = self.cfg.artifact_base(&platform);
185        let pinned_variant = pinned
186            .and_then(|p| p.variant_for(&platform.os, &platform.cpu, platform.libc.as_deref()));
187        let (download, fresh_pin) = match pinned_variant {
188            Some(v) => {
189                let expected =
190                    sha256_from_sri(&v.integrity_sri).ok_or_else(|| Error::ChecksumMismatch {
191                        url: v.url.clone(),
192                        expected: v.integrity_sri.clone(),
193                        actual: "<unparseable lockfile integrity>".to_string(),
194                    })?;
195                (
196                    DownloadSpec {
197                        url: v.url.clone(),
198                        expected_sha256: expected,
199                        zip: v.archive == "zip",
200                    },
201                    fresh_pin,
202                )
203            }
204            None => {
205                if pinned.is_some() {
206                    // Lockfile written before this platform was
207                    // supported — verify against live SHASUMS instead
208                    // and let the caller refresh the pin.
209                    tracing::warn!(
210                        version = %version,
211                        platform = %platform.label(),
212                        "lockfile runtime pin has no variant for this platform; using live checksums"
213                    );
214                }
215                let sums =
216                    shasums::load_shasums(&self.http, &self.cfg, &artifact_base, &version).await?;
217                let filename = artifact_filename(&version, &platform);
218                let digest = sums.for_file(&filename).copied().ok_or_else(|| {
219                    Error::UnsupportedPlatform {
220                        platform: platform.label(),
221                    }
222                })?;
223                let pin = self.build_full_pin(&version).await.unwrap_or_else(|e| {
224                    tracing::debug!(error = %e, "could not build full runtime pin");
225                    PinnedNode {
226                        version: version.clone(),
227                        variants: Vec::new(),
228                    }
229                });
230                (
231                    DownloadSpec {
232                        url: format!("{artifact_base}/v{version}/{filename}"),
233                        expected_sha256: digest,
234                        zip: platform.os == "win32",
235                    },
236                    Some(pin),
237                )
238            }
239        };
240
241        // Install, honoring the delegation mode.
242        let installed = self.install(&version, &download, progress).await?;
243        Ok(Some(Resolution {
244            version: installed.version.clone(),
245            bin_dir: Some(installed.bin_dir.clone()),
246            node_bin: installed.node_bin.clone(),
247            from: ResolvedFrom::FreshInstall(installed.origin),
248            fresh_pin,
249        }))
250    }
251
252    async fn install(
253        &self,
254        version: &node_semver::Version,
255        download: &DownloadSpec,
256        progress: &dyn DownloadProgress,
257    ) -> Result<InstalledNode, Error> {
258        match self.cfg.installer {
259            InstallerMode::Aube => {
260                installer::install(&self.http, version, download, progress).await
261            }
262            InstallerMode::Mise => {
263                let Some(mise_bin) = mise::mise_on_path() else {
264                    return Err(Error::MiseInstallFailed {
265                        version: format!("node@{version}"),
266                        reason: "runtimeInstaller=mise but mise is not on PATH".to_string(),
267                    });
268                };
269                mise::install_via_mise(&mise_bin, version, progress).await
270            }
271            InstallerMode::Auto => match mise::mise_on_path() {
272                Some(mise_bin) => {
273                    match mise::install_via_mise(&mise_bin, version, progress).await {
274                        Ok(node) => Ok(node),
275                        Err(e) => {
276                            tracing::warn!(
277                                code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK,
278                                error = %e,
279                                "mise failed to install the runtime; falling back to aube's own download"
280                            );
281                            installer::install(&self.http, version, download, progress).await
282                        }
283                    }
284                }
285                None => installer::install(&self.http, version, download, progress).await,
286            },
287        }
288    }
289
290    fn unsatisfied(&self, req: &NodeRequest) -> Error {
291        let current = discover::probe_path_node()
292            .map(|(v, _)| format!(" (PATH provides {v})"))
293            .unwrap_or_else(|| " (no node on PATH)".to_string());
294        Error::VersionUnsatisfied {
295            requested: req.raw.clone(),
296            hint: format!(
297                "{current}; required by {} at {}",
298                req.source.label(),
299                req.origin.display()
300            ),
301        }
302    }
303
304    /// Resolve `spec` to an exact version plus the full per-platform
305    /// artifact set — the lockfile-pin path. Always network-backed
306    /// (through the disk caches).
307    pub async fn resolve_for_lockfile(&self, spec: &NodeSpec) -> Result<PinnedNode, Error> {
308        let platform = Platform::current()?;
309        let entries = index::load_index(&self.http, &self.cfg).await?;
310        let entry =
311            index::select(&entries, spec, &platform).ok_or_else(|| Error::NoMatchingVersion {
312                requested: spec.display(),
313                platform_note: String::new(),
314            })?;
315        let version = entry.version.clone();
316        self.build_full_pin(&version).await
317    }
318
319    /// Build a full pin (all platforms) from SHASUMS data: the
320    /// configured mirror's checksums, plus — when running against the
321    /// default official mirror — unofficial-builds' musl checksums,
322    /// best-effort (older releases have no musl builds).
323    async fn build_full_pin(&self, version: &node_semver::Version) -> Result<PinnedNode, Error> {
324        let base = self.cfg.mirror_base();
325        let sums = shasums::load_shasums(&self.http, &self.cfg, &base, version).await?;
326        let mut variants = variants_from_shasums(&base, version, sums.iter());
327        if self.cfg.mirror.is_none() {
328            let musl_base = crate::UNOFFICIAL_BASE;
329            match shasums::load_shasums(&self.http, &self.cfg, musl_base, version).await {
330                Ok(musl_sums) => {
331                    variants.extend(
332                        variants_from_shasums(musl_base, version, musl_sums.iter())
333                            .into_iter()
334                            .filter(|v| v.libc.as_deref() == Some("musl")),
335                    );
336                }
337                Err(e) => {
338                    tracing::debug!(error = %e, "no musl builds recorded for v{version}");
339                }
340            }
341        }
342        Ok(PinnedNode {
343            version: version.clone(),
344            variants,
345        })
346    }
347}
348
349fn warn_version_mismatch(req: &NodeRequest) {
350    tracing::warn!(
351        code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH,
352        requested = %req.raw,
353        source = req.source.label(),
354        "the active Node.js does not satisfy the project's runtime requirement"
355    );
356}
357
358/// Zero-network resolution: PATH probe, then installed scan. Only
359/// meaningful for `Exact` / `Range` targets.
360fn local_resolution(target: &NodeSpec) -> Option<Resolution> {
361    if let Some((version, node_bin)) = discover::probe_path_node()
362        && target.satisfied_by(&version) == Some(true)
363    {
364        return Some(Resolution {
365            version,
366            bin_dir: None,
367            node_bin,
368            from: ResolvedFrom::PathEnv,
369            fresh_pin: None,
370        });
371    }
372    let best = discover::list_installed()
373        .into_iter()
374        .filter(|n| target.satisfied_by(&n.version) == Some(true))
375        .max_by(|a, b| a.version.cmp(&b.version))?;
376    Some(Resolution {
377        version: best.version.clone(),
378        bin_dir: Some(best.bin_dir.clone()),
379        node_bin: best.node_bin.clone(),
380        from: ResolvedFrom::Installed(best.origin),
381        fresh_pin: None,
382    })
383}
384
385/// Map SHASUMS entries (`<hex>  node-v{V}-{os}-{arch}[-musl].{ext}`)
386/// onto lockfile variants, mirroring pnpm's `readNodeAssetsFromMirror`:
387/// `win` → `win32`, bin paths per OS, `prefix` set for zips.
388fn variants_from_shasums<'a>(
389    base: &str,
390    version: &node_semver::Version,
391    entries: impl Iterator<Item = (&'a String, &'a [u8; 32])>,
392) -> Vec<PinnedVariant> {
393    let prefix = format!("node-v{version}-");
394    let mut out = Vec::new();
395    for (filename, digest) in entries {
396        let Some(rest) = filename.strip_prefix(&prefix) else {
397            continue;
398        };
399        let (slug, ext) = if let Some(s) = rest.strip_suffix(".tar.gz") {
400            (s, "tar.gz")
401        } else if let Some(s) = rest.strip_suffix(".zip") {
402            (s, "zip")
403        } else {
404            continue;
405        };
406        let (slug, musl) = match slug.strip_suffix("-musl") {
407            Some(s) => (s, true),
408            None => (slug, false),
409        };
410        let Some((os_raw, cpu)) = slug.split_once('-') else {
411            continue;
412        };
413        // Only the canonical platform pairs; skip exotic artifacts
414        // (headers, pkg, 7z multi-dash names fall out naturally via
415        // the extension filter, `win-x64-7z` via the split shape).
416        if cpu.contains('-') {
417            continue;
418        }
419        let os = match os_raw {
420            "win" => "win32",
421            "osx" | "darwin" => "darwin",
422            "linux" => "linux",
423            "aix" => "aix",
424            _ => continue,
425        };
426        let bin: BTreeMap<String, String> = if os == "win32" {
427            [("node".to_string(), "node.exe".to_string())].into()
428        } else {
429            [("node".to_string(), "bin/node".to_string())].into()
430        };
431        out.push(PinnedVariant {
432            os: os.to_string(),
433            cpu: cpu.to_string(),
434            libc: musl.then(|| "musl".to_string()),
435            archive: if ext == "zip" { "zip" } else { "tarball" }.to_string(),
436            url: format!("{base}/v{version}/{filename}"),
437            integrity_sri: sri_sha256(digest),
438            bin,
439            prefix: (ext == "zip").then(|| {
440                let plat = Platform {
441                    os: os.to_string(),
442                    cpu: cpu.to_string(),
443                    libc: musl.then(|| "musl".to_string()),
444                };
445                artifact_top_dir(version, &plat)
446            }),
447        });
448    }
449    out
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn shasums_variant_mapping() {
458        let version: node_semver::Version = "24.4.1".parse().unwrap();
459        let entries: Vec<(String, [u8; 32])> = vec![
460            ("node-v24.4.1-darwin-arm64.tar.gz".into(), [1; 32]),
461            ("node-v24.4.1-linux-x64.tar.gz".into(), [2; 32]),
462            ("node-v24.4.1-linux-x64-musl.tar.gz".into(), [3; 32]),
463            ("node-v24.4.1-win-x64.zip".into(), [4; 32]),
464            ("node-v24.4.1-headers.tar.gz".into(), [5; 32]),
465            ("node-v24.4.1.pkg".into(), [6; 32]),
466            ("node-v24.4.1-win-x64.7z".into(), [7; 32]),
467            ("node-v24.4.1-darwin-arm64.tar.xz".into(), [8; 32]),
468        ];
469        let variants = variants_from_shasums(
470            "https://nodejs.org/download/release",
471            &version,
472            entries.iter().map(|(k, v)| (k, v)),
473        );
474        let labels: Vec<String> = variants
475            .iter()
476            .map(|v| {
477                format!(
478                    "{}-{}{}",
479                    v.os,
480                    v.cpu,
481                    v.libc
482                        .as_deref()
483                        .map(|l| format!("-{l}"))
484                        .unwrap_or_default()
485                )
486            })
487            .collect();
488        assert_eq!(
489            labels,
490            vec!["darwin-arm64", "linux-x64", "linux-x64-musl", "win32-x64"]
491        );
492        let win = variants.iter().find(|v| v.os == "win32").unwrap();
493        assert_eq!(win.archive, "zip");
494        assert_eq!(win.prefix.as_deref(), Some("node-v24.4.1-win-x64"));
495        assert_eq!(win.bin.get("node").map(String::as_str), Some("node.exe"));
496        assert!(win.url.ends_with("/v24.4.1/node-v24.4.1-win-x64.zip"));
497        let mac = variants.iter().find(|v| v.os == "darwin").unwrap();
498        assert_eq!(mac.archive, "tarball");
499        assert_eq!(mac.prefix, None);
500        assert!(mac.integrity_sri.starts_with("sha256-"));
501    }
502}