baryl 0.0.2

Public SDK for Baryl, a full-system emulation and introspection engine
//! The machine a run boots: `<image>.toml`, beside the disk image it describes.
//!
//! [`MachineConfig`] is that file as a struct, and its fields mirror the file's
//! sections one for one — `[baryl]`, `[machine]`, `[firmware]`, `[network]`,
//! `[env]` — so the two read against each other.
//!
//! Every section is strict: a key nothing here names is an error rather than
//! something quietly ignored, so a typo is caught at the file instead of
//! surfacing as a machine that boots wrong. `[env]` is the way out — entries
//! there are passed to the engine verbatim and nothing checks them.
//!
//! ```toml
//! [baryl]
//! engine = "seeker"
//!
//! [machine]
//! guest_os = "linux"       # required
//! ram_mib = 4096           # default
//! serial = false           # default
//!
//! [firmware]
//! kind = "uefi"            # bios | uefi, default bios
//! vars = "OVMF_VARS.fd"    # resolved against this file's directory
//!
//! [network]
//! subnet = "10.0.2.0/24"   # absent means no NIC at all
//! ```
//!
//! Every string is stored inline, at a fixed capacity. A path over 255 bytes,
//! an engine name over 255, a subnet over 63, more than 32 `[env]` entries or
//! one over 255 bytes: each is an error naming the offending value, never a
//! truncation.

use std::{collections::BTreeMap, fmt};

#[cfg(feature = "cli")]
use std::path::{Path, PathBuf};

#[cfg(feature = "cli")]
use anyhow::Context;
use serde::{
    Deserialize, Deserializer,
    de::{Error, Visitor},
};

use crate::abi::{
    FlatCStr, MAX_ENGINE_NAME_LEN, MAX_ENV_ENTRIES, MAX_ENV_ENTRY_LEN, MAX_PATH_LEN,
    MAX_SUBNET_LEN, SandboxSafe,
};
use crate::engine::{BARYL_FW_BIOS, BARYL_FW_UEFI};
use crate::enlighten::{BARYL_OS_LINUX, BARYL_OS_WINDOWS};

type FlatPath = FlatCStr<MAX_PATH_LEN>;
type FlatEnvEntry = FlatCStr<MAX_ENV_ENTRY_LEN>;

/// What machine to run: the whole of `<image>.toml`.
///
/// Build one with `MachineConfig::read` and hand it to `Options::machine` to
/// boot. A restore does not need one — a checkpoint carries the config it was
/// taken under.
///
/// `Clone`, not `Copy`: one of these is 9304 bytes.
///
/// # Examples
///
/// ```ignore
/// let image = Path::new("disk.qcow2");
/// let cfg = MachineConfig::read(&MachineConfig::path_beside(image))?;
/// assert_eq!(cfg.engine(), "seeker");
/// assert_eq!(cfg.machine.ram_mib, 4096);
///
/// let mut baryl = Baryl::open(image, &Options::default().machine(Some(cfg)))?;
/// ```
#[repr(C)]
#[derive(Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MachineConfig {
    pub baryl: BarylSection,
    pub machine: MachineSection,
    #[serde(default)]
    pub firmware: FirmwareSection,
    #[serde(default)]
    pub network: NetworkSection,
    #[serde(default)]
    pub env: EnvTable,
}
unsafe impl SandboxSafe for MachineConfig {}

// A checkpoint stores this struct verbatim, so resizing it invalidates every
// existing one.
const _: () = {
    assert!(size_of::<MachineConfig>() == 0x2458);
};

impl MachineConfig {
    /// The engine named in `[baryl] engine`.
    pub fn engine(&self) -> &str {
        self.baryl.engine.as_str()
    }
}

/// `[baryl]`: which engine runs this machine.
#[repr(C)]
#[derive(Clone, Copy, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BarylSection {
    /// The engine's name. Required.
    pub engine: FlatCStr<MAX_ENGINE_NAME_LEN>,
}

/// `[machine]`: the hardware.
#[repr(C)]
#[derive(Clone, Copy, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MachineSection {
    /// Guest RAM in MiB; [`RAM_MIB_DEFAULT`] when the file names none.
    #[serde(default = "ram_mib_default")]
    pub ram_mib: u64,
    /// The disk image. Filled in with the path that was opened rather than read
    /// from the file — naming an image inside its own config would be circular.
    #[serde(skip)]
    pub image: FlatPath,
    /// An optional floppy image, resolved against the config's own directory.
    #[serde(default)]
    pub floppy: FlatPath,
    /// `"linux"` or `"windows"`, as a `BARYL_OS_*` value. Required: guessing it
    /// wrong means loading the wrong guest-OS support, so the file has to say.
    #[serde(deserialize_with = "guest_os_word_parse")]
    pub guest_os: u32,
    /// Whether the machine gets a serial port. `false` by default.
    #[serde(default, deserialize_with = "serial_flag_parse")]
    pub serial: u8,
}

