batpak 0.8.2

Event sourcing with causal graphs and caller-defined gates. Sync API, no async runtime.
Documentation
//! PROVES: INV-CHAOS-LINUX-ONLY.

use std::ffi::{OsStr, OsString};
use std::fs::{self, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};

const SECTOR_SIZE: u64 = 512;

pub struct FlakeyDevice {
    pub loop_path: PathBuf,
    pub device_name: String,
    pub mount_point: PathBuf,
    pub backing_file: PathBuf,
    root_dir: PathBuf,
    owns_backing: bool,
}

impl FlakeyDevice {
    pub fn create(size_bytes: u64) -> io::Result<Self> {
        let suffix = unique_suffix();
        let root = std::env::temp_dir().join(format!("batpak-chaos-{suffix}"));
        let backing_file = root.join("backing.img");
        Self::create_inner(&backing_file, size_bytes, true)
    }

    pub fn create_with_backing(backing: &Path, size_bytes: u64) -> io::Result<Self> {
        Self::create_inner(backing, size_bytes, false)
    }

    pub fn open_existing_backing(backing: &Path) -> io::Result<Self> {
        let sectors = backing_file_sectors(backing)?;
        Self::attach_existing(backing.to_path_buf(), sectors, false)
    }

    pub fn format_and_mount_ext4_with_sync(&self) -> io::Result<()> {
        self.format_ext4()?;
        self.mount_ext4([OsStr::new("-o"), OsStr::new("sync")])
    }

    pub fn format_and_mount_ext4_default(&self) -> io::Result<()> {
        self.format_ext4()?;
        self.mount_ext4(std::iter::empty::<&OsStr>())
    }

    pub fn mount_existing_ext4(&self) -> io::Result<()> {
        self.mount_ext4(std::iter::empty::<&OsStr>())
    }

    pub fn data_dir(&self) -> PathBuf {
        self.mount_point.join("data")
    }

    fn create_inner(backing: &Path, size_bytes: u64, owns_backing: bool) -> io::Result<Self> {
        let sectors = sectors_for(size_bytes)?;
        if let Some(parent) = backing.parent() {
            fs::create_dir_all(parent)?;
        }
        OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(backing)?
            .set_len(size_bytes)?;

        Self::attach_existing(backing.to_path_buf(), sectors, owns_backing)
    }

    fn attach_existing(
        backing_file: PathBuf,
        sectors: u64,
        owns_backing: bool,
    ) -> io::Result<Self> {
        let suffix = unique_suffix();
        let root_dir = std::env::temp_dir().join(format!("batpak-chaos-{suffix}"));
        let mount_point = root_dir.join("mnt");
        let device_name = format!("batpak-chaos-{suffix}");

        fs::create_dir_all(&mount_point)?;

        let loop_output = run_privileged(
            "losetup",
            [
                OsStr::new("--find"),
                OsStr::new("--show"),
                backing_file.as_os_str(),
            ],
        )?;
        let loop_path = parse_stdout_path(&loop_output)?;

        let table = format!("0 {sectors} linear {} 0", loop_path.display());
        if let Err(err) = run_privileged(
            "dmsetup",
            [
                OsStr::new("create"),
                OsStr::new(&device_name),
                OsStr::new("--table"),
                OsStr::new(&table),
            ],
        ) {
            let _ = run_privileged("losetup", [OsStr::new("-d"), loop_path.as_os_str()]);
            return Err(err);
        }

        Ok(Self {
            loop_path,
            device_name,
            mount_point,
            backing_file,
            root_dir,
            owns_backing,
        })
    }

    fn format_ext4(&self) -> io::Result<()> {
        let mapper = self.mapper_path();
        run_privileged(
            "mkfs.ext4",
            [OsStr::new("-F"), OsStr::new("-q"), mapper.as_os_str()],
        )?;
        Ok(())
    }

    fn mount_ext4<I, S>(&self, options: I) -> io::Result<()>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<OsStr>,
    {
        let mapper = self.mapper_path();
        let mut args = options
            .into_iter()
            .map(|arg| arg.as_ref().to_os_string())
            .collect::<Vec<_>>();
        args.push(mapper.into_os_string());
        args.push(self.mount_point.as_os_str().to_os_string());
        run_privileged("mount", args)?;
        self.chown_mount_to_current_user()?;
        Ok(())
    }

    fn chown_mount_to_current_user(&self) -> io::Result<()> {
        let owner = format!("{}:{}", current_id("-u")?, current_id("-g")?);
        run_privileged("chown", [OsStr::new(&owner), self.mount_point.as_os_str()])?;
        Ok(())
    }

