Skip to main content

aube_runtime/
self_install.rs

1//! aube self-version management: discovering, downloading, and
2//! installing *aube* binaries so a project's `packageManager` /
3//! `devEngines.packageManager` pin can re-exec the right version
4//! (corepack semantics; pnpm's `managePackageManagerVersions`).
5//!
6//! Sources, same shape as Node runtimes: mise installs
7//! (`installs/aube/<v>/`, binaries at the version root) are reused
8//! read-only, and self-downloads come from GitHub release archives
9//! (`aube-v{V}-{target-triple}.tar.gz` / `.zip`, binaries at the
10//! archive root) into `$XDG_DATA_HOME/aube/self/<v>/`, verified
11//! against GitHub's server-computed release asset digests.
12
13use crate::discover::{self, InstallOrigin};
14use crate::error::Error;
15use crate::http::Http;
16use crate::installer::stream_to_file;
17use crate::mise;
18use crate::progress::{DownloadProgress, InstallPhase};
19use crate::{InstallerMode, RuntimeConfig};
20use std::path::{Path, PathBuf};
21
22/// Default base for release archives. `AUBE_SELF_DOWNLOAD_BASE`
23/// overrides for tests and mirrors; archives live at
24/// `{base}/v{V}/aube-v{V}-{triple}.{ext}`.
25const RELEASE_BASE: &str = "https://github.com/jdx/aube/releases/download";
26
27/// mise-versions host: CDN-cached, rate-limit-free mirrors of the
28/// release version list (`/aube`, plaintext) and GitHub release
29/// metadata (`/api/github/repos/jdx/aube/releases/<tag>`, including
30/// `assets[].digest`) — the same service mise itself consults before
31/// falling back to the GitHub API. `AUBE_VERSIONS_HOST` overrides for
32/// tests.
33const VERSIONS_HOST: &str = "https://mise-versions.jdx.dev";
34
35/// GitHub releases API fallback for asset digests: GitHub computes a
36/// server-side SHA-256 for every release asset (`assets[].digest`,
37/// tamper-evident under immutable releases). Consulted when the
38/// versions host misses; honors `GITHUB_TOKEN`. `AUBE_SELF_API_BASE`
39/// overrides for tests.
40const RELEASE_API_BASE: &str = "https://api.github.com/repos/jdx/aube/releases/tags";
41
42/// Endpoint announcing the newest release (one line, bare version).
43/// Shared with the update notifier. `AUBE_SELF_VERSION_URL` overrides.
44const VERSION_URL: &str = "https://aube.jdx.dev/VERSION";
45
46/// A validated on-disk aube install.
47#[derive(Debug, Clone)]
48pub struct InstalledAube {
49    pub version: node_semver::Version,
50    pub install_dir: PathBuf,
51    /// The `aube` executable. `aubr` / `aubx` siblings live next to it.
52    pub exe: PathBuf,
53    pub origin: InstallOrigin,
54}
55
56/// aube's own versions dir (`$XDG_DATA_HOME/aube/self`).
57/// `AUBE_SELF_DIR` overrides for tests.
58pub fn self_dir() -> Option<PathBuf> {
59    if let Some(dir) = aube_util::env::embedder_env("SELF_DIR")
60        && !dir.is_empty()
61    {
62        return Some(PathBuf::from(dir));
63    }
64    #[cfg(windows)]
65    if let Ok(local) = std::env::var("LOCALAPPDATA") {
66        return Some(PathBuf::from(local).join("aube/self"));
67    }
68    let data_home = aube_util::env::xdg_data_home()
69        .or_else(|| aube_util::env::home_dir().map(|h| h.join(".local/share")))?;
70    Some(data_home.join("aube/self"))
71}
72
73/// Every valid installed aube across mise's installs dir and aube's
74/// self dir. Same collision rule as Node: aube's own copy of a
75/// version wins over mise's.
76pub fn list_installed_aube() -> Vec<InstalledAube> {
77    let mut by_version: std::collections::BTreeMap<node_semver::Version, InstalledAube> =
78        Default::default();
79    if let Some(dir) = discover::mise_tool_installs_dir("aube") {
80        for install in scan_aube_dir(&dir, InstallOrigin::Mise) {
81            by_version.insert(install.version.clone(), install);
82        }
83    }
84    if let Some(dir) = self_dir() {
85        for install in scan_aube_dir(&dir, InstallOrigin::Aube) {
86            by_version.insert(install.version.clone(), install);
87        }
88    }
89    by_version.into_values().collect()
90}
91
92/// Look up one exact installed version (mise first, then self dir —
93/// the self-dir copy wins, mirroring `list_installed_aube`).
94pub fn find_installed_aube(version: &node_semver::Version) -> Option<InstalledAube> {
95    let from_self = self_dir()
96        .map(|d| d.join(version.to_string()))
97        .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Aube));
98    from_self.or_else(|| {
99        discover::mise_tool_installs_dir("aube")
100            .map(|d| d.join(version.to_string()))
101            .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Mise))
102    })
103}
104
105fn scan_aube_dir(root: &Path, origin: InstallOrigin) -> Vec<InstalledAube> {
106    let Ok(entries) = std::fs::read_dir(root) else {
107        return Vec::new();
108    };
109    let mut out = Vec::new();
110    for entry in entries.flatten() {
111        let path = entry.path();
112        let Ok(file_type) = entry.file_type() else {
113            continue;
114        };
115        if !file_type.is_dir() {
116            // Skips mise's alias symlinks (`1`, `1.18`, `latest`).
117            continue;
118        }
119        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
120            continue;
121        };
122        let Ok(version) = node_semver::Version::parse(name.trim_start_matches('v')) else {
123            continue;
124        };
125        if let Some(install) = validate_aube_install(&path, version, origin) {
126            out.push(install);
127        }
128    }
129    out
130}
131
132/// Validate a version dir: no `incomplete` marker (mise's in-progress
133/// signal), and the `aube` executable present at the root or under
134/// `bin/` (mise and the release archives use the root; `bin/` covers
135/// alternative packagings).
136fn validate_aube_install(
137    dir: &Path,
138    version: node_semver::Version,
139    origin: InstallOrigin,
140) -> Option<InstalledAube> {
141    if dir.join("incomplete").exists() {
142        return None;
143    }
144    let exe_name = if cfg!(windows) { "aube.exe" } else { "aube" };
145    let exe = [dir.join(exe_name), dir.join("bin").join(exe_name)]
146        .into_iter()
147        .find(|p| discover::is_executable_file(p))?;
148    Some(InstalledAube {
149        version,
150        install_dir: dir.to_path_buf(),
151        exe,
152        origin,
153    })
154}
155
156/// The release-archive target triple for the host. aube publishes:
157/// `aarch64-apple-darwin`, `{x86_64,aarch64}-unknown-linux-{gnu,musl}`,
158/// `{x86_64,aarch64}-pc-windows-msvc`. Hosts without a published build
159/// (e.g. Intel macOS, or any FreeBSD arch) get an
160/// [`Error::NoReleaseBuild`] pointing at mise / the system package
161/// manager.
162pub fn release_target_triple() -> Result<String, Error> {
163    let os = std::env::consts::OS;
164    let raw_arch = std::env::consts::ARCH;
165
166    // FreeBSD has no published aube archive on any architecture, so
167    // short-circuit before the arch whitelist — otherwise a non-x86/arm
168    // FreeBSD host would fall into the arch reject and miss the
169    // aube-specific guidance. `Platform::current()` still recognizes
170    // FreeBSD (see platform.rs), so Node installs keep working against
171    // system/mise; only the self-updater is unavailable here.
172    if os == "freebsd" {
173        return Err(Error::NoReleaseBuild {
174            platform: format!("freebsd-{raw_arch}"),
175        });
176    }
177
178    let arch = match raw_arch {
179        "x86_64" => "x86_64",
180        "aarch64" => "aarch64",
181        other => {
182            return Err(Error::NoReleaseBuild {
183                platform: format!("{os}-{other}"),
184            });
185        }
186    };
187    let triple = match os {
188        "macos" => {
189            if arch != "aarch64" {
190                return Err(Error::NoReleaseBuild {
191                    platform: "macos-x86_64".to_string(),
192                });
193            }
194            format!("{arch}-apple-darwin")
195        }
196        "linux" => {
197            let libc = if crate::Platform::current()?.libc.as_deref() == Some("musl") {
198                "musl"
199            } else {
200                "gnu"
201            };
202            format!("{arch}-unknown-linux-{libc}")
203        }
204        "windows" => format!("{arch}-pc-windows-msvc"),
205        other => {
206            return Err(Error::NoReleaseBuild {
207                platform: format!("{other}-{arch}"),
208            });
209        }
210    };
211    Ok(triple)
212}
213
214fn release_base() -> String {
215    aube_util::env::embedder_env("SELF_DOWNLOAD_BASE")
216        .and_then(|s| s.into_string().ok())
217        .filter(|s| !s.trim().is_empty())
218        .map(|s| s.trim_end_matches('/').to_string())
219        .unwrap_or_else(|| RELEASE_BASE.to_string())
220}
221
222fn versions_host() -> String {
223    aube_util::env::embedder_env("VERSIONS_HOST")
224        .and_then(|s| s.into_string().ok())
225        .filter(|s| !s.trim().is_empty())
226        .map(|s| s.trim_end_matches('/').to_string())
227        .unwrap_or_else(|| VERSIONS_HOST.to_string())
228}
229
230/// Every published aube version (for resolving range pins to the best
231/// satisfying release). Primary source is the versions host's
232/// plaintext list — CDN-cached, no rate limits; the
233/// `aube.jdx.dev/VERSION` latest-only announcement is the fallback,
234/// degrading range resolution to "newest release" rather than failing.
235pub async fn available_aube_versions(retries: u32) -> Result<Vec<node_semver::Version>, Error> {
236    let http = Http::new(retries);
237    let list_url = format!("{}/aube", versions_host());
238    match fetch_text(&http, &list_url).await {
239        Ok(text) => {
240            let versions: Vec<node_semver::Version> = text
241                .lines()
242                .filter_map(|l| node_semver::Version::parse(l.trim().trim_start_matches('v')).ok())
243                .collect();
244            if !versions.is_empty() {
245                return Ok(versions);
246            }
247            tracing::debug!(%list_url, "versions host returned an empty list; falling back");
248        }
249        Err(e) => {
250            tracing::debug!(%list_url, error = %e, "versions host unreachable; falling back");
251        }
252    }
253    let url = aube_util::env::embedder_env("SELF_VERSION_URL")
254        .and_then(|s| s.into_string().ok())
255        .filter(|s| !s.trim().is_empty())
256        .unwrap_or_else(|| VERSION_URL.to_string());
257    let text = fetch_text(&http, &url).await?;
258    let latest = node_semver::Version::parse(text.trim().trim_start_matches('v')).map_err(|e| {
259        Error::DownloadFailed {
260            url,
261            reason: format!("unparseable version announcement: {e}"),
262        }
263    })?;
264    Ok(vec![latest])
265}
266
267async fn fetch_text(http: &Http, url: &str) -> Result<String, Error> {
268    let resp = http.get(url, None, None, false).await?;
269    let body = resp.body.ok_or_else(|| Error::DownloadFailed {
270        url: url.to_string(),
271        reason: "unexpected empty response".to_string(),
272    })?;
273    body.text().await.map_err(|e| Error::DownloadFailed {
274        url: url.to_string(),
275        reason: e.to_string(),
276    })
277}
278
279/// Install aube `version`, honoring the installer mode: mise
280/// delegation first under `auto`/`mise` (one tool store for mise
281/// users), self-download from GitHub releases otherwise.
282pub async fn install_aube(
283    cfg: &RuntimeConfig,
284    version: &node_semver::Version,
285    progress: &dyn DownloadProgress,
286) -> Result<InstalledAube, Error> {
287    if let Some(existing) = find_installed_aube(version) {
288        return Ok(existing);
289    }
290    match cfg.installer {
291        InstallerMode::Aube => self_download(cfg, version, progress).await,
292        InstallerMode::Mise => {
293            let Some(mise_bin) = mise::mise_on_path() else {
294                return Err(Error::MiseInstallFailed {
295                    version: format!("aube@{version}"),
296                    reason: "runtimeInstaller=mise but mise is not on PATH".to_string(),
297                });
298            };
299            delegate_to_mise(&mise_bin, version, progress).await
300        }
301        InstallerMode::Auto => match mise::mise_on_path() {
302            Some(mise_bin) => match delegate_to_mise(&mise_bin, version, progress).await {
303                Ok(install) => Ok(install),
304                Err(e) => {
305                    tracing::warn!(
306                        code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK,
307                        error = %e,
308                        "mise failed to install aube; falling back to a release download"
309                    );
310                    self_download(cfg, version, progress).await
311                }
312            },
313            None => self_download(cfg, version, progress).await,
314        },
315    }
316}
317
318async fn delegate_to_mise(
319    mise_bin: &Path,
320    version: &node_semver::Version,
321    progress: &dyn DownloadProgress,
322) -> Result<InstalledAube, Error> {
323    mise::install_tool_via_mise(mise_bin, "aube", version, progress).await?;
324    discover::mise_tool_installs_dir("aube")
325        .map(|d| d.join(version.to_string()))
326        .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Mise))
327        .ok_or_else(|| Error::MiseInstallFailed {
328            version: format!("aube@{version}"),
329            reason: "mise reported success but the install was not found — \
330                     if mise uses a custom data dir, export MISE_DATA_DIR so aube sees the same path"
331                .to_string(),
332        })
333}
334
335/// Download a release archive, verify its published `.sha256` when
336/// available (older releases predate checksum publishing; those fall
337/// back to TLS-only with a debug note), extract — binaries sit at the
338/// archive root — and atomically publish.
339async fn self_download(
340    cfg: &RuntimeConfig,
341    version: &node_semver::Version,
342    progress: &dyn DownloadProgress,
343) -> Result<InstalledAube, Error> {
344    let root = self_dir().ok_or_else(|| {
345        Error::io(
346            "locate the aube self dir",
347            std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"),
348        )
349    })?;
350    let dest = root.join(version.to_string());
351    let locks = root.join(".locks");
352    std::fs::create_dir_all(&locks)
353        .map_err(|e| Error::io(format!("create {}", locks.display()), e))?;
354    let lock_path = locks.join(format!("{version}.lock"));
355    let lock = tokio::task::spawn_blocking(move || xx::fslock::FSLock::new(&lock_path).lock())
356        .await
357        .map_err(|e| {
358            Error::io(
359                "acquire self-install lock",
360                std::io::Error::other(e.to_string()),
361            )
362        })?
363        .map_err(|e| {
364            Error::io(
365                "acquire self-install lock",
366                std::io::Error::other(e.to_string()),
367            )
368        })?;
369    if let Some(existing) = validate_aube_install(&dest, version.clone(), InstallOrigin::Aube) {
370        drop(lock);
371        return Ok(existing);
372    }
373
374    let triple = release_target_triple()?;
375    let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
376    let archive_name = format!("aube-v{version}-{triple}.{ext}");
377    let url = format!("{}/v{version}/{archive_name}", release_base());
378    let http = Http::new(cfg.retries);
379    progress.on_phase(Some(version), InstallPhase::Downloading);
380
381    let downloads = root.join(".downloads");
382    let staging_root = root.join(".tmp");
383    std::fs::create_dir_all(&downloads)
384        .map_err(|e| Error::io(format!("create {}", downloads.display()), e))?;
385    std::fs::create_dir_all(&staging_root)
386        .map_err(|e| Error::io(format!("create {}", staging_root.display()), e))?;
387    let archive_path = downloads.join(format!("{archive_name}.{}", std::process::id()));
388    let actual = stream_to_file(&http, &url, &archive_path, progress).await?;
389
390    // Expected checksum: GitHub's server-computed asset digest first
391    // (covers every release, nothing to publish); a `.sha256` sibling
392    // as the fallback for custom mirrors that ship one; TLS-only as
393    // the last resort.
394    progress.on_phase(Some(version), InstallPhase::Verifying);
395    let expected = match fetch_release_digest(&http, version, &archive_name).await {
396        Some(digest) => Some(digest),
397        None => fetch_published_sha256(&http, &url).await,
398    };
399    match expected {
400        Some(expected) if expected != actual => {
401            let _ = std::fs::remove_file(&archive_path);
402            drop(lock);
403            return Err(Error::ChecksumMismatch {
404                url,
405                expected: hex::encode(expected),
406                actual: hex::encode(actual),
407            });
408        }
409        Some(_) => {}
410        None => {
411            tracing::debug!(
412                %url,
413                "no asset digest or .sha256 available for this archive; trusting TLS"
414            );
415        }
416    }
417
418    progress.on_phase(Some(version), InstallPhase::Extracting);
419    let staging = staging_root.join(format!("{version}.{}", std::process::id()));
420    std::fs::create_dir_all(&staging)
421        .map_err(|e| Error::io(format!("create {}", staging.display()), e))?;
422    let extract_from = archive_path.clone();
423    let extract_to = staging.clone();
424    let zip = ext == "zip";
425    let extract_result = tokio::task::spawn_blocking(move || {
426        crate::extract::extract_archive(&extract_from, &extract_to, zip, false)
427    })
428    .await
429    .map_err(|e| Error::ExtractFailed {
430        reason: e.to_string(),
431    })?;
432    let _ = std::fs::remove_file(&archive_path);
433    if let Err(e) = extract_result {
434        let _ = std::fs::remove_dir_all(&staging);
435        drop(lock);
436        return Err(e);
437    }
438
439    if let Err(rename_err) = std::fs::rename(&staging, &dest) {
440        let _ = std::fs::remove_dir_all(&staging);
441        if validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).is_none() {
442            drop(lock);
443            return Err(Error::io(
444                format!("publish aube {} into {}", version, dest.display()),
445                rename_err,
446            ));
447        }
448    }
449    drop(lock);
450    progress.on_done();
451
452    validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).ok_or_else(|| {
453        Error::ExtractFailed {
454            reason: format!(
455                "release archive did not produce a usable aube at {}",
456                dest.display()
457            ),
458        }
459    })
460}
461
462/// Look up the archive's server-computed digest in the GitHub
463/// releases API (`assets[].digest`, `"sha256:<hex>"`). Skipped when a
464/// custom `AUBE_SELF_DOWNLOAD_BASE` mirror is active without its own
465/// `AUBE_SELF_API_BASE` — GitHub's digest describes GitHub's copy,
466/// not whatever a mirror chose to serve. `None` on any miss (network,
467/// rate limit, unknown tag/asset): the caller falls back rather than
468/// failing a download GitHub itself already served over TLS.
469async fn fetch_release_digest(
470    http: &Http,
471    version: &node_semver::Version,
472    archive_name: &str,
473) -> Option<[u8; 32]> {
474    let api_override = aube_util::env::embedder_env("SELF_API_BASE")
475        .and_then(|s| s.into_string().ok())
476        .filter(|s| !s.trim().is_empty())
477        .map(|s| s.trim_end_matches('/').to_string());
478    let host_override = aube_util::env::embedder_env("VERSIONS_HOST").is_some();
479    // Custom download mirrors may serve different bytes than GitHub's
480    // archives; digests describing GitHub's copies don't apply unless
481    // a test override says otherwise.
482    if api_override.is_none()
483        && !host_override
484        && aube_util::env::embedder_env("SELF_DOWNLOAD_BASE").is_some()
485    {
486        return None;
487    }
488
489    // 1. mise-versions proxy: CDN-cached, no rate limits, no token.
490    let host_url = format!(
491        "{}/api/github/repos/jdx/aube/releases/v{version}",
492        versions_host()
493    );
494    if let Some(digest) =
495        digest_from_release_json(http, &host_url, None, version, archive_name).await
496    {
497        return Some(digest);
498    }
499
500    // 2. GitHub API. CI runners and NATed offices share the 60/hr
501    // unauthenticated per-IP limit; a token (always present in GitHub
502    // Actions) lifts that. Attached only for the real GitHub API host
503    // so an `AUBE_SELF_API_BASE` override can never siphon it.
504    let url = format!(
505        "{}/v{version}",
506        api_override.as_deref().unwrap_or(RELEASE_API_BASE)
507    );
508    let token = url
509        .starts_with("https://api.github.com/")
510        .then(|| {
511            std::env::var("GITHUB_TOKEN")
512                .or_else(|_| std::env::var("GH_TOKEN"))
513                .ok()
514                .filter(|t| !t.trim().is_empty())
515        })
516        .flatten();
517    digest_from_release_json(http, &url, token.as_deref(), version, archive_name).await
518}
519
520/// Fetch a GitHub-release-shaped JSON document and pull out
521/// `archive_name`'s digest. The returned `tag_name` must echo the
522/// requested version — guards against a stale or mis-keyed cache
523/// entry on the proxy handing back another release's digests.
524async fn digest_from_release_json(
525    http: &Http,
526    url: &str,
527    bearer: Option<&str>,
528    version: &node_semver::Version,
529    archive_name: &str,
530) -> Option<[u8; 32]> {
531    let resp = http
532        .get_with_bearer(url, None, None, false, bearer)
533        .await
534        .ok()?;
535    let bytes = resp.body?.bytes().await.ok()?;
536    let release: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
537    let tag = release.get("tag_name")?.as_str()?;
538    if tag != format!("v{version}") {
539        tracing::debug!(%url, tag, expected = %format!("v{version}"), "release metadata tag mismatch; ignoring");
540        return None;
541    }
542    let digest = release
543        .get("assets")?
544        .as_array()?
545        .iter()
546        .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(archive_name))?
547        .get("digest")?
548        .as_str()?;
549    parse_sha256_digest(digest)
550}
551
552/// Parse GitHub's `"sha256:<hex>"` digest form.
553fn parse_sha256_digest(digest: &str) -> Option<[u8; 32]> {
554    let hex_part = digest.strip_prefix("sha256:")?;
555    let bytes = hex::decode(hex_part).ok()?;
556    <[u8; 32]>::try_from(bytes.as_slice()).ok()
557}
558
559/// Fetch `{archive_url}.sha256` and parse the leading hex digest
560/// (taiki-e's checksum files are `<hex> *<filename>`). `None` when the
561/// asset doesn't exist or doesn't parse — caller decides the policy.
562async fn fetch_published_sha256(http: &Http, archive_url: &str) -> Option<[u8; 32]> {
563    let url = format!("{archive_url}.sha256");
564    let resp = http.get(&url, None, None, false).await.ok()?;
565    let text = resp.body?.text().await.ok()?;
566    let hex_token = text.split_whitespace().next()?;
567    let bytes = hex::decode(hex_token).ok()?;
568    <[u8; 32]>::try_from(bytes.as_slice()).ok()
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    fn fab_aube(root: &Path, version: &str) {
576        let dir = root.join(version);
577        std::fs::create_dir_all(&dir).unwrap();
578        for bin in ["aube", "aubr", "aubx"] {
579            let path = dir.join(if cfg!(windows) {
580                format!("{bin}.exe")
581            } else {
582                bin.to_string()
583            });
584            std::fs::write(&path, "#!/bin/sh\necho fake\n").unwrap();
585            #[cfg(unix)]
586            {
587                use std::os::unix::fs::PermissionsExt;
588                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
589            }
590        }
591    }
592
593    #[test]
594    fn scans_and_validates_aube_installs() {
595        let tmp = tempfile::tempdir().unwrap();
596        fab_aube(tmp.path(), "1.17.0");
597        fab_aube(tmp.path(), "1.18.2");
598        fab_aube(tmp.path(), "1.19.0");
599        std::fs::write(tmp.path().join("1.19.0/incomplete"), "").unwrap();
600        std::fs::create_dir_all(tmp.path().join("not-a-version")).unwrap();
601
602        let mut versions: Vec<String> = scan_aube_dir(tmp.path(), InstallOrigin::Mise)
603            .into_iter()
604            .map(|i| i.version.to_string())
605            .collect();
606        versions.sort();
607        assert_eq!(versions, vec!["1.17.0", "1.18.2"]);
608    }
609
610    #[test]
611    fn validate_accepts_bin_subdir_layout() {
612        let tmp = tempfile::tempdir().unwrap();
613        let dir = tmp.path().join("2.0.0/bin");
614        std::fs::create_dir_all(&dir).unwrap();
615        let exe = dir.join(if cfg!(windows) { "aube.exe" } else { "aube" });
616        std::fs::write(&exe, "x").unwrap();
617        #[cfg(unix)]
618        {
619            use std::os::unix::fs::PermissionsExt;
620            std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
621        }
622        let install = validate_aube_install(
623            &tmp.path().join("2.0.0"),
624            "2.0.0".parse().unwrap(),
625            InstallOrigin::Aube,
626        )
627        .unwrap();
628        assert!(install.exe.parent().unwrap().ends_with("bin"));
629    }
630
631    #[test]
632    fn parses_github_digest_form() {
633        let digest = format!("sha256:{}", "ab".repeat(32));
634        assert_eq!(parse_sha256_digest(&digest), Some([0xab; 32]));
635        assert_eq!(parse_sha256_digest("sha512:abcd"), None);
636        assert_eq!(parse_sha256_digest("sha256:nothex"), None);
637        assert_eq!(parse_sha256_digest("sha256:abcd"), None); // wrong length
638    }
639
640    #[test]
641    fn target_triple_is_publishable() {
642        // On every platform CI runs, the host triple must map to a
643        // name aube actually publishes. Documented exceptions with no
644        // published build: Intel macOS, and FreeBSD (any arch).
645        match release_target_triple() {
646            Ok(t) => {
647                assert!(
648                    t.contains("apple-darwin")
649                        || t.contains("unknown-linux")
650                        || t.contains("pc-windows"),
651                    "{t}"
652                );
653            }
654            Err(Error::NoReleaseBuild { .. }) => {
655                let os = std::env::consts::OS;
656                assert!(
657                    os == "freebsd" || (os == "macos" && std::env::consts::ARCH == "x86_64"),
658                    "unexpected unsupported host: {os}-{}",
659                    std::env::consts::ARCH
660                );
661            }
662            Err(other) => panic!("unexpected error: {other}"),
663        }
664    }
665}