diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 853c236..1447970 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,11 @@ on: jobs: rust: name: rust client (hh) - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} defaults: run: working-directory: hh @@ -21,11 +25,36 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: hh - - run: cargo fmt --all --check + # fmt is platform-independent; only run it once to avoid duplicate noise. + - if: matrix.os == 'ubuntu-latest' + run: cargo fmt --all --check - run: cargo clippy --all-targets -- -D warnings - run: cargo build --verbose - run: cargo test --verbose + rust-coverage: + name: rust coverage + runs-on: ubuntu-latest + defaults: + run: + working-directory: hh + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + with: + workspaces: hh + - uses: taiki-e/install-action@cargo-llvm-cov + - run: cargo llvm-cov --lcov --output-path lcov.info + - uses: codecov/codecov-action@v4 + with: + files: hh/lcov.info + flags: rust + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + python: name: python server runs-on: ubuntu-latest @@ -38,5 +67,63 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: pip + - run: pip install -r requirements.txt pytest-cov + - run: pytest -q --cov=cmd_chat --cov-report=xml + - if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + flags: python + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + smoke: + name: headless e2e smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: hh + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip - run: pip install -r requirements.txt - - run: pytest -q + - name: install tmux + run: sudo apt-get update && sudo apt-get install -y tmux + - name: build client + run: cargo build + working-directory: hh + - name: run cross-stack smoke test + run: bash hh/smoke-e2e.sh + + audit: + name: dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@cargo-audit + - name: cargo audit (rust client) + run: cargo audit + working-directory: hh + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: pip-audit (python server) + run: | + pip install pip-audit + pip-audit -r requirements.txt + + secrets: + name: secret scanning + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/hh/Cargo.lock b/hh/Cargo.lock index 305525c..5be3e14 100644 --- a/hh/Cargo.lock +++ b/hh/Cargo.lock @@ -110,6 +110,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -402,6 +417,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fernet" version = "0.2.2" @@ -442,6 +463,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -591,6 +618,7 @@ dependencies = [ "num-bigint", "num-traits", "portable-pty", + "proptest", "rand 0.8.6", "ratatui", "reqwest", @@ -1230,6 +1258,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.9" @@ -1359,6 +1412,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -1390,6 +1452,12 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "reqwest" version = "0.12.28" @@ -1520,6 +1588,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1823,6 +1903,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termios" version = "0.2.2" @@ -2088,6 +2181,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2204,6 +2303,15 @@ dependencies = [ "quote", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" diff --git a/hh/Cargo.toml b/hh/Cargo.toml index 623f190..1e77166 100644 --- a/hh/Cargo.toml +++ b/hh/Cargo.toml @@ -48,3 +48,7 @@ serde_json = "1" # cli / errors clap = { version = "4", features = ["derive"] } anyhow = "1" + +[dev-dependencies] +# property-based fuzzing of the frame parsers (attacker-controlled JSON) +proptest = "1" diff --git a/hh/smoke-e2e.sh b/hh/smoke-e2e.sh new file mode 100755 index 0000000..cc2b725 --- /dev/null +++ b/hh/smoke-e2e.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# smoke-e2e.sh — headless cross-stack smoke test (CI-runnable, no GUI/X needed). +# +# Boots the Python relay server + two real Rust TUI clients (alice = host/owner, +# bob = guest) inside tmux panes, then asserts the things that actually exercise +# the client<->server protocol: +# 1. both clients complete the SRP handshake and appear in the roster +# 2. a chat message from alice is relayed + decrypted into bob's pane +# (proves Fernet round-trip through the zero-knowledge relay) +# 3. `/sbx vms` dispatches in the client command parser +# +# Deterministic and self-contained: picks a free port, cleans up sessions + the +# server on exit, and prints a clear PASS/FAIL. No asciinema, VirtualBox, or X. +# +# Env overrides: PY= BIN= PORT= PW= +set -uo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Python: prefer the repo venv locally, fall back to PATH (CI installs into the +# job's own interpreter). +if [[ -z "${PY:-}" ]]; then + if [[ -x "$REPO/.venv/bin/python" ]]; then PY="$REPO/.venv/bin/python"; else PY="$(command -v python3 || command -v python)"; fi +fi +BIN="${BIN:-$REPO/hh/target/debug/hack-house}" +PW="${PW:-smoke-pass}" +pick_port() { local p; for p in $(seq 4300 4360); do ss -ltn 2>/dev/null | grep -q ":$p " || { echo "$p"; return; }; done; echo 4399; } +PORT="${PORT:-$(pick_port)}" + +SRV_SESS="hh-smoke-srv" +RUN_SESS="hh-smoke" +MARKER="HELLO_FROM_ALICE_$$" + +GREEN=$'\e[32m'; RED=$'\e[31m'; YEL=$'\e[33m'; RST=$'\e[0m' +step() { printf '\n%s== %s ==%s\n' "$YEL" "$*" "$RST"; } +ok() { printf '%s ok %s%s\n' "$GREEN" "$*" "$RST"; } +FAIL=0; fail() { printf '%s XX %s%s\n' "$RED" "$*" "$RST"; FAIL=1; } + +cleanup() { + tmux kill-session -t "$RUN_SESS" 2>/dev/null + tmux kill-session -t "$SRV_SESS" 2>/dev/null +} +trap cleanup EXIT + +# Pane-id-safe drivers (don't assume pane-base-index 0 — it may be 1). +APANE=""; BPANE="" +asay() { tmux send-keys -t "$APANE" -l "$*"; sleep 0.4; tmux send-keys -t "$APANE" Enter; sleep 0.6; } +bsay() { tmux send-keys -t "$BPANE" -l "$*"; sleep 0.4; tmux send-keys -t "$BPANE" Enter; sleep 0.6; } +acap() { tmux capture-pane -t "$APANE" -p 2>/dev/null; } +bcap() { tmux capture-pane -t "$BPANE" -p 2>/dev/null; } +await_a() { local re="$1" t="${2:-25}" i=0; while (( i < t*2 )); do acap | grep -qE "$re" && return 0; sleep 0.5; ((i++)); done; return 1; } +await_b() { local re="$1" t="${2:-25}" i=0; while (( i < t*2 )); do bcap | grep -qE "$re" && return 0; sleep 0.5; ((i++)); done; return 1; } + +# ---- preflight -------------------------------------------------------------- +step "preflight" +command -v tmux >/dev/null || { echo "tmux required"; exit 2; } +[[ -n "$PY" && -x "$(command -v "$PY" 2>/dev/null || echo "$PY")" ]] || { echo "python missing: $PY"; exit 2; } +if [[ ! -x "$BIN" ]]; then + step "building client"; ( cd "$REPO/hh" && cargo build ) || exit 2 +fi +ok "python=$PY client=$BIN port=$PORT" + +# ---- server (not asserted directly) ----------------------------------------- +step "boot relay server :$PORT" +tmux new-session -d -s "$SRV_SESS" -x 200 -y 50 \ + "cd '$REPO' && '$PY' cmd_chat.py serve 127.0.0.1 $PORT --password '$PW' --no-tls 2>&1 | tee /tmp/hh-smoke-srv.log" +for i in $(seq 1 30); do ss -ltn 2>/dev/null | grep -q ":$PORT " && break; sleep 0.5; done +ss -ltn 2>/dev/null | grep -q ":$PORT " && ok "server listening" || { fail "server never bound"; exit 1; } + +# ---- two clients in side-by-side panes --------------------------------------- +step "launch alice (host) + bob (guest)" +tmux new-session -d -s "$RUN_SESS" -x 200 -y 50 "bash --noprofile --norc" +APANE="$(tmux list-panes -t "$RUN_SESS" -F '#{pane_id}' | head -1)" +BPANE="$(tmux split-window -h -t "$APANE" -P -F '#{pane_id}' "bash --noprofile --norc")" +sleep 0.5 +asay "$BIN connect 127.0.0.1 $PORT alice --password '$PW' --no-tls" +await_a 'joined as alice|alice|roster|hack-house' 25 && ok "alice joined (SRP ok)" || fail "alice never joined" +bsay "$BIN connect 127.0.0.1 $PORT bob --password '$PW' --no-tls" +await_b 'joined as bob|bob|roster|hack-house' 25 && ok "bob joined (SRP ok)" || fail "bob never joined" + +# ---- chat round-trip (the real protocol assertion) -------------------------- +step "chat relay + Fernet round-trip" +sleep 1.0 +asay "$MARKER" +await_b "$MARKER" 20 && ok "bob received alice's encrypted message" || fail "message never relayed to bob" + +# ---- command parser dispatch ------------------------------------------------ +step "/sbx vms dispatches" +asay "/sbx vms" +# In CI VirtualBox won't be installed; either response mentions VirtualBox, which +# proves the command parsed and ran (this is a local command, not a round-trip). +await_a "VirtualBox" 15 && ok "/sbx vms handled" || fail "/sbx vms produced no response" + +# ---- result ----------------------------------------------------------------- +step "result" +if [[ $FAIL -eq 0 ]]; then + printf '%sSMOKE OK%s\n' "$GREEN" "$RST" +else + printf '%sSMOKE FAIL%s — server log: /tmp/hh-smoke-srv.log\n' "$RED" "$RST" +fi +exit $FAIL diff --git a/hh/src/net.rs b/hh/src/net.rs index 201bacd..ae776df 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -189,12 +189,12 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded { } Err(_) => ("[unreadable — wrong room password?]".to_string(), true), }; + // Server-stamped ISO time "YYYY-MM-DDTHH:MM:SS…"; show just "HH:MM:SS". + // Slice on char boundaries (chars, not bytes): the server is untrusted in our + // zero-knowledge model and could send a non-ASCII timestamp, which byte + // slicing `stamp[11..19]` would panic on at a non-boundary. let stamp = m["timestamp"].as_str().unwrap_or(""); - let ts = if stamp.len() >= 19 { - stamp[11..19].to_string() - } else { - String::new() - }; + let ts: String = stamp.chars().skip(11).take(8).collect(); Decoded::Chat(ChatLine { ts, username: m["username"].as_str().unwrap_or("?").to_string(), @@ -223,6 +223,12 @@ fn parse_sbx(text: &str, sender: &str) -> Option { from: sender.to_string(), bytes: STANDARD.decode(v["b64"].as_str()?).ok()?, }), + // A member opened a shared VirtualBox VM on their own machine. `by` is + // the server-stamped sender (trusted), not the frame's own claim. + "vm" => Some(Net::VmOpened { + by: sender.to_string(), + vm: v["vm"].as_str().unwrap_or("a VM").to_string(), + }), _ => None, } } @@ -321,3 +327,76 @@ pub async fn reader( } let _ = tx.send(Net::Closed); } + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + fn test_room() -> fernet::Fernet { + fernet::Fernet::new(&fernet::Fernet::generate_key()).expect("valid key") + } + + // A decode_msg call must never panic on a malformed timestamp. This is the + // concrete regression for the byte-slice `stamp[11..19]` that would panic on + // a non-ASCII (multibyte) timestamp from an untrusted/zero-knowledge server. + #[test] + fn decode_msg_multibyte_timestamp_does_not_panic() { + let room = test_room(); + // A '✝' (3 bytes) straddling the old 11..19 byte window. + let m = json!({ "text": "not-real-ciphertext", "timestamp": "2026-06-04✝✝✝✝✝✝", "username": "alice" }); + let _ = decode_msg(&room, &m, true); // must not panic + } + + proptest! { + // The frame parsers consume attacker-controlled JSON (decrypted from a + // zero-knowledge relay) — they must classify or reject, never panic. + #[test] + fn parse_sbx_never_panics(s in ".*", sender in ".*") { + let _ = parse_sbx(&s, &sender); + } + + #[test] + fn parse_ai_never_panics(s in ".*") { + let _ = parse_ai(&s); + } + + #[test] + fn parse_perm_never_panics(s in ".*") { + let _ = parse_perm(&s); + } + + // Well-formed JSON envelopes with arbitrary field values (the realistic + // shape a hostile peer would send): still no panic. + #[test] + fn parse_sbx_structured_never_panics( + kind in "[a-z]{0,10}", b64 in ".*", rows in any::(), cols in any::(), sender in ".*" + ) { + let frame = json!({"_sbx": kind, "b64": b64, "rows": rows, "cols": cols, "vm": b64}).to_string(); + let _ = parse_sbx(&frame, &sender); + } + + #[test] + fn parse_ai_structured_never_panics(kind in "[a-z]{0,10}", name in ".*", text in ".*", on in any::()) { + let frame = json!({"_ai": kind, "name": name, "text": text, "on": on, "done": on}).to_string(); + let _ = parse_ai(&frame); + } + + // decode_msg sees server-stamped + peer-encrypted objects; a malicious + // server controls timestamp/username and a peer controls the ciphertext. + // None of those combinations may panic the client. + #[test] + fn decode_msg_never_panics(text in ".*", ts in ".*", user in ".*", live in any::()) { + let room = test_room(); + let m = json!({ "text": text, "timestamp": ts, "username": user }); + let _ = decode_msg(&room, &m, live); + } + + // parse_users tolerates arbitrary array shapes. + #[test] + fn parse_users_never_panics(id in ".*", name in ".*") { + let v = json!([{ "user_id": id, "username": name }, "garbage", 42, null]); + let _ = parse_users(&v); + } + } +} diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs index 51eb0ab..349e635 100644 --- a/hh/src/sbx.rs +++ b/hh/src/sbx.rs @@ -143,6 +143,143 @@ pub fn gui_launch(name: &str) -> Result { Ok(format!("launched {name} (GUI)")) } +/// 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 { + 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 { + 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//...`. +fn multipass_instance(args: &str) -> Option { + 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)] @@ -313,7 +450,10 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result { .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()); + anyhow::bail!( + "docker commit failed: {}", + err.lines().last().unwrap_or("").trim() + ); } Ok(format!("image {tag}")) } @@ -324,7 +464,10 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result { .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()); + anyhow::bail!( + "multipass snapshot failed: {}", + err.lines().last().unwrap_or("").trim() + ); } Ok(format!("snapshot {name}.{label}")) } @@ -610,6 +753,75 @@ mod tests { "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)]