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}