Skip to main content

tartarus_api/
sandbox.rs

1mod fs;
2
3use crate::{
4    config::{FileContents, SandboxConfig},
5    error::{Error, Result, with_io_context},
6    sandbox::fs::FsHandle,
7};
8use std::{
9    env::home_dir,
10    ffi::{OsStr, OsString},
11    fs::File,
12    io::{self, BufReader, BufWriter},
13    path::Path,
14    process::Command,
15};
16
17static FAKE_HOME_DIR: &str = "tartarus-fake-home";
18
19#[derive(Debug)]
20#[cfg_attr(feature = "config_file", derive(serde::Deserialize, serde::Serialize))]
21#[non_exhaustive]
22pub enum SandboxType {
23    #[cfg(target_os = "linux")]
24    #[cfg_attr(feature = "config_file", serde(rename = "bubblewrap"))]
25    BubbleWrap,
26}
27
28#[derive(Debug)]
29pub struct Sandbox {
30    pub sandbox_type: SandboxType,
31    pub config: SandboxConfig,
32}
33
34impl Sandbox {
35    pub const fn new(sandbox_type: SandboxType, config: SandboxConfig) -> Self {
36        Self {
37            sandbox_type,
38            config,
39        }
40    }
41
42    pub const fn configure() -> SandboxConfig {
43        SandboxConfig::new()
44    }
45
46    /// Prints the command that would have been executed instead of running it.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the command cannot be constructed.
51    pub fn dry_run<'a>(self, process: &OsStr, args: impl Iterator<Item = &'a OsStr>) -> Result<()> {
52        let cmd = match self.sandbox_type {
53            SandboxType::BubbleWrap => bubblewrap_cmd(DryRun::Enabled, self.config, process, args)?,
54        };
55
56        print!("{}", cmd.get_program().to_string_lossy());
57
58        for arg in cmd.get_args() {
59            print!(" {}", arg.to_string_lossy());
60        }
61        println!();
62
63        Ok(())
64    }
65
66    /// Executes the sandbox using the specified process and arguments.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the command cannot be constructed or executed.
71    pub fn exec<'a>(self, process: &OsStr, args: impl Iterator<Item = &'a OsStr>) -> Result<()> {
72        let mut cmd = match self.sandbox_type {
73            SandboxType::BubbleWrap => {
74                bubblewrap_cmd(DryRun::Disabled, self.config, process, args)?
75            }
76        };
77
78        cmd.spawn()
79            .map_err(|e| {
80                with_io_context(process.display(), "spawning bubblewrap sandbox for process", e)
81            })?
82            .wait()
83            .map_err(|e| {
84                with_io_context(process.display(), "executing bubblewrap sandbox for process", e)
85            })?;
86
87        Ok(())
88    }
89}
90
91#[derive(Debug, Clone, Copy)]
92enum DryRun {
93    Enabled,
94    Disabled,
95}
96
97/// Executes the sandbox using [`bubblewrap`](https://github.com/containers/bubblewrap).
98///
99/// # Returns
100///
101/// Returns a command to execute sandbox or an error if the command cannot be constructed.
102fn bubblewrap_cmd<'a>(
103    dry_run: DryRun,
104    config: SandboxConfig,
105    process: &OsStr,
106    args: impl Iterator<Item = &'a OsStr>,
107) -> Result<Command> {
108    cfg_select! {
109        feature = "log" => log::info!("config = {config:?}"),
110        _ => {}
111    }
112
113    let temp_dir = std::env::temp_dir();
114    let override_home_dir = temp_dir.join(FAKE_HOME_DIR);
115
116    let Some(real_home_dir) = home_dir() else {
117        return Err(with_io_context(
118            process.display(),
119            "executing bubblewrap with home directory for process",
120            io::ErrorKind::NotFound,
121        ));
122    };
123
124    let fs = FsHandle::new(dry_run);
125
126    prepare_fake_home(fs, &real_home_dir, &override_home_dir, &config)?;
127
128    let mut cmd = Command::new("setsid");
129    cmd.args([
130        // Invoke bubblewrap
131        "bwrap",
132        // Bind root as read-only
133        "--ro-bind",
134        "/",
135        "/",
136        // Create a tmpfs at /tmp (passed separately to avoid needing to convert path/string types
137        // for consistency in array)
138        "--tmpfs",
139    ])
140    .arg(temp_dir)
141    .args([
142        // Bind the overridden home directory as writable
143        OsStr::new("--bind"),
144        override_home_dir.as_os_str(),
145        override_home_dir.as_os_str(),
146    ]);
147
148    if let Some(passthrough_home_dirs) = config.passthrough_home_dirs {
149        cmd.args(passthrough_home_dirs.into_iter().flat_map(|dir| {
150            [
151                OsString::from("--bind"),
152                real_home_dir.join(&dir).into_os_string(),
153                override_home_dir.join(&dir).into_os_string(),
154            ]
155        }));
156    }
157
158    if let Some(writable_dirs) = config.writable_dirs {
159        cmd.args(writable_dirs.into_iter().flat_map(|dir| {
160            [
161                OsString::from("--bind"),
162                dir.as_os_str().into(),
163                dir.as_os_str().into(),
164            ]
165        }));
166    }
167
168    cmd.args([
169        // Remount root as read-only
170        "--remount-ro",
171        "/",
172        // Bind /dev
173        "--dev-bind",
174        "/dev",
175        "/dev",
176        // Bind /proc
177        "--proc",
178        "/proc",
179    ])
180    .args([
181        // Set overridden home directory
182        OsStr::new("--setenv"),
183        OsStr::new("HOME"),
184        override_home_dir.as_os_str(),
185    ]);
186
187    // Allow network access if configured
188    if config.allow_network_access {
189        cmd.args(["--share-net"]);
190    }
191
192    // Bind XDG_RUNTIME_DIR if set
193    if let Ok(xdg_runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
194        cmd.args(["--bind", &xdg_runtime_dir, &xdg_runtime_dir]);
195    }
196
197    // Pass the process and arguments to invoke inside the sandbox
198    cmd.arg(process);
199    cmd.args(args);
200
201    Ok(cmd)
202}
203
204/// Takes any necessary steps to prepare a fake home directory for the sandbox.
205fn prepare_fake_home(
206    fs: FsHandle,
207    real_home_dir: &Path,
208    override_home_dir: &Path,
209    config: &SandboxConfig,
210) -> Result<()> {
211    if !config.needs_fake_home() {
212        return Ok(());
213    }
214
215    fs.create_dir(override_home_dir, "creating override home dir")?;
216
217    prepare_passthrough(fs, override_home_dir, config)?;
218    prepare_overrides(fs, real_home_dir, override_home_dir, config)?;
219
220    Ok(())
221}
222
223/// Creates overridden home directories from the host filesystem for the sandbox.
224///
225/// Each overridden home directory is copied from the host filesystem to the sandbox's overridden
226/// home directory as-is, and then then applies any overrides specified in the configuration.
227///
228/// # Errors
229///
230/// Returns an error if any of the following occurs:
231///
232/// * The subpath for the overridde is not relative
233/// * The path being override is not a directory
234/// * The overridden home directory cannot be created
235/// * An I/O error occurs while copying the original contents of the directory being overridden
236/// * The path of an overriden file is not relative
237/// * An I/O error occurs while reading the original version of an overriden file
238/// * An I/O error occurs while writing the contents of an overriden file
239fn prepare_overrides(
240    fs: FsHandle,
241    real_home_dir: &Path,
242    override_home_dir: &Path,
243    config: &SandboxConfig,
244) -> Result<(), Error> {
245    for override_dir in config.override_home_dirs.iter().flatten() {
246        fs.validate_relative_path(&override_dir.subpath, "creating override home subdirectory")?;
247
248        let override_output_dir = override_home_dir.join(&override_dir.subpath);
249
250        fs.create_dir(
251            &override_output_dir,
252            format_args!("creating override home dir for {}", override_output_dir.display()),
253        )?;
254
255        let original_input_dir = real_home_dir.join(&override_dir.subpath);
256        fs.validate_is_dir(&original_input_dir, "creating override home subdirectory")?;
257
258        fs.sync_dir(&original_input_dir, &override_output_dir)?;
259
260        for override_file in override_dir.overrides.iter().flatten() {
261            fs.validate_relative_path(&override_file.path, "reading file to modify for override")?;
262            let source = original_input_dir.join(&override_file.path);
263            let dest = override_output_dir.join(&override_file.path);
264
265            let file = override_file_arg(&source, &dest)?;
266
267            override_file.behavior.apply(file)?;
268        }
269    }
270
271    Ok(())
272}
273
274// Validates that all passthrough directories are relative and creates empty placeholders in the
275// override home directory.
276fn prepare_passthrough(
277    fs: FsHandle,
278    override_home_dir: &Path,
279    config: &SandboxConfig,
280) -> Result<(), Error> {
281    for passthrough_dir in config.passthrough_home_dirs.iter().flatten() {
282        fs.validate_relative_path(passthrough_dir, "mapping as passthrough home dir")?;
283
284        let passthrough_dest = override_home_dir.join(passthrough_dir);
285        fs.create_dir(&passthrough_dest, "creating override dir at")?;
286    }
287
288    Ok(())
289}
290
291fn override_file_arg(original_path: &Path, override_path: &Path) -> Result<FileContents, Error> {
292    let original = File::open(original_path).map(BufReader::new).map_err(|e| {
293        with_io_context(original_path.display(), "reading file to modify for override", e)
294    })?;
295
296    let output = File::options()
297        .write(true)
298        .truncate(true)
299        .open(override_path)
300        .map(BufWriter::new)
301        .map_err(|e| {
302            with_io_context(
303                format!("{} with {}", original_path.display(), override_path.display()),
304                "opening file to override",
305                e,
306            )
307        })?;
308
309    Ok(FileContents { original, output })
310}