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
159/// build (e.g. Intel macOS) get an [`Error::UnsupportedPlatform`]
160/// pointing at mise.
161pub fn release_target_triple() -> Result<String, Error> {
162    let arch = match std::env::consts::ARCH {
163        "x86_64" => "x86_64",
164        "aarch64" => "aarch64",
165        other => {
166            return Err(Error::UnsupportedPlatform {
167                platform: format!("{}-{other}", std::env::consts::OS),
168            });
169        }
170    };
171    let triple = match std::env::consts::OS {
172        "macos" => {
173            if arch != "aarch64" {
174                return Err(Error::UnsupportedPlatform {
175                    platform: "macos-x86_64 (no published aube build; install via mise)"
176                        .to_string(),
177                });
178            }
179            format!("{arch}-apple-darwin")
180        }
181        "linux" => {
182            let libc = if crate::Platform::current()?.libc.as_deref() == Some("musl") {
183                "musl"
184            } else {
185                "gnu"
186            };
187            format!("{arch}-unknown-linux-{libc}")
188        }
189        "windows" => format!("{arch}-pc-windows-msvc"),
190        other => {
191            return Err(Error::UnsupportedPlatform {
192                platform: format!("{other}-{arch}"),
193            });
194        }
195    };
196    Ok(triple)
197}
198
199fn release_base() -> String {
200    aube_util::env::embedder_env("SELF_DOWNLOAD_BASE")
201        .and_then(|s| s.into_string().ok())
202        .filter(|s| !s.trim().is_empty())
203        .map(|s| s.trim_end_matches('/').to_string())
204        .unwrap_or_else(|| RELEASE_BASE.to_string())
205}
206
207fn versions_host() -> String {
208    aube_util::env::embedder_env("VERSIONS_HOST")
209        .and_then(|s| s.into_string().ok())
210        .filter(|s| !s.trim().is_empty())
211        .map(|s| s.trim_end_matches('/').to_string())
212        .unwrap_or_else(|| VERSIONS_HOST.to_string())
213}
214
215/// Every published aube version (for resolving range pins to the best
216/// satisfying release). Primary source is the versions host's
217/// plaintext list — CDN-cached, no rate limits; the
218/// `aube.jdx.dev/VERSION` latest-only announcement is the fallback,
219/// degrading range resolution to "newest release" rather than failing.
220pub async fn available_aube_versions(retries: u32) -> Result<Vec<node_semver::Version>, Error> {
221    let http = Http::new(retries);
222    let list_url = format!("{}/aube", versions_host());
223    match fetch_text(&http, &list_url).await {
224        Ok(text) => {
225            let versions: Vec<node_semver::Version> = text
226                .lines()
227                .filter_map(|l| node_semver::Version::parse(l.trim().trim_start_matches('v')).ok())
228                .collect();
229            if !versions.is_empty() {
230                return Ok(versions);
231            }
232            tracing::debug!(%list_url, "versions host returned an empty list; falling back");
233        }
234        Err(e) => {
235            tracing::debug!(%list_url, error = %e, "versions host unreachable; falling back");
236        }
237    }
238    let url = aube_util::env::embedder_env("SELF_VERSION_URL")
239        .and_then(|s| s.into_string().ok())
240        .filter(|s| !s.trim().is_empty())
241        .unwrap_or_else(|| VERSION_URL.to_string());
242    let text = fetch_text(&http, &url).await?;
243    let latest = node_semver::Version::parse(text.trim().trim_start_matches('v')).map_err(|e| {
244        Error::DownloadFailed {
245            url,
246            reason: format!("unparseable version announcement: {e}"),
247        }
248    })?;
249    Ok(vec![latest])
250}
251
252async fn fetch_text(http: &Http, url: &str) -> Result<String, Error> {
253    let resp = http.get(url, None, None, false).await?;
254    let body = resp.body.ok_or_else(|| Error::DownloadFailed {
255        url: url.to_string(),
256        reason: "unexpected empty response".to_string(),
257    })?;
258    body.text().await.map_err(|e| Error::DownloadFailed {
259        url: url.to_string(),
260        reason: e.to_string(),
261    })
262}
263
264/// Install aube `version`, honoring the installer mode: mise
265/// delegation first under `auto`/`mise` (one tool store for mise
266/// users), self-download from GitHub releases otherwise.
267pub async fn install_aube(
268    cfg: &RuntimeConfig,
269    version: &node_semver::Version,
270    progress: &dyn DownloadProgress,
271) -> Result<InstalledAube, Error> {
272    if let Some(existing) = find_installed_aube(version) {
273        return Ok(existing);
274    }
275    match cfg.installer {
276        InstallerMode::Aube => self_download(cfg, version, progress).await,
277        InstallerMode::Mise => {
278            let Some(mise_bin) = mise::mise_on_path() else {
279                return Err(Error::MiseInstallFailed {
280                    version: format!("aube@{version}"),
281                    reason: "runtimeInstaller=mise but mise is not on PATH".to_string(),
282                });
283            };
284            delegate_to_mise(&mise_bin, version, progress).await
285        }
286        InstallerMode::Auto => match mise::mise_on_path() {
287            Some(mise_bin) => match delegate_to_mise(&mise_bin, version, progress).await {
288                Ok(install) => Ok(install),
289                Err(e) => {
290                    tracing::warn!(
291                        code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK,
292                        error = %e,
293                        "mise failed to install aube; falling back to a release download"
294                    );
295                    self_download(cfg, version, progress).await
296                }
297            },
298            None => self_download(cfg, version, progress).await,
299        },
300    }
301}
302
303async fn delegate_to_mise(
304    mise_bin: &Path,
305    version: &node_semver::Version,
306    progress: &dyn DownloadProgress,
307) -> Result<InstalledAube, Error> {
308    mise::install_tool_via_mise(mise_bin, "aube", version, progress).await?;
309    discover::mise_tool_installs_dir("aube")
310        .map(|d| d.join(version.to_string()))
311        .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Mise))
312        .ok_or_else(|| Error::MiseInstallFailed {
313            version: format!("aube@{version}"),
314            reason: "mise reported success but the install was not found — \
315                     if mise uses a custom data dir, export MISE_DATA_DIR so aube sees the same path"
316                .to_string(),
317        })
318}
319
320/// Download a release archive, verify its published `.sha256` when
321/// available (older releases predate checksum publishing; those fall
322/// back to TLS-only with a debug note), extract — binaries sit at the
323/// archive root — and atomically publish.
324async fn self_download(
325    cfg: &RuntimeConfig,
326    version: &node_semver::Version,
327    progress: &dyn DownloadProgress,
328) -> Result<InstalledAube, Error> {
329    let root = self_dir().ok_or_else(|| {
330        Error::io(
331            "locate the aube self dir",
332            std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"),
333        )
334    })?;
335    let dest = root.join(version.to_string());
336    let locks = root.join(".locks");
337    std::fs::create_dir_all(&locks)
338        .map_err(|e| Error::io(format!("create {}", locks.display()), e))?;
339    let lock_path = locks.join(format!("{version}.lock"));
340    let lock = tokio::task::spawn_blocking(move || xx::fslock::FSLock::new(&lock_path).lock())
341        .await
342        .map_err(|e| {
343            Error::io(
344                "acquire self-install lock",
345                std::io::Error::other(e.to_string()),
346            )
347        })?
348        .map_err(|e| {
349            Error::io(
350                "acquire self-install lock",
351                std::io::Error::other(e.to_string()),
352            )
353        })?;
354    if let Some(existing) = validate_aube_install(&dest, version.clone(), InstallOrigin::Aube) {
355        drop(lock);
356        return Ok(existing);
357    }
358
359    let triple = release_target_triple()?;
360    let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
361    let archive_name = format!("aube-v{version}-{triple}.{ext}");
362    let url = format!("{}/v{version}/{archive_name}", release_base());
363    let http = Http::new(cfg.retries);
364    progress.on_phase(Some(version), InstallPhase::Downloading);
365
366    let downloads = root.join(".downloads");
367    let staging_root = root.join(".tmp");
368    std::fs::create_dir_all(&downloads)
369        .map_err(|e| Error::io(format!("create {}", downloads.display()), e))?;
370    std::fs::create_dir_all(&staging_root)
371        .map_err(|e| Error::io(format!("create {}", staging_root.display()), e))?;
372    let archive_path = downloads.join(format!("{archive_name}.{}", std::process::id()));
373    let actual = stream_to_file(&http, &url, &archive_path, progress).await?;
374
375    // Expected checksum: GitHub's server-computed asset digest first
376    // (covers every release, nothing to publish); a `.sha256` sibling
377    // as the fallback for custom mirrors that ship one; TLS-only as
378    // the last resort.
379    progress.on_phase(Some(version), InstallPhase::Verifying);
380    let expected = match fetch_release_digest(&http, version, &archive_name).await {
381        Some(digest) => Some(digest),
382        None => fetch_published_sha256(&http, &url).await,
383    };
384    match expected {
385        Some(expected) if expected != actual => {
386            let _ = std::fs::remove_file(&archive_path);
387            drop(lock);
388            return Err(Error::ChecksumMismatch {
389                url,
390                expected: hex::encode(expected),
391                actual: hex::encode(actual),
392            });
393        }
394        Some(_) => {}
395        None => {
396            tracing::debug!(
397                %url,
398                "no asset digest or .sha256 available for this archive; trusting TLS"
399            );
400        }
401    }
402
403    progress.on_phase(Some(version), InstallPhase::Extracting);
404    let staging = staging_root.join(format!("{version}.{}", std::process::id()));
405    std::fs::create_dir_all(&staging)
406        .map_err(|e| Error::io(format!("create {}", staging.display()), e))?;
407    let extract_from = archive_path.clone();
408    let extract_to = staging.clone();
409    let zip = ext == "zip";
410    let extract_result = tokio::task::spawn_blocking(move || {
411        crate::extract::extract_archive(&extract_from, &extract_to, zip, false)
412    })
413    .await
414    .map_err(|e| Error::ExtractFailed {
415        reason: e.to_string(),
416    })?;
417    let _ = std::fs::remove_file(&archive_path);
418    if let Err(e) = extract_result {
419        let _ = std::fs::remove_dir_all(&staging);
420        drop(lock);
421        return Err(e);
422    }
423
424    if let Err(rename_err) = std::fs::rename(&staging, &dest) {
425        let _ = std::fs::remove_dir_all(&staging);
426        if validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).is_none() {
427            drop(lock);
428            return Err(Error::io(
429                format!("publish aube {} into {}", version, dest.display()),
430                rename_err,
431            ));
432        }
433    }
434    drop(lock);
435    progress.on_done();
436
437    validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).ok_or_else(|| {
438        Error::ExtractFailed {
439            reason: format!(
440                "release archive did not produce a usable aube at {}",
441                dest.display()
442            ),
443        }
444    })
445}
446
447/// Look up the archive's server-computed digest in the GitHub
448/// releases API (`assets[].digest`, `"sha256:<hex>"`). Skipped when a
449/// custom `AUBE_SELF_DOWNLOAD_BASE` mirror is active without its own
450/// `AUBE_SELF_API_BASE` — GitHub's digest describes GitHub's copy,
451/// not whatever a mirror chose to serve. `None` on any miss (network,
452/// rate limit, unknown tag/asset): the caller falls back rather than
453/// failing a download GitHub itself already served over TLS.
454async fn fetch_release_digest(
455    http: &Http,
456    version: &node_semver::Version,
457    archive_name: &str,
458) -> Option<[u8; 32]> {
459    let api_override = aube_util::env::embedder_env("SELF_API_BASE")
460        .and_then(|s| s.into_string().ok())
461        .filter(|s| !s.trim().is_empty())
462        .map(|s| s.trim_end_matches('/').to_string());
463    let host_override = aube_util::env::embedder_env("VERSIONS_HOST").is_some();
464    // Custom download mirrors may serve different bytes than GitHub's
465    // archives; digests describing GitHub's copies don't apply unless
466    // a test override says otherwise.
467    if api_override.is_none()
468        && !host_override
469        && aube_util::env::embedder_env("SELF_DOWNLOAD_BASE").is_some()
470    {
471        return None;
472    }
473
474    // 1. mise-versions proxy: CDN-cached, no rate limits, no token.
475    let host_url = format!(
476        "{}/api/github/repos/jdx/aube/releases/v{version}",
477        versions_host()
478    );
479    if let Some(digest) =
480        digest_from_release_json(http, &host_url, None, version, archive_name).await
481    {
482        return Some(digest);
483    }
484
485    // 2. GitHub API. CI runners and NATed offices share the 60/hr
486    // unauthenticated per-IP limit; a token (always present in GitHub
487    // Actions) lifts that. Attached only for the real GitHub API host
488    // so an `AUBE_SELF_API_BASE` override can never siphon it.
489    let url = format!(
490        "{}/v{version}",
491        api_override.as_deref().unwrap_or(RELEASE_API_BASE)
492    );
493    let token = url
494        .starts_with("https://api.github.com/")
495        .then(|| {
496            std::env::var("GITHUB_TOKEN")
497                .or_else(|_| std::env::var("GH_TOKEN"))
498                .ok()
499                .filter(|t| !t.trim().is_empty())
500        })
501        .flatten();
502    digest_from_release_json(http, &url, token.as_deref(), version, archive_name).await
503}
504
505/// Fetch a GitHub-release-shaped JSON document and pull out
506/// `archive_name`'s digest. The returned `tag_name` must echo the
507/// requested version — guards against a stale or mis-keyed cache
508/// entry on the proxy handing back another release's digests.
509async fn digest_from_release_json(
510    http: &Http,
511    url: &str,
512    bearer: Option<&str>,
513    version: &node_semver::Version,
514    archive_name: &str,
515) -> Option<[u8; 32]> {
516    let resp = http
517        .get_with_bearer(url, None, None, false, bearer)
518        .await
519        .ok()?;
520    let bytes = resp.body?.bytes().await.ok()?;
521    let release: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
522    let tag = release.get("tag_name")?.as_str()?;
523    if tag != format!("v{version}") {
524        tracing::debug!(%url, tag, expected = %format!("v{version}"), "release metadata tag mismatch; ignoring");
525        return None;
526    }
527    let digest = release
528        .get("assets")?
529        .as_array()?
530        .iter()
531        .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(archive_name))?
532        .get("digest")?
533        .as_str()?;
534    parse_sha256_digest(digest)
535}
536
537/// Parse GitHub's `"sha256:<hex>"` digest form.
538fn parse_sha256_digest(digest: &str) -> Option<[u8; 32]> {
539    let hex_part = digest.strip_prefix("sha256:")?;
540    let bytes = hex::decode(hex_part).ok()?;
541    <[u8; 32]>::try_from(bytes.as_slice()).ok()
542}
543
544/// Fetch `{archive_url}.sha256` and parse the leading hex digest
545/// (taiki-e's checksum files are `<hex> *<filename>`). `None` when the
546/// asset doesn't exist or doesn't parse — caller decides the policy.
547async fn fetch_published_sha256(http: &Http, archive_url: &str) -> Option<[u8; 32]> {
548    let url = format!("{archive_url}.sha256");
549    let resp = http.get(&url, None, None, false).await.ok()?;
550    let text = resp.body?.text().await.ok()?;
551    let hex_token = text.split_whitespace().next()?;
552    let bytes = hex::decode(hex_token).ok()?;
553    <[u8; 32]>::try_from(bytes.as_slice()).ok()
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    fn fab_aube(root: &Path, version: &str) {
561        let dir = root.join(version);
562        std::fs::create_dir_all(&dir).unwrap();
563        for bin in ["aube", "aubr", "aubx"] {
564            let path = dir.join(if cfg!(windows) {
565                format!("{bin}.exe")
566            } else {
567                bin.to_string()
568            });
569            std::fs::write(&path, "#!/bin/sh\necho fake\n").unwrap();
570            #[cfg(unix)]
571            {
572                use std::os::unix::fs::PermissionsExt;
573                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
574            }
575        }
576    }
577
578    #[test]
579    fn scans_and_validates_aube_installs() {
580        let tmp = tempfile::tempdir().unwrap();
581        fab_aube(tmp.path(), "1.17.0");
582        fab_aube(tmp.path(), "1.18.2");
583        fab_aube(tmp.path(), "1.19.0");
584        std::fs::write(tmp.path().join("1.19.0/incomplete"), "").unwrap();
585        std::fs::create_dir_all(tmp.path().join("not-a-version")).unwrap();
586
587        let mut versions: Vec<String> = scan_aube_dir(tmp.path(), InstallOrigin::Mise)
588            .into_iter()
589            .map(|i| i.version.to_string())
590            .collect();
591        versions.sort();
592        assert_eq!(versions, vec!["1.17.0", "1.18.2"]);
593    }
594
595    #[test]
596    fn validate_accepts_bin_subdir_layout() {
597        let tmp = tempfile::tempdir().unwrap();
598        let dir = tmp.path().join("2.0.0/bin");
599        std::fs::create_dir_all(&dir).unwrap();
600        let exe = dir.join(if cfg!(windows) { "aube.exe" } else { "aube" });
601        std::fs::write(&exe, "x").unwrap();
602        #[cfg(unix)]
603        {
604            use std::os::unix::fs::PermissionsExt;
605            std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
606        }
607        let install = validate_aube_install(
608            &tmp.path().join("2.0.0"),
609            "2.0.0".parse().unwrap(),
610            InstallOrigin::Aube,
611        )
612        .unwrap();
613        assert!(install.exe.parent().unwrap().ends_with("bin"));
614    }
615
616    #[test]
617    fn parses_github_digest_form() {
618        let digest = format!("sha256:{}", "ab".repeat(32));
619        assert_eq!(parse_sha256_digest(&digest), Some([0xab; 32]));
620        assert_eq!(parse_sha256_digest("sha512:abcd"), None);
621        assert_eq!(parse_sha256_digest("sha256:nothex"), None);
622        assert_eq!(parse_sha256_digest("sha256:abcd"), None); // wrong length
623    }
624
625    #[test]
626    fn target_triple_is_publishable() {
627        // On every platform CI runs, the host triple must map to a
628        // name aube actually publishes (Intel macOS is the documented
629        // exception).
630        match release_target_triple() {
631            Ok(t) => {
632                assert!(
633                    t.contains("apple-darwin")
634                        || t.contains("unknown-linux")
635                        || t.contains("pc-windows"),
636                    "{t}"
637                );
638            }
639            Err(Error::UnsupportedPlatform { .. }) => {
640                assert_eq!(std::env::consts::OS, "macos");
641                assert_eq!(std::env::consts::ARCH, "x86_64");
642            }
643            Err(other) => panic!("unexpected error: {other}"),
644        }
645    }
646}