Skip to main content

aube_runtime/
platform.rs

1//! Host platform detection and the two naming vocabularies it maps
2//! into:
3//!
4//! - **lockfile vocabulary** (pnpm / Node `process.platform`):
5//!   `darwin` / `linux` / `win32` / `freebsd`, `x64` / `arm64`,
6//!   `libc: musl`;
7//! - **dist-file vocabulary** (nodejs.org artifact names):
8//!   `node-v{V}-darwin-arm64.tar.gz`, `node-v{V}-linux-x64-musl.tar.gz`,
9//!   `node-v{V}-win-x64.zip`.
10
11use crate::error::Error;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Platform {
15    /// `process.platform` vocabulary: `darwin` / `linux` / `win32`.
16    pub os: String,
17    /// `process.arch` vocabulary: `x64` / `arm64` (others passed
18    /// through as-is from Rust's target arch mapping).
19    pub cpu: String,
20    /// `Some("musl")` on musl-libc Linux hosts.
21    pub libc: Option<String>,
22}
23
24impl Platform {
25    /// Detect the host platform.
26    ///
27    /// musl detection is a *runtime* check: aube ships static-musl
28    /// Linux binaries, so `cfg!(target_env = "musl")` is true even on
29    /// glibc hosts and cannot be trusted. The presence of musl's
30    /// dynamic loader (`/lib/ld-musl-<arch>.so.1`) is the signal mise
31    /// uses for the same decision.
32    pub fn current() -> Result<Platform, Error> {
33        let os = match std::env::consts::OS {
34            "macos" => "darwin",
35            "linux" => "linux",
36            "windows" => "win32",
37            // FreeBSD reports Node's `process.platform` value verbatim.
38            // nodejs.org ships no FreeBSD artifacts, so the download
39            // path can't satisfy a request here — but resolution reaches
40            // it only after the zero-network fast paths (PATH probe,
41            // installed scan, mise delegation) miss, and a missing dist
42            // then surfaces the normal `NoMatchingVersion` /
43            // `UnsupportedPlatform` diagnostic. Detecting the platform
44            // (rather than erroring outright) is what lets those local
45            // paths, `os`-field package filtering, and multi-platform
46            // lockfile pins behave correctly on FreeBSD.
47            "freebsd" => "freebsd",
48            other => {
49                return Err(Error::UnsupportedPlatform {
50                    platform: format!("{other}-{}", std::env::consts::ARCH),
51                });
52            }
53        };
54        let cpu = match std::env::consts::ARCH {
55            "x86_64" => "x64",
56            "aarch64" => "arm64",
57            "x86" => "x86",
58            "powerpc64" => "ppc64",
59            "s390x" => "s390x",
60            other => other,
61        };
62        let libc = (os == "linux" && detect_musl()).then(|| "musl".to_string());
63        Ok(Platform {
64            os: os.to_string(),
65            cpu: cpu.to_string(),
66            libc,
67        })
68    }
69
70    /// The platform segment of a dist artifact name:
71    /// `darwin-arm64`, `linux-x64-musl`, `win-x64`.
72    pub fn dist_slug(&self) -> String {
73        let os = if self.os == "win32" { "win" } else { &self.os };
74        let musl = if self.libc.as_deref() == Some("musl") {
75            "-musl"
76        } else {
77            ""
78        };
79        format!("{os}-{}{musl}", self.cpu)
80    }
81
82    /// The token nodejs.org's `index.json` `files[]` array uses for
83    /// this platform. macOS entries use the legacy `osx-*` prefix
84    /// with a `-tar` suffix; Windows uses `win-<arch>-zip`.
85    ///
86    /// musl builds never appear in the official index (they live on
87    /// unofficial-builds.nodejs.org, whose index has the same shape
88    /// but plain `linux-x64` tokens), so musl maps to the bare linux
89    /// token for `files[]` gating purposes.
90    pub fn index_files_token(&self) -> String {
91        match self.os.as_str() {
92            "darwin" => format!("osx-{}-tar", self.cpu),
93            "win32" => format!("win-{}-zip", self.cpu),
94            _ => format!("linux-{}", self.cpu),
95        }
96    }
97
98    /// Archive extension for this platform's dist artifact.
99    pub fn archive_ext(&self) -> &'static str {
100        if self.os == "win32" { "zip" } else { "tar.gz" }
101    }
102
103    /// pnpm's `archive:` vocabulary for this platform.
104    pub fn archive_kind(&self) -> &'static str {
105        if self.os == "win32" { "zip" } else { "tarball" }
106    }
107
108    /// Human-readable label for error messages.
109    pub fn label(&self) -> String {
110        match &self.libc {
111            Some(libc) => format!("{}-{} ({libc})", self.os, self.cpu),
112            None => format!("{}-{}", self.os, self.cpu),
113        }
114    }
115}
116
117#[cfg(target_os = "linux")]
118fn detect_musl() -> bool {
119    // Rust's arch names match musl's loader names for every
120    // architecture Node ships (x86_64, aarch64), so the constant is
121    // used verbatim.
122    std::path::Path::new(&format!("/lib/ld-musl-{}.so.1", std::env::consts::ARCH)).exists()
123}
124
125#[cfg(not(target_os = "linux"))]
126fn detect_musl() -> bool {
127    false
128}
129
130/// The artifact filename for `version` on `platform`:
131/// `node-v22.1.0-darwin-arm64.tar.gz`.
132pub fn artifact_filename(version: &node_semver::Version, platform: &Platform) -> String {
133    format!(
134        "node-v{version}-{}.{}",
135        platform.dist_slug(),
136        platform.archive_ext()
137    )
138}
139
140/// The top-level directory inside an artifact:
141/// `node-v22.1.0-darwin-arm64`.
142pub fn artifact_top_dir(version: &node_semver::Version, platform: &Platform) -> String {
143    format!("node-v{version}-{}", platform.dist_slug())
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn plat(os: &str, cpu: &str, libc: Option<&str>) -> Platform {
151        Platform {
152            os: os.into(),
153            cpu: cpu.into(),
154            libc: libc.map(String::from),
155        }
156    }
157
158    #[test]
159    fn dist_slugs() {
160        assert_eq!(plat("darwin", "arm64", None).dist_slug(), "darwin-arm64");
161        assert_eq!(plat("linux", "x64", None).dist_slug(), "linux-x64");
162        assert_eq!(
163            plat("linux", "x64", Some("musl")).dist_slug(),
164            "linux-x64-musl"
165        );
166        assert_eq!(plat("win32", "x64", None).dist_slug(), "win-x64");
167        assert_eq!(plat("freebsd", "x64", None).dist_slug(), "freebsd-x64");
168    }
169
170    #[test]
171    fn freebsd_token_falls_back_to_linux() {
172        // nodejs.org has no FreeBSD `files[]` token, so availability
173        // gating reuses the linux token. Download still fails later at
174        // the SHASUMS lookup (no `node-v*-freebsd-*` artifact), which is
175        // the intended graceful outcome — FreeBSD relies on system/mise
176        // Node, not aube's self-download.
177        assert_eq!(
178            plat("freebsd", "x64", None).index_files_token(),
179            "linux-x64"
180        );
181        assert_eq!(plat("freebsd", "x64", None).archive_ext(), "tar.gz");
182    }
183
184    #[test]
185    fn index_tokens() {
186        assert_eq!(
187            plat("darwin", "arm64", None).index_files_token(),
188            "osx-arm64-tar"
189        );
190        assert_eq!(
191            plat("win32", "x64", None).index_files_token(),
192            "win-x64-zip"
193        );
194        assert_eq!(
195            plat("linux", "arm64", None).index_files_token(),
196            "linux-arm64"
197        );
198        assert_eq!(
199            plat("linux", "x64", Some("musl")).index_files_token(),
200            "linux-x64"
201        );
202    }
203
204    #[test]
205    fn artifact_names() {
206        let v: node_semver::Version = "22.1.0".parse().unwrap();
207        assert_eq!(
208            artifact_filename(&v, &plat("win32", "x64", None)),
209            "node-v22.1.0-win-x64.zip"
210        );
211        assert_eq!(
212            artifact_top_dir(&v, &plat("darwin", "arm64", None)),
213            "node-v22.1.0-darwin-arm64"
214        );
215    }
216}