bob/action.rs
1/*
2 * Copyright (c) 2025 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17//! Sandbox action configuration.
18//!
19//! This module defines the types used to configure sandbox setup and teardown
20//! actions. Actions are specified in the `sandboxes.actions` table of the Lua
21//! configuration file.
22//!
23//! # Action Types
24//!
25//! Four action types are supported:
26//!
27//! - **mount**: Mount a filesystem inside the sandbox
28//! - **copy**: Copy files or directories into the sandbox
29//! - **symlink**: Create a symbolic link inside the sandbox
30//! - **cmd**: Execute shell commands during setup/teardown
31//!
32//! # Execution Order
33//!
34//! Actions are processed in order during sandbox creation, and in reverse order
35//! during sandbox destruction.
36//!
37//! # Configuration Examples
38//!
39//! ```lua
40//! sandboxes = {
41//! basedir = "/data/chroot/bob",
42//! actions = {
43//! -- Mount procfs
44//! { action = "mount", fs = "proc", dir = "/proc" },
45//!
46//! -- Mount devfs
47//! { action = "mount", fs = "dev", dir = "/dev" },
48//!
49//! -- Mount tmpfs with size limit
50//! { action = "mount", fs = "tmp", dir = "/tmp", opts = "size=1G" },
51//!
52//! -- Read-only bind mount from host
53//! { action = "mount", fs = "bind", dir = "/usr/bin", opts = "ro" },
54//!
55//! -- Copy /etc into sandbox
56//! { action = "copy", dir = "/etc" },
57//!
58//! -- Create symbolic link
59//! { action = "symlink", src = "usr/bin", dest = "/bin" },
60//!
61//! -- Run command on setup (working directory is sandbox root)
62//! { action = "cmd", create = "chmod 1777 tmp" },
63//!
64//! -- Run different commands on create and destroy
65//! { action = "cmd", create = "mkdir -p home/builder", destroy = "rm -rf home/builder" },
66//!
67//! -- Only mount if source exists on host
68//! { action = "mount", fs = "bind", dir = "/opt/local", ifexists = true },
69//! },
70//! }
71//! ```
72//!
73//! # Common Fields
74//!
75//! | Field | Type | Description |
76//! |-------|------|-------------|
77//! | `dir` | string | Shorthand when `src` and `dest` are the same path |
78//! | `src` | string | Source path on the host system |
79//! | `dest` | string | Destination path inside the sandbox |
80//! | `ifexists` | boolean | Only perform action if source exists (default: false) |
81
82use anyhow::{Error, bail};
83use mlua::{Result as LuaResult, Table};
84use std::path::PathBuf;
85use std::str::FromStr;
86
87/// A sandbox action configuration.
88///
89/// Actions define how sandboxes are set up and torn down. Each action specifies
90/// an operation to perform (mount, copy, symlink, or cmd) along with the
91/// parameters needed for that operation.
92///
93/// Actions are processed in order during sandbox creation and in reverse order
94/// during destruction.
95///
96/// # Fields
97///
98/// The available fields depend on the action type:
99///
100/// ## Mount Actions
101///
102/// | Field | Required | Description |
103/// |-------|----------|-------------|
104/// | `fs` | yes | Filesystem type (bind, dev, fd, nfs, proc, tmp) |
105/// | `dir` or `src`/`dest` | yes | Mount point path |
106/// | `opts` | no | Mount options (e.g., "ro", "size=1G") |
107/// | `ifexists` | no | Only mount if source exists (default: false) |
108///
109/// ## Copy Actions
110///
111/// | Field | Required | Description |
112/// |-------|----------|-------------|
113/// | `dir` or `src`/`dest` | yes | Path to copy |
114///
115/// ## Symlink Actions
116///
117/// | Field | Required | Description |
118/// |-------|----------|-------------|
119/// | `src` | yes | Link target (what the symlink points to) |
120/// | `dest` | yes | Link name (the symlink itself) |
121///
122/// ## Cmd Actions
123///
124/// | Field | Required | Description |
125/// |-------|----------|-------------|
126/// | `create` | no | Command to run during sandbox creation |
127/// | `destroy` | no | Command to run during sandbox destruction |
128/// | `cwd` | no | Working directory relative to sandbox root |
129#[derive(Clone, Debug, Default)]
130pub struct Action {
131 action: String,
132 fs: Option<String>,
133 src: Option<PathBuf>,
134 dest: Option<PathBuf>,
135 opts: Option<String>,
136 create: Option<String>,
137 destroy: Option<String>,
138 cwd: Option<PathBuf>,
139 ifexists: bool,
140}
141
142/// The type of sandbox action to perform.
143///
144/// Used internally to dispatch action handling.
145#[derive(Debug, PartialEq)]
146pub enum ActionType {
147 /// Mount a filesystem inside the sandbox.
148 Mount,
149 /// Copy files or directories from host into sandbox.
150 Copy,
151 /// Execute shell commands during creation and/or destruction.
152 Cmd,
153 /// Create a symbolic link inside the sandbox.
154 Symlink,
155}
156
157/// Filesystem types for mount actions.
158///
159/// These map to platform-specific mount implementations. Not all filesystem
160/// types are supported on all platforms; see individual variants for details.
161///
162/// # Filesystem Types
163///
164/// | Type | Aliases | Linux | macOS | NetBSD | illumos |
165/// |------|---------|-------|-------|--------|---------|
166/// | `bind` | `lofs`, `loop`, `null` | Yes | Yes | Yes | Yes |
167/// | `dev` | | Yes | Yes | No | No |
168/// | `fd` | | Yes | No | Yes | Yes |
169/// | `nfs` | | Yes | Yes | Yes | Yes |
170/// | `proc` | | Yes | No | Yes | Yes |
171/// | `tmp` | | Yes | Yes | Yes | Yes |
172#[derive(Debug, PartialEq)]
173pub enum FSType {
174 /// Bind mount from host filesystem.
175 ///
176 /// Makes a directory from the host visible inside the sandbox. Use
177 /// `opts = "ro"` for read-only access.
178 ///
179 /// Aliases: `lofs`, `loop`, `null` (for cross-platform compatibility).
180 ///
181 /// | Platform | Implementation |
182 /// |----------|----------------|
183 /// | Linux | `mount -o bind` |
184 /// | macOS | `bindfs` (requires installation) |
185 /// | NetBSD | `mount_null` |
186 /// | illumos | `mount -F lofs` |
187 Bind,
188
189 /// Device filesystem.
190 ///
191 /// Provides `/dev` device nodes inside the sandbox.
192 ///
193 /// | Platform | Implementation |
194 /// |----------|----------------|
195 /// | Linux | `devtmpfs` |
196 /// | macOS | `devfs` |
197 /// | NetBSD | Not supported. Use a `cmd` action with `MAKEDEV` instead. |
198 /// | illumos | Not supported. Use a `bind` mount of `/dev` instead. |
199 Dev,
200
201 /// File descriptor filesystem.
202 ///
203 /// Provides `/dev/fd` entries for accessing open file descriptors.
204 ///
205 /// | Platform | Implementation |
206 /// |----------|----------------|
207 /// | Linux | Bind mount of `/dev/fd` |
208 /// | macOS | Not supported. |
209 /// | NetBSD | `mount_fdesc` |
210 /// | illumos | `mount -F fd` |
211 Fd,
212
213 /// Network File System mount.
214 ///
215 /// Mounts an NFS export inside the sandbox. The `src` field must be an
216 /// NFS path in the form `host:/path`.
217 ///
218 /// | Platform | Implementation |
219 /// |----------|----------------|
220 /// | Linux | `mount -t nfs` |
221 /// | macOS | `mount_nfs` |
222 /// | NetBSD | `mount_nfs` |
223 /// | illumos | `mount -F nfs` |
224 Nfs,
225
226 /// Process filesystem.
227 ///
228 /// Provides `/proc` entries for process information. Required by many
229 /// build tools and commands.
230 ///
231 /// | Platform | Implementation |
232 /// |----------|----------------|
233 /// | Linux | `mount -t proc` |
234 /// | macOS | Not supported. |
235 /// | NetBSD | `mount_procfs` |
236 /// | illumos | `mount -F proc` |
237 Proc,
238
239 /// Temporary filesystem.
240 ///
241 /// Memory-backed filesystem. Contents are lost when unmounted. Use
242 /// `opts = "size=1G"` to limit size (Linux, NetBSD). Useful for `/tmp`
243 /// and build directories.
244 ///
245 /// | Platform | Implementation |
246 /// |----------|----------------|
247 /// | Linux | `mount -t tmpfs` |
248 /// | macOS | `mount_tmpfs` |
249 /// | NetBSD | `mount_tmpfs` |
250 /// | illumos | `mount -F tmpfs` |
251 Tmp,
252}
253
254impl FromStr for ActionType {
255 type Err = Error;
256
257 fn from_str(s: &str) -> Result<Self, Self::Err> {
258 match s {
259 "mount" => Ok(ActionType::Mount),
260 "copy" => Ok(ActionType::Copy),
261 "cmd" => Ok(ActionType::Cmd),
262 "symlink" => Ok(ActionType::Symlink),
263 _ => bail!(
264 "Unsupported action type '{}' (expected 'mount', 'copy', 'cmd', or 'symlink')",
265 s
266 ),
267 }
268 }
269}
270
271impl FromStr for FSType {
272 type Err = Error;
273
274 fn from_str(s: &str) -> Result<Self, Self::Err> {
275 match s {
276 "bind" => Ok(FSType::Bind),
277 "dev" => Ok(FSType::Dev),
278 "fd" => Ok(FSType::Fd),
279 "nfs" => Ok(FSType::Nfs),
280 "proc" => Ok(FSType::Proc),
281 "tmp" => Ok(FSType::Tmp),
282 /*
283 * Aliases for bind mount types across different systems.
284 */
285 "lofs" => Ok(FSType::Bind),
286 "loop" => Ok(FSType::Bind),
287 "null" => Ok(FSType::Bind),
288 _ => bail!("Unsupported filesystem type '{}'", s),
289 }
290 }
291}
292
293impl Action {
294 pub fn from_lua(t: &Table) -> LuaResult<Self> {
295 // "dir" can be used as shorthand when src and dest are the same
296 let dir = t.get::<Option<String>>("dir")?.map(PathBuf::from);
297 let src = t
298 .get::<Option<String>>("src")?
299 .map(PathBuf::from)
300 .or_else(|| dir.clone());
301 let dest = t
302 .get::<Option<String>>("dest")?
303 .map(PathBuf::from)
304 .or_else(|| dir.clone());
305
306 Ok(Self {
307 action: t.get("action")?,
308 fs: t.get("fs").ok(),
309 src,
310 dest,
311 opts: t.get("opts").ok(),
312 create: t.get("create").ok(),
313 destroy: t.get("destroy").ok(),
314 cwd: t.get::<Option<String>>("cwd")?.map(PathBuf::from),
315 ifexists: t.get("ifexists").unwrap_or(false),
316 })
317 }
318
319 pub fn src(&self) -> Option<&PathBuf> {
320 self.src.as_ref()
321 }
322
323 pub fn dest(&self) -> Option<&PathBuf> {
324 self.dest.as_ref()
325 }
326
327 pub fn action_type(&self) -> Result<ActionType, Error> {
328 ActionType::from_str(&self.action)
329 }
330
331 pub fn fs_type(&self) -> Result<FSType, Error> {
332 match &self.fs {
333 Some(fs) => FSType::from_str(fs),
334 None => bail!("'mount' action requires 'fs' field"),
335 }
336 }
337
338 pub fn opts(&self) -> Option<&String> {
339 self.opts.as_ref()
340 }
341
342 pub fn create_cmd(&self) -> Option<&String> {
343 self.create.as_ref()
344 }
345
346 pub fn destroy_cmd(&self) -> Option<&String> {
347 self.destroy.as_ref()
348 }
349
350 pub fn cwd(&self) -> Option<&PathBuf> {
351 self.cwd.as_ref()
352 }
353
354 pub fn ifexists(&self) -> bool {
355 self.ifexists
356 }
357
358 /// Validate the action configuration.
359 /// Returns an error if the action is misconfigured.
360 pub fn validate(&self) -> Result<(), Error> {
361 let action_type = self.action_type()?;
362
363 match action_type {
364 ActionType::Cmd => {
365 if self.create.is_none() && self.destroy.is_none() {
366 bail!(
367 "'cmd' action requires 'create' or 'destroy' command"
368 );
369 }
370 }
371 ActionType::Mount => {
372 // mount requires fs and either src or dest
373 if self.fs.is_none() {
374 bail!("'mount' action requires 'fs' field");
375 }
376 self.fs_type()?; // Validate fs type
377 if self.src.is_none() && self.dest.is_none() {
378 bail!("'mount' action requires 'src' or 'dest' path");
379 }
380 }
381 ActionType::Copy => {
382 // copy requires src or dest
383 if self.src.is_none() && self.dest.is_none() {
384 bail!("'copy' action requires 'src' or 'dest' path");
385 }
386 }
387 ActionType::Symlink => {
388 // symlink requires both src and dest
389 if self.src.is_none() {
390 bail!("'symlink' action requires 'src' (link target)");
391 }
392 if self.dest.is_none() {
393 bail!("'symlink' action requires 'dest' (link name)");
394 }
395 }
396 }
397
398 Ok(())
399 }
400}