supermachine 0.6.0

Run any OCI/Docker image as a hardware-isolated microVM on macOS HVF (Linux KVM and Windows WHP in progress). Single library API, zero flags for the common case, sub-100 ms cold-restore from snapshot.
//! Locate the runtime assets that supermachine needs to start a
//! microVM: the Linux kernel image, the in-VM init shim source +
//! prebuilt binary, and the HVF entitlements plist.
//!
//! The CLI subcommands `supermachine bundle`, `supermachine
//! codesign`, `supermachine assets-path`, and `supermachine
//! entitlements-path` use these helpers. Embedders shipping a
//! `.app` bundle should call [`AssetPaths::from_app_bundle`] with
//! the `Contents/Resources/` dir of their bundle.
//!
//! ## Discovery order
//!
//! 1. `$SUPERMACHINE_ASSETS_DIR` if set (explicit override).
//! 2. The directory containing the running binary's executable (so
//!    a `.app/Contents/MacOS/your-app` finds assets in
//!    `Contents/Resources/`).
//! 3. `$HOME/.local/share/supermachine/` (release-tarball install).
//! 4. The dev-tree path `crates/supermachine/{kernel,oci,...}`
//!    relative to the binary's ancestors (cargo workspace dev).
//! 5. **Bundled fallback** — extract the kernel + init shim that
//!    rode inside this binary (via the `supermachine-kernel`
//!    crate's `KERNEL_BYTES` / `INIT_OCI_BYTES` constants) into
//!    `$XDG_DATA_HOME/supermachine/v{VERSION}/` and return that.
//!    First call writes the files; subsequent calls reuse them.
//!    This is what makes `cargo install supermachine` work with
//!    zero manual setup — the bytes are always available, even on
//!    a host that's never seen a supermachine release tarball or
//!    dev tree.
//!
//! The first match wins. Each helper returns `Option<PathBuf>` so
//! callers can compose their own fallback strategy.

use std::path::{Path, PathBuf};
use std::sync::OnceLock;

/// Bundled HVF entitlements plist content. Embedded at compile
/// time so the CLI's `codesign` subcommand and `cargo-supermachine`
/// plugin work without an on-disk plist file. Customers signing
/// their own .app for distribution should write this to a temp
/// file and pass it to `codesign --entitlements`.
pub const ENTITLEMENTS_PLIST: &str =
    include_str!("../entitlements.plist");

/// Where supermachine looks for runtime assets, in priority order.
#[derive(Debug, Clone)]
pub struct AssetPaths {
    /// Path to the Linux kernel image. Required to start a VM.
    pub kernel: Option<PathBuf>,
    /// Path to the prebuilt in-VM init binary, if any. The CLI
    /// builds this from `oci/init-oci.c` on first bake if absent;
    /// release-tarball installs ship it pre-built.
    pub init_oci_bin: Option<PathBuf>,
    /// Path to the in-VM init source. Used as a fallback when the
    /// prebuilt binary isn't shipped (the CLI compiles it via zig).
    pub init_oci_src: Option<PathBuf>,
    /// Path to the prebuilt in-VM `supermachine-agent` binary
    /// (statically-linked aarch64-musl). Bake copies it into the
    /// delta layer at `/supermachine-agent`; init-oci forks and
    /// exec's it post-pivot for the in-guest exec / control RPCs.
    pub supermachine_agent: Option<PathBuf>,
}

impl AssetPaths {
    /// Auto-discover assets from the standard search paths.
    pub fn discover() -> Self {
        // Honored: $SUPERMACHINE_ASSETS_DIR
        if let Some(dir) = std::env::var_os("SUPERMACHINE_ASSETS_DIR") {
            let dir = PathBuf::from(dir);
            return Self::from_dir(&dir);
        }

        let exe = std::env::current_exe().ok();

        // .app bundle: <prefix>/Contents/MacOS/your-app  →  <prefix>/Contents/Resources/
        if let Some(exe) = exe.as_deref() {
            if let Some(macos_dir) = exe.parent() {
                if macos_dir.file_name().and_then(|s| s.to_str()) == Some("MacOS") {
                    if let Some(contents) = macos_dir.parent() {
                        let res = contents.join("Resources");
                        let probe = Self::from_dir(&res);
                        if probe.kernel.is_some() {
                            return probe;
                        }
                    }
                }
            }
        }

        // Tarball install: <prefix>/bin/supermachine + <prefix>/share/supermachine/
        if let Some(exe) = exe.as_deref() {
            for ancestor in exe.ancestors() {
                let share = ancestor.join("share/supermachine");
                if share.join("kernel").is_file() {
                    return Self::from_dir(&share);
                }
            }
        }

        // Dev tree: walk ancestors of the binary. Kernel lives in
        // the `supermachine-kernel` data crate; init shim sources
        // and prebuilt binaries live next to the main crate's `oci/`.
        if let Some(exe) = exe.as_deref() {
            for ancestor in exe.ancestors() {
                let kernel_crate = ancestor.join("crates/supermachine-kernel");
                let main_crate = ancestor.join("crates/supermachine");
                let agent_crate = ancestor.join("crates/supermachine-guest-agent");
                if kernel_crate.join("kernel").is_file() {
                    return Self {
                        kernel: Some(kernel_crate.join("kernel")),
                        init_oci_bin: Some(main_crate.join("oci/init-oci"))
                            .filter(|p| p.is_file()),
                        init_oci_src: Some(main_crate.join("oci/init-oci.c"))
                            .filter(|p| p.is_file()),
                        supermachine_agent: Some(agent_crate.join(
                            "target/aarch64-unknown-linux-musl/release/supermachine-agent",
                        ))
                        .filter(|p| p.is_file()),
                    };
                }
            }
        }

        // Final fallback: extract the bundled bytes (rode inside
        // this binary via supermachine-kernel) into a per-user
        // data dir. This is the path that makes
        // `cargo install supermachine` produce a working CLI with
        // zero manual setup.
        if let Some(dir) = ensure_bundled_extracted() {
            let probe = Self::from_dir(&dir);
            if probe.kernel.is_some() {
                return probe;
            }
        }

        Self {
            kernel: None,
            init_oci_bin: None,
            init_oci_src: None,
            supermachine_agent: None,
        }
    }

