bob/
action.rs

1/*
2 * Copyright (c) 2026 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",
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 inside sandbox via chroot
62//!         { action = "cmd", chroot = true, create = "ldconfig" },
63//!
64//!         -- Run command on host (working directory is sandbox root on host)
65//!         { action = "cmd", create = "touch .stamp" },
66//!
67//!         -- Run different commands on create and destroy
68//!         { action = "cmd", chroot = true,
69//!           create = "mkdir -p /home/builder",
70//!           destroy = "rm -rf /home/builder" },
71//!
72//!         -- Only mount if source exists on host
73//!         { action = "mount", fs = "bind", dir = "/opt/local", ifexists = true },
74//!     },
75//! }
76//! ```
77//!
78//! # Common Fields
79//!
80//! | Field | Type | Description |
81//! |-------|------|-------------|
82//! | `dir` | string | Shorthand when `src` and `dest` are the same path |
83//! | `src` | string | Source path on the host system |
84//! | `dest` | string | Destination path inside the sandbox |
85//! | `ifexists` | boolean | Only perform action if source exists (default: false) |
86
87use anyhow::{Error, bail};
88use mlua::{Result as LuaResult, Table};
89use std::path::PathBuf;
90use std::str::FromStr;
91
92/// A sandbox action configuration.
93///
94/// Actions define how sandboxes are set up and torn down. Each action specifies
95/// an operation to perform (mount, copy, symlink, or cmd) along with the
96/// parameters needed for that operation.
97///
98/// Actions are processed in order during sandbox creation and in reverse order
99/// during destruction.
100///
101/// # Fields
102///
103/// The available fields depend on the action type:
104///
105/// ## Mount Actions
106///
107/// | Field | Required | Description |
108/// |-------|----------|-------------|
109/// | `fs` | yes | Filesystem type (bind, dev, fd, nfs, proc, tmp) |
110/// | `dir` or `src`/`dest` | yes | Mount point path |
111/// | `opts` | no | Mount options (e.g., "ro", "size=1G") |
112/// | `ifexists` | no | Only mount if source exists (default: false) |
113///
114/// ## Copy Actions
115///
116/// | Field | Required | Description |
117/// |-------|----------|-------------|
118/// | `dir` or `src`/`dest` | yes | Path to copy |
119///
120/// ## Symlink Actions
121///
122/// | Field | Required | Description |
123/// |-------|----------|-------------|
124/// | `src` | yes | Link target (what the symlink points to) |
125/// | `dest` | yes | Link name (the symlink itself) |
126///
127/// ## Cmd Actions
128///
129/// | Field | Required | Description |
130/// |-------|----------|-------------|
131/// | `create` | no | Command to run during sandbox creation |
132/// | `destroy` | no | Command to run during sandbox destruction |
133/// | `chroot` | no | If true, run command inside sandbox chroot (default: false) |
134///
135/// When `chroot = true`, commands run inside the sandbox via chroot with `/`
136/// as the working directory. Use `cd /path &&` in the command if a different
137/// working directory is needed.
138///
139/// When `chroot = false` (default), commands run on the host system with the
140/// sandbox root as the working directory.
141#[derive(Clone, Debug, Default)]
142pub struct Action {
143    action: String,
144    fs: Option<String>,
145    src: Option<PathBuf>,
146    dest: Option<PathBuf>,
147    opts: Option<String>,
148    create: Option<String>,
149    destroy: Option<String>,
150    chroot: bool,
151    ifexists: bool,
152}
153
154/// The type of sandbox action to perform.
155///
156/// Used internally to dispatch action handling.
157#[derive(Debug, PartialEq)]
158pub enum ActionType {
159    /// Mount a filesystem inside the sandbox.
160    Mount,
161    /// Copy files or directories from host into sandbox.
162    Copy,
163    /// Execute shell commands during creation and/or destruction.
164    Cmd,
165    /// Create a symbolic link inside the sandbox.
166    Symlink,
167}
168
169/// Filesystem types for mount actions.
170///
171/// These map to platform-specific mount implementations. Not all filesystem
172/// types are supported on all platforms; see individual variants for details.
173///
174/// # Filesystem Types
175///
176/// | Type | Aliases | Linux | macOS | NetBSD | illumos |
177/// |------|---------|-------|-------|--------|---------|
178/// | `bind` | `lofs`, `loop`, `null` | Yes | Yes | Yes | Yes |
179/// | `dev` | | Yes | Yes | No | No |
180/// | `fd` | | Yes | No | Yes | Yes |
181/// | `nfs` | | Yes | Yes | Yes | Yes |
182/// | `proc` | | Yes | No | Yes | Yes |
183/// | `tmp` | | Yes | Yes | Yes | Yes |
184#[derive(Debug, PartialEq)]
185pub enum FSType {
186    /// Bind mount from host filesystem.
187    ///
188    /// Makes a directory from the host visible inside the sandbox. Use
189    /// `opts = "ro"` for read-only access.
190    ///
191    /// Aliases: `lofs`, `loop`, `null` (for cross-platform compatibility).
192    ///
193    /// | Platform | Implementation |
194    /// |----------|----------------|
195    /// | Linux | `mount -o bind` |
196    /// | macOS | `bindfs` (requires installation) |
197    /// | NetBSD | `mount_null` |
198    /// | illumos | `mount -F lofs` |
199    Bind,
200
201    /// Device filesystem.
202    ///
203    /// Provides `/dev` device nodes inside the sandbox.
204    ///
205    /// | Platform | Implementation |
206    /// |----------|----------------|
207    /// | Linux | `devtmpfs` |
208    /// | macOS | `devfs` |
209    /// | NetBSD | Not supported. Use a `cmd` action with `MAKEDEV` instead. |
210    /// | illumos | Not supported. Use a `bind` mount of `/dev` instead. |
211    Dev,
212
213    /// File descriptor filesystem.
214    ///
215    /// Provides `/dev/fd` entries for accessing open file descriptors.
216    ///
217    /// | Platform | Implementation |
218    /// |----------|----------------|
219    /// | Linux | Bind mount of `/dev/fd` |
220    /// | macOS | Not supported. |
221    /// | NetBSD | `mount_fdesc` |
222    /// | illumos | `mount -F fd` |
223    Fd,
224
225    /// Network File System mount.
226    ///
227    /// Mounts an NFS export inside the sandbox. The `src` field must be an
228    /// NFS path in the form `host:/path`.
229    ///
230    /// | Platform | Implementation |
231    /// |----------|----------------|
232    /// | Linux | `mount -t nfs` |
233    /// | macOS | `mount_nfs` |
234    /// | NetBSD | `mount_nfs` |
235    /// | illumos | `mount -F nfs` |
236    Nfs,
237
238    /// Process filesystem.
239    ///
240    /// Provides `/proc` entries for process information. Required by many
241    /// build tools and commands.
242    ///
243    /// | Platform | Implementation |
244    /// |----------|----------------|
245    /// | Linux | `mount -t proc` |
246    /// | macOS | Not supported. |
247    /// | NetBSD | `mount_procfs` |
248    /// | illumos | `mount -F proc` |
249    Proc,
250
251    /// Temporary filesystem.
252    ///
253    /// Memory-backed filesystem. Contents are lost when unmounted. Use
254    /// `opts = "size=1G"` to limit size (Linux, NetBSD). Useful for `/tmp`
255    /// and build directories.
256    ///
257    /// | Platform | Implementation |
258    /// |----------|----------------|
259    /// | Linux | `mount -t tmpfs` |
260    /// | macOS | `mount_tmpfs` |
261    /// | NetBSD | `mount_tmpfs` |
262    /// | illumos | `mount -F tmpfs` |
263    Tmp,
264}
265
266impl FromStr for ActionType {
267    type Err = Error;
268
269    fn from_str(s: &str) -> Result<Self, Self::Err> {
270        match s {
271            "mount" => Ok(ActionType::Mount),
272            "copy" => Ok(ActionType::Copy),
273            "cmd" => Ok(ActionType::Cmd),
274            "symlink" => Ok(ActionType::Symlink),
275            _ => bail!(
276                "Unsupported action type '{}' (expected 'mount', 'copy', 'cmd', or 'symlink')",
277                s
278            ),
279        }
280    }
281}
282
283impl FromStr for FSType {
284    type Err = Error;
285
286    fn from_str(s: &str) -> Result<Self, Self::Err> {
287        match s {
288            "bind" => Ok(FSType::Bind),
289            "dev" => Ok(FSType::Dev),
290            "fd" => Ok(FSType::Fd),
291            "nfs" => Ok(FSType::Nfs),
292            "proc" => Ok(FSType::Proc),
293            "tmp" => Ok(FSType::Tmp),
294            /*
295             * Aliases for bind mount types across different systems.
296             */
297            "lofs" => Ok(FSType::Bind),
298            "loop" => Ok(FSType::Bind),
299            "null" => Ok(FSType::Bind),
300            _ => bail!("Unsupported filesystem type '{}'", s),
301        }
302    }
303}
304
305impl Action {
306    pub fn from_lua(t: &Table) -> LuaResult<Self> {
307        // "dir" can be used as shorthand when src and dest are the same
308        let dir = t.get::<Option<String>>("dir")?.map(PathBuf::from);
309        let src = t
310            .get::<Option<String>>("src")?
311            .map(PathBuf::from)
312            .or_else(|| dir.clone());
313        let dest = t
314            .get::<Option<String>>("dest")?
315            .map(PathBuf::from)
316            .or_else(|| dir.clone());
317
318        Ok(Self {
319            action: t.get("action")?,
320            fs: t.get("fs").ok(),
321            src,
322            dest,
323            opts: t.get("opts").ok(),
324            create: t.get("create").ok(),
325            destroy: t.get("destroy").ok(),
326            chroot: t.get("chroot").unwrap_or(false),
327            ifexists: t.get("ifexists").unwrap_or(false),
328        })
329    }
330
331    pub fn src(&self) -> Option<&PathBuf> {
332        self.src.as_ref()
333    }
334
335    pub fn dest(&self) -> Option<&PathBuf> {
336        self.dest.as_ref()
337    }
338
339    pub fn action_type(&self) -> Result<ActionType, Error> {
340        ActionType::from_str(&self.action)
341    }
342
343    pub fn fs_type(&self) -> Result<FSType, Error> {
344        match &self.fs {
345            Some(fs) => FSType::from_str(fs),
346            None => bail!("'mount' action requires 'fs' field"),
347        }
348    }
349
350    pub fn opts(&self) -> Option<&String> {
351        self.opts.as_ref()
352    }
353
354    pub fn create_cmd(&self) -> Option<&String> {
355        self.create.as_ref()
356    }
357
358    pub fn destroy_cmd(&self) -> Option<&String> {
359        self.destroy.as_ref()
360    }
361
362    pub fn chroot(&self) -> bool {
363        self.chroot
364    }
365
366    pub fn ifexists(&self) -> bool {
367        self.ifexists
368    }
369
370    /// Validate the action configuration.
371    /// Returns an error if the action is misconfigured.
372    pub fn validate(&self) -> Result<(), Error> {
373        let action_type = self.action_type()?;
374
375        match action_type {
376            ActionType::Cmd => {
377                if self.create.is_none() && self.destroy.is_none() {
378                    bail!(
379                        "'cmd' action requires 'create' or 'destroy' command"
380                    );
381                }
382            }
383            ActionType::Mount => {
384                // mount requires fs and either src or dest
385                if self.fs.is_none() {
386                    bail!("'mount' action requires 'fs' field");
387                }
388                self.fs_type()?; // Validate fs type
389                if self.src.is_none() && self.dest.is_none() {
390                    bail!("'mount' action requires 'src' or 'dest' path");
391                }
392            }
393            ActionType::Copy => {
394                // copy requires src or dest
395                if self.src.is_none() && self.dest.is_none() {
396                    bail!("'copy' action requires 'src' or 'dest' path");
397                }
398            }
399            ActionType::Symlink => {
400                // symlink requires both src and dest
401                if self.src.is_none() {
402                    bail!("'symlink' action requires 'src' (link target)");
403                }
404                if self.dest.is_none() {
405                    bail!("'symlink' action requires 'dest' (link name)");
406                }
407            }
408        }
409
410        Ok(())
411    }
412}