Skip to main content

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