    /// Locate assets relative to a single directory (used by the
    /// `.app/Contents/Resources/` and tarball-install layouts).
    pub fn from_dir(dir: &Path) -> Self {
        Self {
            kernel: Some(dir.join("kernel")).filter(|p| p.is_file()),
            init_oci_bin: Some(dir.join("init-oci")).filter(|p| p.is_file()),
            init_oci_src: Some(dir.join("init-oci.c")).filter(|p| p.is_file()),
            supermachine_agent: Some(dir.join("supermachine-agent"))
                .filter(|p| p.is_file()),
        }
    }

    /// Locate assets inside a macOS `.app` bundle. Pass the path to
    /// the `.app` itself (not its `Contents/Resources/`).
    pub fn from_app_bundle(app: &Path) -> Self {
        Self::from_dir(&app.join("Contents/Resources"))
    }
}

/// Extract the bundled kernel + init-oci bytes (linked into this
/// binary via the `supermachine-kernel` crate) to a per-user data
/// dir, returning the dir path on success. First invocation writes
/// the files; subsequent invocations short-circuit and return the
/// same dir.
///
/// Layout:
///
/// ```text
/// $XDG_DATA_HOME/supermachine/v{VERSION}/
///     kernel
///     init-oci
/// ```
///
/// Falls back to `$HOME/.local/share/...` when XDG isn't set, and
/// gives up (returns `None`) when neither HOME nor XDG_DATA_HOME
/// resolves — caller will then surface a "kernel not found" error
/// the same way it would on any other host without assets.
///
/// Errors writing the files (permission denied, disk full, ...)
/// are swallowed: we return `None` so the caller's error path
/// triggers cleanly. The next discover() attempt will retry.
fn ensure_bundled_extracted() -> Option<PathBuf> {
    let dir = bundled_assets_dir()?;
    let kernel_dst = dir.join("kernel");
    let init_dst = dir.join("init-oci");
    let agent_dst = dir.join("supermachine-agent");
    let kernel_hash_dst = dir.join("kernel.hash");
    let init_hash_dst = dir.join("init-oci.hash");
    let agent_hash_dst = dir.join("supermachine-agent.hash");

    let want_kernel = kernel_bytes_hash();
    let want_init = init_oci_bytes_hash();
    let want_agent = supermachine_agent_bytes_hash();

    // Cache hit: all three binaries AND their sibling .hash files
    // exist AND every hash matches this process's expectation.
    // Missing .hash file (old install) or mismatching hash (bytes
    // changed without a version bump — happens with path deps in
    // dev workflows) is treated as a miss and forces re-extract,
    // so a rebuilt kernel.xz isn't silently shadowed by a stale
    // cached copy.
    if kernel_dst.is_file()
        && init_dst.is_file()
        && agent_dst.is_file()
        && read_hash_file(&kernel_hash_dst).as_deref() == Some(want_kernel)
        && read_hash_file(&init_hash_dst).as_deref() == Some(want_init)
        && read_hash_file(&agent_hash_dst).as_deref() == Some(want_agent)
    {
        return Some(dir);
    }

    if std::fs::create_dir_all(&dir).is_err() {
        return None;
    }

    // Re-extract any asset whose on-disk hash doesn't match the
    // current process's bytes (or that's missing entirely). We
    // write the binary first, then the .hash file, so a crash
    // mid-extract leaves the .hash stale-or-missing and the next
    // run treats it as a miss and retries — never the other way
    // around (which could mask a torn binary as "valid").
    if !kernel_dst.is_file()
        || read_hash_file(&kernel_hash_dst).as_deref() != Some(want_kernel)
    {
        if !atomic_write(&dir, &kernel_dst, "kernel.partial", |tmp| {
            std::fs::write(tmp, supermachine_kernel::KERNEL_BYTES)
        }) {
            return None;
        }
        if !atomic_write_str(&dir, &kernel_hash_dst, "kernel.hash.partial", want_kernel)
        {
            return None;
        }
    }
    if !init_dst.is_file()
        || read_hash_file(&init_hash_dst).as_deref() != Some(want_init)
    {
        if !atomic_write(&dir, &init_dst, "init-oci.partial", |tmp| {
            supermachine_kernel::extract_init_oci_to(tmp)
        }) {
            return None;
        }
        if !atomic_write_str(&dir, &init_hash_dst, "init-oci.hash.partial", want_init)
        {
            return None;
        }
    }
    if !agent_dst.is_file()
        || read_hash_file(&agent_hash_dst).as_deref() != Some(want_agent)
    {
        if !atomic_write(&dir, &agent_dst, "supermachine-agent.partial", |tmp| {
            supermachine_kernel::extract_supermachine_agent_to(tmp)
        }) {
            return None;
        }
        if !atomic_write_str(
            &dir,
            &agent_hash_dst,
            "supermachine-agent.hash.partial",
            want_agent,
        ) {
            return None;
        }
    }
    Some(dir)
}

