test(client),ci: fuzz frame parsers, VT-x classifier tests, smoke + CI hardening

- proptest-fuzz the untrusted frame parsers (sbx/ai/perm/users/decode_msg) so
  a hostile relay/peer can never panic a client; fixes a decode_msg timestamp
  byte-slice that panicked on a non-ASCII stamp (now char-boundary safe)
- extract a pure classify_vtx_holders() out of vtx_holders() and unit-test the
  KVM/QEMU/multipass detection and stoppability rules
- headless cross-stack smoke test (smoke-e2e.sh): real relay + two TUI clients
  in tmux, asserting SRP join, Fernet chat round-trip, and command dispatch
- CI: macOS matrix for the Rust client, cargo-audit + pip-audit, gitleaks
  secret scan, llvm-cov/pytest-cov coverage, and a smoke-test job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-06-04 22:56:00 -07:00
parent dc23e0b44e
commit 01e607dced
6 changed files with 600 additions and 10 deletions

View File

@ -9,7 +9,11 @@ on:
jobs: jobs:
rust: rust:
name: rust client (hh) name: rust client (hh)
runs-on: ubuntu-latest strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
defaults: defaults:
run: run:
working-directory: hh working-directory: hh
@ -21,11 +25,36 @@ jobs:
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
workspaces: hh 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 clippy --all-targets -- -D warnings
- run: cargo build --verbose - run: cargo build --verbose
- run: cargo test --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: python:
name: python server name: python server
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -38,5 +67,63 @@ jobs:
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: pip 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: 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 }}

108
hh/Cargo.lock generated
View File

@ -110,6 +110,21 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -402,6 +417,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "fernet" name = "fernet"
version = "0.2.2" version = "0.2.2"
@ -442,6 +463,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@ -591,6 +618,7 @@ dependencies = [
"num-bigint", "num-bigint",
"num-traits", "num-traits",
"portable-pty", "portable-pty",
"proptest",
"rand 0.8.6", "rand 0.8.6",
"ratatui", "ratatui",
"reqwest", "reqwest",
@ -1230,6 +1258,31 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@ -1359,6 +1412,15 @@ dependencies = [
"getrandom 0.3.4", "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]] [[package]]
name = "ratatui" name = "ratatui"
version = "0.29.0" version = "0.29.0"
@ -1390,6 +1452,12 @@ dependencies = [
"bitflags 2.11.1", "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]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@ -1520,6 +1588,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 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]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@ -1823,6 +1903,19 @@ dependencies = [
"xattr", "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]] [[package]]
name = "termios" name = "termios"
version = "0.2.2" version = "0.2.2"
@ -2088,6 +2181,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -2204,6 +2303,15 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"

View File

@ -48,3 +48,7 @@ serde_json = "1"
# cli / errors # cli / errors
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
anyhow = "1" anyhow = "1"
[dev-dependencies]
# property-based fuzzing of the frame parsers (attacker-controlled JSON)
proptest = "1"

100
hh/smoke-e2e.sh Executable file
View File

@ -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=<python> BIN=<client binary> PORT=<port> PW=<password>
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

View File

@ -189,12 +189,12 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded {
} }
Err(_) => ("[unreadable — wrong room password?]".to_string(), true), 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 stamp = m["timestamp"].as_str().unwrap_or("");
let ts = if stamp.len() >= 19 { let ts: String = stamp.chars().skip(11).take(8).collect();
stamp[11..19].to_string()
} else {
String::new()
};
Decoded::Chat(ChatLine { Decoded::Chat(ChatLine {
ts, ts,
username: m["username"].as_str().unwrap_or("?").to_string(), username: m["username"].as_str().unwrap_or("?").to_string(),
@ -223,6 +223,12 @@ fn parse_sbx(text: &str, sender: &str) -> Option<Net> {
from: sender.to_string(), from: sender.to_string(),
bytes: STANDARD.decode(v["b64"].as_str()?).ok()?, 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, _ => None,
} }
} }
@ -321,3 +327,76 @@ pub async fn reader(
} }
let _ = tx.send(Net::Closed); 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::<i64>(), cols in any::<i64>(), 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::<bool>()) {
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::<bool>()) {
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);
}
}
}

View File

@ -143,6 +143,143 @@ pub fn gui_launch(name: &str) -> Result<String> {
Ok(format!("launched {name} (GUI)")) 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<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), /// Which sandbox to summon. Multipass = strong isolation (default for real use),
/// Docker = fast, Local = no isolation (dev/testing only). /// Docker = fast, Local = no isolation (dev/testing only).
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -313,7 +450,10 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
.context("docker commit (is docker installed?)")?; .context("docker commit (is docker installed?)")?;
if !out.status.success() { if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr); 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}")) Ok(format!("image {tag}"))
} }
@ -324,7 +464,10 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
.context("multipass snapshot (is multipass installed?)")?; .context("multipass snapshot (is multipass installed?)")?;
if !out.status.success() { if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr); 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}")) Ok(format!("snapshot {name}.{label}"))
} }
@ -610,6 +753,75 @@ mod tests {
"pty output missing marker; got: {acc:?}" "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)] #[cfg(test)]