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//! The adapter that places a command: a copy of the working tree synced
7//! to it, the platform's wrapper where the arm has one, and the budget
8//! that keeps a copy from growing without a bound.
9//!
10//! Where a command runs is the caller's choice and one of two words
11//! ([`Placement`]); what runs there is the arm's, and the arm's promises
12//! are [`super::Confinement`]'s. This file holds no policy about which
13//! arm a machine should get.
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use kernel::{AxCode, AxError};
20use serde_json::{Map, Value};
21
22use crate::backlog::{BacklogId, Finished};
23
24use super::{Confinement, Missing, Offerings};
25
26/// Where a host command runs. The safe arm is what a call gets when it
27/// says nothing, because an agent that already knew a command was
28/// dangerous would not need to be told to sandbox it.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Placement {
31 /// In the confinement [`Confinement::detect`](super::Confinement::detect) reports: a copy of
32 /// the working tree, and the platform's own isolation where there is
33 /// a wrapper for it.
34 Sandbox,
35 /// Where it stands, on the room the run may write in. This is the
36 /// placement a person decides on; a command that needs it asks for
37 /// it by name.
38 Host,
39}
40
41/// Reads the placement out of the call. Absent is [`Placement::Sandbox`].
42pub fn parse_placement(args: &Map<String, Value>) -> Result<Placement, AxError> {
43 match args.get("where") {
44 None => Ok(Placement::Sandbox),
45 Some(Value::String(said)) if said == "sandbox" => Ok(Placement::Sandbox),
46 Some(Value::String(said)) if said == "host" => Ok(Placement::Host),
47 Some(other) => Err(AxError::failure(
48 AxCode::InvalidArgs,
49 "run exec",
50 format!("unrecognised placement: {other}"),
51 )
52 .with_recovery(
53 "leave `where` out to run in the sandbox, or name one of `sandbox` and \
54 `host`",
55 )),
56 }
57}
58
59impl Placement {
60 /// This placement, when the run's write limit opens it.
61 ///
62 /// A command on the host could change, remove or link any file it
63 /// reaches, and nothing on this machine makes the existing ones
64 /// read-only to it without administrator rights, so under `Create`
65 /// only the copy is open; what a command writes there stays there
66 /// (`crates/runtime/spec/Tools.lean` §8-55, runtime D11).
67 ///
68 /// # Errors
69 /// The refusal of `kernel::gate::replacing`, naming the run's
70 /// domain, before any process starts.
71 pub fn opened_by(self, setup: &crate::tools::ExecSetup) -> Result<Placement, AxError> {
72 match self {
73 Placement::Sandbox => Ok(self),
74 Placement::Host => {
75 match kernel::gate::replacing(setup.policy.now().write, &setup.domain) {
76 kernel::GateOutcome::Allow => Ok(self),
77 kernel::GateOutcome::Deny { refusal } => Err(*refusal),
78 kernel::GateOutcome::Ask { question } => Err(*question),
79 }
80 }
81 }
82 }
83}
84
85/// The arm in use, the copy kept for the next command, and the copies
86/// commands still hold.
87///
88/// The copy of a command that settled is kept for the next command,
89/// which syncs it rather than making another (runtime D10). A
90/// command handed to the backlog keeps its copy until the member reports
91/// its ending, because the process is still in it.
92pub struct Confined {
93 arm: Confinement,
94 scratch: Option<PathBuf>,
95 idle: Option<Mirror>,
96 outstanding: BTreeMap<BacklogId, Mirror>,
97}
98
99/// One copy, and the working directory it mirrors.
100struct Mirror {
101 of: PathBuf,
102 at: PathBuf,
103}
104
105/// Where one command was put, so that whoever ends the wait can hand
106/// the copy back.
107pub struct Placed {
108 copy: Option<Mirror>,
109 work: storage::FileWork,
110}
111
112impl Placed {
113 /// What putting the copy in place cost the filesystem (`crates/storage/Spec.lean`
114 /// §8-31).
115 pub fn work(&self) -> storage::FileWork {
116 self.work
117 }
118}
119
120impl Confined {
121 /// The arm this machine gives, and where its copies go.
122 pub fn detect() -> Confined {
123 let offered = Offerings::this_machine();
124 Confined {
125 arm: Confinement::choose(&offered),
126 scratch: offered.scratch,
127 idle: None,
128 outstanding: BTreeMap::new(),
129 }
130 }
131
132 /// An arm stated rather than found: a test's machine, or a report
133 /// about one.
134 pub fn with_arm(arm: Confinement, scratch: Option<PathBuf>) -> Confined {
135 Confined {
136 arm,
137 scratch,
138 idle: None,
139 outstanding: BTreeMap::new(),
140 }
141 }
142
143 pub fn arm(&self) -> &Confinement {
144 &self.arm
145 }
146
147 /// What this arm promises and does not, for the caller's own
148 /// description of itself.
149 pub fn statement(&self) -> String {
150 self.arm.statement()
151 }
152
153 /// Places one command under this machine's arm: the tool's copy of
154 /// `workdir`, synced to it, for the command to run in, wrapped in the
155 /// platform's isolation where the arm is one that has a wrapper.
156 ///
157 /// # Errors
158 /// `E_SANDBOX_DENIED` when this machine has no arm, when the arm
159 /// cannot be given by any build of this crate, or when the working
160 /// tree is too large or too deep to copy.
161 pub fn place(
162 &mut self,
163 mut command: Command,
164 workdir: &Path,
165 ) -> Result<(Command, Placed), AxError> {
166 let wrapper = match &self.arm {
167 Confinement::Unavailable { missing } => return Err(no_arm(*missing)),
168 Confinement::WindowsJobObject => return Err(no_job_object()),
169 Confinement::LinuxNamespaces { wrapper } => Some(wrapper.clone()),
170 Confinement::CopiedTree => None,
171 };
172 let (copy, work) = self.synced(workdir)?;
173 let command = match wrapper {
174 Some(wrapper) => namespaced(&wrapper, ©.at, workdir, &command),
175 None => {
176 command.current_dir(©.at);
177 command
178 }
179 };
180 Ok((
181 command,
182 Placed {
183 copy: Some(copy),
184 work,
185 },
186 ))
187 }
188
189 /// The wait ended and the command is over: its copy is kept for the
190 /// next command, or goes when one is kept already.
191 pub fn settled(&mut self, placed: Placed) {
192 if let Some(copy) = placed.copy {
193 self.keep(copy);
194 }
195 }
196
197 /// The command is still going: its copy is kept against the member
198 /// that will report the ending.
199 pub fn handed(&mut self, id: BacklogId, placed: Placed) {
200 if let Some(copy) = placed.copy {
201 self.outstanding.insert(id, copy);
202 }
203 }
204
205 /// Copies whose member has reported are kept for the next command,
206 /// or go.
207 pub fn reaped(&mut self, finished: &[Finished]) {
208 for member in finished {
209 if let Some(copy) = self.outstanding.remove(&member.id) {
210 self.keep(copy);
211 }
212 }
213 }
214
215 /// Keeps `copy` for the next command when no copy is kept yet, and
216 /// removes it otherwise.
217 ///
218 /// A copy that cannot be removed costs disk space rather than the
219 /// answer, so the command's ending is not turned into a failure of
220 /// the tool: the judgment is `backlog/member.rs`'s about the place
221 /// a child's output was written, and it is recorded here rather than
222 /// repeated there.
223 fn keep(&mut self, copy: Mirror) {
224 match self.idle {
225 None => self.idle = Some(copy),
226 Some(_) => remove(©.at),
227 }
228 }
229
230 /// The copy kept for the next command, or a fresh one, brought to
231 /// what `workdir` holds.
232 fn synced(&mut self, workdir: &Path) -> Result<(Mirror, storage::FileWork), AxError> {
233 let root = self
234 .scratch
235 .as_deref()
236 .ok_or_else(|| no_arm(Missing::ScratchDirectory))?;
237 let copy = match self.idle.take() {
238 Some(kept) if kept.of == workdir => kept,
239 Some(other) => {
240 remove(&other.at);
241 Mirror {
242 of: workdir.to_path_buf(),
243 at: fresh(root)?,
244 }
245 }
246 None => Mirror {
247 of: workdir.to_path_buf(),
248 at: fresh(root)?,
249 },
250 };
251 let mut budget = Budget::new();
252 match mirror(
253 &Stage {
254 from: workdir,
255 into: ©.at,
256 copy: ©.at,
257 },
258 0,
259 &mut budget,
260 ) {
261 Ok(()) => Ok((copy, budget.work())),
262 Err(fault) => {
263 // The half-synced copy goes before the refusal is
264 // returned: a scratch root that grew a tree per refusal
265 // would be a leak nothing reports. A removal that fails
266 // here is the secondary failure and stays unstated rather
267 // than replacing the one that caused it.
268 remove(©.at);
269 Err(fault)
270 }
271 }
272 }
273}
274
275impl Drop for Confined {
276 fn drop(&mut self) {
277 // The copy kept for a next command that will not come, and those
278 // of commands still running when the tool ended. There is no
279 // caller left to tell, and the alternative is a panic, which this
280 // crate forbids: a directory that will not go stays behind in the
281 // scratch root, where an operating system clears it.
282 for copy in self.idle.iter().chain(self.outstanding.values()) {
283 remove(©.at);
284 }
285 }
286}
287
288/// The refusal for a machine that cannot take a command at all.
289fn no_arm(missing: Missing) -> AxError {
290 AxError::failure(
291 AxCode::SandboxDenied,
292 "run outside the confinement",
293 missing.phrase(),
294 )
295 .with_recovery(missing.recovery())
296}
297
298/// The refusal for the one arm no build of this crate constructs.
299///
300/// No build constructs it because a job object made through the safe
301/// interface keeps neither a job-wide resource limit nor the processes a
302/// child starts before it joins the job (`crates/runtime/spec/Tools/Exec.lean`
303/// §8-13-2). Refused rather than served as the copied tree: the copied tree does
304/// not isolate the network, and answering for an arm that does would
305/// tell a caller its network was closed while it was open.
306fn no_job_object() -> AxError {
307 AxError::failure(
308 AxCode::SandboxDenied,
309 "run under a job object",
310 "this build makes no job object",
311 )
312 .with_recovery(
313 "run under the copied tree instead: it copies the working directory and does \
314 not isolate the network, so keep the command off the network or run it on a \
315 machine that has the arm",
316 )
317}
318
319mod copy;
320
321use copy::{Budget, Stage, fresh, mirror, remove};
322
323/// The command as the platform's wrapper runs it: the whole machine
324/// readable, the copy bound over the working directory, and every
325/// namespace the wrapper knows how to separate.
326fn namespaced(wrapper: &Path, copy: &Path, workdir: &Path, command: &Command) -> Command {
327 let mut wrapped = Command::new(wrapper);
328 wrapped
329 .current_dir(workdir)
330 .arg("--ro-bind")
331 .arg("/")
332 .arg("/")
333 .arg("--bind")
334 .arg(copy)
335 .arg(workdir)
336 .arg("--chdir")
337 .arg(workdir)
338 .arg("--proc")
339 .arg("/proc")
340 .arg("--dev")
341 .arg("/dev")
342 .arg("--unshare-all")
343 .arg("--die-with-parent")
344 .arg("--")
345 .arg(command.get_program());
346 for arg in command.get_args() {
347 wrapped.arg(arg);
348 }
349 for (name, value) in command.get_envs() {
350 match value {
351 Some(value) => wrapped.env(name, value),
352 None => wrapped.env_remove(name),
353 };
354 }
355 wrapped
356}