/// Atomic write helper: stage via a sibling `.partial` file then
/// rename into place. The writer closure gets the tmp path. Two
/// parallel discover() calls (e.g. CLI + library in the same
/// process tree) won't tear a half-written file. Returns true on
/// success.
fn atomic_write<F>(dir: &Path, dst: &Path, tmp_name: &str, write: F) -> bool
where
    F: FnOnce(&Path) -> std::io::Result<()>,
{
    let tmp = dir.join(tmp_name);
    if write(&tmp).is_err() {
        let _ = std::fs::remove_file(&tmp);
        return false;
    }
    if std::fs::rename(&tmp, dst).is_err() {
        let _ = std::fs::remove_file(&tmp);
        return false;
    }
    true
}

fn atomic_write_str(dir: &Path, dst: &Path, tmp_name: &str, contents: &str) -> bool {
    atomic_write(dir, dst, tmp_name, |tmp| std::fs::write(tmp, contents))
}

/// Read a sibling `.hash` file and return its contents as a
/// trimmed string. Returns `None` if the file is missing,
/// unreadable, or longer than a sane bound — the caller treats
/// any of those as a cache miss and re-extracts. The size cap
/// keeps a corrupted-or-malicious cache dir from making us slurp
/// gigabytes.
fn read_hash_file(path: &Path) -> Option<String> {
    let bytes = std::fs::read(path).ok()?;
    if bytes.len() > 128 {
        return None;
    }
    Some(String::from_utf8(bytes).ok()?.trim().to_owned())
}

/// 12-hex-char SHA-256 prefix of `KERNEL_BYTES`. Hashed once per
/// process and cached in a `OnceLock` so multiple `discover()`
/// calls don't re-hash ~29 MiB every time.
fn kernel_bytes_hash() -> &'static str {
    static H: OnceLock<String> = OnceLock::new();
    H.get_or_init(|| short_sha256(supermachine_kernel::KERNEL_BYTES))
}

fn init_oci_bytes_hash() -> &'static str {
    static H: OnceLock<String> = OnceLock::new();
    H.get_or_init(|| short_sha256(supermachine_kernel::INIT_OCI_BYTES))
}

fn supermachine_agent_bytes_hash() -> &'static str {
    static H: OnceLock<String> = OnceLock::new();
    H.get_or_init(|| short_sha256(supermachine_kernel::SUPERMACHINE_AGENT_BYTES))
}

/// SHA-256 of `bytes`, hex-encoded, truncated to 12 chars
/// (48 bits). Enough to distinguish accidental rebuilds of the
/// same asset — not a security boundary; the cache dir is
/// already user-writable, so a hostile actor can swap files
/// regardless.
fn short_sha256(bytes: &[u8]) -> String {
    let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
    let mut s = String::with_capacity(12);
    for b in &digest.as_ref()[..6] {
        s.push_str(&format!("{:02x}", b));
    }
    s
}

/// Resolve `$XDG_DATA_HOME/supermachine/v{VERSION}/`, falling back
/// to `$HOME/.local/share/supermachine/v{VERSION}/`. Versioned so
/// upgrading supermachine doesn't reuse stale assets — each crate
/// version gets its own dir.
fn bundled_assets_dir() -> Option<PathBuf> {
    let base = if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
        PathBuf::from(d)
    } else if let Some(h) = std::env::var_os("HOME") {
        PathBuf::from(h).join(".local/share")
    } else {
        return None;
    };
    Some(
        base.join("supermachine")
            .join(format!("v{}", env!("CARGO_PKG_VERSION"))),
    )
}