tartarus-api 0.1.1

Structured API for sandboxing system (currently utilizing `bubblewrap`)
Documentation
mod fs;

use crate::{
    config::{FileContents, SandboxConfig},
    error::{Error, Result, with_io_context},
    sandbox::fs::FsHandle,
};
use std::{
    env::home_dir,
    ffi::{OsStr, OsString},
    fs::File,
    io::{self, BufReader, BufWriter},
    path::Path,
    process::Command,
};

static FAKE_HOME_DIR: &str = "tartarus-fake-home";

#[derive(Debug)]
#[cfg_attr(feature = "config_file", derive(serde::Deserialize, serde::Serialize))]
#[non_exhaustive]
pub enum SandboxType {
    #[cfg(target_os = "linux")]
    #[cfg_attr(feature = "config_file", serde(rename = "bubblewrap"))]
    BubbleWrap,
}

#[derive(Debug)]
pub struct Sandbox {
    pub sandbox_type: SandboxType,
    pub config: SandboxConfig,
}

impl Sandbox {
    pub const fn new(sandbox_type: SandboxType, config: SandboxConfig) -> Self {
        Self {
            sandbox_type,
            config,
        }
    }

    pub const fn configure() -> SandboxConfig {
        SandboxConfig::new()
    }

    /// Prints the command that would have been executed instead of running it.
    ///
    /// # Errors
    ///
    /// Returns an error if the command cannot be constructed.
    pub fn dry_run<'a>(self, process: &OsStr, args: impl Iterator<Item = &'a OsStr>) -> Result<()> {
        let cmd = match self.sandbox_type {
            SandboxType::BubbleWrap => bubblewrap_cmd(DryRun::Enabled, self.config, process, args)?,
        };

        print!("{}", cmd.get_program().to_string_lossy());

        for arg in cmd.get_args() {
            print!(" {}", arg.to_string_lossy());
        }
        println!();

        Ok(())
    }

    /// Executes the sandbox using the specified process and arguments.
    ///
    /// # Errors
    ///
    /// Returns an error if the command cannot be constructed or executed.
    pub fn exec<'a>(self, process: &OsStr, args: impl Iterator<Item = &'a OsStr>) -> Result<()> {
        let mut cmd = match self.sandbox_type {
            SandboxType::BubbleWrap => {
                bubblewrap_cmd(DryRun::Disabled, self.config, process, args)?
            }
        };

        cmd.spawn()
            .map_err(|e| {
                with_io_context(process.display(), "spawning bubblewrap sandbox for process", e)
            })?
            .wait()
            .map_err(|e| {
                with_io_context(process.display(), "executing bubblewrap sandbox for process", e)
            })?;

        Ok(())
    }
}

#[derive(Debug, Clone, Copy)]
enum DryRun {
    Enabled,
    Disabled,
}

/// Executes the sandbox using [`bubblewrap`](https://github.com/containers/bubblewrap).
///
/// # Returns
///
/// Returns a command to execute sandbox or an error if the command cannot be constructed.
fn bubblewrap_cmd<'a>(
    dry_run: DryRun,
    config: SandboxConfig,
    process: &OsStr,
    args: impl Iterator<Item = &'a OsStr>,
) -> Result<Command> {
    cfg_select! {
        feature = "log" => log::info!("config = {config:?}"),
        _ => {}
    }

    let temp_dir = std::env::temp_dir();
    let override_home_dir = temp_dir.join(FAKE_HOME_DIR);

    let Some(real_home_dir) = home_dir() else {
        return Err(with_io_context(
            process.display(),
            "executing bubblewrap with home directory for process",
            io::ErrorKind::NotFound,
        ));
    };

    let fs = FsHandle::new(dry_run);

    prepare_fake_home(fs, &real_home_dir, &override_home_dir, &config)?;

    let mut cmd = Command::new("setsid");
    cmd.args([
        // Invoke bubblewrap
        "bwrap",
        // Bind root as read-only
        "--ro-bind",
        "/",
        "/",
        // Create a tmpfs at /tmp (passed separately to avoid needing to convert path/string types
        // for consistency in array)
        "--tmpfs",
    ])
    .arg(temp_dir)
    .args([
        // Bind the overridden home directory as writable
        OsStr::new("--bind"),
        override_home_dir.as_os_str(),
        override_home_dir.as_os_str(),
    ]);

    if let Some(passthrough_home_dirs) = config.passthrough_home_dirs {
        cmd.args(passthrough_home_dirs.into_iter().flat_map(|dir| {
            [
                OsString::from("--bind"),
                real_home_dir.join(&dir).into_os_string(),
                override_home_dir.join(&dir).into_os_string(),
            ]
        }));
    }

    if let Some(writable_dirs) = config.writable_dirs {
        cmd.args(writable_dirs.into_iter().flat_map(|dir| {
            [
                OsString::from("--bind"),
                dir.as_os_str().into(),
                dir.as_os_str().into(),
            ]
        }));
    }

    cmd.args([
        // Remount root as read-only
        "--remount-ro",
        "/",
        // Bind /dev
        "--dev-bind",
        "/dev",
        "/dev",
        // Bind /proc
        "--proc",
        "/proc",
    ])
    .args([
        // Set overridden home directory
        OsStr::new("--setenv"),
        OsStr::new("HOME"),
        override_home_dir.as_os_str(),
    ]);

    // Allow network access if configured
    if config.allow_network_access {
        cmd.args(["--share-net"]);
    }

    // Bind XDG_RUNTIME_DIR if set
    if let Ok(xdg_runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
        cmd.args(["--bind", &xdg_runtime_dir, &xdg_runtime_dir]);
    }

    // Pass the process and arguments to invoke inside the sandbox
    cmd.arg(process);
    cmd.args(args);

    Ok(cmd)
}