/// `[firmware]`: what the machine starts executing.
#[repr(C)]
#[derive(Clone, Copy, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FirmwareSection {
    /// `"bios"` or `"uefi"`, as a `BARYL_FW_*` value. BIOS by default.
    #[serde(
        default = "firmware_kind_default",
        deserialize_with = "firmware_kind_parse"
    )]
    pub kind: u32,
    /// The writable UEFI variable store, resolved against the config's own
    /// directory. Empty for a machine that needs none.
    #[serde(default)]
    pub vars: FlatPath,
}

/// No `[firmware]` section at all means BIOS, which is what a bare PC starts
/// with.
impl Default for FirmwareSection {
    fn default() -> FirmwareSection {
        FirmwareSection {
            kind: BARYL_FW_BIOS,
            vars: FlatPath::empty(),
        }
    }
}

/// `[network]`: the local network the guest is on.
#[repr(C)]
#[derive(Clone, Copy, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkSection {
    /// CIDR, such as `"10.0.2.0/24"`. Empty — or no `[network]` section at all
    /// — means the machine boots with no NIC, and every `ctl.subs.net` call
    /// answers as if there were none.
    #[serde(default)]
    pub subnet: FlatCStr<MAX_SUBNET_LEN>,
}

/// `[env]`: variables handed to the engine verbatim.
///
/// A fixed array and a count in one type, so the count cannot come apart from
/// what is filled. Read it with [`as_slice`](Self::as_slice) rather than
/// indexing `entries` past `count`.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct EnvTable {
    /// The slots, of which only the first `count` hold anything.
    pub entries: [FlatEnvEntry; MAX_ENV_ENTRIES],
    /// How many slots are filled.
    pub count: u32,
}

impl Default for EnvTable {
    fn default() -> EnvTable {
        EnvTable {
            entries: [FlatEnvEntry::empty(); MAX_ENV_ENTRIES],
            count: 0,
        }
    }
}

impl EnvTable {
    /// The filled slots, each already a `KEY=VALUE` string.
    pub fn as_slice(&self) -> &[FlatEnvEntry] {
        &self.entries[..self.count as usize]
    }
}

/// Reads a TOML table into `KEY=VALUE` slots, sorted by key.
///
/// A value may be a string, integer, float or boolean, and arrives as its plain
/// spelling — a quoted string is not what a `KEY=VALUE` reader wants after the
/// `=`. More than [`MAX_ENV_ENTRIES`] entries, or an entry over
/// [`MAX_ENV_ENTRY_LEN`] bytes, is an error naming the offender.
impl<'de> Deserialize<'de> for EnvTable {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<EnvTable, D::Error> {
        let table: BTreeMap<String, EnvEntryValue> = BTreeMap::deserialize(d)?;
        if table.len() > MAX_ENV_ENTRIES {
            return Err(D::Error::custom(format!(
                "[env] has {} entries, over MAX_ENV_ENTRIES ({MAX_ENV_ENTRIES})",
                table.len()
            )));
        }
        let mut entries = [FlatEnvEntry::empty(); MAX_ENV_ENTRIES];
        for (slot, (k, v)) in entries.iter_mut().zip(&table) {
            let entry = format!("{k}={}", v.0);
            *slot = FlatEnvEntry::try_new(&entry).ok_or_else(|| {
                D::Error::custom(format!("[env] entry exceeds MAX_ENV_ENTRY_LEN: {entry}"))
            })?;
        }
        Ok(EnvTable {
            entries: entries,
            count: table.len() as u32,
        })
    }
}

/// One `[env]` value, as the text that goes after the `=`.
struct EnvEntryValue(String);

impl<'de> Deserialize<'de> for EnvEntryValue {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<EnvEntryValue, D::Error> {
        d.deserialize_any(EnvEntryValueVisitor)
    }
}

/// A scalar and nothing else: an array or a table has no spelling after an `=`.
struct EnvEntryValueVisitor;

impl Visitor<'_> for EnvEntryValueVisitor {
    type Value = EnvEntryValue;

    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("a string, integer, float or boolean")
    }

    fn visit_str<E: Error>(self, v: &str) -> Result<EnvEntryValue, E> {
        Ok(EnvEntryValue(v.to_string()))
    }

    fn visit_i64<E: Error>(self, v: i64) -> Result<EnvEntryValue, E> {
        Ok(EnvEntryValue(v.to_string()))
    }

    fn visit_u64<E: Error>(self, v: u64) -> Result<EnvEntryValue, E> {
        Ok(EnvEntryValue(v.to_string()))
    }

    fn visit_f64<E: Error>(self, v: f64) -> Result<EnvEntryValue, E> {
        Ok(EnvEntryValue(v.to_string()))
    }

    fn visit_bool<E: Error>(self, v: bool) -> Result<EnvEntryValue, E> {
        Ok(EnvEntryValue(v.to_string()))
    }
}

