Skip to main content

max / alloy

pkg: write the workspace export wrapper `workspace` is podman directly, so it has no `distrobox-export` and Alloy owes the host-PATH wrapper itself. `e` on the boxes tab exports a box's declared `export.bin`: `distrobox-export` at `host`, a generated script at `workspace`. The write is the first thing the console does that is not a command, so `Backend::export` returns `Effect` — `Run(Invocation)` or `Write` — rather than `Invocation`. The log's promise is that it shows what the console did, and a write is something it did, so widening the log's vocabulary beat letting one action slip past it or contorting a heredoc into an argv. The wrapper starts the box first, picks a working directory by testing $PWD against the declared mounts, and only asks for a TTY when it has one. Without the workdir case a command runs in the box's home wherever it was invoked, so `rg` in a mounted project silently searches nothing. Verified against a real box on fw13 rather than fixtures, since the wrapper is shell source Alloy authors: `a_real_wrapper_runs_the_command_inside_the_box` runs it from a mount, from outside one, and through a pipe. Mutation-checked the TTY guard, which turned up that `podman exec -it` on a pipe does not fail — it hangs. 138 tests pass, clippy and rustdoc clean.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 22:55 UTC
Signed with PGP, not checked
Commit: 3466862b2eafa2a3e94b609b5b57990ed921836d
Parent: 8f9ff4f
2 files changed, +518 insertions, -21 deletions
@@ -5,8 +5,18 @@
5 5 //! not run, and it cannot run one it does not show. docs/CONSOLE.md — "the
6 6 //! console is not trying to hide the CLI, it's trying to make the CLI
7 7 //! approachable" — is enforced structurally rather than by remembering to log.
8 + //!
9 + //! [`Invocation`] is the common case and [`Effect`] is the general one. Almost
10 + //! everything the console does is running someone else's CLI, but not quite
11 + //! everything: the `workspace` export wrapper is a file Alloy writes, because
12 + //! that level has no `distrobox-export` to call (wiki `alloy-package-ux`, "Why
13 + //! `workspace` is podman directly"). Rather than let that one action slip the
14 + //! log by not being a command, [`Effect`] widens what the log can describe.
15 + //! The promise is that the console shows what it did, and a write is something
16 + //! it did.
8 17
9 18 use std::collections::VecDeque;
19 + use std::path::{Path, PathBuf};
10 20 use std::process::Command;
11 21
12 22 use alloy_tui::{LogEntry, Severity};
@@ -192,10 +202,131 @@
192 202 }
193 203 }
194 204
205 + /// Something the console does, which is usually but not always a command.
206 + ///
207 + /// Backends return these rather than performing them, for the same reason
208 + /// [`Invocation`] is returned rather than run: it keeps the log's coverage
209 + /// structural, and it keeps the backends testable on a machine with none of
210 + /// the tools installed. See the module docs for why the enum exists at all.
211 + #[derive(Debug, Clone)]
212 + pub enum Effect {
213 + /// Run a command.
214 + Run(Invocation),
215 + /// Write a file, replacing whatever was there.
216 + ///
217 + /// `mode` rather than a bool for the executable bit: every write this has
218 + /// so far is a wrapper script, but spelling the permission out keeps the
219 + /// variant from quietly meaning "and also chmod +x".
220 + Write {
221 + path: PathBuf,
222 + contents: String,
223 + mode: u32,
224 + },
225 + }
226 +
227 + impl Effect {
228 + /// The one-line description the log shows.
229 + ///
230 + /// A [`Effect::Run`] displays as its argv, which the user can paste into a
231 + /// shell. A [`Effect::Write`] cannot round-trip that way — the contents are
232 + /// a whole file — so it names the verb and the path instead. Being honest
233 + /// about the shape beats contorting a heredoc into the pane.
234 + pub fn display(&self) -> String {
235 + match self {
236 + Effect::Run(invocation) => invocation.display(),
237 + Effect::Write { path, .. } => format!("write {}", contract_home(path)),
238 + }
239 + }
240 +
241 + /// Perform it, recording what was done and whether it worked.
242 + pub fn apply(&self, log: &mut CommandLog) -> Result<()> {
243 + match self {
244 + Effect::Run(invocation) => invocation.run(log).map(drop),
245 + Effect::Write {
246 + path,
247 + contents,
248 + mode,
249 + } => {
250 + let result = write_file(path, contents, *mode);
251 + log.record(
252 + self.display(),
253 + if result.is_ok() { Severity::Healthy } else { Severity::Error },
254 + );
255 + result
256 + }
257 + }
258 + }
259 + }
260 +
261 + fn write_file(path: &Path, contents: &str, mode: u32) -> Result<()> {
262 + use std::os::unix::fs::PermissionsExt;
263 +
264 + // The parent is `~/.local/bin` on a fresh account that has never had one.
265 + // Creating it is part of the job; failing because the directory the write
266 + // was always going to need does not exist yet would be a footgun with an
267 + // obvious fix nobody should have to find.
268 + if let Some(parent) = path.parent() {
269 + std::fs::create_dir_all(parent)
270 + .with_context(|| format!("failed to create {}", contract_home(parent)))?;
271 + }
272 + std::fs::write(path, contents)
273 + .with_context(|| format!("failed to write {}", contract_home(path)))?;
274 + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
275 + .with_context(|| format!("failed to chmod {}", contract_home(path)))
276 + }
277 +
278 + /// A path with the home directory folded back to `~`.
279 + ///
280 + /// Only for display. The log pane is narrow and every path this writes lives
281 + /// under the home directory, so spelling it out costs a fifth of the line to
282 + /// say something the reader already knows.
283 + fn contract_home(path: &Path) -> String {
284 + let shown = path.display().to_string();
285 + let Some(home) = std::env::var_os("HOME") else {
286 + return shown;
287 + };
288 + let home = home.to_string_lossy();
289 + if home.is_empty() {
290 + return shown;
291 + }
292 + match shown.strip_prefix(home.as_ref()) {
293 + Some(rest) => format!("~{rest}"),
294 + None => shown,
295 + }
296 + }
297 +
195 298 #[cfg(test)]
196 299 mod tests {
197 300 use super::*;
198 301
302 + // A write cannot round-trip into a shell the way an argv can — the contents
303 + // are a whole file — so it names the verb and the path instead of pretending
304 + // to be a command.
305 + #[test]
306 + fn a_write_effect_describes_itself_as_a_write() {
307 + let effect = Effect::Write {
308 + path: PathBuf::from("/opt/thing/rg"),
309 + contents: "#!/bin/sh\n".to_string(),
310 + mode: 0o755,
311 + };
312 + assert_eq!(effect.display(), "write /opt/thing/rg");
313 + }
314 +
315 + // Every path this writes is under the home directory, and the log pane is
316 + // narrow enough that spelling it out costs a fifth of the line.
317 + #[test]
318 + fn a_written_path_under_home_is_shown_with_a_tilde() {
319 + let Some(home) = std::env::var_os("HOME") else {
320 + return;
321 + };
322 + let effect = Effect::Write {
323 + path: PathBuf::from(home).join(".local/bin/rg"),
324 + contents: String::new(),
325 + mode: 0o755,
326 + };
327 + assert_eq!(effect.display(), "write ~/.local/bin/rg");
328 + }
329 +
199 330 #[test]
200 331 fn display_round_trips_a_plain_command() {
201 332 let inv = Invocation::new("nmcli").args(["-t", "-f", "DEVICE,TYPE", "device", "status"]);
@@ -35,9 +35,9 @@
35 35 //!
36 36 //! Distrobox is a shell script over podman, so `workspace` is not a second
37 37 //! runtime — it is the same one with the wrapper's opinions left off. The cost
38 - //! is that `workspace` has no `distrobox-export` and Alloy owes it a wrapper of
39 - //! its own, which is not written yet. See wiki `alloy-package-ux`, "Why
40 - //! `workspace` is podman directly".
38 + //! is that `workspace` has no `distrobox-export`, so Alloy writes that wrapper
39 + //! itself: see [`wrapper`]. See also wiki `alloy-package-ux`, "Why `workspace`
40 + //! is podman directly".
41 41 //!
42 42 //! # Backends build commands, they do not run them
43 43 //!
@@ -64,7 +64,7 @@
64 64 use ratatui::text::{Line, Span};
65 65 use serde::Deserialize;
66 66
67 - use crate::cli::{CommandLog, Invocation};
67 + use crate::cli::{CommandLog, Effect, Invocation};
68 68 use crate::shell::{Confirm, Flow, View, block_title, truncate};
69 69
70 70 /// Ticks between background refreshes.
@@ -332,10 +332,7 @@
332 332 /// The box spec file.
333 333 ///
334 334 /// Unknown keys are ignored rather than rejected so a spec written against a
335 - /// later Alloy still yields its levels. `export` is parsed nowhere yet: at
336 - /// `host` it is `distrobox-export`'s job, and at `workspace` it is a wrapper
337 - /// Alloy has to write itself (wiki `alloy-package-ux`, "Why `workspace` is
338 - /// podman directly"), which is its own piece of work.
335 + /// later Alloy still yields its levels.
339 336 #[derive(Deserialize, Default)]
340 337 struct SpecFile {
341 338 #[serde(default)]
@@ -367,6 +364,21 @@
367 364 /// is right when only one remote carries the app and an error worth seeing
368 365 /// when several do.
369 366 remote: Option<String>,
367 + /// Which of the box's binaries reach the host PATH.
368 + #[serde(default)]
369 + export: Export,
370 + }
371 +
372 + /// The `export` table: what a box puts on the host PATH.
373 + ///
374 + /// A table rather than a bare list because `bin` is not the only thing a box
375 + /// can export — `distrobox-export` also does `--app` for desktop entries — and
376 + /// a spec that spells `export = ["rg"]` today has nowhere to put the second
377 + /// kind tomorrow without breaking every file already written.
378 + #[derive(Debug, Default, Deserialize)]
379 + struct Export {
380 + #[serde(default)]
381 + bin: Vec<String>,
370 382 }
371 383
372 384 impl SpecBox {
@@ -377,6 +389,18 @@
377 389 })
378 390 }
379 391
392 + /// The binaries this box exports, or an error saying it declares none.
393 + ///
394 + /// An error rather than an empty slice: `e` on a box with nothing to export
395 + /// would otherwise report success having done nothing, and the spec file is
396 + /// exactly where the user would go looking for why.
397 + fn bins(&self, name: &str) -> Result<&[String]> {
398 + if self.export.bin.is_empty() {
399 + anyhow::bail!("box `{name}` declares no `export.bin`");
400 + }
401 + Ok(&self.export.bin)
402 + }
403 +
380 404 /// The app id this box wants, or an error naming what the level requires.
381 405 fn app(&self, name: &str) -> Result<&str> {
382 406 self.app.as_deref().with_context(|| {
@@ -416,6 +440,19 @@
416 440 /// so one bad entry does not cost every other box its declared marker.
417 441 fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation>;
418 442
443 + /// Put the box's declared binaries on the host PATH.
444 + ///
445 + /// The one verb whose two levels do genuinely different things, which is
446 + /// why it returns [`Effect`] rather than [`Invocation`]: `host` has
447 + /// `distrobox-export` to call, and `workspace` has no such tool because it
448 + /// is not distrobox, so Alloy writes the wrapper itself. Both still keep
449 + /// the "return, do not perform" rule, so both are testable here.
450 + ///
451 + /// A `Vec` because exporting three binaries is three effects, and the log
452 + /// should say so — one line per file that appeared on the PATH, not one
453 + /// line claiming an export happened.
454 + fn export(&self, name: &str, spec: &SpecBox) -> Result<Vec<Effect>>;
455 +
419 456 /// Start `boxed`, or `None` when the concept does not apply.
420 457 fn start(&self, boxed: &Box) -> Option<Invocation>;
421 458
@@ -544,6 +581,55 @@
544 581 }
545 582 }
546 583
584 + /// `distrobox-export` at `host`, an Alloy-written wrapper at `workspace`.
585 + ///
586 + /// The split is the same one `create` makes and for the same reason: only
587 + /// `host` went through distrobox, so only `host` has distrobox's export
588 + /// tool. `distrobox-export` runs inside the container, so from here it is
589 + /// reached through `distrobox enter -- `, which is the documented host-side
590 + /// idiom for it.
591 + ///
592 + /// `/usr/bin/{bin}` is an assumption, and a flagged one: `--bin` wants an
593 + /// absolute path inside the container, the spec names a command rather than
594 + /// a path, and resolving it properly means asking the box where the command
595 + /// lives. Distrobox is not installed on the dev box, so this path is argv
596 + /// only — see the module docs on parsers checked against nothing but their
597 + /// own fixtures, which is the same trap.
598 + fn export(&self, name: &str, spec: &SpecBox) -> Result<Vec<Effect>> {
599 + let bins = spec.bins(name)?;
600 + let dir = export_dir()?;
601 + match spec.level {
602 + Level::Host => Ok(bins
603 + .iter()
604 + .map(|bin| {
605 + Effect::Run(Invocation::new("distrobox").args([
606 + "enter",
607 + name,
608 + "--",
609 + "distrobox-export",
610 + "--bin",
611 + &format!("/usr/bin/{bin}"),
612 + "--export-path",
613 + &dir.to_string_lossy(),
614 + ]))
615 + })
616 + .collect()),
617 + Level::Workspace => bins
618 + .iter()
619 + .map(|bin| {
620 + Ok(Effect::Write {
621 + path: dir.join(bin),
622 + contents: wrapper(name, bin, &spec.mounts)?,
623 + // Executable, or it is a file on the PATH that the shell
624 + // will not run — the whole point of putting it there.
625 + mode: 0o755,
626 + })
627 + })
628 + .collect(),
629 + Level::Sandboxed => unreachable!("the dial routes sandboxed to flatpak"),
630 + }
631 + }
632 +
547 633 fn start(&self, boxed: &Box) -> Option<Invocation> {
548 634 (!boxed.state.is_running())
549 635 .then(|| Invocation::new("podman").args(["start", &boxed.name]))
@@ -602,10 +688,19 @@
602 688 /// expansion of its own — an unexpanded `~` would silently create a directory
603 689 /// with that literal name rather than mounting the home path meant.
604 690 fn bind(mount: &str) -> Result<String> {
605 - let (path, mode) = match mount.strip_suffix(":ro") {
606 - Some(path) => (path, ":ro"),
607 - None => (mount, ""),
608 - };
691 + let path = mount_path(mount)?;
692 + let mode = if mount.ends_with(":ro") { ":ro" } else { "" };
693 + Ok(format!("{path}:{path}{mode}"))
694 + }
695 +
696 + /// The host path a mount entry names, expanded and checked.
697 + ///
698 + /// Split out from [`bind`] because the export wrapper wants the path without
699 + /// podman's `source:target` framing: it compares the caller's working directory
700 + /// against the same list, and it has to be the same expansion or the two
701 + /// disagree about what `~/code` means.
702 + fn mount_path(mount: &str) -> Result<String> {
703 + let path = mount.strip_suffix(":ro").unwrap_or(mount);
609 704
610 705 let path = match path.strip_prefix("~/") {
611 706 Some(rest) => {
@@ -620,7 +715,97 @@
620 715 if !path.starts_with('/') {
621 716 anyhow::bail!("mount `{mount}` must be an absolute path or start with `~/`");
622 717 }
623 - Ok(format!("{path}:{path}{mode}"))
718 + Ok(path)
719 + }
720 +
721 + /// Where export wrappers land: `~/.local/bin`.
722 + ///
723 + /// Fedora puts it on the default PATH, and it is the path distrobox's own
724 + /// `--export-path` examples use, so `host` and `workspace` exports land in the
725 + /// same directory and the user has one place to look.
726 + fn export_dir() -> Result<std::path::PathBuf> {
727 + let home = std::env::var("HOME").context("exporting needs HOME set")?;
728 + Ok(std::path::PathBuf::from(home).join(".local").join("bin"))
729 + }
730 +
731 + /// The host-PATH wrapper for one binary in a `workspace` box.
732 + ///
733 + /// This is what `distrobox-export --bin` would have written if `workspace` were
734 + /// distrobox, and the reason Alloy owes it is that the level is podman directly
735 + /// (wiki `alloy-package-ux`, "Why `workspace` is podman directly"). It re-enters
736 + /// the box, so running the wrapper is running the command under the level's
737 + /// isolation — putting it on the PATH moves the convenience out, not the access.
738 + ///
739 + /// Three things it does beyond forwarding arguments:
740 + ///
741 + /// - **Starts the box first.** A `workspace` box is created stopped and stops
742 + /// again on reboot, and a wrapper that only works when the user happens to
743 + /// have pressed `s` is a wrapper that looks broken.
744 + /// - **Picks a working directory from the mounts.** Without this the command
745 + /// runs in the container's home wherever the user invoked it, so `rg pattern`
746 + /// in a mounted project silently searches an empty directory and reports no
747 + /// matches — a wrong answer, which is worse than an error. The mounts are
748 + /// bound at the same path in and out, so a `$PWD` under one of them is valid
749 + /// inside; anything else falls back to the box's home.
750 + /// - **Only asks for a TTY when it has one.** A wrapper on the PATH will be
751 + /// piped, and `podman exec -it` on a pipe does not fail — it hangs, waiting
752 + /// for a terminal that is never coming. Measured on fw13 by generating the
753 + /// wrapper with an unconditional `-it` and piping into it. A command that
754 + /// hangs forever is the worst of the failure modes available here, since the
755 + /// user has nothing to read and no reason to suspect the wrapper.
756 + fn wrapper(name: &str, bin: &str, mounts: &[String]) -> Result<String> {
757 + let box_name = quote(name)?;
758 + let command = quote(bin)?;
759 +
760 + let workdir = if mounts.is_empty() {
761 + format!("workdir={}", quote(WORKSPACE_HOME)?)
762 + } else {
763 + let arms = mounts
764 + .iter()
765 + .map(|mount| {
766 + let path = quote(&mount_path(mount)?)?;
767 + Ok(format!("{path}|{path}/*"))
768 + })
769 + .collect::<Result<Vec<_>>>()?
770 + .join("|");
771 + format!(
772 + "case \"$PWD\" in\n\
773 + {arms}) workdir=\"$PWD\" ;;\n\
774 + *) workdir={} ;;\n\
775 + esac",
776 + quote(WORKSPACE_HOME)?
777 + )
778 + };
779 +
780 + Ok(format!(
781 + "#!/bin/sh\n\
782 + # Generated by alloy. Rewritten on every export; edits here are lost.\n\
783 + # Runs `{bin}` inside the `{name}` workspace box.\n\
784 + \n\
785 + podman start {box_name} >/dev/null || exit\n\
786 + \n\
787 + {workdir}\n\
788 + \n\
789 + if [ -t 0 ]; then\n\
790 + \x20 exec podman exec -it --workdir \"$workdir\" {box_name} {command} \"$@\"\n\
791 + fi\n\
792 + exec podman exec -i --workdir \"$workdir\" {box_name} {command} \"$@\"\n"
793 + ))
794 + }
795 +
796 + /// A spec value as a single shell word.
797 + ///
798 + /// The wrapper is the one place a spec string becomes shell source rather than
799 + /// an argv element, so it is the one place quoting can go wrong. Single quotes
800 + /// make every other metacharacter inert, which leaves the single quote itself:
801 + /// rejected rather than escaped, because a container name or a command name
802 + /// containing one is a typo every time and `'\''` in a generated file is worth
803 + /// nobody's confusion.
804 + fn quote(value: &str) -> Result<String> {
805 + if value.contains('\'') {
806 + anyhow::bail!("`{value}` cannot be exported: a single quote is not allowed here");
807 + }
808 + Ok(format!("'{value}'"))
624 809 }
625 810
626 811 /// Map podman's state word onto [`BoxState`], keeping anything unrecognized.
@@ -741,6 +926,18 @@
741 926 Ok(invocation.arg(app))
742 927 }
743 928
929 + /// Nothing to do, and saying so rather than succeeding silently.
930 + ///
931 + /// Flatpak already puts an app on the host: `flatpak install` writes the
932 + /// desktop entry and an `/var/lib/flatpak/exports/bin` launcher, both on the
933 + /// default PATH. An Alloy wrapper on top would be a second launcher for the
934 + /// same app, which is the two-tools-one-job problem the dial exists to end.
935 + /// The error names where the app already is, so a user who pressed `e`
936 + /// looking for it stops looking.
937 + fn export(&self, name: &str, _spec: &SpecBox) -> Result<Vec<Effect>> {
938 + anyhow::bail!("flatpak already exports `{name}`; it is on the PATH as its app id")
939 + }
940 +
744 941 // A sandboxed box is one app. It is installed or it is not; there is no
745 942 // container to start and stop, and offering the verbs would imply a
746 943 // lifecycle that does not exist at this level.
@@ -1007,19 +1204,57 @@
1007 1204 if boxed.state != BoxState::Absent {
1008 1205 anyhow::bail!("{} already exists", boxed.name);
1009 1206 }
1010 - let name = boxed
1011 - .declared
1012 - .as_deref()
1013 - .context("only a declared box can be created")?;
1014 - let (_, spec) = self
1015 - .spec
1016 - .resolve(name)
1017 - .context("the box left the spec since it was read")?;
1207 + let (name, spec) = self.declared_spec(boxed, "created")?;
1018 1208 self.backends[index].create(name, spec)?.run(log).map(drop)
1019 1209 });
1020 1210 self.finish(result, log);
1021 1211 }
1022 1212
1213 + /// The spec entry behind a row.
1214 + ///
1215 + /// Both spec-reading verbs need the same two lookups, and both can fail the
1216 + /// same two ways: the row is ad-hoc and has no entry, or the file changed
1217 + /// under a list that was read before it did. `verb` puts the caller's word
1218 + /// in the first message, since "only a declared box can be created" and the
1219 + /// same sentence about exporting are the two halves of one rule.
1220 + fn declared_spec(&self, boxed: &Box, verb: &str) -> Result<(&str, &SpecBox)> {
1221 + let name = boxed
1222 + .declared
1223 + .as_deref()
1224 + .with_context(|| format!("only a declared box can be {verb}"))?;
1225 + self.spec
1226 + .resolve(name)
1227 + .context("the box left the spec since it was read")
1228 + }
1229 +
1230 + /// Put the selected box's declared binaries on the host PATH.
1231 + ///
1232 + /// Every effect runs even though each is logged separately, and the first
1233 + /// failure stops the rest: exporting three binaries is three files, and a
1234 + /// user who sees two `write` lines and an error knows exactly how far it
1235 + /// got. Re-running after fixing the spec is safe, since both backends
1236 + /// overwrite rather than append.
1237 + fn export(&mut self, log: &mut CommandLog) {
1238 + let Some(backend) = self.selected_backend() else {
1239 + return;
1240 + };
1241 + let result = backend.and_then(|(index, boxed)| {
1242 + // The `host` path enters the box and the `workspace` wrapper starts
1243 + // it, and neither can act on a box that was never created. Same
1244 + // check and same wording as `toggle`, for the same reason: the state
1245 + // is the console's fact, not the backend's.
1246 + if boxed.state == BoxState::Absent {
1247 + anyhow::bail!("{} does not exist yet — press c to create it", boxed.name);
1248 + }
1249 + let (name, spec) = self.declared_spec(boxed, "exported")?;
1250 + for effect in self.backends[index].export(name, spec)? {
1251 + effect.apply(log)?;
1252 + }
1253 + Ok(())
1254 + });
1255 + self.finish(result, log);
1256 + }
1257 +
1023 1258 /// Start a stopped box, stop a running one.
1024 1259 ///
1025 1260 /// One key rather than two because the states are exclusive and the row
@@ -1196,6 +1431,7 @@
1196 1431 hint("j/k", "select"),
1197 1432 hint("s", "start/stop"),
1198 1433 hint("c", "create"),
1434 + hint("e", "export"),
1199 1435 hint("enter", "shell"),
1200 1436 hint("x", "remove"),
1201 1437 hint("r", "refresh"),
@@ -1280,6 +1516,7 @@
1280 1516 KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
1281 1517 KeyCode::Char('s') => self.toggle(log),
1282 1518 KeyCode::Char('c') => self.create(log),
1519 + KeyCode::Char('e') => self.export(log),
1283 1520 KeyCode::Char('x') => return self.confirm_remove(),
1284 1521 KeyCode::Char('r') => self.refresh(log),
1285 1522 KeyCode::Enter => return self.enter(log),
@@ -1356,6 +1593,7 @@
1356 1593 app: None,
1357 1594 mounts: Vec::new(),
1358 1595 remote: None,
1596 + export: Export::default(),
1359 1597 },
1360 1598 )
1361 1599 })
@@ -1560,6 +1798,173 @@
1560 1798 assert!(Flatpak.stop(chromium).is_none());
1561 1799 }
1562 1800
1801 + // ---- exporting ----
1802 +
1803 + /// A spec covering every shape the export verb branches on.
1804 + fn export_spec() -> Spec {
1805 + Spec::parse(
1806 + r#"
1807 + [box.dev]
1808 + level = "host"
1809 + image = "registry.fedoraproject.org/fedora-toolbox:43"
1810 + export = { bin = ["rg", "fd"] }
1811 +
1812 + [box.scratch]
1813 + level = "workspace"
1814 + image = "registry.fedoraproject.org/fedora-toolbox:43"
1815 + mounts = ["/srv/thing", "/srv/read-only:ro"]
1816 + export = { bin = ["cargo"] }
1817 +
1818 + [box.solo]
1819 + level = "workspace"
1820 + image = "alpine"
1821 + export = { bin = ["hx"] }
1822 +
1823 + [box.bare]
1824 + level = "workspace"
1825 + image = "alpine"
1826 +
1827 + [box.chromium]
1828 + level = "sandboxed"
1829 + app = "org.chromium.Chromium"
1830 + "#,
1831 + )
1832 + .unwrap()
1833 + }
1834 +
1835 + fn exports(backend: &dyn Backend, spec: &Spec, name: &str) -> Result<Vec<Effect>> {
1836 + let (_, entry) = spec.resolve(name).unwrap();
1837 + backend.export(name, entry)
1838 + }
1839 +
1840 + /// The written file for a one-binary box, which is most of these tests.
1841 + fn wrapper_of(spec: &Spec, name: &str) -> String {
1842 + let effects = exports(&Podman, spec, name).unwrap();
1843 + match effects.as_slice() {
1844 + [Effect::Write { contents, .. }] => contents.clone(),
1845 + other => panic!("expected one write, got {} effects", other.len()),
1846 + }
1847 + }
1848 +
1849 + // `host` kept distrobox, so it kept distrobox's export tool. Argv only:
1850 + // distrobox is not installed on the dev box, and this is exactly the kind of
1851 + // path the module docs warn about shipping on fixtures alone.
1852 + #[test]
1853 + fn a_host_export_calls_distrobox_export_once_per_binary() {
1854 + let spec = export_spec();
1855 + let effects = exports(&Podman, &spec, "dev").unwrap();
1856 + let lines: Vec<String> = effects.iter().map(Effect::display).collect();
1857 +
1858 + let dir = export_dir().unwrap();
1859 + let dir = dir.to_string_lossy();
1860 + assert_eq!(
1861 + lines,
1862 + vec![
1863 + format!("distrobox enter dev -- distrobox-export --bin /usr/bin/rg --export-path {dir}"),
1864 + format!("distrobox enter dev -- distrobox-export --bin /usr/bin/fd --export-path {dir}"),
1865 + ]
1866 + );
1867 + }
1868 +
1869 + // The level with no incumbent: there is no `distrobox-export` here, so the
1870 + // effect is a file Alloy writes rather than a command it runs. This is the
1871 + // reason `Effect` is an enum at all.
1872 + #[test]
1873 + fn a_workspace_export_writes_an_executable_wrapper_per_binary() {
1874 + let spec = export_spec();
1875 + let effects = exports(&Podman, &spec, "scratch").unwrap();
1876 +
1877 + let [Effect::Write { path, mode, .. }] = effects.as_slice() else {
1878 + panic!("workspace exports are writes, not commands");
1879 + };
1880 + assert!(path.ends_with(".local/bin/cargo"), "landed at {}", path.display());
1881 + assert_eq!(*mode, 0o755, "a wrapper the shell will not run is not on the PATH");
1882 + }
1883 +
1884 + // The wrapper is what makes the level's promise hold once the binary is on
1885 + // the host PATH: running it runs the command inside the box.
1886 + #[test]
1887 + fn the_wrapper_re_enters_the_box_and_forwards_its_arguments() {
1888 + let script = wrapper_of(&export_spec(), "scratch");
1889 +
1890 + assert!(script.starts_with("#!/bin/sh\n"), "{script}");
1891 + assert!(
1892 + script.contains("podman start 'scratch' >/dev/null || exit"),
1893 + "a box created stopped has to be started, or the wrapper looks broken:\n{script}"
1894 + );
1895 + assert!(
1896 + script.contains("exec podman exec -i --workdir \"$workdir\" 'scratch' 'cargo' \"$@\""),
1897 + "{script}"
1898 + );
1899 + assert!(
1900 + script.contains("if [ -t 0 ]; then"),
1901 + "a wrapper on the PATH gets piped, and `-it` on a pipe fails:\n{script}"
1902 + );
1903 + }
1904 +
1905 + // Without this the command runs in the box's home wherever it was invoked,
1906 + // so `cargo` in a mounted project silently acts on an empty directory. A
1907 + // wrong answer is worse than an error, and the mounts are bound at the same
1908 + // path in and out, so the caller's `$PWD` is testable against them.
1909 + #[test]
1910 + fn the_wrapper_works_in_a_mounted_directory_and_falls_back_to_the_box_home() {
1911 + let script = wrapper_of(&export_spec(), "scratch");
1912 +
1913 + assert!(
1914 + script.contains("'/srv/thing'|'/srv/thing'/*|'/srv/read-only'|'/srv/read-only'/*)"),
1915 + "every mount, and everything under it, is a valid working directory:\n{script}"
1916 + );
1917 + assert!(script.contains("workdir=\"$PWD\""), "{script}");
1918 + assert!(
1919 + script.contains("*) workdir='/root' ;;"),
1920 + "anywhere else is not visible inside the box:\n{script}"
1921 + );
1922 + }
1923 +
1924 + // A `case` with no patterns before the default is not valid shell, so the
1925 + // no-mounts box takes a plain assignment rather than an empty branch.
1926 + #[test]
1927 + fn a_box_with_no_mounts_gets_a_fixed_working_directory() {
1928 + let script = wrapper_of(&export_spec(), "solo");
Lines truncated