Skip to main content

max / alloy

Refuse an export whose wrapper name is already taken Both levels write into ~/.local/bin, so a name taken there is taken whichever tool would write it, and there was no check of any kind. Two boxes declaring export.bin = ["rg"] left one of them pointing at the other's container - a literal overwrite, certain rather than merely possible - and exporting a name the host already carries put the box's copy ahead of it, because ~/.local/bin precedes /usr/bin. Refusing rather than warning: the warning prints at export time and the surprise arrives weeks later at a shell prompt. The message names the file or the host path in the way. A box may still land on the wrapper it wrote itself, which is how a changed mount list gets picked up; the check reads the box name out of the existing script, so a host box is not refused by its own previous distrobox-export. The host half also distinguishes the two orders. If the export directory precedes, the export shadows; if it does not, the export is the thing that never runs, and saying "would shadow" there would be a lie. Podman gains the same seam Flatpak has, since a check that stats ~/.local/bin would otherwise make these tests pass or fail on whether the person running them has an rg exported.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 18:04 UTC
Commit: 5232a751c5339d136a69189aaafc943abb37a718
Parent: 3126075
2 files changed, +320 insertions, -37 deletions
@@ -43,9 +43,12 @@
43 43 `~/.local/bin`, which Fedora already searches. Running `rg` then runs it inside
44 44 its box, in the right directory, starting the box first if it was stopped.
45 45
46 - One caution: an exported wrapper takes the plain name of the binary, so
47 - exporting something the host already has puts the box's copy ahead of the
48 - system one.
46 + An exported wrapper takes the plain name of the binary, so two boxes exporting
47 + `rg` would land on one file, and exporting something the host already has would
48 + put the box's copy ahead of the system one. Neither happens quietly: an export
49 + whose name is already taken is refused, and the message names the file or the
50 + host path in the way. Drop the name from that box's `export.bin`, or clear
51 + whatever holds it, and export again.
49 52
50 53 ## Declaring boxes
51 54
@@ -531,7 +531,7 @@
531 531 pub(crate) fn detect() -> Vec<std::boxed::Box<dyn Backend>> {
532 532 let mut backends: Vec<std::boxed::Box<dyn Backend>> = Vec::new();
533 533 if Invocation::new("podman").arg("--version").probe() {
534 - backends.push(std::boxed::Box::new(Podman));
534 + backends.push(std::boxed::Box::new(Podman::new()));
535 535 }
536 536 if Invocation::new("flatpak").arg("--version").probe() {
537 537 backends.push(std::boxed::Box::new(Flatpak::new()));
@@ -541,7 +541,28 @@
541 541
542 542 // ---- podman: the host and workspace levels ----
543 543
544 - pub(crate) struct Podman;
544 + pub(crate) struct Podman {
545 + /// Where export wrappers land, when it is not the environment's answer.
546 + ///
547 + /// Held rather than read per call so the collision check below can be
548 + /// pointed at a directory a test owns. `set_var` is unsafe in a threaded
549 + /// test binary and this module has already paid for reading `HOME`
550 + /// underneath its own tests once (see [`mount_path_in`]).
551 + dir: Option<std::path::PathBuf>,
552 + /// The `PATH` a shell would find the wrapper on. `None` disables the
553 + /// host-collision half, which is right when there is no `PATH` to read:
554 + /// a check that cannot see the search order has nothing to say about it.
555 + path: Option<String>,
556 + }
557 +
558 + impl Podman {
559 + pub(crate) fn new() -> Self {
560 + Self {
561 + dir: None,
562 + path: std::env::var("PATH").ok(),
563 + }
564 + }
565 + }
545 566
546 567 impl Backend for Podman {
547 568 fn name(&self) -> &'static str {
@@ -650,7 +671,20 @@
650 671 /// means asking the box where the command lives.
651 672 fn export(&self, name: &str, spec: &SpecBox) -> Result<Vec<Effect>> {
652 673 let bins = spec.bins(name)?;
653 - let dir = export_dir()?;
674 + let dir = match &self.dir {
675 + Some(dir) => dir.clone(),
676 + None => export_dir()?,
677 + };
678 +
679 + // Before anything is written or run, and for both levels: the two of
680 + // them land in one directory, so a name taken there is taken whichever
681 + // tool would write it.
682 + for bin in bins {
683 + if let Some(reason) = export_conflict(&dir, bin, name, self.path.as_deref()) {
684 + anyhow::bail!(reason);
685 + }
686 + }
687 +
654 688 match spec.level {
655 689 Level::Host => Ok(bins
656 690 .iter()
@@ -790,14 +824,108 @@
790 824
791 825 /// Where export wrappers land: `~/.local/bin`.
792 826 ///
793 - /// Fedora puts it on the default PATH, and it is the path distrobox's own
794 - /// `--export-path` examples use, so `host` and `workspace` exports land in the
795 - /// same directory and the user has one place to look.
827 + /// On the PATH, and ahead of `/usr/bin`. Measured rather than assumed: a booted
828 + /// Alloy session on fw12 had
829 + /// `~/.local/bin:~/.cargo/bin:/usr/local/bin:/usr/bin:...` (the same
830 + /// measurement [`crate::cli::EXTRA_PATH`] rests on), and Alloy puts it there
831 + /// itself — `etc/skel/.config/nushell/env.nu` prepends it, so this does not
832 + /// depend on Fedora's bash profile that a nushell session never reads.
833 + ///
834 + /// It is also the path distrobox's own `--export-path` examples use, so `host`
835 + /// and `workspace` exports land in the same directory and the user has one
836 + /// place to look. That shared directory is why [`export_conflict`] exists.
796 837 fn export_dir() -> Result<std::path::PathBuf> {
797 838 let home = std::env::var("HOME").context("exporting needs HOME set")?;
798 839 Ok(std::path::PathBuf::from(home).join(".local").join("bin"))
799 840 }
800 841
842 + /// Why `bin` cannot be exported under its own name, if it cannot.
843 + ///
844 + /// Two collisions, and they are different problems that look alike:
845 + ///
846 + /// - **Another box's wrapper.** Same filename in the same directory, so it is a
847 + /// literal overwrite and the search order never enters. Two boxes both
848 + /// declaring `export.bin = ["rg"]` used to leave one of them pointing at the
849 + /// other's container, silently, with the surprise arriving weeks later at a
850 + /// shell prompt. This one is a certainty rather than a hazard.
851 + /// - **A binary already on the PATH.** A search-order question. `~/.local/bin`
852 + /// precedes `/usr/bin` here (see [`export_dir`]), so the export wins and the
853 + /// host command the user has been running for years quietly becomes a
854 + /// container.
855 + ///
856 + /// Refusing rather than warning is Max's call, 2026-08-28 (GoingsOn alloy
857 + /// `77de1302`): a warning is printed at export time and the surprise happens
858 + /// later, which is the wrong way round for a message nobody re-reads.
859 + ///
860 + /// A wrapper this box wrote itself is not a conflict. Re-exporting is how a
861 + /// user picks up a changed mount list, and the file says on its own first lines
862 + /// that it is rewritten every time.
863 + fn export_conflict(
864 + dir: &std::path::Path,
865 + bin: &str,
866 + name: &str,
867 + path: Option<&str>,
868 + ) -> Option<String> {
869 + let wrapper = dir.join(bin);
870 + if wrapper.exists() {
871 + return owns(&wrapper, name).then_some(()).map_or_else(
872 + || {
873 + Some(format!(
874 + "`{}` is already there and box `{name}` did not write it; \
875 + unexport `{bin}` from the box that did, or drop it from this box's \
876 + `export.bin`",
877 + wrapper.display(),
878 + ))
879 + },
880 + |()| None,
881 + );
882 + }
883 +
884 + let entries: Vec<&std::path::Path> = path?.split(':').map(std::path::Path::new).collect();
885 + let found = entries
886 + .iter()
887 + .find(|entry| **entry != dir && entry.join(bin).is_file())?;
888 + let ours = entries.iter().position(|entry| *entry == dir);
889 + let theirs = entries.iter().position(|entry| entry == found);
890 +
891 + Some(match (ours, theirs) {
892 + // The ordinary case here, and the one that costs the user something.
893 + (Some(ours), Some(theirs)) if ours < theirs => format!(
894 + "`{bin}` is already on the PATH at `{}`, and `{}` precedes it, so exporting \
895 + would shadow the host command in every shell; drop `{bin}` from `export.bin`, \
896 + or remove the host copy if the box's is the one you want",
897 + found.join(bin).display(),
898 + dir.display(),
899 + ),
900 + // Not a shadow at all: the export would be the thing that never runs.
901 + _ => format!(
902 + "`{bin}` is already on the PATH at `{}`, which wins over `{}`, so the exported \
903 + wrapper would never be the one that runs; drop `{bin}` from `export.bin`",
904 + found.join(bin).display(),
905 + dir.display(),
906 + ),
907 + })
908 + }
909 +
910 + /// Whether the file at `path` is an export wrapper for box `name`.
911 + ///
912 + /// Both wrapper kinds name their box in the script: Alloy's says so in its
913 + /// header and passes the name to `podman exec`, and `distrobox-export`'s calls
914 + /// `distrobox-enter` with it. So one read answers "is this mine" for a level
915 + /// whose wrapper Alloy does not write, which is what keeps a `host` re-export
916 + /// from being refused by its own previous run.
917 + ///
918 + /// Unreadable counts as not ours. A file that cannot be read is one this cannot
919 + /// reason about, and overwriting it silently is the outcome being prevented.
920 + fn owns(path: &std::path::Path, name: &str) -> bool {
921 + let Ok(contents) = std::fs::read_to_string(path) else {
922 + return false;
923 + };
924 + contents
925 + .split(|character: char| !character.is_alphanumeric() && !"-_.".contains(character))
926 + .any(|word| word == name)
927 + }
928 +
801 929 /// The host-PATH wrapper for one binary in a `workspace` box.
802 930 ///
803 931 /// This is what `distrobox-export --bin` would have written if `workspace` were
@@ -849,7 +977,9 @@
849 977
850 978 Ok(format!(
851 979 "#!/bin/sh\n\
852 - # Generated by alloy. Rewritten on every export; edits here are lost.\n\
980 + # Generated by alloy for this box. Rewritten on every export of it;\n\
981 + # edits here are lost. Another box cannot land on this file: an export\n\
982 + # whose name is taken is refused rather than written.\n\
853 983 # Runs `{bin}` inside the `{name}` workspace box.\n\
854 984 \n\
855 985 podman start {box_name} >/dev/null || exit\n\
@@ -2489,7 +2619,7 @@
2489 2619
2490 2620 #[test]
2491 2621 fn parses_containers_into_rows() {
2492 - let boxes = Podman.parse(PODMAN, &Spec::default()).unwrap();
2622 + let boxes = podman().parse(PODMAN, &Spec::default()).unwrap();
2493 2623 assert_eq!(boxes.len(), 2);
2494 2624 assert_eq!(boxes[0].name, "scratch");
2495 2625 assert_eq!(boxes[0].source, "docker.io/library/alpine:latest");
@@ -2500,7 +2630,7 @@
2500 2630 // one has to reach the screen rather than collapsing to "unknown".
2501 2631 #[test]
2502 2632 fn an_unfamiliar_podman_state_is_kept_verbatim() {
2503 - let boxes = Podman.parse(PODMAN, &Spec::default()).unwrap();
2633 + let boxes = podman().parse(PODMAN, &Spec::default()).unwrap();
2504 2634 assert_eq!(boxes[1].state, BoxState::Other("created".into()));
2505 2635 assert_eq!(boxes[1].state.label(), "created");
2506 2636 assert_eq!(boxes[1].state.severity(), Severity::Warn);
@@ -2511,7 +2641,7 @@
2511 2641 // that must not be guessed at.
2512 2642 #[test]
2513 2643 fn an_undeclared_container_has_no_level() {
2514 - let boxes = Podman.parse(PODMAN, &Spec::default()).unwrap();
2644 + let boxes = podman().parse(PODMAN, &Spec::default()).unwrap();
2515 2645 assert_eq!(boxes[0].level, None);
2516 2646 assert_eq!(boxes[0].level_label(), "unknown");
2517 2647 assert_eq!(boxes[0].origin(), Origin::AdHoc);
@@ -2520,7 +2650,7 @@
2520 2650 #[test]
2521 2651 fn a_declared_container_takes_its_level_from_the_spec() {
2522 2652 let spec = spec_with(&[("scratch", Level::Workspace)]);
2523 - let boxes = Podman.parse(PODMAN, &spec).unwrap();
2653 + let boxes = podman().parse(PODMAN, &spec).unwrap();
2524 2654 let scratch = boxes.iter().find(|b| b.name == "scratch").unwrap();
2525 2655 assert_eq!(scratch.level, Some(Level::Workspace));
2526 2656 assert_eq!(scratch.origin(), Origin::Declared);
@@ -2531,19 +2661,19 @@
2531 2661 #[test]
2532 2662 fn a_nameless_container_falls_back_to_its_id() {
2533 2663 let raw = r#"[{"Id":"abcdef0123456789","Image":"alpine","State":"exited"}]"#;
2534 - let boxes = Podman.parse(raw, &Spec::default()).unwrap();
2664 + let boxes = podman().parse(raw, &Spec::default()).unwrap();
2535 2665 assert_eq!(boxes[0].name, "abcdef012345", "twelve-character short id");
2536 2666 }
2537 2667
2538 2668 #[test]
2539 2669 fn no_containers_parses_to_an_empty_list() {
2540 - let boxes = Podman.parse("[]", &Spec::default()).unwrap();
2670 + let boxes = podman().parse("[]", &Spec::default()).unwrap();
2541 2671 assert!(boxes.is_empty());
2542 2672 }
2543 2673
2544 2674 #[test]
2545 2675 fn malformed_podman_json_is_an_error() {
2546 - assert!(Podman.parse("not json", &Spec::default()).is_err());
2676 + assert!(podman().parse("not json", &Spec::default()).is_err());
2547 2677 }
2548 2678
2549 2679 // ---- flatpak ----
@@ -2694,29 +2824,29 @@
2694 2824 // are checkable on a machine with neither tool installed.
2695 2825 #[test]
2696 2826 fn podman_commands_are_built_not_run() {
2697 - let boxes = Podman.parse(PODMAN, &Spec::default()).unwrap();
2827 + let boxes = podman().parse(PODMAN, &Spec::default()).unwrap();
2698 2828 let running = &boxes[0];
2699 2829 let stopped = &boxes[1];
2700 2830
2701 - assert_eq!(Podman.list().display(), "podman ps --format json --all");
2831 + assert_eq!(podman().list().display(), "podman ps --format json --all");
2702 2832 assert_eq!(
2703 - Podman.stop(running).unwrap().display(),
2833 + podman().stop(running).unwrap().display(),
2704 2834 "podman stop scratch"
2705 2835 );
2706 2836 assert!(
2707 - Podman.start(running).is_none(),
2837 + podman().start(running).is_none(),
2708 2838 "a running box has nothing to start"
2709 2839 );
2710 2840 assert_eq!(
2711 - Podman.start(stopped).unwrap().display(),
2841 + podman().start(stopped).unwrap().display(),
2712 2842 "podman start stray"
2713 2843 );
2714 2844 assert!(
2715 - Podman.stop(stopped).is_none(),
2845 + podman().stop(stopped).is_none(),
2716 2846 "a stopped box has nothing to stop"
2717 2847 );
2718 2848 assert_eq!(
2719 - Podman.remove(running).display(),
2849 + podman().remove(running).display(),
2720 2850 "podman rm --force scratch"
2721 2851 );
2722 2852 }
@@ -2726,13 +2856,16 @@
2726 2856 #[test]
2727 2857 fn enter_matches_how_the_box_was_made() {
2728 2858 let spec = spec_with(&[("scratch", Level::Host)]);
2729 - let boxes = Podman.parse(PODMAN, &spec).unwrap();
2859 + let boxes = podman().parse(PODMAN, &spec).unwrap();
2730 2860 let declared = boxes.iter().find(|b| b.name == "scratch").unwrap();
2731 2861 let adhoc = boxes.iter().find(|b| b.name == "stray").unwrap();
2732 2862
2733 - assert_eq!(Podman.enter(declared).display(), "distrobox enter scratch");
2734 2863 assert_eq!(
2735 - Podman.enter(adhoc).display(),
2864 + podman().enter(declared).display(),
2865 + "distrobox enter scratch"
2866 + );
2867 + assert_eq!(
2868 + podman().enter(adhoc).display(),
2736 2869 "podman exec -it stray /bin/sh"
2737 2870 );
2738 2871 }
@@ -2743,7 +2876,7 @@
2743 2876 #[test]
2744 2877 fn a_declared_workspace_box_is_entered_through_podman() {
2745 2878 let spec = spec_with(&[("scratch", Level::Workspace)]);
2746 - let boxes = Podman.parse(PODMAN, &spec).unwrap();
2879 + let boxes = podman().parse(PODMAN, &spec).unwrap();
2747 2880 let workspace = boxes.iter().find(|b| b.name == "scratch").unwrap();
2748 2881
2749 2882 assert_eq!(
@@ -2752,7 +2885,7 @@
2752 2885 "declared, and still not distrobox's"
2753 2886 );
2754 2887 assert_eq!(
2755 - Podman.enter(workspace).display(),
2888 + podman().enter(workspace).display(),
2756 2889 "podman exec -it scratch /bin/sh"
2757 2890 );
2758 2891 }
@@ -2815,6 +2948,32 @@
2815 2948 .unwrap()
2816 2949 }
2817 2950
2951 + /// A podman backend whose export directory is a path nothing creates.
2952 + ///
2953 + /// Every test that is not about collisions wants one: the check stats the
2954 + /// export directory, and pointing it at the real `~/.local/bin` would make
2955 + /// these tests pass or fail on whether the person running them happens to
2956 + /// have exported an `rg`. `path: None` switches off the PATH half for the
2957 + /// same reason.
2958 + fn podman() -> Podman {
2959 + Podman {
2960 + dir: Some(fixture_export_dir()),
2961 + path: None,
2962 + }
2963 + }
2964 +
2965 + /// The directory [`podman`] exports into. Deliberately never created.
2966 + fn fixture_export_dir() -> std::path::PathBuf {
2967 + std::env::temp_dir().join("alloy-tests-export-dir-that-is-never-created")
2968 + }
2969 +
2970 + /// A directory this test owns, for the collision tests that write into one.
2971 + fn owned_export_dir(label: &str) -> std::path::PathBuf {
2972 + let dir = std::env::temp_dir().join(format!("alloy-export-{}-{label}", std::process::id()));
2973 + std::fs::create_dir_all(&dir).expect("a temp directory");
2974 + dir
2975 + }
2976 +
2818 2977 fn exports(backend: &dyn Backend, spec: &Spec, name: &str) -> Result<Vec<Effect>> {
2819 2978 let (_, entry) = spec.resolve(name).unwrap();
2820 2979 backend.export(name, entry)
@@ -2822,7 +2981,7 @@
2822 2981
2823 2982 /// The written file for a one-binary box, which is most of these tests.
2824 2983 fn wrapper_of(spec: &Spec, name: &str) -> String {
2825 - let effects = exports(&Podman, spec, name).unwrap();
2984 + let effects = exports(&podman(), spec, name).unwrap();
2826 2985 match effects.as_slice() {
2827 2986 [Effect::Write { contents, .. }] => contents.clone(),
2828 2987 other => panic!("expected one write, got {} effects", other.len()),
@@ -2835,10 +2994,10 @@
2835 2994 #[test]
2836 2995 fn a_host_export_calls_distrobox_export_once_per_binary() {
2837 2996 let spec = export_spec();
2838 - let effects = exports(&Podman, &spec, "dev").unwrap();
2997 + let effects = exports(&podman(), &spec, "dev").unwrap();
2839 2998 let lines: Vec<String> = effects.iter().map(Effect::display).collect();
2840 2999
2841 - let dir = export_dir().unwrap();
3000 + let dir = fixture_export_dir();
2842 3001 let dir = dir.to_string_lossy();
2843 3002 assert_eq!(
2844 3003 lines,
@@ -2859,15 +3018,15 @@
2859 3018 #[test]
2860 3019 fn a_workspace_export_writes_an_executable_wrapper_per_binary() {
2861 3020 let spec = export_spec();
2862 - let effects = exports(&Podman, &spec, "scratch").unwrap();
3021 + let effects = exports(&podman(), &spec, "scratch").unwrap();
2863 3022
2864 3023 let [Effect::Write { path, mode, .. }] = effects.as_slice() else {
2865 3024 panic!("workspace exports are writes, not commands");
2866 3025 };
2867 - assert!(
2868 - path.ends_with(".local/bin/cargo"),
2869 - "landed at {}",
2870 - path.display()
3026 + assert_eq!(
3027 + *path,
3028 + fixture_export_dir().join("cargo"),
3029 + "the wrapper lands in the export directory under the binary's own name"
2871 3030 );
2872 3031 assert_eq!(
2873 3032 *mode, 0o755,
@@ -2875,6 +3034,131 @@
2875 3034 );
2876 3035 }
2877 3036
3037 + // The certain collision: two boxes declaring the same binary name land on
3038 + // one filename in one directory, so the second export used to overwrite the
3039 + // first and leave a wrapper pointing at the wrong container. Nothing about
3040 + // PATH enters this one.
3041 + #[test]
3042 + fn an_export_onto_another_boxs_wrapper_is_refused_and_names_it() {
3043 + let dir = owned_export_dir("taken");
3044 + std::fs::write(
3045 + dir.join("cargo"),
3046 + "#!/bin/sh\n# Generated by alloy for this box.\nexec podman exec 'other' 'cargo' \"$@\"\n",
3047 + )
3048 + .unwrap();
3049 +
3050 + let backend = Podman {
3051 + dir: Some(dir.clone()),
3052 + path: None,
3053 + };
3054 + let spec = export_spec();
3055 + let err = exports(&backend, &spec, "scratch").unwrap_err().to_string();
3056 + assert!(
3057 + err.contains("cargo") && err.contains("scratch"),
3058 + "names the binary and the box asking: {err}"
3059 + );
3060 + assert!(err.contains("did not write it"), "{err}");
3061 +
3062 + std::fs::remove_dir_all(dir).ok();
3063 + }
3064 +
3065 + // Re-exporting is how a user picks up a changed mount list, and the wrapper
3066 + // says on its own second line that it is rewritten every time. A box may
3067 + // always land on its own file.
3068 + #[test]
3069 + fn a_box_may_overwrite_the_wrapper_it_wrote_itself() {
3070 + let dir = owned_export_dir("mine");
3071 + let spec = export_spec();
3072 + let backend = Podman {
3073 + dir: Some(dir.clone()),
3074 + path: None,
3075 + };
3076 + let contents = match exports(&backend, &spec, "scratch").unwrap().as_slice() {
3077 + [Effect::Write { contents, .. }] => contents.clone(),
3078 + other => panic!("expected one write, got {}", other.len()),
3079 + };
3080 + std::fs::write(dir.join("cargo"), &contents).unwrap();
3081 +
3082 + assert!(
3083 + exports(&backend, &spec, "scratch").is_ok(),
3084 + "a second export of the same box is refused by its own first one"
3085 + );
3086 +
3087 + std::fs::remove_dir_all(dir).ok();
3088 + }
3089 +
3090 + // The PATH half. `~/.local/bin` precedes `/usr/bin` on an Alloy session, so
3091 + // the export wins and the host command the user has been running for years
3092 + // quietly becomes a container.
3093 + #[test]
3094 + fn an_export_that_would_shadow_a_host_binary_is_refused() {
3095 + let host = owned_export_dir("host-bin");
3096 + std::fs::write(host.join("cargo"), "#!/bin/sh\n").unwrap();
3097 + let dir = owned_export_dir("ours-first");
3098 +
3099 + let backend = Podman {
3100 + dir: Some(dir.clone()),
3101 + path: Some(format!("{}:{}", dir.display(), host.display())),
3102 + };
3103 + let err = exports(&backend, &export_spec(), "scratch")
3104 + .unwrap_err()
3105 + .to_string();
3106 + assert!(
3107 + err.contains(&host.join("cargo").display().to_string()),
3108 + "names the host path in the way: {err}"
3109 + );
3110 + assert!(err.contains("shadow"), "{err}");
3111 +
3112 + std::fs::remove_dir_all(host).ok();
3113 + std::fs::remove_dir_all(dir).ok();
3114 + }
3115 +
3116 + // The other way round is not a shadow at all, and saying "would shadow"
3117 + // there would be a lie: the export is the thing that never runs. Kept as a
3118 + // refusal, because an export nobody can reach is not a successful one.
3119 + #[test]
3120 + fn an_export_the_host_would_win_says_so_instead() {
3121 + let host = owned_export_dir("host-wins");
3122 + std::fs::write(host.join("cargo"), "#!/bin/sh\n").unwrap();
3123 + let dir = owned_export_dir("ours-second");
3124 +
3125 + let backend = Podman {
3126 + dir: Some(dir.clone()),
3127 + path: Some(format!("{}:{}", host.display(), dir.display())),
3128 + };
3129 + let err = exports(&backend, &export_spec(), "scratch")
3130 + .unwrap_err()
3131 + .to_string();
3132 + assert!(err.contains("would never be the one that runs"), "{err}");
3133 +
3134 + std::fs::remove_dir_all(host).ok();
3135 + std::fs::remove_dir_all(dir).ok();
3136 + }
3137 +
3138 + // Both levels land in one directory, so the check runs before the branch:
3139 + // a `host` export goes through distrobox-export, which would overwrite a
3140 + // workspace wrapper just as silently.
3141 + #[test]
3142 + fn a_host_export_is_refused_by_a_workspace_wrapper_of_the_same_name() {
3143 + let dir = owned_export_dir("cross-level");
3144 + std::fs::write(
3145 + dir.join("rg"),
3146 + "#!/bin/sh\nexec podman exec 'scratch' 'rg'\n",
3147 + )
3148 + .unwrap();
3149 +
3150 + let backend = Podman {
3151 + dir: Some(dir.clone()),
3152 + path: None,
3153 + };
3154 + let err = exports(&backend, &export_spec(), "dev")
3155 + .unwrap_err()
3156 + .to_string();
3157 + assert!(err.contains("rg"), "{err}");
Lines truncated