/// `"linux"` or `"windows"` to a `BARYL_OS_*` value; anything else is an error
/// naming both.
fn guest_os_word_parse<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
    match String::deserialize(d)?.as_str() {
        "linux" => Ok(BARYL_OS_LINUX),
        "windows" => Ok(BARYL_OS_WINDOWS),
        other => Err(D::Error::custom(format!(
            "unknown guest os: {other} (expected 'linux' or 'windows')"
        ))),
    }
}

/// `"bios"` or `"uefi"` to a `BARYL_FW_*` value; anything else is an error
/// naming both.
fn firmware_kind_parse<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
    match String::deserialize(d)?.as_str() {
        "bios" => Ok(BARYL_FW_BIOS),
        "uefi" => Ok(BARYL_FW_UEFI),
        other => Err(D::Error::custom(format!(
            "unknown firmware kind: {other} (expected 'bios' or 'uefi')"
        ))),
    }
}

fn firmware_kind_default() -> u32 {
    BARYL_FW_BIOS
}

/// Guest RAM in MiB when `[machine] ram_mib` is absent.
pub const RAM_MIB_DEFAULT: u64 = 4096;

fn ram_mib_default() -> u64 {
    RAM_MIB_DEFAULT
}

/// A TOML `true`/`false` as the byte the config carries.
fn serial_flag_parse<'de, D: Deserializer<'de>>(d: D) -> Result<u8, D::Error> {
    Ok(u8::from(bool::deserialize(d)?))
}

#[cfg(feature = "cli")]
impl MachineConfig {
    /// Where the config for `image` lives: the image's own path with `.toml`
    /// appended, so `disk.qcow2` pairs with `disk.qcow2.toml`.
    pub fn path_beside(image: &Path) -> PathBuf {
        let mut p = image.as_os_str().to_owned();
        p.push(".toml");
        PathBuf::from(p)
    }

    /// A config file to start from: the required keys live, everything else
    /// commented out at its default.
    ///
    /// Write it beside an image and it boots; uncomment a line to change one
    /// thing.
    pub fn template_build(engine: &str) -> String {
        format!(
            "# Written by `baryl configure`. Commented keys show their defaults.\n\
             \n\
             [baryl]\n\
             engine = \"{engine}\"\n\
             \n\
             [machine]\n\
             # The guest kernel; picks the enlighten subsystem.\n\
             guest_os = \"linux\"\n\
             # ram_mib = {RAM_MIB_DEFAULT}\n\
             # serial = false\n\
             # floppy = \"seed.img\"\n\
             \n\
             # [firmware]\n\
             # kind = \"bios\"   # bios | uefi\n\
             # vars = \"OVMF_VARS.fd\"\n\
             \n\
             # [network]\n\
             # subnet = \"10.0.2.0/24\"\n\
             \n\
             # Passed to the engine verbatim.\n\
             # [env]\n"
        )
    }

    /// Read and validate a config file.
    ///
    /// `floppy` and `vars` come back resolved against `path`'s own directory,
    /// so a config that names `seed.img` works wherever the run is launched
    /// from.
    ///
    /// # Errors
    ///
    /// The file is unreadable; the TOML is malformed; a section holds a key
    /// nothing here names; `[machine] guest_os` is missing or is not `"linux"`
    /// or `"windows"`; `[firmware] kind` is not `"bios"` or `"uefi"`; a
    /// resolved path is not UTF-8 or is over [`MAX_PATH_LEN`] bytes. Each
    /// message names the file and the offending value.
    pub fn read(path: &Path) -> anyhow::Result<MachineConfig> {
        let text = std::fs::read_to_string(path)
            .with_context(|| format!("read the machine config {}", path.display()))?;
        let cfg: MachineConfig = toml::from_str(&text)
            .with_context(|| format!("parse the machine config {}", path.display()))?;
        let dir = path.parent().unwrap_or_else(|| Path::new("."));
        Ok(MachineConfig {
            machine: MachineSection {
                floppy: config_path_rebase(&cfg.machine.floppy, dir)?,
                ..cfg.machine
            },
            firmware: FirmwareSection {
                vars: config_path_rebase(&cfg.firmware.vars, dir)?,
                ..cfg.firmware
            },
            ..cfg
        })
    }
}

/// Resolve a config-named path against the config's own directory; an empty
/// path stays empty.
#[cfg(feature = "cli")]
fn config_path_rebase(p: &FlatPath, dir: &Path) -> anyhow::Result<FlatPath> {
    if p.is_empty() {
        return Ok(FlatPath::empty());
    }
    let joined = dir.join(p.as_str());
    let s = joined
        .to_str()
        .context("machine config names a non-UTF-8 path")?;
    FlatPath::try_new(s).with_context(|| format!("path exceeds {MAX_PATH_LEN} bytes: {s}"))
}