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:
parent
dc23e0b44e
commit
01e607dced
93
.github/workflows/ci.yml
vendored
93
.github/workflows/ci.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
108
hh/Cargo.lock
generated
108
hh/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
100
hh/smoke-e2e.sh
Executable file
100
hh/smoke-e2e.sh
Executable 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
|
||||
|
|
@ -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<Net> {
|
|||
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::<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
216
hh/src/sbx.rs
216
hh/src/sbx.rs
|
|
@ -143,6 +143,143 @@ pub fn gui_launch(name: &str) -> Result<String> {
|
|||
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),
|
||||
/// 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<String> {
|
|||
.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<String> {
|
|||
.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)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user