Skip to main content

runtime/tools/exec/confinement/
placing.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Places commands in bounded, synchronized copies and probes their platform wrapper.
7//! Selection belongs to `Confinement`; copy lifetime belongs to this adapter.
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use kernel::{AxCode, AxError};
14
15use crate::backlog::{BacklogId, Finished};
16
17use super::{Confinement, Missing, Offerings};
18
19/// Owns reusable copies and holds background copies until their member ends (D10).
20pub struct Confined {
21    arm: Confinement,
22    scratch: Option<PathBuf>,
23    idle: Option<Mirror>,
24    outstanding: BTreeMap<BacklogId, Mirror>,
25}
26
27/// One copy, and the working directory it mirrors.
28struct Mirror {
29    of: PathBuf,
30    at: PathBuf,
31}
32
33/// A command's copy claim, returned after its execution ends.
34pub struct Placed {
35    copy: Option<Mirror>,
36    work: storage::FileWork,
37}
38
39impl Placed {
40    pub(in crate::tools::exec) fn into_container_copy(self) -> Result<PathBuf, AxError> {
41        self.copy
42            .map(|copy| copy.at)
43            .ok_or_else(|| no_arm(Missing::ScratchDirectory))
44    }
45
46    /// What putting the copy in place cost the filesystem (`crates/storage/Spec.lean`
47    /// ยง8-31).
48    pub fn work(&self) -> storage::FileWork {
49        self.work
50    }
51}
52
53impl Confined {
54    /// The arm this machine gives, and where its copies go.
55    pub fn detect() -> Confined {
56        let offered = Offerings::this_machine();
57        Confined {
58            arm: Confinement::choose(&offered),
59            scratch: offered.scratch,
60            idle: None,
61            outstanding: BTreeMap::new(),
62        }
63    }
64
65    /// An arm stated rather than found: a test's machine, or a report
66    /// about one.
67    pub fn with_arm(arm: Confinement, scratch: Option<PathBuf>) -> Confined {
68        Confined {
69            arm,
70            scratch,
71            idle: None,
72            outstanding: BTreeMap::new(),
73        }
74    }
75
76    pub fn arm(&self) -> &Confinement {
77        &self.arm
78    }
79
80    /// What this arm promises and does not, for the caller's own
81    /// description of itself.
82    pub fn statement(&self) -> String {
83        self.arm.statement()
84    }
85
86    /// Places one command under this machine's arm: the tool's copy of
87    /// `workdir`, synced to it, for the command to run in, wrapped in the
88    /// platform's isolation where the arm is one that has a wrapper.
89    ///
90    /// # Errors
91    /// `E_SANDBOX_DENIED` when this machine has no arm, when the arm
92    /// cannot be given by any build of this crate, or when the working
93    /// tree is too large or too deep to copy. On Linux, namespace setup
94    /// must succeed with a required unprivileged user namespace before
95    /// the working tree is copied.
96    pub fn place(
97        &mut self,
98        mut command: Command,
99        workdir: &Path,
100    ) -> Result<(Command, Placed), AxError> {
101        let wrapper = match &self.arm {
102            Confinement::Unavailable { missing } => return Err(no_arm(*missing)),
103            Confinement::WindowsJobObject => {
104                #[cfg(not(windows))]
105                return Err(no_job_object());
106                #[cfg(windows)]
107                {
108                    None
109                }
110            }
111            Confinement::MacosSeatbelt { .. } => {
112                #[cfg(target_os = "macos")]
113                {
114                    None
115                }
116                #[cfg(not(target_os = "macos"))]
117                {
118                    return Err(AxError::failure(
119                        AxCode::SandboxDenied,
120                        "initialize macOS Seatbelt confinement",
121                        "this host is not macOS",
122                    )
123                    .with_recovery("select a native sandbox for this host platform"));
124                }
125            }
126            Confinement::LinuxNamespaces { wrapper } => {
127                #[cfg(target_os = "linux")]
128                {
129                    let denied = |detail| {
130                        AxError::failure(
131                            AxCode::SandboxDenied,
132                            "create the Linux confinement",
133                            format!(
134                                "this arm requires an unprivileged user namespace; \
135                                 namespace setup failed: {detail}"
136                            ),
137                        )
138                        .with_recovery(
139                            "use a Linux machine that permits unprivileged user namespaces, \
140                             or ask the User to choose `where: host`",
141                        )
142                    };
143                    let probe = namespaced(wrapper, workdir, workdir, &child::command("/bin/true"))
144                        .output()
145                        .map_err(|err| denied(err.to_string()))?;
146                    if !probe.status.success() {
147                        return Err(denied(format!(
148                            "{}: {}",
149                            probe.status,
150                            String::from_utf8_lossy(&probe.stderr).trim()
151                        )));
152                    }
153                }
154                Some(wrapper.clone())
155            }
156            Confinement::CopiedTree => None,
157        };
158        let (copy, work) = self.synced(workdir)?;
159        #[cfg(target_os = "macos")]
160        if let Confinement::MacosSeatbelt { wrapper } = &self.arm {
161            let native = super::super::native_macos::probe(wrapper, &copy.at)
162                .and_then(|()| super::super::native_macos::wrap(wrapper, &copy.at, &command));
163            return match native {
164                Ok(command) => Ok((
165                    command,
166                    Placed {
167                        copy: Some(copy),
168                        work,
169                    },
170                )),
171                Err(error) => {
172                    remove(&copy.at);
173                    Err(error)
174                }
175            };
176        }
177        let command = match wrapper {
178            Some(wrapper) => namespaced(&wrapper, &copy.at, workdir, &command),
179            None => {
180                command.current_dir(&copy.at);
181                command
182            }
183        };
184        Ok((
185            command,
186            Placed {
187                copy: Some(copy),
188                work,
189            },
190        ))
191    }
192
193    /// Retains a settled command's copy for reuse.
194    pub fn settled(&mut self, placed: Placed) {
195        if let Some(copy) = placed.copy {
196            self.keep(copy);
197        }
198    }
199
200    /// Holds a background command's copy until its member reports.
201    pub fn handed(&mut self, id: BacklogId, placed: Placed) {
202        if let Some(copy) = placed.copy {
203            self.outstanding.insert(id, copy);
204        }
205    }
206
207    /// Releases copies whose background members have ended.
208    pub fn reaped(&mut self, finished: &[Finished]) {
209        for member in finished {
210            if let Some(copy) = self.outstanding.remove(&member.id) {
211                self.keep(copy);
212            }
213        }
214    }
215
216    /// Keeps `copy` for the next command when no copy is kept yet, and
217    /// removes it otherwise.
218    ///
219    /// A copy that cannot be removed costs disk space rather than the
220    /// answer, so the command's ending is not turned into a failure of
221    /// the tool: the judgment is `backlog/member.rs`'s about the place
222    /// a child's output was written, and it is recorded here rather than
223    /// repeated there.
224    fn keep(&mut self, copy: Mirror) {
225        match self.idle {
226            None => self.idle = Some(copy),
227            Some(_) => remove(&copy.at),
228        }
229    }
230
231    /// The copy kept for the next command, or a fresh one, brought to
232    /// what `workdir` holds.
233    fn synced(&mut self, workdir: &Path) -> Result<(Mirror, storage::FileWork), AxError> {
234        let root = self
235            .scratch
236            .as_deref()
237            .ok_or_else(|| no_arm(Missing::ScratchDirectory))?;
238        let copy = match self.idle.take() {
239            Some(kept) if kept.of == workdir => kept,
240            Some(other) => {
241                remove(&other.at);
242                Mirror {
243                    of: workdir.to_path_buf(),
244                    at: fresh(root)?,
245                }
246            }
247            None => Mirror {
248                of: workdir.to_path_buf(),
249                at: fresh(root)?,
250            },
251        };
252        let mut budget = Budget::new();
253        match mirror(
254            &Stage {
255                from: workdir,
256                into: &copy.at,
257                copy: &copy.at,
258            },
259            0,
260            &mut budget,
261        ) {
262            Ok(()) => Ok((copy, budget.work())),
263            Err(fault) => {
264                // The half-synced copy goes before the refusal is
265                // returned: a scratch root that grew a tree per refusal
266                // would be a leak nothing reports. A removal that fails
267                // here is the secondary failure and stays unstated rather
268                // than replacing the one that caused it.
269                remove(&copy.at);
270                Err(fault)
271            }
272        }
273    }
274}
275
276impl Drop for Confined {
277    fn drop(&mut self) {
278        // The copy kept for a next command that will not come, and those
279        // of commands still running when the tool ended. There is no
280        // caller left to tell, and the alternative is a panic, which this
281        // crate forbids: a directory that will not go stays behind in the
282        // scratch root, where an operating system clears it.
283        for copy in self.idle.iter().chain(self.outstanding.values()) {
284            remove(&copy.at);
285        }
286    }
287}
288
289/// The refusal for a machine that cannot take a command at all.
290fn no_arm(missing: Missing) -> AxError {
291    AxError::failure(
292        AxCode::SandboxDenied,
293        "run outside the confinement",
294        missing.phrase(),
295    )
296    .with_recovery(missing.recovery())
297}
298
299/// Refuses the unavailable job-object arm instead of silently weakening it.
300#[cfg(not(windows))]
301fn no_job_object() -> AxError {
302    AxError::failure(
303        AxCode::SandboxDenied,
304        "run under a job object",
305        "this build makes no job object",
306    )
307    .with_recovery(
308        "run under the copied tree instead: it copies the working directory and does \
309         not isolate the network, so keep the command off the network or run it on a \
310         machine that has the arm",
311    )
312}
313
314pub(super) mod copy;
315
316use copy::{Budget, Stage, fresh, mirror, remove};
317
318/// The command as the platform's wrapper runs it: the whole machine
319/// readable, the copy bound over the working directory, and every
320/// namespace the wrapper knows how to separate.
321fn namespaced(wrapper: &Path, copy: &Path, workdir: &Path, command: &Command) -> Command {
322    let mut wrapped = child::command(wrapper);
323    wrapped
324        .current_dir(workdir)
325        .arg("--ro-bind")
326        .arg("/")
327        .arg("/")
328        .arg("--bind")
329        .arg(copy)
330        .arg(workdir)
331        .arg("--chdir")
332        .arg(workdir)
333        .arg("--proc")
334        .arg("/proc")
335        .arg("--dev")
336        .arg("/dev")
337        .arg("--unshare-all");
338    #[cfg(target_os = "linux")]
339    wrapped.arg("--unshare-user");
340    wrapped
341        .arg("--die-with-parent")
342        .arg("--")
343        .arg(command.get_program());
344    for arg in command.get_args() {
345        wrapped.arg(arg);
346    }
347    for (name, value) in command.get_envs() {
348        match value {
349            Some(value) => wrapped.env(name, value),
350            None => wrapped.env_remove(name),
351        };
352    }
353    wrapped
354}