| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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}");
|