Skip to main content

max / alloy

5.6 KB · 129 lines History Blame Raw
1 //! The host paths a `workspace` box is given, and the one path it always has.
2 //!
3 //! Two callers with one expansion between them: [`podman`](super::podman) turns
4 //! a spec entry into a `--volume` argument, and [`export`](super::export) asks
5 //! the same question for the wrapper's working directory. They have to agree
6 //! about what `~/code` means, which is why the expansion lives here rather than
7 //! in either of them.
8
9 use anyhow::{Context, Result};
10
11 /// Where a `workspace` box's private home lives inside the container.
12 ///
13 /// `/root` because a rootless podman container runs as root inside while mapping
14 /// to the invoking user outside, so files it writes into a bind mount come out
15 /// owned by the user on the host. Matching the host's own home path would gain
16 /// nothing and would collide with a mount of that path.
17 pub(super) const WORKSPACE_HOME: &str = "/root";
18
19 /// The named volume backing a box's private home.
20 pub(super) fn home_volume(name: &str) -> String {
21 format!("alloy-{name}-home")
22 }
23
24 /// A host path as a podman `--volume` argument, bound at the same path inside.
25 ///
26 /// Same path in and out so a directory the user names is the directory they see,
27 /// and a `:ro` suffix passes through to podman, which spells read-only the same
28 /// way. `~` is expanded here because the spec is hand-written and podman does no
29 /// expansion of its own — an unexpanded `~` would silently create a directory
30 /// with that literal name rather than mounting the home path meant.
31 pub(super) fn bind(mount: &str) -> Result<String> {
32 let path = mount_path(mount)?;
33 let mode = if mount.ends_with(":ro") { ":ro" } else { "" };
34 Ok(format!("{path}:{path}{mode}"))
35 }
36
37 /// The host path a mount entry names, expanded and checked.
38 ///
39 /// Split out from [`bind`] because the export wrapper wants the path without
40 /// podman's `source:target` framing: it compares the caller's working directory
41 /// against the same list, and it has to be the same expansion or the two
42 /// disagree about what `~/code` means.
43 pub(super) fn mount_path(mount: &str) -> Result<String> {
44 mount_path_in(mount, std::env::var("HOME").ok().as_deref())
45 }
46
47 /// [`mount_path`] against an explicit home.
48 ///
49 /// Split out for the same reason [`crate::settings::expand`] is, and its comment
50 /// there already said why: `set_var` is unsafe in a threaded test binary, and a
51 /// test that changes `HOME` under the other tests is a flake waiting for a slow
52 /// machine. This module did not follow that and got the flake.
53 ///
54 /// It was `a_mount_expands_a_leading_tilde` setting `HOME=/home/tester`
55 /// process-wide under a comment reading "single-threaded test", which is not what
56 /// a Rust test binary is: the harness runs tests on a thread pool in one process.
57 /// Any test reading `HOME` concurrently saw the fixture's value.
58 /// `a_host_export_calls_distrobox_export_once_per_binary` is the one that
59 /// noticed, because it compares an argv built from [`export_root`](super::export::export_root) against a
60 /// second call to it, and failed roughly one run in five.
61 fn mount_path_in(mount: &str, home: Option<&str>) -> Result<String> {
62 let path = mount.strip_suffix(":ro").unwrap_or(mount);
63
64 let path = match path.strip_prefix("~/") {
65 Some(rest) => {
66 let home = home.context("`~` in a mount needs HOME set")?;
67 format!("{home}/{rest}")
68 }
69 None => path.to_string(),
70 };
71
72 // Podman rejects a relative source, but only once the command runs, and by
73 // then the message is podman's rather than one naming the spec.
74 if !path.starts_with('/') {
75 anyhow::bail!("mount `{mount}` must be an absolute path or start with `~/`");
76 }
77 Ok(path)
78 }
79
80 #[cfg(test)]
81 mod tests {
82 use super::*;
83
84 // Same path in and out, so the directory a user names is the one they see.
85 #[test]
86 fn a_mount_binds_at_the_same_path() {
87 assert_eq!(bind("/srv/thing").unwrap(), "/srv/thing:/srv/thing");
88 }
89
90 #[test]
91 fn a_read_only_mount_keeps_its_suffix() {
92 assert_eq!(bind("/srv/thing:ro").unwrap(), "/srv/thing:/srv/thing:ro");
93 }
94
95 // Podman does no tilde expansion, so an unexpanded `~` would create a
96 // directory named `~` rather than mounting the home path meant.
97 //
98 // Against an explicit home rather than by setting one. The previous version
99 // of this test set `HOME` process-wide and restored it, under a comment
100 // claiming it was single-threaded; the harness runs tests on a thread pool in
101 // one process, so for the length of this test every other test saw
102 // `/home/tester`. `a_host_export_calls_distrobox_export_once_per_binary`
103 // reads `HOME` through `export_root` and failed about one run in five.
104 #[test]
105 fn a_mount_expands_a_leading_tilde() {
106 let path = mount_path_in("~/code/thing", Some("/home/tester")).unwrap();
107 assert_eq!(path, "/home/tester/code/thing");
108 }
109
110 // The error names the variable rather than the mount, because a `~` with no
111 // HOME is an environment problem and nothing about the spec would fix it.
112 #[test]
113 fn a_tilde_with_no_home_says_which_is_missing() {
114 let err = mount_path_in("~/code/thing", None).unwrap_err().to_string();
115 assert!(err.contains("HOME"), "got: {err}");
116 }
117
118 // Podman rejects a relative source too, but only once the command runs, and
119 // by then the message names podman rather than the spec.
120 #[test]
121 fn a_relative_mount_is_rejected_with_the_spec_in_the_message() {
122 let err = bind("code/thing").unwrap_err().to_string();
123 assert!(
124 err.contains("code/thing") && err.contains("absolute"),
125 "got: {err}"
126 );
127 }
128 }
129