6160d957f5
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
The existing vbox paths only open pre-made images; there was no way to build one from scratch. Add `/sbx launch vbox new [name]` backed by scripts/vbox-new.sh: download an Ubuntu cloud image, convert it to a VDI, build a cloud-init NoCloud seed that creates a sudo login user and installs the sandbox-tools.json toolchain on first boot, then create + boot the VM. VirtualBox guests can't be exec'd into like docker/multipass, so cloud-init is the provisioning channel. Generates a one-time login password (or takes --pass) and authorizes a local SSH key if present. Runs off-thread; rolls back a half-built VM on failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1314 lines
50 KiB
Rust
1314 lines
50 KiB
Rust
//! Sandbox backends + a PTY-backed sandbox the broker drives.
|
||
//!
|
||
//! The broker (owner's client) spawns a sandbox shell inside a PTY. Output bytes
|
||
//! are pumped out of a reader thread onto an mpsc channel; the broker encrypts
|
||
//! them with the room key and relays them to the clergy as `sbx pty_data` frames.
|
||
//! Input frames (`sbx pty_input`) are written back into the PTY. The server only
|
||
//! ever sees ciphertext — identical trust model to chat/file transfer.
|
||
|
||
use anyhow::{Context, Result};
|
||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
||
use std::io::{Read, Write};
|
||
use std::process::{Command, Stdio};
|
||
use std::sync::mpsc;
|
||
|
||
/// Helper that ensures the Docker daemon is running (ships in hh/scripts/).
|
||
const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-docker.sh");
|
||
/// Detect-first VirtualBox installer (ships in hh/scripts/).
|
||
const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-vbox.sh");
|
||
/// Baseline dev-toolchain installer run inside Docker sandboxes (hh/scripts/).
|
||
const SBX_BOOTSTRAP: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-bootstrap.sh");
|
||
/// Editable package schema the bootstrap installs — vim+curl always added.
|
||
const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-tools.json");
|
||
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
|
||
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
|
||
|
||
/// Is the Docker daemon accepting connections? (`docker info` succeeds.)
|
||
pub fn docker_daemon_up() -> bool {
|
||
Command::new("docker")
|
||
.arg("info")
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.status()
|
||
.map(|s| s.success())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Start the Docker daemon via `ensure-docker.sh --yes`, waiting until it's
|
||
/// ready. Returns the script's last error line on failure (e.g. needs sudo).
|
||
fn start_docker_daemon() -> Result<()> {
|
||
let out = Command::new("bash")
|
||
.arg(ENSURE_DOCKER)
|
||
.arg("--yes")
|
||
.output()
|
||
.context("running ensure-docker.sh")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
let last = err
|
||
.lines()
|
||
.last()
|
||
.unwrap_or("could not start the docker daemon");
|
||
anyhow::bail!("{last}");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ---- VirtualBox (local GUI VMs) ---------------------------------------------
|
||
// VirtualBox is integrated as a *local* facility rather than a shared-PTY
|
||
// backend: a room shares a VM by handing out its appliance, and each member
|
||
// boots it in the real VirtualBox GUI on their own machine. None of this relays
|
||
// over the room — only the (separately `/send`-ed) image does — so the
|
||
// zero-knowledge model is untouched. A Windows guest has no sshd/guestcontrol
|
||
// shell to drive, so the GUI launch is the honest fit, not a faked PTY.
|
||
|
||
/// Is VirtualBox installed? (`VBoxManage --version` succeeds.)
|
||
pub fn vbox_installed() -> bool {
|
||
Command::new("VBoxManage")
|
||
.arg("--version")
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.status()
|
||
.map(|s| s.success())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// VirtualBox version string (e.g. `7.1.2r164945`), or None if not installed.
|
||
pub fn vbox_version() -> Option<String> {
|
||
let out = Command::new("VBoxManage").arg("--version").output().ok()?;
|
||
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||
(out.status.success() && !v.is_empty()).then_some(v)
|
||
}
|
||
|
||
/// Install VirtualBox via `ensure-vbox.sh --yes`. Consent is the caller's job
|
||
/// (they passed `--install`); detection is the script's (idempotent if present).
|
||
/// Returns the script's last error line on failure (e.g. needs sudo).
|
||
pub fn ensure_vbox_install() -> Result<()> {
|
||
let out = Command::new("bash")
|
||
.arg(ENSURE_VBOX)
|
||
.arg("--yes")
|
||
.output()
|
||
.context("running ensure-vbox.sh")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
let last = err.lines().last().unwrap_or("could not install VirtualBox");
|
||
anyhow::bail!("{last}");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Names of registered VirtualBox VMs (`VBoxManage list vms`). Each line is
|
||
/// `"name" {uuid}`; we return the unquoted names.
|
||
pub fn list_vms() -> Result<Vec<String>> {
|
||
let out = Command::new("VBoxManage")
|
||
.args(["list", "vms"])
|
||
.output()
|
||
.context("VBoxManage list vms (is VirtualBox installed?)")?;
|
||
Ok(String::from_utf8_lossy(&out.stdout)
|
||
.lines()
|
||
.filter_map(|l| {
|
||
let start = l.find('"')? + 1;
|
||
let end = l[start..].find('"')? + start;
|
||
Some(l[start..end].to_string())
|
||
})
|
||
.filter(|s| !s.is_empty())
|
||
.collect())
|
||
}
|
||
|
||
/// Is a VM with this name registered locally? (i.e. present in `list_vms`).
|
||
/// Used to tell a "host" (already has the appliance imported) from someone who
|
||
/// still needs it pulled in before `gui_launch` can find it.
|
||
pub fn vm_registered(name: &str) -> bool {
|
||
list_vms()
|
||
.map(|vms| vms.iter().any(|v| v == name))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Import a VirtualBox appliance (`.ova`/`.ovf`) so its VM registers locally and
|
||
/// becomes launchable via `gui_launch`. This is how a non-host "pulls" a shared
|
||
/// VM onto their own machine after the appliance arrives over the encrypted
|
||
/// channel. Blocking — run off the UI thread. Returns the imported VM's name.
|
||
pub fn import_appliance(ova: &std::path::Path) -> Result<String> {
|
||
let before = list_vms().unwrap_or_default();
|
||
let out = Command::new("VBoxManage")
|
||
.arg("import")
|
||
.arg(ova)
|
||
.output()
|
||
.context("VBoxManage import (is VirtualBox installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"VBoxManage import failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
// The imported name is whatever's newly registered; fall back to the file's
|
||
// stem if the diff is ambiguous (e.g. a re-import of an existing name).
|
||
let after = list_vms().unwrap_or_default();
|
||
let added = after.into_iter().find(|v| !before.contains(v));
|
||
Ok(added.unwrap_or_else(|| {
|
||
ova.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("imported-vm")
|
||
.to_string()
|
||
}))
|
||
}
|
||
|
||
/// Is a VM currently running? (`VBoxManage list runningvms`)
|
||
pub fn vm_running(name: &str) -> bool {
|
||
Command::new("VBoxManage")
|
||
.args(["list", "runningvms"])
|
||
.output()
|
||
.map(|o| {
|
||
let needle = format!("\"{name}\"");
|
||
String::from_utf8_lossy(&o.stdout)
|
||
.lines()
|
||
.any(|l| l.contains(&needle))
|
||
})
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Launch a registered VM's GUI locally (`VBoxManage startvm <name> --type gui`).
|
||
/// The window opens on the caller's own desktop — this is the "share a VM, run
|
||
/// it locally" path; nothing about the display is relayed to the room.
|
||
pub fn gui_launch(name: &str) -> Result<String> {
|
||
if vm_running(name) {
|
||
return Ok(format!("{name} is already running"));
|
||
}
|
||
let out = Command::new("VBoxManage")
|
||
.args(["startvm", name, "--type", "gui"])
|
||
.output()
|
||
.context("VBoxManage startvm (is VirtualBox installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"startvm failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
Ok(format!("launched {name} (GUI)"))
|
||
}
|
||
|
||
/// Create + boot a *fresh* Ubuntu VirtualBox VM pre-provisioned with the dev
|
||
/// toolchain, by driving `scripts/vbox-new.sh` (downloads a cloud image, builds
|
||
/// a cloud-init NoCloud seed installing scripts/sandbox-tools.json, then boots
|
||
/// the GUI). Unlike `/sbx launch vbox <vm>`, which opens an *existing* image,
|
||
/// this builds one from scratch — the only path that doesn't need a pre-made VM.
|
||
///
|
||
/// Blocking and slow on first run (~600MB image download). Runs the script
|
||
/// non-interactively (`--yes`); returns its final status line(s) — including the
|
||
/// generated login password, which is shown exactly once.
|
||
pub fn vbox_new(name: &str, user: &str) -> Result<String> {
|
||
let out = Command::new("bash")
|
||
.arg(VBOX_NEW)
|
||
.args(["--yes", "--name", name, "--user", user])
|
||
.output()
|
||
.context("running vbox-new.sh (is bash available?)")?;
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
if !out.status.success() {
|
||
anyhow::bail!(
|
||
"vbox-new failed: {}",
|
||
stderr.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim()
|
||
);
|
||
}
|
||
// The script narrates progress on stderr. Surface the final success line and,
|
||
// if one was generated, the one-time login password.
|
||
let last = stderr
|
||
.lines()
|
||
.rev()
|
||
.find(|l| l.trim_start().starts_with('✓'))
|
||
.unwrap_or("VM created")
|
||
.trim();
|
||
let pw = stderr.lines().find(|l| l.contains("login password"));
|
||
Ok(match pw {
|
||
Some(p) => format!("{last} · {}", p.trim()),
|
||
None => last.to_string(),
|
||
})
|
||
}
|
||
|
||
/// A running hypervisor that holds the CPU's VT-x/VMX root mode. While one is
|
||
/// live, VirtualBox can't boot a *hardware* VM (it aborts with
|
||
/// `VERR_VMX_IN_VMX_ROOT_MODE`). We only mark holders we know how to stop
|
||
/// cleanly (`stoppable`); an unrecognised qemu/kvm process is reported but never
|
||
/// killed — the caller aborts and lets the user deal with it.
|
||
#[derive(Clone, Debug)]
|
||
pub struct VtxHolder {
|
||
/// Human label, e.g. "Docker Desktop" or "multipass VM 'molt-mania-vm'".
|
||
pub label: String,
|
||
/// "docker" | "multipass" | "unknown" — selects the stop strategy.
|
||
pub kind: String,
|
||
/// Instance name to `multipass stop`; empty for non-multipass holders.
|
||
pub instance: String,
|
||
/// Whether we know a clean, reversible way to stop it (restartable after).
|
||
pub stoppable: bool,
|
||
}
|
||
|
||
/// Is a KVM kernel module loaded? (Cheap early-out before scanning processes.)
|
||
fn kvm_loaded() -> bool {
|
||
std::fs::read_to_string("/proc/modules")
|
||
.map(|s| {
|
||
s.lines()
|
||
.any(|l| l.starts_with("kvm_intel") || l.starts_with("kvm_amd"))
|
||
})
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Detect running KVM-accelerated hypervisors that hold VT-x and would block a
|
||
/// VirtualBox hardware VM. Pure detection — stops nothing. Empty vec = clear.
|
||
pub fn vtx_holders() -> Vec<VtxHolder> {
|
||
if !kvm_loaded() {
|
||
return Vec::new();
|
||
}
|
||
let out = match Command::new("ps").args(["-eo", "args="]).output() {
|
||
Ok(o) => o,
|
||
Err(_) => return Vec::new(),
|
||
};
|
||
classify_vtx_holders(&String::from_utf8_lossy(&out.stdout))
|
||
}
|
||
|
||
/// Pure classifier: turn `ps -eo args=` output into the VT-x holders we know how
|
||
/// to (or refuse to) stop. Split out from `vtx_holders` so it can be unit-tested
|
||
/// without a live process table. Only KVM-accelerated qemu lines count; Docker
|
||
/// Desktop collapses to a single holder; multipass is named; anything else is an
|
||
/// unknown, non-stoppable holder (we won't kill what we can't cleanly restart).
|
||
fn classify_vtx_holders(text: &str) -> Vec<VtxHolder> {
|
||
let mut holders = Vec::new();
|
||
let mut docker_seen = false;
|
||
for args in text.lines() {
|
||
let is_qemu = args.contains("qemu-system");
|
||
let uses_kvm = args.contains("--enable-kvm")
|
||
|| args.contains("-accel kvm")
|
||
|| args.contains("accel=kvm");
|
||
if !(is_qemu && uses_kvm) {
|
||
continue;
|
||
}
|
||
if args.contains("docker-desktop") || args.contains("/.docker/") {
|
||
// Docker Desktop runs one linuxkit VM; collapse to a single holder.
|
||
if !docker_seen {
|
||
docker_seen = true;
|
||
holders.push(VtxHolder {
|
||
label: "Docker Desktop".into(),
|
||
kind: "docker".into(),
|
||
instance: String::new(),
|
||
stoppable: true,
|
||
});
|
||
}
|
||
} else if let Some(name) = multipass_instance(args) {
|
||
holders.push(VtxHolder {
|
||
label: format!("multipass VM '{name}'"),
|
||
kind: "multipass".into(),
|
||
instance: name,
|
||
stoppable: true,
|
||
});
|
||
} else {
|
||
holders.push(VtxHolder {
|
||
label: "an unknown KVM/QEMU virtual machine".into(),
|
||
kind: "unknown".into(),
|
||
instance: String::new(),
|
||
stoppable: false,
|
||
});
|
||
}
|
||
}
|
||
holders
|
||
}
|
||
|
||
/// Pull a multipass instance name out of a qemu command line. Multipass keeps
|
||
/// each disk under `.../multipassd/vault/instances/<name>/...`.
|
||
fn multipass_instance(args: &str) -> Option<String> {
|
||
if !args.contains("multipass") {
|
||
return None;
|
||
}
|
||
let marker = "/instances/";
|
||
let i = args.find(marker)? + marker.len();
|
||
let rest = &args[i..];
|
||
let end = rest.find('/').unwrap_or(rest.len());
|
||
let name = &rest[..end];
|
||
(!name.is_empty()).then(|| name.to_string())
|
||
}
|
||
|
||
/// Stop one VT-x holder with a clean, *reversible* command so it can be
|
||
/// restarted later. Refuses to touch holders marked `stoppable=false`.
|
||
pub fn stop_vtx_holder(h: &VtxHolder) -> Result<()> {
|
||
match h.kind.as_str() {
|
||
"docker" => {
|
||
let out = Command::new("systemctl")
|
||
.args(["--user", "stop", "docker-desktop"])
|
||
.output()
|
||
.context("stopping Docker Desktop")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"could not stop Docker Desktop: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
"multipass" => {
|
||
let out = Command::new("multipass")
|
||
.args(["stop", &h.instance])
|
||
.output()
|
||
.context("stopping multipass VM")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"could not stop multipass '{}': {}",
|
||
h.instance,
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
_ => anyhow::bail!("refusing to stop {} automatically", h.label),
|
||
}
|
||
}
|
||
|
||
/// Which sandbox to summon. Multipass = strong isolation (default for real use),
|
||
/// Docker = fast, Local = no isolation (dev/testing only).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Backend {
|
||
Local,
|
||
Docker,
|
||
Multipass,
|
||
}
|
||
|
||
impl Backend {
|
||
pub fn parse(s: &str) -> Option<Backend> {
|
||
match s {
|
||
"local" => Some(Backend::Local),
|
||
"docker" => Some(Backend::Docker),
|
||
"multipass" => Some(Backend::Multipass),
|
||
_ => None,
|
||
}
|
||
}
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Backend::Local => "local-shell",
|
||
Backend::Docker => "docker",
|
||
Backend::Multipass => "multipass",
|
||
}
|
||
}
|
||
/// Default image/release when the user doesn't specify one.
|
||
pub fn default_image(self) -> &'static str {
|
||
match self {
|
||
Backend::Multipass => "24.04",
|
||
Backend::Docker => "ubuntu:24.04",
|
||
Backend::Local => "",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One-time setup before the PTY shell is spawned. Blocking — run off the UI
|
||
/// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance
|
||
/// that already exists.
|
||
pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) -> Result<()> {
|
||
match backend {
|
||
Backend::Local => Ok(()),
|
||
Backend::Multipass => {
|
||
let exists = Command::new("multipass")
|
||
.args(["info", name])
|
||
.output()
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false);
|
||
if !exists {
|
||
// Capture output so it can't bleed onto the TUI surface; surface
|
||
// the failure reason through the returned error instead.
|
||
let out = Command::new("multipass")
|
||
.args([
|
||
"launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G",
|
||
image,
|
||
])
|
||
.output()
|
||
.context("multipass launch (is multipass installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"multipass launch failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
} else {
|
||
let _ = Command::new("multipass")
|
||
.args(["start", name])
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.status();
|
||
}
|
||
Ok(())
|
||
}
|
||
Backend::Docker => {
|
||
// The daemon must be up before any `docker` call. Rather than fail
|
||
// with a raw connection error, start it (the caller confirmed via
|
||
// `/sbx launch docker --start`).
|
||
if !docker_daemon_up() {
|
||
if start_daemon {
|
||
start_docker_daemon().context("starting docker daemon")?;
|
||
} else {
|
||
anyhow::bail!(
|
||
"docker daemon is not running — retry with `/sbx launch docker --start`"
|
||
);
|
||
}
|
||
}
|
||
// Persistent container so we can exec in to provision users + shells.
|
||
let _ = Command::new("docker")
|
||
.args(["rm", "-f", name])
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.status();
|
||
// Capture output so a failure can't paint over the TUI; the reason is
|
||
// surfaced through the returned error (shown in the error popup).
|
||
let out = Command::new("docker")
|
||
.args([
|
||
"run",
|
||
"-d",
|
||
"--name",
|
||
name,
|
||
"--hostname",
|
||
name,
|
||
"-w",
|
||
"/root",
|
||
image,
|
||
"sleep",
|
||
"infinity",
|
||
])
|
||
.output()
|
||
.context("docker run (is docker installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"docker run failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Destroy ephemeral resources after stop. Multipass instance is purged;
|
||
/// the Docker container is removed; Local is a no-op.
|
||
pub fn teardown(backend: Backend, name: &str) {
|
||
match backend {
|
||
Backend::Multipass => {
|
||
// Multipass snapshots live *inside* the instance, so `delete --purge`
|
||
// would destroy any saved state. If the user has snapshots worth
|
||
// keeping (so `/sbx load <label>` can restore them), just *stop* the
|
||
// instance — it persists, powered off, ready to be restored. With no
|
||
// snapshots there's nothing to preserve, so purge to stay clean.
|
||
let has_snaps = list_snapshots(Backend::Multipass, name)
|
||
.map(|s| !s.is_empty())
|
||
.unwrap_or(false);
|
||
let args: &[&str] = if has_snaps {
|
||
&["stop", name]
|
||
} else {
|
||
&["delete", name, "--purge"]
|
||
};
|
||
let _ = Command::new("multipass")
|
||
.args(args)
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.status();
|
||
}
|
||
Backend::Docker => {
|
||
let _ = Command::new("docker")
|
||
.args(["rm", "-f", name])
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.status();
|
||
}
|
||
Backend::Local => {}
|
||
}
|
||
}
|
||
|
||
/// Local Docker image repo under which sandbox snapshots are committed
|
||
/// (`hh-snap:<label>`). Owner-side only — never pushed anywhere.
|
||
pub const SNAP_REPO: &str = "hh-snap";
|
||
|
||
/// Directory (relative to the working dir) where portable snapshot artifacts
|
||
/// land when the saver opts into a local file copy (`/sbx save … --local`).
|
||
/// Created on demand. These are standalone files the owner keeps — they survive
|
||
/// even if the in-backend image/snapshot is later pruned.
|
||
pub const SNAP_DIR: &str = "hh-snapshots";
|
||
|
||
/// Ensure `hh-snapshots/` exists and return it. Blocking.
|
||
fn snap_dir() -> Result<std::path::PathBuf> {
|
||
let dir = std::path::PathBuf::from(SNAP_DIR);
|
||
std::fs::create_dir_all(&dir).with_context(|| format!("creating {SNAP_DIR}/"))?;
|
||
Ok(dir)
|
||
}
|
||
|
||
/// Snapshot the running sandbox's filesystem state to a named artifact on the
|
||
/// owner's machine. Blocking — run off the UI thread.
|
||
///
|
||
/// - **Docker:** `docker commit` into `hh-snap:<label>` — instant, captures the
|
||
/// live container (including provisioned users), and survives `/sbx stop`
|
||
/// because the image is independent of the container.
|
||
/// - **Multipass:** `multipass snapshot <name> --name <label>`. Multipass
|
||
/// requires the instance be **stopped** first; if it isn't, multipass's own
|
||
/// error is surfaced verbatim.
|
||
/// - **Local:** no backing VM/container, so nothing to save.
|
||
///
|
||
/// `local` adds a portable, standalone copy under `hh-snapshots/` (a file the
|
||
/// owner fully controls) on top of the in-backend snapshot:
|
||
/// - **Docker:** `docker save` the committed image to a `.tar`.
|
||
/// - **Multipass:** snapshots already live on this host under multipass's own
|
||
/// data dir; the CLI has no single-file export, so we note that and skip the
|
||
/// extra file rather than fake one.
|
||
///
|
||
/// Returns a short human description of what was written on success.
|
||
pub fn save_state(backend: Backend, name: &str, label: &str, local: bool) -> Result<String> {
|
||
match backend {
|
||
Backend::Docker => {
|
||
let tag = format!("{SNAP_REPO}:{label}");
|
||
let out = Command::new("docker")
|
||
.args(["commit", name, &tag])
|
||
.output()
|
||
.context("docker commit (is docker installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"docker commit failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
if local {
|
||
let path = snap_dir()?.join(format!("hh-snap-{label}.tar"));
|
||
let out = Command::new("docker")
|
||
.args(["save", &tag, "-o"])
|
||
.arg(&path)
|
||
.output()
|
||
.context("docker save")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"image {tag} committed, but local export failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
return Ok(format!("image {tag} + local file {}", path.display()));
|
||
}
|
||
Ok(format!("image {tag}"))
|
||
}
|
||
Backend::Multipass => {
|
||
let out = Command::new("multipass")
|
||
.args(["snapshot", name, "--name", label])
|
||
.output()
|
||
.context("multipass snapshot (is multipass installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"multipass snapshot failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
if local {
|
||
// multipass stores snapshots on this host already; no portable
|
||
// single-file export exists in the CLI, so be honest about it.
|
||
return Ok(format!(
|
||
"snapshot {name}.{label} (already stored locally by multipass; no portable file)"
|
||
));
|
||
}
|
||
Ok(format!("snapshot {name}.{label}"))
|
||
}
|
||
Backend::Local => {
|
||
anyhow::bail!("the local shell has no VM state to save — launch a docker or multipass sandbox first")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Restore a Multipass instance to a saved snapshot — the load-side inverse of
|
||
/// the multipass branch of `save_state`. `multipass restore <name>.<label>`
|
||
/// requires the instance to exist and be **stopped**, which is exactly the
|
||
/// state `teardown` leaves it in when snapshots are present. Blocking; the
|
||
/// caller boots it afterwards via the normal `prepare` (existing instance →
|
||
/// `multipass start`) path. The leading `--destructive` skips the interactive
|
||
/// "overwrite current state?" prompt — safe here because restoring *is* the
|
||
/// intent and any unsaved drift since the snapshot is what the user wants gone.
|
||
pub fn mp_restore(name: &str, label: &str) -> Result<String> {
|
||
let target = format!("{name}.{label}");
|
||
let out = Command::new("multipass")
|
||
.args(["restore", "--destructive", &target])
|
||
.output()
|
||
.context("multipass restore (is multipass installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"multipass restore failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
Ok(format!("restored multipass instance to snapshot '{label}'"))
|
||
}
|
||
|
||
/// Snapshot a VirtualBox VM's state. VirtualBox isn't a brokered `Backend` (its
|
||
/// GUI runs locally, never relayed), so it has its own save path. Blocking.
|
||
///
|
||
/// `VBoxManage snapshot <vm> take <label>` works whether the VM is running
|
||
/// (live snapshot) or powered off. `local` additionally exports the whole VM to
|
||
/// a portable `.ova` appliance under `hh-snapshots/` — the standalone artifact
|
||
/// you'd hand to someone else (or re-import yourself) via `/sbx gui`.
|
||
pub fn vm_save_state(vm: &str, label: &str, local: bool) -> Result<String> {
|
||
let out = Command::new("VBoxManage")
|
||
.args(["snapshot", vm, "take", label])
|
||
.output()
|
||
.context("VBoxManage snapshot take (is VirtualBox installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"VBoxManage snapshot failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
if local {
|
||
let path = snap_dir()?.join(format!("{vm}-{label}.ova"));
|
||
let out = Command::new("VBoxManage")
|
||
.args(["export", vm, "-o"])
|
||
.arg(&path)
|
||
.output()
|
||
.context("VBoxManage export")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"snapshot '{label}' taken, but OVA export failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
return Ok(format!(
|
||
"snapshot '{label}' + local appliance {}",
|
||
path.display()
|
||
));
|
||
}
|
||
Ok(format!("snapshot '{label}' of {vm}"))
|
||
}
|
||
|
||
/// Restore a VirtualBox VM to a snapshot — the inverse of `vm_save_state`.
|
||
/// `label` picks a named snapshot; `None` restores the VM's *current* snapshot.
|
||
/// VirtualBox refuses to restore a live machine, so the VM must be powered off.
|
||
/// Blocking; the caller boots the GUI afterwards.
|
||
pub fn vm_restore(vm: &str, label: Option<&str>) -> Result<String> {
|
||
if vm_running(vm) {
|
||
anyhow::bail!("‘{vm}’ is running — power it off first, then restore the snapshot");
|
||
}
|
||
let args: Vec<&str> = match label {
|
||
Some(l) => vec!["snapshot", vm, "restore", l],
|
||
None => vec!["snapshot", vm, "restorecurrent"],
|
||
};
|
||
let out = Command::new("VBoxManage")
|
||
.args(&args)
|
||
.output()
|
||
.context("VBoxManage snapshot restore (is VirtualBox installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
anyhow::bail!(
|
||
"VBoxManage snapshot restore failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
Ok(match label {
|
||
Some(l) => format!("restored ‘{vm}’ to snapshot '{l}'"),
|
||
None => format!("restored ‘{vm}’ to its current snapshot"),
|
||
})
|
||
}
|
||
|
||
/// Snapshot names for a VirtualBox VM (`VBoxManage snapshot <vm> list
|
||
/// --machinereadable` → `SnapshotName*="..."` lines). Blocking. An empty list
|
||
/// (exit 1 "does not have any snapshots") is reported as no snapshots, not an
|
||
/// error.
|
||
pub fn vm_snapshots(vm: &str) -> Result<Vec<String>> {
|
||
let out = Command::new("VBoxManage")
|
||
.args(["snapshot", vm, "list", "--machinereadable"])
|
||
.output()
|
||
.context("VBoxManage snapshot list (is VirtualBox installed?)")?;
|
||
if !out.status.success() {
|
||
let err = String::from_utf8_lossy(&out.stderr);
|
||
if err.contains("does not have any snapshots") {
|
||
return Ok(Vec::new());
|
||
}
|
||
anyhow::bail!(
|
||
"VBoxManage snapshot list failed: {}",
|
||
err.lines().last().unwrap_or("").trim()
|
||
);
|
||
}
|
||
Ok(String::from_utf8_lossy(&out.stdout)
|
||
.lines()
|
||
.filter_map(|l| {
|
||
let v = l.strip_prefix("SnapshotName")?;
|
||
let q = v.split_once('=')?.1.trim();
|
||
Some(q.trim_matches('"').to_string())
|
||
})
|
||
.filter(|s| !s.is_empty())
|
||
.collect())
|
||
}
|
||
|
||
/// List saved snapshot labels for a backend (Docker image tags under
|
||
/// `hh-snap`, or multipass snapshots of the instance). Blocking.
|
||
pub fn list_snapshots(backend: Backend, name: &str) -> Result<Vec<String>> {
|
||
match backend {
|
||
Backend::Docker => {
|
||
let out = Command::new("docker")
|
||
.args(["images", SNAP_REPO, "--format", "{{.Tag}}"])
|
||
.output()
|
||
.context("docker images")?;
|
||
Ok(String::from_utf8_lossy(&out.stdout)
|
||
.lines()
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty() && *s != "<none>")
|
||
.map(str::to_string)
|
||
.collect())
|
||
}
|
||
Backend::Multipass => {
|
||
// `multipass list --snapshots` columns: Instance Snapshot Parent Comment.
|
||
let out = Command::new("multipass")
|
||
.args(["list", "--snapshots"])
|
||
.output()
|
||
.context("multipass list --snapshots")?;
|
||
Ok(String::from_utf8_lossy(&out.stdout)
|
||
.lines()
|
||
.skip(1) // header row
|
||
.filter_map(|l| {
|
||
let mut cols = l.split_whitespace();
|
||
let inst = cols.next()?;
|
||
let snap = cols.next()?;
|
||
(inst == name).then(|| snap.to_string())
|
||
})
|
||
.collect())
|
||
}
|
||
Backend::Local => Ok(Vec::new()),
|
||
}
|
||
}
|
||
|
||
/// Where a `/sbx load <label>` snapshot lives, so the loader knows which backend
|
||
/// to restore through. A bare label is ambiguous (a docker image tag *and* a
|
||
/// multipass snapshot could share it), so we probe and prefer whichever exists.
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
pub enum SnapKind {
|
||
Docker,
|
||
Multipass,
|
||
None,
|
||
}
|
||
|
||
/// Resolve a snapshot label to the backend that holds it. Probes multipass first
|
||
/// (its snapshots are instance-scoped and rarer), then docker's `hh-snap` repo.
|
||
/// Blocking — run off the UI thread. A backend whose CLI is absent simply
|
||
/// reports no match rather than erroring, so a missing multipass doesn't block a
|
||
/// docker load and vice-versa.
|
||
pub fn locate_snapshot(name: &str, label: &str) -> SnapKind {
|
||
if list_snapshots(Backend::Multipass, name)
|
||
.map(|s| s.iter().any(|l| l == label))
|
||
.unwrap_or(false)
|
||
{
|
||
return SnapKind::Multipass;
|
||
}
|
||
if list_snapshots(Backend::Docker, name)
|
||
.map(|s| s.iter().any(|l| l == label))
|
||
.unwrap_or(false)
|
||
{
|
||
return SnapKind::Docker;
|
||
}
|
||
SnapKind::None
|
||
}
|
||
|
||
/// Build the shell command for a backend, running as unix user `run_user`
|
||
/// (empty = backend default). The container/VM is already up (see `prepare`).
|
||
fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
|
||
match backend {
|
||
Backend::Local => {
|
||
let mut c = CommandBuilder::new("bash");
|
||
c.arg("-i");
|
||
c
|
||
}
|
||
Backend::Docker => {
|
||
let user = if run_user.is_empty() {
|
||
"root"
|
||
} else {
|
||
run_user
|
||
};
|
||
let mut c = CommandBuilder::new("docker");
|
||
c.args(["exec", "-it", "-u", user, name, "bash", "-il"]);
|
||
c
|
||
}
|
||
Backend::Multipass => {
|
||
let mut c = CommandBuilder::new("multipass");
|
||
if run_user.is_empty() {
|
||
c.args(["exec", name, "--", "bash", "-il"]);
|
||
} else {
|
||
// Login shell as the provisioned owner account (a real sudoer).
|
||
c.args(["exec", name, "--", "sudo", "-u", run_user, "-i"]);
|
||
}
|
||
c
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sanitize a clergy display name into a safe unix username.
|
||
pub fn unix_name(name: &str) -> String {
|
||
let s: String = name
|
||
.to_lowercase()
|
||
.chars()
|
||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||
.take(31)
|
||
.collect();
|
||
s.trim_start_matches(['-', '_']).to_string()
|
||
}
|
||
|
||
fn mp(name: &str, args: &[&str]) {
|
||
let mut a = vec!["exec", name, "--"];
|
||
a.extend_from_slice(args);
|
||
// Null stdio so provisioning chatter never bleeds onto the TUI surface.
|
||
let _ = Command::new("multipass")
|
||
.args(a)
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.status();
|
||
}
|
||
fn dk(name: &str, args: &[&str]) {
|
||
let mut a = vec!["exec", name];
|
||
a.extend_from_slice(args);
|
||
let _ = Command::new("docker")
|
||
.args(a)
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.status();
|
||
}
|
||
|
||
#[derive(serde::Deserialize)]
|
||
struct ToolsCfg {
|
||
packages: Vec<String>,
|
||
}
|
||
|
||
/// The space-joined apt package list every Docker sandbox installs at launch.
|
||
/// Read from `scripts/sandbox-tools.json` so it's editable without code changes;
|
||
/// `vim`+`curl` are always present, names are sanitized to a safe charset, and
|
||
/// duplicates collapse. Missing/invalid JSON degrades gracefully to vim+curl.
|
||
fn sandbox_pkgs() -> String {
|
||
let mut out: Vec<String> = vec!["vim".into(), "curl".into()];
|
||
if let Ok(raw) = std::fs::read_to_string(SBX_TOOLS_JSON) {
|
||
if let Ok(cfg) = serde_json::from_str::<ToolsCfg>(&raw) {
|
||
for p in cfg.packages {
|
||
// Keep only valid apt package-name chars so a stray edit can't
|
||
// smuggle shell metacharacters into the install command.
|
||
let p: String = p
|
||
.trim()
|
||
.chars()
|
||
.filter(|c| c.is_ascii_alphanumeric() || "._+-".contains(*c))
|
||
.collect();
|
||
if !p.is_empty() && !out.contains(&p) {
|
||
out.push(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out.join(" ")
|
||
}
|
||
|
||
/// Run the sandbox bootstrap inside the container, piping the script to `bash -s`
|
||
/// on stdin (keeps it out of argv) with the resolved package list in the env.
|
||
/// Blocking — provision() is already off the UI thread.
|
||
fn dk_bootstrap(name: &str) {
|
||
let script = std::fs::read_to_string(SBX_BOOTSTRAP).unwrap_or_else(|_| {
|
||
// Degraded fallback if the script file is missing: still refresh the
|
||
// index and install whatever the env carries (at minimum vim+curl).
|
||
"apt-get update -qq && apt-get install -y --no-install-recommends $HH_SBX_PKGS".into()
|
||
});
|
||
let env = format!("HH_SBX_PKGS={}", sandbox_pkgs());
|
||
let child = Command::new("docker")
|
||
.args(["exec", "-i", "-e", &env, name, "bash", "-s"])
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.spawn();
|
||
if let Ok(mut c) = child {
|
||
if let Some(mut stdin) = c.stdin.take() {
|
||
let _ = stdin.write_all(script.as_bytes());
|
||
}
|
||
let _ = c.wait();
|
||
}
|
||
}
|
||
|
||
/// Install the baseline dev toolchain (scripts/sandbox-tools.json; vim+curl
|
||
/// guaranteed) inside a Multipass VM. It already ships apt + sudo with a
|
||
/// populated index, so this is a direct install. The package list is sanitized
|
||
/// by `sandbox_pkgs`, so interpolating it into the shell command is safe.
|
||
/// Blocking — provision() is off the UI thread.
|
||
fn mp_bootstrap(name: &str) {
|
||
let cmd = format!(
|
||
"export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && \
|
||
apt-get install -y --no-install-recommends {} || true",
|
||
sandbox_pkgs()
|
||
);
|
||
mp(name, &["sudo", "bash", "-c", &cmd]);
|
||
}
|
||
|
||
/// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in)
|
||
/// so they're a usable superuser non-interactively. `u` is already unix-safe.
|
||
fn mp_grant_sudo(name: &str, u: &str) {
|
||
let script = format!(
|
||
"usermod -aG sudo {u}; printf '{u} ALL=(ALL) NOPASSWD:ALL\\n' > /etc/sudoers.d/90-{u}; chmod 440 /etc/sudoers.d/90-{u}"
|
||
);
|
||
mp(name, &["sudo", "bash", "-c", &script]);
|
||
}
|
||
fn mp_revoke_sudo(name: &str, u: &str) {
|
||
let script = format!("gpasswd -d {u} sudo 2>/dev/null; rm -f /etc/sudoers.d/90-{u}");
|
||
mp(name, &["sudo", "bash", "-c", &script]);
|
||
}
|
||
|
||
/// Provision a real unix account per clergy member inside the VM/container and
|
||
/// make the owner a superuser (sudoer). Returns the unix user the shared shell
|
||
/// should run as. Blocking — call off the UI thread.
|
||
pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String]) -> String {
|
||
let run = unix_name(owner);
|
||
match backend {
|
||
Backend::Multipass => {
|
||
for m in members {
|
||
let u = unix_name(m);
|
||
if !u.is_empty() {
|
||
mp(name, &["sudo", "useradd", "-m", "-s", "/bin/bash", &u]);
|
||
}
|
||
}
|
||
if !run.is_empty() {
|
||
mp_grant_sudo(name, &run); // owner = passwordless superuser
|
||
}
|
||
mp_bootstrap(name); // same baseline dev toolchain as the docker sandbox
|
||
run
|
||
}
|
||
Backend::Docker => {
|
||
// Install the baseline dev toolchain (editable list in
|
||
// scripts/sandbox-tools.json; vim+curl guaranteed) so a fresh
|
||
// sandbox comes up usable instead of bare. The script also refreshes
|
||
// the apt index (base images ship without /var/lib/apt/lists) and is
|
||
// idempotent + sentinel-guarded, so re-provisions stay fast.
|
||
dk_bootstrap(name);
|
||
for m in members {
|
||
let u = unix_name(m);
|
||
if !u.is_empty() {
|
||
dk(name, &["useradd", "-m", "-s", "/bin/bash", &u]);
|
||
}
|
||
}
|
||
// Base images usually lack the sudo package; the shared shell runs as
|
||
// root (superuser) and drive-grant is the delegation mechanism.
|
||
"root".to_string()
|
||
}
|
||
Backend::Local => String::new(),
|
||
}
|
||
}
|
||
|
||
/// Grant/revoke real sudo for a member's unix account (Multipass; sudo is
|
||
/// preinstalled). No-op for Docker (no sudo pkg) / Local.
|
||
pub fn set_sudo(backend: Backend, name: &str, user: &str, enable: bool) {
|
||
let u = unix_name(user);
|
||
if u.is_empty() || backend != Backend::Multipass {
|
||
return;
|
||
}
|
||
if enable {
|
||
mp_grant_sudo(name, &u);
|
||
} else {
|
||
mp_revoke_sudo(name, &u);
|
||
}
|
||
}
|
||
|
||
/// The unix user the shared shell runs as for a backend — mirrors `provision`'s
|
||
/// return (owner on Multipass, root on Docker, none on Local) but side-effect
|
||
/// free, so callers like file-push can resolve the destination home without
|
||
/// threading extra state through the broker.
|
||
pub fn run_user_for(backend: Backend, owner: &str) -> String {
|
||
match backend {
|
||
Backend::Multipass => unix_name(owner),
|
||
Backend::Docker => "root".to_string(),
|
||
Backend::Local => String::new(),
|
||
}
|
||
}
|
||
|
||
/// Inject a local file/dir into the running sandbox, dropping it under the
|
||
/// shared shell user's home and leaving it owned by that user. The path is
|
||
/// streamed in as a tar (built + size-capped by `ft::tar_path`) and unpacked by
|
||
/// `tar` inside the container/VM — uniform for files and directories, and no
|
||
/// shell interpolation of the path. Blocking — run off the UI thread. Returns
|
||
/// the in-sandbox destination path on success.
|
||
pub fn push(
|
||
backend: Backend,
|
||
name: &str,
|
||
run_user: &str,
|
||
local: &std::path::Path,
|
||
) -> Result<String> {
|
||
let (base, tar) = crate::ft::tar_path(local)?;
|
||
match backend {
|
||
Backend::Local => anyhow::bail!(
|
||
"local sandbox shares the host filesystem — {} is already reachable",
|
||
local.display()
|
||
),
|
||
Backend::Docker => {
|
||
// Shell runs as root with $HOME=/root (see `prepare`'s `-w /root`).
|
||
let mut c = Command::new("docker");
|
||
c.args(["exec", "-i", name, "tar", "-C", "/root", "-xf", "-"]);
|
||
extract_tar(c, &tar)?;
|
||
Ok(format!("/root/{base}"))
|
||
}
|
||
Backend::Multipass => {
|
||
let home = format!("/home/{run_user}");
|
||
let mut c = Command::new("multipass");
|
||
c.args(["exec", name, "--", "sudo", "tar", "-C", &home, "-xf", "-"]);
|
||
extract_tar(c, &tar)?;
|
||
// Hand ownership to the account whose login shell the clergy share
|
||
// (the tar unpacked as root via sudo).
|
||
let owns = format!("{run_user}:{run_user}");
|
||
let target = format!("{home}/{base}");
|
||
mp(name, &["sudo", "chown", "-R", &owns, &target]);
|
||
Ok(target)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Feed `tar` bytes to a `tar -x` process over stdin, surfacing the extractor's
|
||
/// own last error line on failure.
|
||
fn extract_tar(mut cmd: Command, tar: &[u8]) -> Result<()> {
|
||
cmd.stdin(Stdio::piped())
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::piped());
|
||
let mut child = cmd
|
||
.spawn()
|
||
.context("spawn tar extractor (is the backend CLI installed?)")?;
|
||
{
|
||
let mut si = child.stdin.take().context("tar stdin")?;
|
||
si.write_all(tar).context("stream tar to sandbox")?;
|
||
} // drop closes stdin → tar sees EOF and finishes
|
||
let out = child.wait_with_output().context("await tar extractor")?;
|
||
anyhow::ensure!(
|
||
out.status.success(),
|
||
"extract failed: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
.lines()
|
||
.last()
|
||
.unwrap_or("")
|
||
.trim()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
pub struct Sandbox {
|
||
// Held for the PTY's lifetime (dropping it closes the terminal) + resize.
|
||
#[allow(dead_code)]
|
||
master: Box<dyn MasterPty + Send>,
|
||
child: Box<dyn Child + Send + Sync>,
|
||
writer: Box<dyn Write + Send>,
|
||
#[allow(dead_code)]
|
||
pub backend: Backend,
|
||
}
|
||
|
||
impl Sandbox {
|
||
/// Spawn the backend in a PTY. A reader thread pushes raw output bytes onto
|
||
/// `out`; the caller relays them (encrypted) to the clergy.
|
||
pub fn launch(
|
||
backend: Backend,
|
||
name: &str,
|
||
run_user: &str,
|
||
rows: u16,
|
||
cols: u16,
|
||
out: mpsc::Sender<Vec<u8>>,
|
||
) -> Result<Sandbox> {
|
||
let pty = native_pty_system();
|
||
let pair = pty
|
||
.openpty(PtySize {
|
||
rows,
|
||
cols,
|
||
pixel_width: 0,
|
||
pixel_height: 0,
|
||
})
|
||
.context("openpty")?;
|
||
|
||
let cmd = command_for(backend, name, run_user);
|
||
let child = pair
|
||
.slave
|
||
.spawn_command(cmd)
|
||
.with_context(|| format!("spawn {} sandbox", backend.label()))?;
|
||
drop(pair.slave); // close our handle so EOF propagates on exit
|
||
|
||
let mut reader = pair.master.try_clone_reader().context("clone pty reader")?;
|
||
let writer = pair.master.take_writer().context("take pty writer")?;
|
||
|
||
std::thread::spawn(move || {
|
||
let mut buf = [0u8; 8192];
|
||
loop {
|
||
match reader.read(&mut buf) {
|
||
Ok(0) | Err(_) => break,
|
||
Ok(n) => {
|
||
if out.send(buf[..n].to_vec()).is_err() {
|
||
break; // broker gone
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
Ok(Sandbox {
|
||
master: pair.master,
|
||
child,
|
||
writer,
|
||
backend,
|
||
})
|
||
}
|
||
|
||
pub fn write_input(&mut self, data: &[u8]) -> Result<()> {
|
||
self.writer.write_all(data)?;
|
||
self.writer.flush()?;
|
||
Ok(())
|
||
}
|
||
|
||
#[allow(dead_code)] // wired up with PTY-resize sync (P3b)
|
||
pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
|
||
self.master
|
||
.resize(PtySize {
|
||
rows,
|
||
cols,
|
||
pixel_width: 0,
|
||
pixel_height: 0,
|
||
})
|
||
.context("pty resize")
|
||
}
|
||
|
||
pub fn stop(&mut self) {
|
||
let _ = self.child.kill();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::time::{Duration, Instant};
|
||
|
||
/// Proves the PTY pipeline: spawn a real shell, send a command, read its
|
||
/// output back off the channel. (Local backend — no container needed.)
|
||
#[test]
|
||
fn local_shell_pty_roundtrip() {
|
||
let (tx, rx) = mpsc::channel();
|
||
let mut sb =
|
||
Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch local shell");
|
||
sb.write_input(b"echo HELLO_PTY_42\n").unwrap();
|
||
|
||
let mut acc = String::new();
|
||
let deadline = Instant::now() + Duration::from_secs(4);
|
||
while Instant::now() < deadline {
|
||
if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(200)) {
|
||
acc.push_str(&String::from_utf8_lossy(&chunk));
|
||
if acc.contains("HELLO_PTY_42") {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
sb.stop();
|
||
assert!(
|
||
acc.contains("HELLO_PTY_42"),
|
||
"pty output missing marker; got: {acc:?}"
|
||
);
|
||
}
|
||
|
||
// --- VT-x holder classification (pure, no live process table) ------------
|
||
|
||
#[test]
|
||
fn multipass_instance_parsed_from_qemu_args() {
|
||
let args = "/usr/bin/qemu-system-x86_64 ... -drive file=/var/snap/multipass/common/data/multipassd/vault/instances/molt-mania-vm/ubuntu.img ... --enable-kvm";
|
||
assert_eq!(multipass_instance(args).as_deref(), Some("molt-mania-vm"));
|
||
}
|
||
|
||
#[test]
|
||
fn multipass_instance_none_without_marker() {
|
||
assert_eq!(multipass_instance("qemu-system-x86_64 --enable-kvm"), None);
|
||
// has the path marker but no multipass keyword → not a multipass holder
|
||
assert_eq!(multipass_instance("/instances/foo/x"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn classify_ignores_non_kvm_and_non_qemu() {
|
||
// qemu WITHOUT kvm accel (TCG) does not hold VT-x; a random process is ignored.
|
||
let text = "qemu-system-x86_64 -accel tcg disk.img\n/usr/bin/firefox\nbash";
|
||
assert!(classify_vtx_holders(text).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn classify_collapses_docker_to_single_stoppable_holder() {
|
||
// Docker Desktop spawns its linuxkit qemu (sometimes >1 matching line);
|
||
// we want exactly one "docker" holder, marked stoppable.
|
||
let text = "\
|
||
qemu-system-x86_64 --enable-kvm -name docker-desktop ...\n\
|
||
/Applications/Docker.app/.docker/qemu --enable-kvm ...";
|
||
let h = classify_vtx_holders(text);
|
||
assert_eq!(h.len(), 1, "docker must collapse to one holder");
|
||
assert_eq!(h[0].kind, "docker");
|
||
assert!(h[0].stoppable);
|
||
}
|
||
|
||
#[test]
|
||
fn classify_names_multipass_and_marks_stoppable() {
|
||
let text = "qemu-system-x86_64 --enable-kvm -drive file=/x/multipassd/vault/instances/lab-vm/disk.img";
|
||
let h = classify_vtx_holders(text);
|
||
assert_eq!(h.len(), 1);
|
||
assert_eq!(h[0].kind, "multipass");
|
||
assert_eq!(h[0].instance, "lab-vm");
|
||
assert!(h[0].stoppable);
|
||
assert!(h[0].label.contains("lab-vm"));
|
||
}
|
||
|
||
#[test]
|
||
fn classify_unknown_kvm_vm_is_not_stoppable() {
|
||
// A raw qemu+kvm VM we don't recognize: detected, but we refuse to stop
|
||
// it (no clean, reversible restart path) → stoppable == false.
|
||
let text = "qemu-system-x86_64 -enable-kvm -accel kvm -hda mystery.qcow2";
|
||
let h = classify_vtx_holders(text);
|
||
assert_eq!(h.len(), 1);
|
||
assert_eq!(h[0].kind, "unknown");
|
||
assert!(!h[0].stoppable);
|
||
}
|
||
|
||
#[test]
|
||
fn classify_recognizes_all_kvm_accel_spellings() {
|
||
for accel in ["--enable-kvm", "-accel kvm", "accel=kvm"] {
|
||
let text = format!("qemu-system-aarch64 {accel} -hda d.img");
|
||
assert_eq!(
|
||
classify_vtx_holders(&text).len(),
|
||
1,
|
||
"missed accel: {accel}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod relay_tests {
|
||
use super::*;
|
||
use std::sync::mpsc;
|
||
use std::time::{Duration, Instant};
|
||
|
||
/// End-to-end (headless) sandbox relay: launch a local sandbox, run a
|
||
/// command, encode the PTY output exactly as the broker sends it over the
|
||
/// encrypted channel (`{"_sbx":"data","b64":...}`), then decode it back the
|
||
/// way a remote client does and feed it to a vt100 screen — asserting the
|
||
/// command's output lands on the rendered terminal.
|
||
#[test]
|
||
fn sandbox_output_reaches_a_vt100_screen_via_frames() {
|
||
use base64::engine::general_purpose::STANDARD;
|
||
use base64::Engine;
|
||
|
||
let (tx, rx) = mpsc::channel();
|
||
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch");
|
||
sb.write_input(b"echo RELAY_MARKER_7\n").unwrap();
|
||
|
||
// Remote client side: a vt100 parser fed from decoded data frames.
|
||
let mut screen = vt100::Parser::new(24, 80, 0);
|
||
let deadline = Instant::now() + Duration::from_secs(4);
|
||
let mut hit = false;
|
||
while Instant::now() < deadline {
|
||
if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(200)) {
|
||
// broker: encode → (server relay) → client: decode
|
||
let frame = serde_json::json!({"_sbx":"data","b64": STANDARD.encode(&chunk)});
|
||
let b64 = frame["b64"].as_str().unwrap();
|
||
let decoded = STANDARD.decode(b64).unwrap();
|
||
screen.process(&decoded);
|
||
if screen.screen().contents().contains("RELAY_MARKER_7") {
|
||
hit = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
sb.stop();
|
||
assert!(hit, "command output never reached the rendered terminal");
|
||
}
|
||
}
|