    pub fn flip_to_error(&self) -> io::Result<()> {
        let sectors = backing_file_sectors(&self.backing_file)?;
        let table = format!("0 {sectors} error");
        run_privileged(
            "dmsetup",
            [OsStr::new("suspend"), OsStr::new(&self.device_name)],
        )?;
        let reload = run_privileged(
            "dmsetup",
            [
                OsStr::new("reload"),
                OsStr::new(&self.device_name),
                OsStr::new("--table"),
                OsStr::new(&table),
            ],
        );
        let resume = run_privileged(
            "dmsetup",
            [OsStr::new("resume"), OsStr::new(&self.device_name)],
        );
        reload?;
        resume?;
        Ok(())
    }

    pub fn unmount(&self) -> io::Result<()> {
        run_privileged("umount", [self.mount_point.as_os_str()])?;
        Ok(())
    }

    fn mapper_path(&self) -> PathBuf {
        PathBuf::from("/dev/mapper").join(&self.device_name)
    }

    fn teardown(&self) -> io::Result<()> {
        let mut first_error = None;
        let mut record = |result: io::Result<()>| {
            if let Err(err) = result {
                first_error.get_or_insert(err);
            }
        };

        record(self.unmount());
        record(
            run_privileged(
                "dmsetup",
                [
                    OsStr::new("remove"),
                    OsStr::new("--force"),
                    OsStr::new(&self.device_name),
                ],
            )
            .map(drop),
        );
        record(run_privileged("losetup", [OsStr::new("-d"), self.loop_path.as_os_str()]).map(drop));
        if self.owns_backing {
            record(fs::remove_file(&self.backing_file));
            if self.backing_file.parent() != Some(self.root_dir.as_path()) {
                record(remove_parent_dir(&self.backing_file));
            }
        }
        record(fs::remove_dir(&self.mount_point));
        record(fs::remove_dir(&self.root_dir));

        if let Some(err) = first_error {
            return Err(err);
        }
        Ok(())
    }
}

impl Drop for FlakeyDevice {
    fn drop(&mut self) {
        if let Err(err) = self.teardown() {
            eprintln!(
                "warning: dm-flakey teardown for {} was incomplete: {err}",
                self.device_name
            );
        }
    }
}

fn sectors_for(size_bytes: u64) -> io::Result<u64> {
    if size_bytes == 0 || !size_bytes.is_multiple_of(SECTOR_SIZE) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("device size {size_bytes} must be a non-zero multiple of {SECTOR_SIZE}"),
        ));
    }
    Ok(size_bytes / SECTOR_SIZE)
}

fn backing_file_sectors(path: &Path) -> io::Result<u64> {
    sectors_for(fs::metadata(path)?.len())
}

fn parse_stdout_path(output: &Output) -> io::Result<PathBuf> {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let trimmed = stdout.trim();
    if trimmed.is_empty() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "losetup returned an empty loop-device path",
        ));
    }
    Ok(PathBuf::from(trimmed))
}

fn run_privileged<I, S>(program: &str, args: I) -> io::Result<Output>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let args = args
        .into_iter()
        .map(|arg| arg.as_ref().to_os_string())
        .collect::<Vec<_>>();
    let mut command = privileged_command(program, &args);
    let description = format!("{command:?}");
    let output = command.output()?;
    if !output.status.success() {
        return Err(command_error(&description, &output));
    }
    Ok(output)
}

fn privileged_command(program: &str, args: &[OsString]) -> Command {
    if running_as_root() {
        let mut command = Command::new(program);
        command.args(args);
        return command;
    }

    let mut command = Command::new("sudo");
    command.arg("--non-interactive").arg(program).args(args);
    command
}

fn running_as_root() -> bool {
    let Ok(output) = Command::new("id").arg("-u").output() else {
        return false;
    };
    if !output.status.success() {
        return false;
    }
    String::from_utf8_lossy(&output.stdout).trim() == "0"
}

fn current_id(flag: &str) -> io::Result<String> {
    let output = Command::new("id").arg(flag).output()?;
    if !output.status.success() {
        return Err(command_error(&format!("id {flag}"), &output));
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}

fn command_error(description: &str, output: &Output) -> io::Error {
    let stderr = String::from_utf8_lossy(&output.stderr);
    let stdout = String::from_utf8_lossy(&output.stdout);
    io::Error::other(format!(
        "{description} failed with status {} stdout={stdout:?} stderr={stderr:?}",
        output.status
    ))
}

fn unique_suffix() -> String {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    format!("{}-{nanos}", std::process::id())
}

fn remove_parent_dir(path: &Path) -> io::Result<()> {
    match path.parent() {
        Some(parent) => fs::remove_dir(parent),
        None => Ok(()),
    }
}