/// Takes any necessary steps to prepare a fake home directory for the sandbox.
fn prepare_fake_home(
    fs: FsHandle,
    real_home_dir: &Path,
    override_home_dir: &Path,
    config: &SandboxConfig,
) -> Result<()> {
    if !config.needs_fake_home() {
        return Ok(());
    }

    fs.create_dir(override_home_dir, "creating override home dir")?;

    prepare_passthrough(fs, override_home_dir, config)?;
    prepare_overrides(fs, real_home_dir, override_home_dir, config)?;

    Ok(())
}

/// Creates overridden home directories from the host filesystem for the sandbox.
///
/// Each overridden home directory is copied from the host filesystem to the sandbox's overridden
/// home directory as-is, and then then applies any overrides specified in the configuration.
///
/// # Errors
///
/// Returns an error if any of the following occurs:
///
/// * The subpath for the overridde is not relative
/// * The path being override is not a directory
/// * The overridden home directory cannot be created
/// * An I/O error occurs while copying the original contents of the directory being overridden
/// * The path of an overriden file is not relative
/// * An I/O error occurs while reading the original version of an overriden file
/// * An I/O error occurs while writing the contents of an overriden file
fn prepare_overrides(
    fs: FsHandle,
    real_home_dir: &Path,
    override_home_dir: &Path,
    config: &SandboxConfig,
) -> Result<(), Error> {
    for override_dir in config.override_home_dirs.iter().flatten() {
        fs.validate_relative_path(&override_dir.subpath, "creating override home subdirectory")?;

        let override_output_dir = override_home_dir.join(&override_dir.subpath);

        fs.create_dir(
            &override_output_dir,
            format_args!("creating override home dir for {}", override_output_dir.display()),
        )?;

        let original_input_dir = real_home_dir.join(&override_dir.subpath);
        fs.validate_is_dir(&original_input_dir, "creating override home subdirectory")?;

        fs.sync_dir(&original_input_dir, &override_output_dir)?;

        for override_file in override_dir.overrides.iter().flatten() {
            fs.validate_relative_path(&override_file.path, "reading file to modify for override")?;
            let source = original_input_dir.join(&override_file.path);
            let dest = override_output_dir.join(&override_file.path);

            let file = override_file_arg(&source, &dest)?;

            override_file.behavior.apply(file)?;
        }
    }

    Ok(())
}

// Validates that all passthrough directories are relative and creates empty placeholders in the
// override home directory.
fn prepare_passthrough(
    fs: FsHandle,
    override_home_dir: &Path,
    config: &SandboxConfig,
) -> Result<(), Error> {
    for passthrough_dir in config.passthrough_home_dirs.iter().flatten() {
        fs.validate_relative_path(passthrough_dir, "mapping as passthrough home dir")?;

        let passthrough_dest = override_home_dir.join(passthrough_dir);
        fs.create_dir(&passthrough_dest, "creating override dir at")?;
    }

    Ok(())
}

fn override_file_arg(original_path: &Path, override_path: &Path) -> Result<FileContents, Error> {
    let original = File::open(original_path).map(BufReader::new).map_err(|e| {
        with_io_context(original_path.display(), "reading file to modify for override", e)
    })?;

    let output = File::options()
        .write(true)
        .truncate(true)
        .open(override_path)
        .map(BufWriter::new)
        .map_err(|e| {
            with_io_context(
                format!("{} with {}", original_path.display(), override_path.display()),
                "opening file to override",
                e,
            )
        })?;

    Ok(FileContents { original, output })
}