1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum MbxInstallKind {
96 Install,
97 Upgrade,
98}
99
100#[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#[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
127pub 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
142pub 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
635pub(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
684fn 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}