max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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