Skip to main content

mj_controller/controller/mbx/
install.rs

1//! Install the pinned native mbx binary on a local or SSH container host.
2
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::Duration;
7
8use anyhow::{Context, Result, bail, ensure};
9use sha2::{Digest, Sha256};
10
11use super::super::cache_host::CacheHost;
12use super::{MBX_VERSION, NativeMbx, host_supports_cache, probe_native_version, version_at_least};
13use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec};
14use mj_core::config::Machine;
15use mj_core::state::{BuildCacheOff, BuildCachePreview};
16
17const MBX_X86_64_SHA256: &str = "c375135e2a3916f58da1b47537b6a954159eeacd55523d9a618b42259bd89014";
18const MBX_AARCH64_SHA256: &str = "ae4d66308c706ffbf912beb45b3166cf1cfda4d3efd23273262791235d0accf5";
19
20const PROFILE_SCRIPT: &str = r#"set -eu
21login_shell=
22if command -v getent >/dev/null 2>&1 && command -v id >/dev/null 2>&1; then
23    login_name=$(id -un 2>/dev/null || true)
24    if [ -n "$login_name" ]; then
25        login_shell=$(getent passwd "$login_name" 2>/dev/null | awk -F: 'NR == 1 { print $7; exit }' || true)
26    fi
27fi
28[ -n "$login_shell" ] || login_shell=${SHELL:-sh}
29shell_name=${login_shell##*/}
30[ -n "$shell_name" ] || shell_name=sh
31case "$shell_name" in
32    zsh) profile=$HOME/.zprofile; shown='~/.zprofile'; shell_kind=posix ;;
33    bash)
34        if [ -e "$HOME/.bash_profile" ]; then
35            profile=$HOME/.bash_profile; shown='~/.bash_profile'
36        elif [ -e "$HOME/.bash_login" ]; then
37            profile=$HOME/.bash_login; shown='~/.bash_login'
38        else
39            profile=$HOME/.profile; shown='~/.profile'
40        fi
41        shell_kind=posix
42        ;;
43    fish)
44        config_home=${XDG_CONFIG_HOME:-$HOME/.config}
45        case "$config_home" in /*) ;; *) config_home=$HOME/.config ;; esac
46        config_home=${config_home%/}
47        [ -n "$config_home" ] || config_home=/
48        profile=$config_home/fish/conf.d/mbx.fish
49        case "$profile" in
50            "$HOME"/*) shown="~/${profile#"$HOME"/}" ;;
51            *) shown=$profile ;;
52        esac
53        shell_kind=fish
54        ;;
55    *) profile=$HOME/.profile; shown='~/.profile'; shell_kind=unknown ;;
56esac
57binary_dir=$2
58data_home=${XDG_DATA_HOME:-$HOME/.local/share}
59case "$data_home" in /*) ;; *) data_home=$HOME/.local/share ;; esac
60data_home=${data_home%/}
61[ -n "$data_home" ] || data_home=/
62shim_dir=$data_home/mbx/bin
63action=$1
64if [ "$action" = preview ]; then
65    printf 'preview\n%s\n%s\n%s' "$shown" "$shell_kind" "$shim_dir"
66    exit 0
67fi
68[ "$action" = update ] || { echo 'unknown profile action' >&2; exit 2; }
69posix_block=$3
70fish_block=$4
71start='# >>> mbx (added by Mjolnir) >>>'
72if grep -Fq "$start" "$profile" 2>/dev/null; then
73    status=unchanged
74else
75    profile_dir=${profile%/*}
76    mkdir -p -- "$profile_dir"
77    : >> "$profile"
78    if [ -s "$profile" ]; then
79        printf '\n' >> "$profile"
80    fi
81    if [ "$shell_kind" = fish ]; then
82        printf '%s\n' "$fish_block" >> "$profile"
83    else
84        printf '%s\n' "$posix_block" >> "$profile"
85    fi
86    status=changed
87fi
88printf '%s\n%s\n%s\n%s' "$status" "$shown" "$shell_kind" "$shim_dir"
89"#;
90
91static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0);
92
93/// Whether machine settings should offer an install or upgrade.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum MbxInstallKind {
96    Install,
97    Upgrade,
98}
99
100/// The verified result of an mbx installation.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct MbxInstallResult {
103    pub program: PathBuf,
104    pub version: String,
105    pub profile_changed: bool,
106    pub profile_file: String,
107    pub profile_warning: Option<String>,
108    pub manual_path_line: Option<String>,
109    pub kind: MbxInstallKind,
110}
111
112/// The login-profile change shown before and after installing mbx.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub(super) struct MbxProfileDetails {
115    pub file: String,
116    pub warning: Option<String>,
117    pub manual_path_line: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121struct ProfileSelection {
122    file: String,
123    shell_kind: String,
124    shim_dir: PathBuf,
125}
126
127/// The install action to show for the current read-only cache preview.
128pub fn install_kind(preview: &BuildCachePreview) -> Option<MbxInstallKind> {
129    if matches!(
130        &preview.off_reason,
131        Some(BuildCacheOff::Unavailable(reason)) if reason == super::UNSUPPORTED_HOST
132    ) {
133        return None;
134    }
135    match preview.native_mbx.as_deref() {
136        None => Some(MbxInstallKind::Install),
137        Some(version) if !version_at_least(version, MBX_VERSION) => Some(MbxInstallKind::Upgrade),
138        Some(_) => None,
139    }
140}
141
142/// Install or upgrade the pinned Linux mbx on a machine with a persistent host.
143pub fn install_mbx(machine: &Machine, executor: &impl CommandExecutor) -> Result<MbxInstallResult> {
144    install_mbx_with(machine, executor, None, download)
145}
146
147fn install_mbx_with(
148    machine: &Machine,
149    executor: &impl CommandExecutor,
150    home_override: Option<&Path>,
151    get_binary: impl FnOnce(&str) -> Result<PathBuf>,
152) -> Result<MbxInstallResult> {
153    let host = CacheHost::for_machine(machine)
154        .context("this machine has no persistent container host where mbx can be installed")?;
155    ensure!(
156        host_supports_cache(&host, executor)?,
157        "mbx can only be installed on a Linux container host"
158    );
159    let triple = host_architecture(&host, executor)?;
160    let home = match home_override {
161        Some(home) => home.to_path_buf(),
162        None => canonical_home(&host, executor)?,
163    };
164
165    check_cancelled(executor)?;
166    let existing = probe_native_version(&host, executor)?;
167    let (program, kind) = destination(&home, existing.as_ref())?;
168    ensure!(
169        program.is_absolute(),
170        "the selected mbx destination is not absolute"
171    );
172
173    check_cancelled(executor)?;
174    let binary = get_binary(triple)?;
175    ensure!(
176        binary.is_file(),
177        "the verified mbx download is not a regular file"
178    );
179    check_cancelled(executor)?;
180    transfer_binary(&host, &home, &binary, &program, executor)?;
181
182    check_cancelled(executor)?;
183    run_setup(&host, &program, executor)?;
184    let (profile_changed, profile) = update_profile(&host, &program, executor)?;
185
186    check_cancelled(executor)?;
187    let verified =
188        probe_native_version(&host, executor)?.context("mbx was not found after installation")?;
189    ensure!(
190        verified.program == program,
191        "mbx verification found {} instead of the installed path {}",
192        verified.program.display(),
193        program.display()
194    );
195    ensure!(
196        verified.version == MBX_VERSION,
197        "installed mbx reported version {}, expected {MBX_VERSION}",
198        verified.version
199    );
200    if machine.build_cache().and_then(|settings| settings.enabled) != Some(false) {
201        let synchronized =
202            super::native_cache_directory(&host, &verified, executor).and_then(|directory| {
203                super::sync_mbx_binary_from_native(&host, &verified, &directory, executor)
204                    .map(|_| ())
205            });
206        if let Err(error) = synchronized {
207            tracing::warn!(
208                host = host.key(),
209                "mbx installed successfully, but its shared cache copy will be refreshed by reconciliation: {error:#}"
210            );
211        }
212    }
213    Ok(MbxInstallResult {
214        program,
215        version: verified.version,
216        profile_changed,
217        profile_file: profile.file,
218        profile_warning: profile.warning,
219        manual_path_line: profile.manual_path_line,
220        kind,
221    })
222}
223
224fn canonical_home(host: &CacheHost, executor: &impl CommandExecutor) -> Result<PathBuf> {
225    let home = host.home(executor)?;
226    let command = host.shell_command(
227        r#"cd -- "$1" && pwd -P"#,
228        "hel-mbx-home",
229        [home.to_string_lossy().into_owned()],
230        "resolve the container host's physical home directory",
231    );
232    let output = checked(executor.execute(&command)?, &command)?;
233    let text = String::from_utf8(output.stdout).context("host home directory is not UTF-8")?;
234    let home = PathBuf::from(text.trim());
235    ensure!(
236        home.is_absolute(),
237        "container host home is not an absolute path"
238    );
239    Ok(home)
240}
241
242fn host_architecture(host: &CacheHost, executor: &impl CommandExecutor) -> Result<&'static str> {
243    let command = host.command(
244        vec!["uname".into(), "-m".into()],
245        "detect mbx host architecture",
246    );
247    let output = checked(executor.execute(&command)?, &command)?;
248    let architecture = std::str::from_utf8(&output.stdout)
249        .context("decode mbx host architecture")?
250        .trim();
251    match architecture {
252        "x86_64" => Ok("x86_64"),
253        "aarch64" => Ok("aarch64"),
254        _ => bail!("no pinned static mbx release for Linux architecture {architecture:?}"),
255    }
256}
257
258fn destination(home: &Path, existing: Option<&NativeMbx>) -> Result<(PathBuf, MbxInstallKind)> {
259    let Some(existing) = existing else {
260        return Ok((home.join(".local/bin/mbx"), MbxInstallKind::Install));
261    };
262    ensure!(
263        existing.program.is_absolute(),
264        "native mbx probe returned a non-absolute path"
265    );
266    let allowed = [home.join(".local/bin"), home.join(".cargo/bin")];
267    if allowed
268        .iter()
269        .any(|directory| existing.program.strip_prefix(directory).is_ok())
270    {
271        return Ok((existing.program.clone(), MbxInstallKind::Upgrade));
272    }
273    bail!(
274        "mbx at {} is managed outside Mjolnir's install locations. Upgrade it with its package manager (for mise, run `mise upgrade`).",
275        existing.program.display()
276    )
277}
278
279fn transfer_binary(
280    host: &CacheHost,
281    home: &Path,
282    binary: &Path,
283    program: &Path,
284    executor: &impl CommandExecutor,
285) -> Result<()> {
286    let parent = program
287        .parent()
288        .context("the selected mbx path has no parent directory")?;
289    if host.ssh().is_none() {
290        std::fs::create_dir_all(parent)
291            .with_context(|| format!("create mbx install directory {}", parent.display()))?;
292        let mut temporary = tempfile::NamedTempFile::new_in(parent)
293            .with_context(|| format!("create temporary mbx binary in {}", parent.display()))?;
294        let mut source = std::fs::File::open(binary)
295            .with_context(|| format!("open downloaded mbx binary {}", binary.display()))?;
296        std::io::copy(&mut source, temporary.as_file_mut())
297            .context("copy downloaded mbx binary into the install directory")?;
298        check_cancelled(executor)?;
299        temporary.as_file_mut().sync_all()?;
300        set_executable(temporary.path())?;
301        temporary
302            .persist(program)
303            .map_err(|error| error.error)
304            .with_context(|| format!("publish mbx binary {}", program.display()))?;
305        return Ok(());
306    }
307
308    let command = host.command(
309        vec![
310            "mkdir".into(),
311            "-p".into(),
312            "--".into(),
313            parent.to_string_lossy().into_owned(),
314        ],
315        "create the mbx install directory",
316    );
317    checked(executor.execute(&command)?, &command)?;
318
319    let sequence = NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed);
320    let temporary = parent.join(format!(
321        ".mbx.mjolnir-{}-{sequence}.tmp",
322        std::process::id()
323    ));
324    let remote_relative = temporary
325        .strip_prefix(home)
326        .context("mbx install directory is outside the SSH host home")?
327        .to_string_lossy()
328        .into_owned();
329    let ssh = host.ssh().expect("the SSH host was checked");
330    let upload = targets::scp_upload(ssh, binary, &remote_relative, false)
331        .purpose("upload the pinned mbx binary to the SSH host");
332    if let Err(error) = executor
333        .execute(&upload)
334        .and_then(|output| checked(output, &upload))
335    {
336        remove_remote_temporary(host, &temporary, executor);
337        return Err(error);
338    }
339    if let Err(error) = check_cancelled(executor) {
340        remove_remote_temporary(host, &temporary, executor);
341        return Err(error);
342    }
343    let command = host.shell_command(
344        r#"chmod 755 -- "$1" && mv -f -- "$1" "$2""#,
345        "hel-mbx-install-publish",
346        [
347            temporary.to_string_lossy().into_owned(),
348            program.to_string_lossy().into_owned(),
349        ],
350        "publish the mbx binary atomically on the SSH host",
351    );
352    if let Err(error) = executor
353        .execute(&command)
354        .and_then(|output| checked(output, &command))
355    {
356        remove_remote_temporary(host, &temporary, executor);
357        return Err(error);
358    }
359    Ok(())
360}
361
362fn remove_remote_temporary(host: &CacheHost, path: &Path, executor: &impl CommandExecutor) {
363    let command = host.command(
364        vec![
365            "rm".into(),
366            "-f".into(),
367            "--".into(),
368            path.to_string_lossy().into_owned(),
369        ],
370        "remove partial mbx install upload",
371    );
372    match executor.execute_cleanup(&command) {
373        Ok(output) if output.status == 0 => {}
374        Ok(output) => tracing::warn!(
375            status = output.status,
376            "could not remove a partial mbx installation upload: {}",
377            String::from_utf8_lossy(&output.stderr).trim()
378        ),
379        Err(error) => {
380            tracing::warn!("could not remove a partial mbx installation upload: {error:#}")
381        }
382    }
383}
384
385#[cfg(unix)]
386fn set_executable(path: &Path) -> Result<()> {
387    use std::os::unix::fs::PermissionsExt;
388    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
389        .context("make downloaded mbx executable")
390}
391
392#[cfg(not(unix))]
393fn set_executable(_path: &Path) -> Result<()> {
394    Ok(())
395}
396
397fn run_setup(host: &CacheHost, program: &Path, executor: &impl CommandExecutor) -> Result<()> {
398    let command = host.command(
399        vec![
400            program.to_string_lossy().into_owned(),
401            "setup".into(),
402            "--yes".into(),
403        ],
404        "run mbx setup on the container host",
405    );
406    checked(executor.execute(&command)?, &command).map(|_| ())
407}
408
409pub(super) fn login_profile_details(
410    host: &CacheHost,
411    executor: &impl CommandExecutor,
412) -> Result<MbxProfileDetails> {
413    let home = canonical_home(host, executor)?;
414    let existing = probe_native_version(host, executor)?;
415    let (program, _) = destination(&home, existing.as_ref())?;
416    let binary_dir = program
417        .parent()
418        .context("the selected mbx path has no parent directory")?;
419    let selection = inspect_profile_selection(host, binary_dir, executor)?;
420    Ok(profile_details(&selection, binary_dir))
421}
422
423fn inspect_profile_selection(
424    host: &CacheHost,
425    binary_dir: &Path,
426    executor: &impl CommandExecutor,
427) -> Result<ProfileSelection> {
428    let command = host.shell_command(
429        PROFILE_SCRIPT,
430        "hel-mbx-profile",
431        ["preview".into(), binary_dir.to_string_lossy().into_owned()],
432        "resolve the host login profile for the mbx PATH block",
433    );
434    let output = checked(executor.execute(&command)?, &command)?;
435    parse_profile_selection(&output.stdout)
436}
437
438fn update_profile(
439    host: &CacheHost,
440    program: &Path,
441    executor: &impl CommandExecutor,
442) -> Result<(bool, MbxProfileDetails)> {
443    let binary_dir = program
444        .parent()
445        .context("the selected mbx path has no parent directory")?;
446    let selection = inspect_profile_selection(host, binary_dir, executor)?;
447    let posix_block = posix_profile_block(binary_dir, &selection.shim_dir);
448    let fish_block = fish_profile_block(binary_dir, &selection.shim_dir);
449    let command = host.shell_command(
450        PROFILE_SCRIPT,
451        "hel-mbx-profile",
452        [
453            "update".into(),
454            binary_dir.to_string_lossy().into_owned(),
455            posix_block,
456            fish_block,
457        ],
458        "add the mbx PATH block to the container host login profile",
459    );
460    let output = checked(executor.execute(&command)?, &command)?;
461    let text = String::from_utf8(output.stdout).context("profile update result is not UTF-8")?;
462    let mut lines = text.lines().map(str::to_owned);
463    let status = lines.next().context("profile update returned no result")?;
464    let updated_selection = selection_from_lines(&mut lines)?;
465    let changed = match status.as_str() {
466        "changed" => true,
467        "unchanged" => false,
468        _ => bail!("profile update returned an unexpected result: {status:?}"),
469    };
470    Ok((changed, profile_details(&updated_selection, binary_dir)))
471}
472
473fn parse_profile_selection(output: &[u8]) -> Result<ProfileSelection> {
474    let text = std::str::from_utf8(output).context("host profile selection is not UTF-8")?;
475    let mut lines = text.lines().map(str::to_owned);
476    ensure!(
477        lines.next().as_deref() == Some("preview"),
478        "host profile selection returned an unexpected result"
479    );
480    selection_from_lines(&mut lines)
481}
482
483fn selection_from_lines(lines: &mut impl Iterator<Item = String>) -> Result<ProfileSelection> {
484    let file = lines.next().context("profile selection returned no path")?;
485    validate_profile_file(&file)?;
486    let shell_kind = lines
487        .next()
488        .context("profile selection returned no shell kind")?;
489    ensure!(
490        matches!(shell_kind.as_str(), "posix" | "fish" | "unknown"),
491        "host selected an unexpected login shell kind: {shell_kind:?}"
492    );
493    let shim_dir = lines
494        .next()
495        .context("profile selection returned no mbx shim directory")?;
496    ensure!(
497        Path::new(&shim_dir).is_absolute(),
498        "host selected a non-absolute mbx shim directory: {shim_dir:?}"
499    );
500    ensure!(
501        lines.next().is_none(),
502        "profile selection returned unexpected extra fields"
503    );
504    Ok(ProfileSelection {
505        file,
506        shell_kind,
507        shim_dir: PathBuf::from(shim_dir),
508    })
509}
510
511fn validate_profile_file(file: &str) -> Result<()> {
512    ensure!(
513        file.starts_with("~/") || Path::new(file).is_absolute(),
514        "host selected a non-absolute login profile path: {file:?}"
515    );
516    Ok(())
517}
518
519fn profile_details(selection: &ProfileSelection, binary_dir: &Path) -> MbxProfileDetails {
520    let manual_path_line = (selection.shell_kind == "unknown").then(|| {
521        let directories = format!("{}:{}", selection.shim_dir.display(), binary_dir.display());
522        format!(
523            "export PATH={}{}",
524            shell_quote(&directories),
525            "${PATH:+:$PATH}"
526        )
527    });
528    let warning = manual_path_line.as_ref().map(|_| {
529        "Your login shell may not read ~/.profile. Add this PATH line to a startup file it reads:"
530            .to_owned()
531    });
532    MbxProfileDetails {
533        file: selection.file.clone(),
534        warning,
535        manual_path_line,
536    }
537}
538
539fn posix_profile_block(binary_dir: &Path, shim_dir: &Path) -> String {
540    let binary_dir = shell_quote(&binary_dir.to_string_lossy());
541    let shim_dir = shell_quote(&shim_dir.to_string_lossy());
542    format!(
543        r#"# >>> mbx (added by Mjolnir) >>>
544_mjolnir_mbx_prepend_path() {{
545    _mjolnir_mbx_dir=$1
546    _mjolnir_mbx_rest=${{PATH-}}
547    _mjolnir_mbx_new=
548    _mjolnir_mbx_first=yes
549    if [ -n "$_mjolnir_mbx_rest" ]; then
550        while :; do
551            case $_mjolnir_mbx_rest in
552                *:*)
553                    _mjolnir_mbx_entry=${{_mjolnir_mbx_rest%%:*}}
554                    _mjolnir_mbx_rest=${{_mjolnir_mbx_rest#*:}}
555                    _mjolnir_mbx_more=yes
556                    ;;
557                *)
558                    _mjolnir_mbx_entry=$_mjolnir_mbx_rest
559                    _mjolnir_mbx_more=no
560                    ;;
561            esac
562            if [ "$_mjolnir_mbx_entry" != "$_mjolnir_mbx_dir" ]; then
563                if [ "$_mjolnir_mbx_first" = yes ]; then
564                    _mjolnir_mbx_new=$_mjolnir_mbx_entry
565                    _mjolnir_mbx_first=no
566                else
567                    _mjolnir_mbx_new=$_mjolnir_mbx_new:$_mjolnir_mbx_entry
568                fi
569            fi
570            [ "$_mjolnir_mbx_more" = yes ] || break
571        done
572    fi
573    if [ "$_mjolnir_mbx_first" = yes ]; then
574        PATH=$_mjolnir_mbx_dir
575    else
576        PATH=$_mjolnir_mbx_dir:$_mjolnir_mbx_new
577    fi
578}}
579_mjolnir_mbx_prepend_path {binary_dir}
580_mjolnir_mbx_prepend_path {shim_dir}
581export PATH
582# <<< mbx <<<"#
583    )
584}
585
586fn fish_profile_block(binary_dir: &Path, shim_dir: &Path) -> String {
587    let binary_dir = fish_quote(&binary_dir.to_string_lossy());
588    let shim_dir = fish_quote(&shim_dir.to_string_lossy());
589    format!(
590        "# >>> mbx (added by Mjolnir) >>>\nfish_add_path --path --prepend --move -- {binary_dir}\nfish_add_path --path --prepend --move -- {shim_dir}\n# <<< mbx <<<"
591    )
592}
593
594fn shell_quote(value: &str) -> String {
595    format!("'{}'", value.replace('\'', "'\\''"))
596}
597
598fn fish_quote(value: &str) -> String {
599    format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
600}
601
602fn check_cancelled(executor: &impl CommandExecutor) -> Result<()> {
603    ensure!(
604        !executor.cancellation_requested(),
605        "mbx installation cancelled"
606    );
607    Ok(())
608}
609
610fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
611    ensure!(
612        output.status == 0,
613        "{} failed with status {}: {}",
614        command.purpose,
615        output.status,
616        String::from_utf8_lossy(&output.stderr).trim()
617    );
618    Ok(output)
619}
620
621fn release_url(triple: &str) -> String {
622    format!(
623        "https://github.com/jdx/mr-boxington/releases/download/v{MBX_VERSION}/mbx-{triple}-unknown-linux-musl.tar.gz"
624    )
625}
626
627fn expected_digest(triple: &str) -> Result<&'static str> {
628    match triple {
629        "x86_64" => Ok(MBX_X86_64_SHA256),
630        "aarch64" => Ok(MBX_AARCH64_SHA256),
631        _ => bail!("no pinned mbx release for {triple}"),
632    }
633}
634
635/// Download the pinned release once into the controller data directory and
636/// verify its archive before extracting anything.
637pub(super) fn download(triple: &str) -> Result<PathBuf> {
638    let expected = expected_digest(triple)?;
639    let directory = mj_core::config::data_dir()
640        .join("mbx")
641        .join(MBX_VERSION)
642        .join(triple);
643    let destination = directory.join("mbx");
644    if destination.is_file() {
645        return Ok(destination);
646    }
647    std::fs::create_dir_all(&directory)
648        .with_context(|| format!("create the mbx cache {}", directory.display()))?;
649    let url = release_url(triple);
650    let archive = reqwest::blocking::Client::builder()
651        .timeout(Duration::from_secs(120))
652        .build()?
653        .get(&url)
654        .send()
655        .with_context(|| format!("download {url}"))?
656        .error_for_status()
657        .with_context(|| format!("download {url}"))?
658        .bytes()?;
659    let actual = mj_core::hex::lower_hex(Sha256::digest(&archive));
660    ensure!(
661        actual.eq_ignore_ascii_case(expected),
662        "downloaded mbx checksum mismatch: expected {expected}, got {actual}"
663    );
664    let binary = extract_binary(&archive)?;
665    let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
666    std::io::Write::write_all(&mut temporary, &binary)?;
667    temporary.as_file_mut().sync_all()?;
668    #[cfg(unix)]
669    {
670        use std::os::unix::fs::PermissionsExt;
671        std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
672    }
673    match temporary.persist_noclobber(&destination) {
674        Ok(_) => Ok(destination),
675        Err(error) if destination.is_file() => {
676            drop(error);
677            Ok(destination)
678        }
679        Err(error) => Err(error.error)
680            .with_context(|| format!("publish the mbx binary {}", destination.display())),
681    }
682}
683
684/// Extract the archive's single binary; its licence texts are not installed.
685fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
686    let mut reader = tar::Archive::new(flate2::read::GzDecoder::new(archive));
687    for entry in reader.entries().context("read the mbx release archive")? {
688        let mut entry = entry.context("read the mbx release archive")?;
689        if entry.path().context("read an mbx archive path")?.as_ref() != Path::new("mbx") {
690            continue;
691        }
692        let mut bytes = Vec::new();
693        entry
694            .read_to_end(&mut bytes)
695            .context("read the mbx binary from its release archive")?;
696        return Ok(bytes);
697    }
698    bail!("the mbx release archive contains no mbx binary")
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use std::collections::VecDeque;
705    use std::sync::Mutex;
706
707    use mj_core::config::{SshConnection, TargetBuildCache};
708
709    #[derive(Default)]
710    struct FakeExecutor {
711        commands: Mutex<Vec<CommandSpec>>,
712        probe_answers: Mutex<VecDeque<CommandOutput>>,
713        setup_error: Option<String>,
714    }
715
716    impl FakeExecutor {
717        fn with_probes(probes: impl IntoIterator<Item = CommandOutput>) -> Self {
718            Self {
719                commands: Mutex::new(Vec::new()),
720                probe_answers: Mutex::new(probes.into_iter().collect()),
721                setup_error: None,
722            }
723        }
724
725        fn failing_setup(probes: impl IntoIterator<Item = CommandOutput>) -> Self {
726            Self {
727                setup_error: Some("mbx setup failed".into()),
728                ..Self::with_probes(probes)
729            }
730        }
731
732        fn commands(&self) -> Vec<CommandSpec> {
733            self.commands.lock().unwrap().clone()
734        }
735    }
736
737    impl CommandExecutor for FakeExecutor {
738        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
739            self.commands.lock().unwrap().push(command.clone());
740            let output = match command.purpose.as_str() {
741                "detect build cache host platform" => output(0, "Linux x86_64\n", ""),
742                "detect mbx host architecture" => output(0, "x86_64\n", ""),
743                "read the container host mbx version" => self
744                    .probe_answers
745                    .lock()
746                    .unwrap()
747                    .pop_front()
748                    .expect("a probe answer was configured"),
749                "run mbx setup on the container host" => self
750                    .setup_error
751                    .as_ref()
752                    .map_or_else(|| output(0, "", ""), |error| output(7, "", error)),
753                "resolve the host login profile for the mbx PATH block" => output(
754                    0,
755                    "preview\n~/.bash_profile\nposix\n/home/build/.local/share/mbx/bin",
756                    "",
757                ),
758                "add the mbx PATH block to the container host login profile" => output(
759                    0,
760                    "changed\n~/.bash_profile\nposix\n/home/build/.local/share/mbx/bin",
761                    "",
762                ),
763                _ => output(0, "", ""),
764            };
765            Ok(output)
766        }
767    }
768
769    fn output(status: i32, stdout: &str, stderr: &str) -> CommandOutput {
770        CommandOutput {
771            status,
772            stdout: stdout.as_bytes().to_vec(),
773            stderr: stderr.as_bytes().to_vec(),
774        }
775    }
776
777    fn local_machine() -> Machine {
778        Machine::Local { build_cache: None }
779    }
780
781    fn ssh_machine() -> Machine {
782        Machine::Ssh {
783            ssh: SshConnection {
784                host: "builder.example.test".into(),
785                user: Some("build".into()),
786                identity_file: None,
787                extra_args: Vec::new(),
788            },
789            workspace_prefix: PathBuf::from(".local/share/mjolnir/workspaces"),
790            build_cache: Some(TargetBuildCache::default()),
791        }
792    }
793
794    fn absent_then(path: &Path, version: &str) -> [CommandOutput; 2] {
795        [
796            output(1, "", ""),
797            output(0, &format!("{}\nmbx {version}\n", path.display()), ""),
798        ]
799    }
800
801    fn cache_preview(
802        native_mbx: Option<&str>,
803        off_reason: Option<BuildCacheOff>,
804    ) -> BuildCachePreview {
805        BuildCachePreview {
806            native_mbx: native_mbx.map(str::to_owned),
807            mbx_profile_file: None,
808            mbx_profile_warning: None,
809            mbx_manual_path_line: None,
810            directory: None,
811            max_total_size: None,
812            user_managed: false,
813            application: mj_core::state::BuildCacheApplication::Pending,
814            budget_note: None,
815            stats: None,
816            off_reason,
817        }
818    }
819
820    #[test]
821    fn install_offer_tracks_absent_old_compatible_and_unsupported_hosts() {
822        assert_eq!(
823            install_kind(&cache_preview(None, None)),
824            Some(MbxInstallKind::Install)
825        );
826        assert_eq!(
827            install_kind(&cache_preview(Some("1.21.0"), None)),
828            Some(MbxInstallKind::Upgrade)
829        );
830        assert_eq!(install_kind(&cache_preview(Some(MBX_VERSION), None)), None);
831        assert_eq!(
832            install_kind(&cache_preview(
833                None,
834                Some(BuildCacheOff::Unavailable(
835                    super::super::UNSUPPORTED_HOST.into()
836                ))
837            )),
838            None
839        );
840    }
841
842    #[test]
843    fn destination_installs_absent_mbx_and_replaces_only_managed_paths() {
844        let home = Path::new("/home/build");
845        assert_eq!(
846            destination(home, None).unwrap(),
847            (
848                PathBuf::from("/home/build/.local/bin/mbx"),
849                MbxInstallKind::Install
850            )
851        );
852        for path in ["/home/build/.local/bin/mbx", "/home/build/.cargo/bin/mbx"] {
853            assert_eq!(
854                destination(
855                    home,
856                    Some(&NativeMbx {
857                        program: PathBuf::from(path),
858                        version: "1.21.0".into(),
859                    })
860                )
861                .unwrap(),
862                (PathBuf::from(path), MbxInstallKind::Upgrade)
863            );
864        }
865        let error = destination(
866            home,
867            Some(&NativeMbx {
868                program: PathBuf::from("/home/build/.local/share/mise/installs/mbx/1.21/bin/mbx"),
869                version: "1.21.0".into(),
870            }),
871        )
872        .unwrap_err();
873        assert!(format!("{error:#}").contains("mise upgrade"));
874    }
875
876    #[test]
877    fn local_install_copies_atomically_runs_setup_and_verifies_the_version() {
878        let temp = tempfile::tempdir().unwrap();
879        let home = temp.path().join("home with ' quotes");
880        std::fs::create_dir_all(&home).unwrap();
881        let binary = temp.path().join("downloaded-mbx");
882        std::fs::write(&binary, b"recorded mbx executable").unwrap();
883        let program = home.join(".local/bin/mbx");
884        let executor = FakeExecutor::with_probes(absent_then(&program, MBX_VERSION));
885
886        let result = install_mbx_with(&local_machine(), &executor, Some(&home), |triple| {
887            assert_eq!(triple, "x86_64");
888            Ok(binary.clone())
889        })
890        .unwrap();
891
892        assert_eq!(result.program, program);
893        assert_eq!(result.version, MBX_VERSION);
894        assert_eq!(result.kind, MbxInstallKind::Install);
895        assert!(result.profile_changed);
896        assert_eq!(result.profile_file, "~/.bash_profile");
897        assert_eq!(std::fs::read(&program).unwrap(), b"recorded mbx executable");
898        #[cfg(unix)]
899        {
900            use std::os::unix::fs::PermissionsExt;
901            assert_eq!(
902                std::fs::metadata(&program).unwrap().permissions().mode() & 0o777,
903                0o755
904            );
905        }
906        let commands = executor.commands();
907        let setup = commands
908            .iter()
909            .find(|command| command.purpose == "run mbx setup on the container host")
910            .unwrap();
911        assert_eq!(setup.program, program.to_string_lossy());
912        assert_eq!(setup.args, ["setup", "--yes"]);
913        assert!(commands.iter().any(|command| {
914            command.purpose == "add the mbx PATH block to the container host login profile"
915        }));
916    }
917
918    #[test]
919    fn ssh_install_uses_scp_to_a_home_relative_sibling_then_renames_atomically() {
920        let temp = tempfile::tempdir().unwrap();
921        let binary = temp.path().join("downloaded-mbx");
922        std::fs::write(&binary, b"recorded mbx executable").unwrap();
923        let home = PathBuf::from("/remote home/O'Neil");
924        let program = home.join(".local/bin/mbx");
925        let executor = FakeExecutor::with_probes(absent_then(&program, MBX_VERSION));
926
927        let result =
928            install_mbx_with(&ssh_machine(), &executor, Some(&home), |_| Ok(binary)).unwrap();
929
930        assert_eq!(result.program, program);
931        let commands = executor.commands();
932        let upload = commands
933            .iter()
934            .find(|command| command.purpose == "upload the pinned mbx binary to the SSH host")
935            .unwrap();
936        assert_eq!(upload.program, "scp");
937        assert!(upload.ssh_session.is_some());
938        assert!(upload.args.iter().any(|arg| {
939            arg.starts_with("build@builder.example.test:.local/bin/.mbx.mjolnir-")
940                && arg.ends_with(".tmp")
941        }));
942        let publish = commands
943            .iter()
944            .find(|command| command.purpose == "publish the mbx binary atomically on the SSH host")
945            .unwrap();
946        assert!(publish.args.last().unwrap().contains("chmod 755"));
947        assert!(publish.args.last().unwrap().contains("mv -f"));
948        assert!(
949            publish
950                .args
951                .last()
952                .unwrap()
953                .contains("'/remote home/O'\\''Neil/.local/bin/mbx'")
954        );
955        assert!(commands.iter().any(|command| {
956            command.purpose == "run mbx setup on the container host"
957                && command
958                    .args
959                    .last()
960                    .unwrap()
961                    .contains("'/remote home/O'\\''Neil/.local/bin/mbx'")
962        }));
963    }
964
965    #[test]
966    fn setup_failure_surfaces_stderr_and_stops_before_profile_changes() {
967        let temp = tempfile::tempdir().unwrap();
968        let home = temp.path().join("home");
969        std::fs::create_dir_all(&home).unwrap();
970        let binary = temp.path().join("downloaded-mbx");
971        std::fs::write(&binary, b"recorded mbx executable").unwrap();
972        let program = home.join(".local/bin/mbx");
973        let executor = FakeExecutor::failing_setup([
974            output(1, "", ""),
975            output(0, &format!("{}\nmbx 1.21.0\n", program.display()), ""),
976        ]);
977
978        let error =
979            install_mbx_with(&local_machine(), &executor, Some(&home), |_| Ok(binary)).unwrap_err();
980
981        assert!(format!("{error:#}").contains("mbx setup failed"));
982        assert!(!executor.commands().iter().any(|command| {
983            command.purpose == "add the mbx PATH block to the container host login profile"
984        }));
985    }
986
987    #[test]
988    fn mise_install_refusal_happens_before_download_or_host_mutation() {
989        let home = Path::new("/home/build");
990        let executor = FakeExecutor::with_probes([output(
991            0,
992            "/home/build/.local/share/mise/installs/mbx/1.21/bin/mbx\nmbx 1.21.0\n",
993            "",
994        )]);
995        let mut downloaded = false;
996
997        let error = install_mbx_with(&local_machine(), &executor, Some(home), |_| {
998            downloaded = true;
999            bail!("download should not run")
1000        })
1001        .unwrap_err();
1002
1003        assert!(format!("{error:#}").contains("mise upgrade"));
1004        assert!(!downloaded);
1005        assert!(executor.commands().iter().all(|command| {
1006            !command.purpose.starts_with("run mbx setup")
1007                && !command.purpose.starts_with("add the mbx PATH")
1008        }));
1009    }
1010
1011    #[cfg(unix)]
1012    #[test]
1013    fn profile_block_selects_and_updates_the_login_shell_file_idempotently() {
1014        use std::os::unix::fs::PermissionsExt;
1015
1016        struct Case {
1017            name: &'static str,
1018            login_shell: &'static str,
1019            existing: &'static [&'static str],
1020            selected: Option<&'static str>,
1021            shell_kind: &'static str,
1022        }
1023        let cases = [
1024            Case {
1025                name: "zsh",
1026                login_shell: "/bin/zsh",
1027                existing: &[".bash_profile"],
1028                selected: Some("~/.zprofile"),
1029                shell_kind: "posix",
1030            },
1031            Case {
1032                name: "bash-profile",
1033                login_shell: "/bin/bash",
1034                existing: &[".bash_profile", ".bash_login"],
1035                selected: Some("~/.bash_profile"),
1036                shell_kind: "posix",
1037            },
1038            Case {
1039                name: "bash-login",
1040                login_shell: "/usr/bin/bash",
1041                existing: &[".bash_login"],
1042                selected: Some("~/.bash_login"),
1043                shell_kind: "posix",
1044            },
1045            Case {
1046                name: "bash-default",
1047                login_shell: "/bin/bash",
1048                existing: &[],
1049                selected: Some("~/.profile"),
1050                shell_kind: "posix",
1051            },
1052            Case {
1053                name: "unknown",
1054                login_shell: "/usr/bin/nu",
1055                existing: &[".zprofile", ".bash_profile", ".bash_login"],
1056                selected: Some("~/.profile"),
1057                shell_kind: "unknown",
1058            },
1059            Case {
1060                name: "fish",
1061                login_shell: "/usr/bin/fish",
1062                existing: &[],
1063                selected: None,
1064                shell_kind: "fish",
1065            },
1066        ];
1067
1068        for case in cases {
1069            let temp = tempfile::tempdir().unwrap();
1070            let home = temp
1071                .path()
1072                .join(format!("home {} with ' quotes", case.name));
1073            let tools = temp.path().join("tools");
1074            let xdg_config = temp.path().join("xdg config");
1075            let xdg_data = temp.path().join("xdg data with ' quote");
1076            let binary_dir = home.join(".local/bin");
1077            std::fs::create_dir_all(&home).unwrap();
1078            std::fs::create_dir_all(&tools).unwrap();
1079            std::fs::write(
1080                tools.join("id"),
1081                "#!/bin/sh\nprintf 'profile-test-user\\n'\n",
1082            )
1083            .unwrap();
1084            std::fs::write(
1085                tools.join("getent"),
1086                "#!/bin/sh\nprintf 'profile-test-user:x:1000:1000::/tmp:%s\\n' \"$PROFILE_TEST_LOGIN_SHELL\"\n",
1087            )
1088            .unwrap();
1089            for name in ["id", "getent"] {
1090                std::fs::set_permissions(tools.join(name), std::fs::Permissions::from_mode(0o755))
1091                    .unwrap();
1092            }
1093            for name in case.existing {
1094                std::fs::write(home.join(name), "export EXISTING_PROFILE=yes").unwrap();
1095            }
1096            let executor =
1097                targets::CancellableProcessExecutor::with_timeout(Duration::from_secs(5));
1098            let mut command = CommandSpec::new(
1099                "/bin/sh",
1100                [
1101                    "-c",
1102                    PROFILE_SCRIPT,
1103                    "mj-profile",
1104                    "preview",
1105                    binary_dir.to_str().unwrap(),
1106                ],
1107            );
1108            command
1109                .env
1110                .insert("HOME".into(), home.to_string_lossy().into_owned());
1111            command
1112                .env
1113                .insert("PATH".into(), format!("{}:/usr/bin:/bin", tools.display()));
1114            command
1115                .env
1116                .insert("PROFILE_TEST_LOGIN_SHELL".into(), case.login_shell.into());
1117            command.env.insert("SHELL".into(), "/bin/sh".into());
1118            command.env.insert(
1119                "XDG_CONFIG_HOME".into(),
1120                xdg_config.to_string_lossy().into_owned(),
1121            );
1122            command.env.insert(
1123                "XDG_DATA_HOME".into(),
1124                xdg_data.to_string_lossy().into_owned(),
1125            );
1126            let preview = executor.execute(&command).unwrap();
1127            let selection = parse_profile_selection(&preview.stdout).unwrap();
1128            let selected = case.selected.map(str::to_owned).unwrap_or_else(|| {
1129                xdg_config
1130                    .join("fish/conf.d/mbx.fish")
1131                    .to_string_lossy()
1132                    .into_owned()
1133            });
1134            assert_eq!(selection.file, selected);
1135            assert_eq!(selection.shell_kind, case.shell_kind);
1136            assert_eq!(selection.shim_dir, xdg_data.join("mbx/bin"));
1137
1138            let posix_block = posix_profile_block(&binary_dir, &selection.shim_dir);
1139            let fish_block = fish_profile_block(&binary_dir, &selection.shim_dir);
1140            let update = CommandSpec::new(
1141                "/bin/sh",
1142                [
1143                    "-c",
1144                    PROFILE_SCRIPT,
1145                    "mj-profile",
1146                    "update",
1147                    binary_dir.to_str().unwrap(),
1148                    &posix_block,
1149                    &fish_block,
1150                ],
1151            );
1152            let mut update = update;
1153            update.env.clone_from(&command.env);
1154            let first_update = executor.execute(&update).unwrap();
1155            assert_eq!(
1156                std::str::from_utf8(&first_update.stdout).unwrap(),
1157                format!(
1158                    "changed\n{selected}\n{}\n{}",
1159                    case.shell_kind,
1160                    selection.shim_dir.display()
1161                )
1162            );
1163            let second_update = executor.execute(&update).unwrap();
1164            assert_eq!(
1165                std::str::from_utf8(&second_update.stdout).unwrap(),
1166                format!(
1167                    "unchanged\n{selected}\n{}\n{}",
1168                    case.shell_kind,
1169                    selection.shim_dir.display()
1170                )
1171            );
1172            let profile_path = if let Some(relative) = selected.strip_prefix("~/") {
1173                home.join(relative)
1174            } else {
1175                PathBuf::from(&selected)
1176            };
1177            let profile = std::fs::read_to_string(profile_path).unwrap();
1178            if case
1179                .existing
1180                .iter()
1181                .any(|name| selected == format!("~/{}", name))
1182            {
1183                assert!(profile.starts_with("export EXISTING_PROFILE=yes\n# >>> mbx"));
1184            }
1185            assert_eq!(
1186                profile.matches("# >>> mbx (added by Mjolnir) >>>").count(),
1187                1
1188            );
1189            assert_eq!(profile.matches("# <<< mbx <<<").count(), 1);
1190            for name in case.existing {
1191                if selected != format!("~/{}", name) {
1192                    assert!(
1193                        !std::fs::read_to_string(home.join(name))
1194                            .unwrap()
1195                            .contains("# >>> mbx")
1196                    );
1197                }
1198            }
1199            if case.shell_kind == "unknown" {
1200                let details = profile_details(&selection, &binary_dir);
1201                assert!(details.warning.unwrap().contains("may not read ~/.profile"));
1202                assert!(
1203                    details
1204                        .manual_path_line
1205                        .unwrap()
1206                        .starts_with("export PATH='")
1207                );
1208            }
1209            if case.shell_kind == "posix" {
1210                assert!(profile.contains(&shell_quote(&selection.shim_dir.to_string_lossy())));
1211                assert!(profile.contains(&shell_quote(&binary_dir.to_string_lossy())));
1212                assert!(!profile.contains("$HOME/.local/share/mbx/bin"));
1213            }
1214        }
1215    }
1216
1217    #[cfg(unix)]
1218    #[test]
1219    fn profile_selection_falls_back_to_shell_then_sh() {
1220        use std::os::unix::fs::PermissionsExt;
1221
1222        let temp = tempfile::tempdir().unwrap();
1223        let home = temp.path().join("home");
1224        let tools = temp.path().join("tools");
1225        let xdg_config = temp.path().join("xdg config");
1226        let xdg_data = temp.path().join("xdg data");
1227        std::fs::create_dir_all(&home).unwrap();
1228        std::fs::create_dir_all(&tools).unwrap();
1229        std::fs::write(tools.join("id"), "#!/bin/sh\nprintf 'test-user\\n'\n").unwrap();
1230        std::fs::write(tools.join("getent"), "#!/bin/sh\nexit 2\n").unwrap();
1231        for name in ["id", "getent"] {
1232            std::fs::set_permissions(tools.join(name), std::fs::Permissions::from_mode(0o755))
1233                .unwrap();
1234        }
1235        let executor = targets::CancellableProcessExecutor::with_timeout(Duration::from_secs(5));
1236        let binary_dir = home.join(".local/bin");
1237        let mut command = CommandSpec::new(
1238            "/bin/sh",
1239            [
1240                "-c",
1241                PROFILE_SCRIPT,
1242                "mj-profile",
1243                "preview",
1244                binary_dir.to_str().unwrap(),
1245            ],
1246        );
1247        command
1248            .env
1249            .insert("HOME".into(), home.to_string_lossy().into_owned());
1250        command
1251            .env
1252            .insert("PATH".into(), format!("{}:/usr/bin:/bin", tools.display()));
1253        command.env.insert("SHELL".into(), "/usr/bin/zsh".into());
1254        command.env.insert(
1255            "XDG_CONFIG_HOME".into(),
1256            xdg_config.to_string_lossy().into_owned(),
1257        );
1258        command.env.insert(
1259            "XDG_DATA_HOME".into(),
1260            xdg_data.to_string_lossy().into_owned(),
1261        );
1262        assert_eq!(
1263            parse_profile_selection(&executor.execute(&command).unwrap().stdout)
1264                .unwrap()
1265                .file,
1266            "~/.zprofile"
1267        );
1268        command.env.insert("SHELL".into(), "".into());
1269        assert_eq!(
1270            parse_profile_selection(&executor.execute(&command).unwrap().stdout)
1271                .unwrap()
1272                .file,
1273            "~/.profile"
1274        );
1275    }
1276
1277    #[test]
1278    fn profile_preview_and_install_use_one_shared_rule_script() {
1279        assert!(PROFILE_SCRIPT.contains("getent passwd"));
1280        assert!(PROFILE_SCRIPT.contains("${SHELL:-sh}"));
1281        assert!(PROFILE_SCRIPT.contains("grep -Fq \"$start\" \"$profile\""));
1282        assert!(PROFILE_SCRIPT.contains("fish/conf.d/mbx.fish"));
1283        assert!(PROFILE_SCRIPT.contains("XDG_DATA_HOME"));
1284        let directory = Path::new("/home/example/.local/bin");
1285        assert!(
1286            posix_profile_block(directory, Path::new("/home/example/.local/share/mbx/bin"))
1287                .contains("# >>> mbx (added by Mjolnir) >>>")
1288        );
1289        assert!(
1290            fish_profile_block(directory, Path::new("/home/example/.local/share/mbx/bin"))
1291                .contains("# <<< mbx <<<")
1292        );
1293    }
1294
1295    #[test]
1296    fn posix_profile_moves_shim_and_binary_directories_ahead_of_existing_path_entries() {
1297        let temp = tempfile::tempdir().unwrap();
1298        let home = temp.path().join("home");
1299        let shim_dir = temp.path().join("xdg data's/mbx/bin");
1300        let binary_dir = temp.path().join("local bin's");
1301        std::fs::create_dir_all(&home).unwrap();
1302        let block = posix_profile_block(&binary_dir, &shim_dir);
1303        std::fs::write(home.join(".profile"), block).unwrap();
1304        let initial = format!(
1305            "/usr/bin:{}:/bin:{}:{}",
1306            binary_dir.display(),
1307            shim_dir.display(),
1308            binary_dir.display()
1309        );
1310        let mut command = CommandSpec::new(
1311            "/bin/sh",
1312            [
1313                "-c",
1314                ". \"$HOME/.profile\"; . \"$HOME/.profile\"; printf '%s' \"$PATH\"",
1315            ],
1316        );
1317        command
1318            .env
1319            .insert("HOME".into(), home.to_string_lossy().into_owned());
1320        command.env.insert("PATH".into(), initial);
1321        let output = targets::ProcessExecutor.execute(&command).unwrap();
1322        assert_eq!(
1323            String::from_utf8(output.stdout).unwrap(),
1324            format!(
1325                "{}:{}:/usr/bin:/bin",
1326                shim_dir.display(),
1327                binary_dir.display()
1328            )
1329        );
1330    }
1331
1332    #[test]
1333    fn fish_profile_is_idempotent_when_fish_is_installed() {
1334        let probe = targets::ProcessExecutor.execute(&CommandSpec::new("fish", ["--version"]));
1335        let Ok(probe) = probe else {
1336            return;
1337        };
1338        if probe.status != 0 {
1339            return;
1340        }
1341        let temp = tempfile::tempdir().unwrap();
1342        let home = temp.path().join("home");
1343        let config = temp.path().join("xdg config");
1344        let data = temp.path().join("xdg data");
1345        let binary_dir = temp.path().join("binary");
1346        let shim_dir = temp.path().join("shim");
1347        std::fs::create_dir_all(config.join("fish/conf.d")).unwrap();
1348        std::fs::create_dir_all(&binary_dir).unwrap();
1349        std::fs::create_dir_all(&shim_dir).unwrap();
1350        let file = config.join("fish/conf.d/mbx.fish");
1351        std::fs::write(&file, fish_profile_block(&binary_dir, &shim_dir)).unwrap();
1352        let path = format!(
1353            "/usr/bin:{}:/bin:{}:{}",
1354            binary_dir.display(),
1355            shim_dir.display(),
1356            binary_dir.display()
1357        );
1358        let fish_script = format!(
1359            "source '{}'; source '{}'; string join ':' $PATH",
1360            file.display(),
1361            file.display()
1362        );
1363        let mut command = CommandSpec::new("fish", ["-c", fish_script.as_str()]);
1364        command
1365            .env
1366            .insert("HOME".into(), home.to_string_lossy().into_owned());
1367        command.env.insert(
1368            "XDG_CONFIG_HOME".into(),
1369            config.to_string_lossy().into_owned(),
1370        );
1371        command
1372            .env
1373            .insert("XDG_DATA_HOME".into(), data.to_string_lossy().into_owned());
1374        command.env.insert("PATH".into(), path);
1375        let output = targets::ProcessExecutor.execute(&command).unwrap();
1376        assert_eq!(
1377            output.status,
1378            0,
1379            "{}",
1380            String::from_utf8_lossy(&output.stderr)
1381        );
1382        assert_eq!(
1383            String::from_utf8(output.stdout).unwrap().trim(),
1384            format!(
1385                "{}:{}:/usr/bin:/bin",
1386                shim_dir.display(),
1387                binary_dir.display()
1388            )
1389        );
1390    }
1391
1392    #[test]
1393    fn installer_scripts_cover_absolute_candidates_and_marked_profile_paths() {
1394        for expected in [
1395            "command -v mbx",
1396            "$HOME/.local/bin/mbx",
1397            "$HOME/.cargo/bin/mbx",
1398            "readlink -f --",
1399            "case \"$resolved\" in /*)",
1400        ] {
1401            assert!(
1402                super::super::NATIVE_VERSION_SCRIPT.contains(expected),
1403                "missing {expected:?}"
1404            );
1405        }
1406    }
1407}