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
+84 -5
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),
};
// 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);
}
}
}
+214 -2
View File
@@ -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)]