Skip to main content

max / alloy

Give each box its own export directory, and report the PATH Exports landed in one shared ~/.local/bin, which made a binary name a scarce resource: two boxes declaring the same one wrote a single file, so the second export was refused, and a name the host already had was refused too because that directory precedes /usr/bin. Every box gets ~/.local/bin/<box> now. The first collision cannot happen, and the second is no longer a collision: a fresh per-box directory is on nobody's PATH, so exporting shadows nothing until its owner says otherwise. What replaces the refusals is a report. The export says where the wrappers went, gives the nushell line that puts the directory on the PATH, and names any exported binary that also exists elsewhere on it, saying which copy would win. Wrappers earlier versions left loose in ~/.local/bin move into the box's directory rather than staying as a second copy that wins the search order. Two new effects carry it: Move for the migration, Note for the report, so both still pass through the log rather than around it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-29 15:22 UTC
Signed with PGP, not checked
Commit: bf807c79f68d64f495349a3c980c4f720d912879
Parent: bc347c5
3 files changed, +293 insertions, -149 deletions
M docs/STACK.md +4 -2
@@ -260,9 +260,11 @@
260 260
261 261 **podman as the runtime, distrobox at the `host` level only, flatpak as the `sandboxed` backend.** These are not three tools the user chooses between. They are the three backends behind the isolation dial in [CONSOLE.md](CONSOLE.md), and the user picks a level rather than a tool. Design in the wiki note `alloy-package-ux`.
262 262
263 - **distrobox** was picked over toolbx for `host`. Both are plain Fedora main-repo RPMs, no COPR. The deciding factor is Alloy-specific: the usual answer is that the choice does not matter because Ptyxis speaks to podman, toolbx, and distrobox directly, but Ptyxis is GTK and the 2026-07-17 pivot dropped the GTK stack, so nothing else in Alloy's stack does container integration and the wrapper has to. `distrobox-export --bin foo --export-path ~/.local/bin` writes a host wrapper script, and `~/.local/bin` is already on Fedora's default PATH. Toolbx has no export mechanism at all: its verbs are create, enter, run, list, rm, rmi, help, and getting a container tool onto the host PATH under it has no command, only a paragraph telling you to hand-write a wrapper. That cannot be taught in the console's own idiom. Distrobox also takes arbitrary images, where toolbx requires images to satisfy a checklist most upstream images fail.
263 + **distrobox** was picked over toolbx for `host`. Both are plain Fedora main-repo RPMs, no COPR. The deciding factor is Alloy-specific: the usual answer is that the choice does not matter because Ptyxis speaks to podman, toolbx, and distrobox directly, but Ptyxis is GTK and the 2026-07-17 pivot dropped the GTK stack, so nothing else in Alloy's stack does container integration and the wrapper has to. `distrobox-export --bin foo --export-path <dir>` writes a host wrapper script into whatever directory it is pointed at, which is what lets Alloy give every box its own. Toolbx has no export mechanism at all: its verbs are create, enter, run, list, rm, rmi, help, and getting a container tool onto the host PATH under it has no command, only a paragraph telling you to hand-write a wrapper. That cannot be taught in the console's own idiom. Distrobox also takes arbitrary images, where toolbx requires images to satisfy a checklist most upstream images fail.
264 264
265 - **podman directly** for `workspace`, because distrobox cannot express that level. Checked against distrobox-create's own documentation: `--home` relocates HOME but does not stop the host home being mounted, and the five unshare flags cover devices and sysfs, groups, ipc, netns, and process, none of them the mounts. There is no flag that withholds the host filesystem paths or the host D-Bus session socket. That is not an oversight to work around. Host integration is what distrobox is for. Two of the six rows `workspace` promises are unreachable through it, so `workspace` is podman with the wrapper's opinions left off, and Alloy writes the export wrapper itself at that level: a generated script on `~/.local/bin` that re-enters the box and forwards its arguments, starting it first if stopped. A few lines doing what `distrobox-export --bin` does. `host` keeps `distrobox-export` because `host` keeps distrobox.
265 + **podman directly** for `workspace`, because distrobox cannot express that level. Checked against distrobox-create's own documentation: `--home` relocates HOME but does not stop the host home being mounted, and the five unshare flags cover devices and sysfs, groups, ipc, netns, and process, none of them the mounts. There is no flag that withholds the host filesystem paths or the host D-Bus session socket. That is not an oversight to work around. Host integration is what distrobox is for. Two of the six rows `workspace` promises are unreachable through it, so `workspace` is podman with the wrapper's opinions left off, and Alloy writes the export wrapper itself at that level: a generated script in the box's export directory that re-enters the box and forwards its arguments, starting it first if stopped. A few lines doing what `distrobox-export --bin` does. `host` keeps `distrobox-export` because `host` keeps distrobox.
266 +
267 + **Exports land in a directory per box, `~/.local/bin/<box>/`, and nothing is refused** (Max, 2026-08-28). A single shared directory made a name a scarce resource: two boxes declaring `export.bin = ["rg"]` wrote one file, so the console had to refuse the second, and a name the host already had was refused too because `~/.local/bin` precedes `/usr/bin` and exporting was itself the act that shadowed. A directory per box ends the first collision outright and defuses the second: a fresh directory is on nobody's PATH, so the export shadows nothing until its owner says otherwise. What replaces the refusal is a report. `alloy pkg` export says where the wrappers went, gives the nushell line that puts that directory on the PATH, and names any exported binary that already exists elsewhere on it, saying which copy would win. Wrappers written by earlier versions, which sit loose in `~/.local/bin`, are moved into the box's directory on its next export rather than left as a second copy that wins the search order.
266 268
267 269 **Not shipping both wrappers**, despite that being Universal Blue's choice. Principle 5 is opinionated defaults and principle 6 is legibility; two wrappers doing one job, in a distro whose headline claim is making the install model legible, works against both. Bluefin affords the ambiguity because Ptyxis hides it.
268 270
@@ -677,6 +677,21 @@
677 677 contents: String,
678 678 mode: u32,
679 679 },
680 + /// Move a file, replacing whatever is at the destination.
681 + ///
682 + /// Exports moved from one shared `~/.local/bin` to a directory per box, and
683 + /// the wrappers earlier versions wrote are still sitting loose in the shared
684 + /// one. Migrating them is a rename rather than a write, and it is the box's
685 + /// own doing, so it is an effect the log names like any other.
686 + Move { from: PathBuf, to: PathBuf },
687 + /// Say something the user needs to know, having done it.
688 + ///
689 + /// The one variant that performs nothing. A per-box export directory is on
690 + /// nobody's `PATH` until the user puts it there, so the export has to end by
691 + /// saying which directory it wrote to and what would put it on the search
692 + /// path; that is a statement of fact rather than a command, and inventing a
693 + /// second channel for it would be a message the log pane cannot show.
694 + Note(String),
680 695 /// Set a key in the console's config store.
681 696 ///
682 697 /// The theme preference is neither a command nor a file the way a wrapper
@@ -705,6 +720,10 @@
705 720 match self {
706 721 Effect::Run(invocation) => invocation.display(),
707 722 Effect::Write { path, .. } => format!("write {}", contract_home(path)),
723 + Effect::Move { from, to } => {
724 + format!("move {} -> {}", contract_home(from), contract_home(to))
725 + }
726 + Effect::Note(text) => text.clone(),
708 727 Effect::Config { key, value, .. } => format!("set {key} = {value}"),
709 728 }
710 729 }
@@ -719,7 +738,10 @@
719 738 pub(crate) fn command(&self) -> Option<Command> {
720 739 match self {
721 740 Effect::Run(invocation) => Some(invocation.command()),
722 - Effect::Write { .. } | Effect::Config { .. } => None,
741 + Effect::Write { .. }
742 + | Effect::Move { .. }
743 + | Effect::Note(_)
744 + | Effect::Config { .. } => None,
723 745 }
724 746 }
725 747
@@ -743,6 +765,24 @@
743 765 );
744 766 result
745 767 }
768 + Effect::Move { from, to } => {
769 + let result = move_file(from, to);
770 + log.record(
771 + self.display(),
772 + if result.is_ok() {
773 + Severity::Healthy
774 + } else {
775 + Severity::Error
776 + },
777 + );
778 + result
779 + }
780 + // Nothing to do and nothing to fail: saying it is the whole effect,
781 + // and `record` is what says it.
782 + Effect::Note(text) => {
783 + log.record(text.clone(), Severity::Info);
784 + Ok(())
785 + }
746 786 Effect::Config { db, key, value } => {
747 787 let result = write_config(db, key, value);
748 788 log.record(
@@ -799,6 +839,27 @@
799 839 .with_context(|| format!("failed to chmod {}", contract_home(path)))
800 840 }
801 841
842 + /// Move `from` onto `to`, creating the destination's directory.
843 + ///
844 + /// `rename` rather than a copy and a delete: both ends are under the home
845 + /// directory, so it is one filesystem and the operation is atomic. The parent is
846 + /// created for the same reason [`write_file`] creates it — the destination of a
847 + /// migration is a directory that by definition did not exist under the version
848 + /// being migrated from.
849 + fn move_file(from: &Path, to: &Path) -> Result<()> {
850 + if let Some(parent) = to.parent() {
851 + std::fs::create_dir_all(parent)
852 + .with_context(|| format!("failed to create {}", contract_home(parent)))?;
853 + }
854 + std::fs::rename(from, to).with_context(|| {
855 + format!(
856 + "failed to move {} to {}",
857 + contract_home(from),
858 + contract_home(to)
859 + )
860 + })
861 + }
862 +
802 863 /// A path with the home directory folded back to `~`.
803 864 ///
804 865 /// Only for display. The log pane is narrow and every path this writes lives
@@ -33,7 +33,7 @@
33 33 //!
34 34 //! | Level | Backend | Why that one |
35 35 //! |---|---|---|
36 - //! | `host` | distrobox | host integration is the point, and `distrobox-export` puts binaries on the host PATH |
36 + //! | `host` | distrobox | host integration is the point, and `distrobox-export` writes the host wrapper |
37 37 //! | `workspace` | podman directly | distrobox has no flag that withholds the host home, the host filesystem paths, or the host D-Bus socket |
38 38 //! | `sandboxed` | flatpak | portals, seccomp, and D-Bus proxying are a decade of work nobody should reimplement |
39 39 //!
@@ -502,8 +502,9 @@
502 502 /// the "return, do not perform" rule, so both are testable here.
503 503 ///
504 504 /// A `Vec` because exporting three binaries is three effects, and the log
505 - /// should say so — one line per file that appeared on the PATH, not one
506 - /// line claiming an export happened.
505 + /// should say so — one line per file that appeared, not one line claiming
506 + /// an export happened. The same `Vec` carries the migration of any wrapper
507 + /// an earlier version left loose, and the report that ends it.
507 508 fn export(&self, name: &str, spec: &SpecBox) -> Result<Vec<Effect>>;
508 509
509 510 /// Start `boxed`, or `None` when the concept does not apply.
@@ -542,16 +543,17 @@
542 543 // ---- podman: the host and workspace levels ----
543 544
544 545 pub(crate) struct Podman {
545 - /// Where export wrappers land, when it is not the environment's answer.
546 + /// The directory the per-box export directories go under, when it is not
547 + /// the environment's answer.
546 548 ///
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`
549 + /// Held rather than read per call so the migration scan and the report below
550 + /// can be pointed at a directory a test owns. `set_var` is unsafe in a
551 + /// threaded test binary and this module has already paid for reading `HOME`
550 552 /// underneath its own tests once (see [`mount_path_in`]).
551 553 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.
554 + /// The `PATH` a shell would search. `None` leaves the report saying only
555 + /// where the wrappers went, which is right when there is no `PATH` to read:
556 + /// something that cannot see the search order has nothing to say about it.
555 557 path: Option<String>,
556 558 }
557 559
@@ -671,50 +673,46 @@
671 673 /// means asking the box where the command lives.
672 674 fn export(&self, name: &str, spec: &SpecBox) -> Result<Vec<Effect>> {
673 675 let bins = spec.bins(name)?;
674 - let dir = match &self.dir {
676 + let root = match &self.dir {
675 677 Some(dir) => dir.clone(),
676 - None => export_dir()?,
678 + None => export_root()?,
677 679 };
680 + let dir = root.join(name);
678 681
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 - }
682 + // The wrappers an earlier version left loose in the shared directory
683 + // come first, so the exports below land on top of what was moved rather
684 + // than under it.
685 + let mut effects = migrations(&root, &dir, name);
687 686
688 687 match spec.level {
689 - Level::Host => Ok(bins
690 - .iter()
691 - .map(|bin| {
692 - Effect::Run(Invocation::new("distrobox").args([
693 - "enter",
694 - name,
695 - "--",
696 - "distrobox-export",
697 - "--bin",
698 - &format!("/usr/bin/{bin}"),
699 - "--export-path",
700 - &dir.to_string_lossy(),
701 - ]))
702 - })
703 - .collect()),
704 - Level::Workspace => bins
705 - .iter()
706 - .map(|bin| {
707 - Ok(Effect::Write {
688 + Level::Host => effects.extend(bins.iter().map(|bin| {
689 + Effect::Run(Invocation::new("distrobox").args([
690 + "enter",
691 + name,
692 + "--",
693 + "distrobox-export",
694 + "--bin",
695 + &format!("/usr/bin/{bin}"),
696 + "--export-path",
697 + &dir.to_string_lossy(),
698 + ]))
699 + })),
700 + Level::Workspace => {
701 + for bin in bins {
702 + effects.push(Effect::Write {
708 703 path: dir.join(bin),
709 704 contents: wrapper(name, bin, &spec.mounts)?,
710 705 // Executable, or it is a file on the PATH that the shell
711 706 // will not run — the whole point of putting it there.
712 707 mode: 0o755,
713 - })
714 - })
715 - .collect(),
708 + });
709 + }
710 + }
716 711 Level::Sandboxed => unreachable!("the dial routes sandboxed to flatpak"),
717 712 }
713 +
714 + effects.extend(report(&dir, bins, self.path.as_deref()));
715 + Ok(effects)
718 716 }
719 717
720 718 fn start(&self, boxed: &Box) -> Option<Invocation> {
@@ -801,7 +799,7 @@
801 799 /// a Rust test binary is: the harness runs tests on a thread pool in one process.
802 800 /// Any test reading `HOME` concurrently saw the fixture's value.
803 801 /// `a_host_export_calls_distrobox_export_once_per_binary` is the one that
804 - /// noticed, because it compares an argv built from [`export_dir`] against a
802 + /// noticed, because it compares an argv built from [`export_root`] against a
805 803 /// second call to it, and failed roughly one run in five.
806 804 fn mount_path_in(mount: &str, home: Option<&str>) -> Result<String> {
807 805 let path = mount.strip_suffix(":ro").unwrap_or(mount);
@@ -822,89 +820,121 @@
822 820 Ok(path)
823 821 }
824 822
825 - /// Where export wrappers land: `~/.local/bin`.
823 + /// The directory the per-box export directories sit in: `~/.local/bin`.
826 824 ///
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.
825 + /// A place the user already has, and the one distrobox's own `--export-path`
826 + /// examples use. It is deliberately not where wrappers land any more: each box
827 + /// gets `<root>/<box>` under it, so two boxes exporting `rg` write two files
828 + /// that never meet (Max, 2026-08-28, GoingsOn alloy `57d42d1c`). What that costs
829 + /// is that a box's directory is on nobody's `PATH` until the user says so, which
830 + /// is what [`report`] exists to say.
833 831 ///
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.
837 - fn export_dir() -> Result<std::path::PathBuf> {
832 + /// Neither writer needs the directory made for it. A `workspace` wrapper goes
833 + /// through [`Effect::Write`], which creates its parent, and `distrobox-export`
834 + /// does its own `mkdir -p` on `--export-path` (read in distrobox 1.8.2.5, the
835 + /// version the Containerfile pins).
836 + fn export_root() -> Result<std::path::PathBuf> {
838 837 let home = std::env::var("HOME").context("exporting needs HOME set")?;
839 838 Ok(std::path::PathBuf::from(home).join(".local").join("bin"))
840 839 }
841 840
842 - /// Why `bin` cannot be exported under its own name, if it cannot.
841 + /// The wrappers this box left in the shared directory, as moves into its own.
843 842 ///
844 - /// Two collisions, and they are different problems that look alike:
843 + /// Versions before the per-box directory wrote straight into `<root>`, so a user
844 + /// who exported under one and re-exports under this one would otherwise keep two
845 + /// copies of every wrapper: the live one in the box's directory and a stale one
846 + /// still on the `PATH`, which is the worse of the two to be running. [`owns`] is
847 + /// what identifies them, so a file another box wrote, or one the user put there
848 + /// themselves, is left exactly where it is.
845 849 ///
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 - }
850 + /// A directory that cannot be read yields nothing. There is no migration to do
851 + /// on a machine that has never exported, and a `~/.local/bin` this cannot list
852 + /// is not a reason to refuse the export it is a preamble to.
853 + fn migrations(root: &std::path::Path, dir: &std::path::Path, name: &str) -> Vec<Effect> {
854 + let Ok(entries) = std::fs::read_dir(root) else {
855 + return Vec::new();
856 + };
857 + let mut moves: Vec<Effect> = entries
858 + .flatten()
859 + .map(|entry| entry.path())
860 + .filter(|path| path.is_file() && owns(path, name))
861 + .filter_map(|path| {
862 + let file = path.file_name()?;
863 + Some(Effect::Move {
864 + to: dir.join(file),
865 + from: path.clone(),
866 + })
867 + })
868 + .collect();
869 + // `read_dir` is in whatever order the filesystem answers in, and the log
870 + // pane is read top to bottom.
871 + moves.sort_by_key(|effect| match effect {
872 + Effect::Move { from, .. } => from.clone(),
873 + _ => std::path::PathBuf::new(),
874 + });
875 + moves
876 + }
883 877
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())?;
878 + /// What the user has to know once the export has happened.
879 + ///
880 + /// Max ruled on 2026-08-28 (GoingsOn alloy `77de1302`) that a shadowed host
881 + /// binary is refused rather than warned about, because "a warning is printed at
882 + /// export time and the surprise happens later". That reasoning was about a
883 + /// shared `~/.local/bin` that was already ahead of `/usr/bin`, where exporting
884 + /// was itself the act that shadowed. Under a per-box directory nothing happens
885 + /// until the user edits their `PATH`, so there is no later surprise to be early
886 + /// about and refusing a harmless write would be the only cost. The wording
887 + /// survives; the refusal does not.
888 + ///
889 + /// Three things get said, in the order a reader needs them: where the wrappers
890 + /// are, what would put that directory on the search path, and which of the names
891 + /// just exported already exist elsewhere on it.
892 + fn report(dir: &std::path::Path, bins: &[String], path: Option<&str>) -> Vec<Effect> {
893 + let shown = crate::cli::contract_home(dir);
894 + let entries: Vec<&std::path::Path> = path
895 + .unwrap_or_default()
896 + .split(':')
897 + .filter(|entry| !entry.is_empty())
898 + .map(std::path::Path::new)
899 + .collect();
888 900 let ours = entries.iter().position(|entry| *entry == dir);
889 - let theirs = entries.iter().position(|entry| entry == found);
890 901
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(),
902 + let mut notes = vec![Effect::Note(format!("exported into {shown}"))];
903 + notes.push(Effect::Note(match ours {
904 + Some(_) => format!("{shown} is already on the PATH"),
905 + // Nushell's, because that is the shell Alloy ships and the file that
906 + // already prepends `~/.local/bin` for it is the one being edited.
907 + None => format!(
908 + "to run them by name, add to ~/.config/nushell/env.nu: \
909 + $env.PATH = ($env.PATH | prepend '{}')",
910 + dir.display()
899 911 ),
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 - })
912 + }));
913 +
914 + // Only the first hit per name: the search order stops there too, so a second
915 + // copy further down the PATH is not the one anything was going to run.
916 + notes.extend(bins.iter().filter_map(|bin| {
917 + let (theirs, entry) = entries
918 + .iter()
919 + .enumerate()
920 + .find(|(_, entry)| **entry != dir && entry.join(bin).is_file())?;
921 + let found = entry.join(bin).display().to_string();
922 + Some(Effect::Note(match ours {
923 + Some(ours) if ours < theirs => format!(
924 + "`{bin}` is also on the PATH at `{found}`, and {shown} precedes it, \
925 + so the box's copy is the one that runs"
926 + ),
927 + Some(_) => format!(
928 + "`{bin}` is also on the PATH at `{found}`, which precedes {shown}, \
929 + so the host copy is the one that runs"
930 + ),
931 + None => format!(
932 + "`{bin}` is also on the PATH at `{found}`; putting {shown} ahead of it \
933 + would make the box's copy the one that runs"
934 + ),
935 + }))
936 + }));
937 + notes
908 938 }
909 939
910 940 /// Whether the file at `path` is an export wrapper for box `name`.
@@ -912,11 +942,13 @@
912 942 /// Both wrapper kinds name their box in the script: Alloy's says so in its
913 943 /// header and passes the name to `podman exec`, and `distrobox-export`'s calls
914 944 /// `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.
945 + /// whose wrapper Alloy does not write, which is what lets [`migrations`] pick
946 + /// this box's leftovers out of a shared directory holding other boxes' and the
947 + /// user's own files.
917 948 ///
918 949 /// 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.
950 + /// reason about, and moving it out from under whoever put it there is the
951 + /// outcome being prevented.
920 952 fn owns(path: &std::path::Path, name: &str) -> bool {
921 953 let Ok(contents) = std::fs::read_to_string(path) else {
922 954 return false;
@@ -2948,13 +2980,13 @@
2948 2980 .unwrap()
2949 2981 }
2950 2982
2951 - /// A podman backend whose export directory is a path nothing creates.
2983 + /// A podman backend whose export root is a path nothing creates.
2952 2984 ///
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.
2985 + /// Every test that is not about the migration wants one: the scan lists the
2986 + /// root, and pointing it at the real `~/.local/bin` would make these tests
2987 + /// pass or fail on what the person running them happens to have exported.
2988 + /// `path: None` leaves the report with nothing to say about the search
2989 + /// order, for the same reason.
2958 2990 fn podman() -> Podman {
2959 2991 Podman {
2960 2992 dir: Some(fixture_export_dir()),
@@ -2962,12 +2994,12 @@
2962 2994 }
2963 2995 }
2964 2996
2965 - /// The directory [`podman`] exports into. Deliberately never created.
2997 + /// The directory [`podman`] exports under. Deliberately never created.
2966 2998 fn fixture_export_dir() -> std::path::PathBuf {
2967 2999 std::env::temp_dir().join("alloy-tests-export-dir-that-is-never-created")
2968 3000 }
2969 3001
2970 - /// A directory this test owns, for the collision tests that write into one.
3002 + /// A directory this test owns, for the tests that write into one.
2971 3003 fn owned_export_dir(label: &str) -> std::path::PathBuf {
2972 3004 let dir = std::env::temp_dir().join(format!("alloy-export-{}-{label}", std::process::id()));
2973 3005 std::fs::create_dir_all(&dir).expect("a temp directory");
@@ -2979,9 +3011,29 @@
2979 3011 backend.export(name, entry)
2980 3012 }
2981 3013
3014 + /// The effects that are not the report, which is what most of these are about.
3015 + fn actions(effects: Vec<Effect>) -> Vec<Effect> {
3016 + effects
3017 + .into_iter()
3018 + .filter(|effect| !matches!(effect, Effect::Note(_)))
3019 + .collect()
3020 + }
3021 +
3022 + /// The report lines, joined, for the tests that are about what it says.
3023 + fn notes(effects: &[Effect]) -> String {
3024 + effects
3025 + .iter()
3026 + .filter_map(|effect| match effect {
3027 + Effect::Note(text) => Some(text.clone()),
3028 + _ => None,
3029 + })
3030 + .collect::<Vec<_>>()
3031 + .join("\n")
3032 + }
3033 +
2982 3034 /// The written file for a one-binary box, which is most of these tests.
2983 3035 fn wrapper_of(spec: &Spec, name: &str) -> String {
2984 - let effects = exports(&podman(), spec, name).unwrap();
3036 + let effects = actions(exports(&podman(), spec, name).unwrap());
2985 3037 match effects.as_slice() {
2986 3038 [Effect::Write { contents, .. }] => contents.clone(),
2987 3039 other => panic!("expected one write, got {} effects", other.len()),
@@ -2994,10 +3046,10 @@
2994 3046 #[test]
2995 3047 fn a_host_export_calls_distrobox_export_once_per_binary() {
2996 3048 let spec = export_spec();
2997 - let effects = exports(&podman(), &spec, "dev").unwrap();
3049 + let effects = actions(exports(&podman(), &spec, "dev").unwrap());
2998 3050 let lines: Vec<String> = effects.iter().map(Effect::display).collect();
2999 3051
3000 - let dir = fixture_export_dir();
3052 + let dir = fixture_export_dir().join("dev");
3001 3053 let dir = dir.to_string_lossy();
3002 3054 assert_eq!(
3003 3055 lines,
@@ -3018,14 +3070,14 @@
3018 3070 #[test]
3019 3071 fn a_workspace_export_writes_an_executable_wrapper_per_binary() {
3020 3072 let spec = export_spec();
3021 - let effects = exports(&podman(), &spec, "scratch").unwrap();
3073 + let effects = actions(exports(&podman(), &spec, "scratch").unwrap());
3022 3074
3023 3075 let [Effect::Write { path, mode, .. }] = effects.as_slice() else {
3024 3076 panic!("workspace exports are writes, not commands");
3025 3077 };
3026 3078 assert_eq!(
3027 3079 *path,
3028 - fixture_export_dir().join("cargo"),
3080 + fixture_export_dir().join("scratch").join("cargo"),
3029 3081 "the wrapper lands in the export directory under the binary's own name"
3030 3082 );
3031 3083 assert_eq!(
@@ -3034,129 +3086,150 @@
3034 3086 );
3035 3087 }
3036 3088
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.
3089 + // What used to be the certain collision: two boxes declaring the same binary
3090 + // name landed on one filename in one directory, and the second export
3091 + // overwrote the first. A directory per box is what ends it, so the two
3092 + // writes are the assertion.
3041 3093 #[test]
3042 - fn an_export_onto_another_boxs_wrapper_is_refused_and_names_it() {
3043 - let dir = owned_export_dir("taken");
3094 + fn two_boxes_exporting_the_same_name_write_two_files() {
3095 + let spec = Spec::parse(
3096 + r#"
3097 + [box.one]
3098 + level = "workspace"
3099 + image = "alpine"
3100 + export = { bin = ["rg"] }
3101 +
3102 + [box.two]
3103 + level = "workspace"
3104 + image = "alpine"
3105 + export = { bin = ["rg"] }
3106 + "#,
3107 + )
3108 + .unwrap();
3109 +
3110 + let path_of =
3111 + |name: &str| match actions(exports(&podman(), &spec, name).unwrap()).as_slice() {
3112 + [Effect::Write { path, .. }] => path.clone(),
3113 + other => panic!("expected one write, got {}", other.len()),
3114 + };
3115 +
3116 + assert_eq!(path_of("one"), fixture_export_dir().join("one").join("rg"));
3117 + assert_eq!(path_of("two"), fixture_export_dir().join("two").join("rg"));
3118 + }
3119 +
3120 + // The wrappers of a version that exported into the shared directory are
3121 + // still on the PATH after a re-export writes the real one, and a stale
3122 + // wrapper that wins the search order is the worse of the two to be running.
3123 + #[test]
3124 + fn a_wrapper_left_loose_by_an_earlier_version_moves_into_the_boxs_directory() {
3125 + let root = owned_export_dir("migrate");
3044 3126 std::fs::write(
3045 - dir.join("cargo"),
3046 - "#!/bin/sh\n# Generated by alloy for this box.\nexec podman exec 'other' 'cargo' \"$@\"\n",
3127 + root.join("cargo"),
3128 + "#!/bin/sh\n# Generated by alloy for this box.\nexec podman exec 'scratch' 'cargo' \"$@\"\n",
3129 + )
3130 + .unwrap();
3131 + // Another box's, and it stays exactly where it is.
3132 + std::fs::write(
3133 + root.join("rg"),
3134 + "#!/bin/sh\nexec podman exec 'other' 'rg' \"$@\"\n",
3047 3135 )
3048 3136 .unwrap();
3049 3137
3050 3138 let backend = Podman {
3051 - dir: Some(dir.clone()),
3139 + dir: Some(root.clone()),
3052 3140 path: None,
3053 3141 };
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}");
Lines truncated