"""Failure-path tests for the conditions found in the reliability review. Every test here corresponds to a way the server used to fail badly: a corrupt state file taking down every tool, a destructive tool deleting outside its tree or while QEMU still held the file, a guest agent that stops answering mid-call, two instances clobbering each other's records. """ import asyncio import json import socket import pytest from fastmcp import Client from fastmcp.exceptions import ToolError from conftest import FakeQMPClient, result_data, write_registry from mcqemu.config import Config from mcqemu.models import VMRecord from mcqemu.registry import VMRegistry from mcqemu.server import mcp from test_lifecycle import seeded_record def config_for(dirs) -> Config: return Config(state_dir=dirs.state, runtime_dir=dirs.run) # --- registry survives damage ---------------------------------------------- async def test_corrupt_registry_does_not_kill_the_server(dirs): """A truncated state file must not make every tool unreachable.""" (dirs.state / "vms.json").write_text('{"broken": ') async with Client(mcp) as client: listing = result_data(await client.call_tool("list_vms", {})) assert listing["vms"] == [] assert any("quarantined" in w for w in listing["registry_warnings"]) assert list(dirs.state.glob("vms.corrupt.*.json")), "bad file should be preserved" async def test_malformed_record_is_skipped_not_fatal(dirs): good = seeded_record(dirs, "good") (dirs.state / "vms.json").write_text( json.dumps({"good": good.to_dict(), "broken": {"no": "required fields"}}) ) async with Client(mcp) as client: listing = result_data(await client.call_tool("list_vms", {})) assert [v["name"] for v in listing["vms"]] == ["good"] assert any("broken" in w for w in listing["registry_warnings"]) async def test_traversal_key_is_dropped_on_load(dirs): """A registry key that is not a valid VM name never reaches the tools.""" rec = seeded_record(dirs, "ok") payload = {"../../escape": rec.to_dict(), "ok": rec.to_dict()} (dirs.state / "vms.json").write_text(json.dumps(payload)) async with Client(mcp) as client: listing = result_data(await client.call_tool("list_vms", {})) assert [v["name"] for v in listing["vms"]] == ["ok"] assert any("invalid VM name" in w for w in listing["registry_warnings"]) def test_unknown_schema_version_refuses_rather_than_stripping(dirs): (dirs.state / "vms.json").write_text(json.dumps({"version": 99, "vms": {}})) registry = VMRegistry(config_for(dirs)) registry.load() assert registry.all() == [] assert "newer than this build" in registry.degraded def test_unknown_record_fields_round_trip(dirs): """An older build must not strip a newer one's fields when it saves.""" registry = VMRegistry(config_for(dirs)) registry.load() raw = seeded_record(dirs, "vm1").to_dict() | {"future_field": {"keep": "me"}} (dirs.state / "vms.json").write_text(json.dumps({"vm1": raw})) registry2 = VMRegistry(config_for(dirs)) registry2.load() registry2.add(seeded_record(dirs, "vm2")) # triggers a full rewrite on_disk = json.loads((dirs.state / "vms.json").read_text())["vms"] assert on_disk["vm1"]["future_field"] == {"keep": "me"} def test_concurrent_instances_merge_instead_of_clobbering(dirs): """Two mcqemu processes sharing a registry must not orphan each other.""" a, b = VMRegistry(config_for(dirs)), VMRegistry(config_for(dirs)) a.load() b.load() # both start from an empty view a.add(seeded_record(dirs, "from-a")) b.add(seeded_record(dirs, "from-b")) # stale in-memory view, must still merge fresh = VMRegistry(config_for(dirs)) fresh.load() assert sorted(fresh.names()) == ["from-a", "from-b"] def test_name_reservation_blocks_a_second_in_flight_launch(dirs): registry = VMRegistry(config_for(dirs)) registry.load() assert registry.reserve("vm1") is True assert registry.reserve("vm1") is False registry.release("vm1") assert registry.reserve("vm1") is True # --- destructive tool refuses to guess ------------------------------------- async def test_sandbox_destroy_refuses_path_outside_state_dir(dirs, all_pids_dead, monkeypatch): """Even with a hostile record, deletion stays inside the VM state tree.""" victim = dirs.state / "IMPORTANT" victim.mkdir() (victim / "precious.txt").write_text("keep me") record = seeded_record(dirs, "sb", config={"disks": [], "sandbox_overlay": "/tmp/x.qcow2"}) write_registry(dirs, record) # Simulate a corrupted config whose state dir escapes the vms/ root. monkeypatch.setattr(Config, "vm_state_dir", lambda self, name: victim) async with Client(mcp) as client: with pytest.raises(ToolError, match="not inside"): await client.call_tool("sandbox_destroy", {"name": "sb"}) assert (victim / "precious.txt").exists() async def test_sandbox_destroy_refuses_when_vm_survives_kill(dirs, all_pids_alive, monkeypatch): """If the process will not die, its overlay must not be deleted.""" overlay = dirs.state / "vms" / "sb" / "overlay.qcow2" overlay.parent.mkdir(parents=True) overlay.write_bytes(b"disk") write_registry( dirs, seeded_record(dirs, "sb", config={"disks": [], "sandbox_overlay": str(overlay)}), ) monkeypatch.setattr("mcqemu.tools.sandbox.os.kill", lambda pid, sig: None) monkeypatch.setattr("mcqemu.tools.sandbox._await_exit", _never_exits) async with Client(mcp) as client: with pytest.raises(ToolError, match="survived both"): await client.call_tool("sandbox_destroy", {"name": "sb"}) assert overlay.exists(), "overlay must survive a failed kill" async with Client(mcp) as client: listing = result_data(await client.call_tool("list_vms", {})) assert [v["name"] for v in listing["vms"]] == ["sb"], "record must not be dropped" async def _never_exits(pid, seconds): return False async def test_sandbox_destroy_reports_cleanup_failure_honestly(dirs, all_pids_dead, monkeypatch): overlay = dirs.state / "vms" / "sb" / "overlay.qcow2" overlay.parent.mkdir(parents=True) overlay.write_bytes(b"disk") write_registry( dirs, seeded_record(dirs, "sb", config={"disks": [], "sandbox_overlay": str(overlay)}), ) def boom(target, onexc=None): onexc(None, str(target), PermissionError("read-only filesystem")) monkeypatch.setattr("mcqemu.tools.sandbox.shutil.rmtree", boom) async with Client(mcp) as client: data = result_data(await client.call_tool("sandbox_destroy", {"name": "sb"})) assert data["destroyed"] is False assert data["cleanup_errors"], "a failed delete must be reported, not swallowed" # --- launch does not trample a live VM ------------------------------------- async def test_launch_refuses_to_reuse_a_live_qmp_socket(dirs): """An unregistered but running QEMU must not lose its monitor socket.""" run_dir = dirs.run / "ghost" run_dir.mkdir(parents=True) sock_path = run_dir / "qmp.sock" listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) listener.bind(str(sock_path)) listener.listen(1) try: async with Client(mcp) as client: with pytest.raises(ToolError, match="LIVE QMP socket"): await client.call_tool("launch_vm", {"name": "ghost", "no_net": True}) assert sock_path.exists(), "the live socket must not be unlinked" finally: listener.close() # --- guest agent cannot hang the server ------------------------------------ async def test_guest_exec_times_out_when_agent_stops_answering(dirs, fake_qmp, monkeypatch): monkeypatch.setattr("mcqemu.tools.guest.CALL_TIMEOUT", 1.0) FakeQMPClient.responses["guest-sync"] = lambda args: args["id"] FakeQMPClient.responses["guest-exec"] = {"pid": 7} FakeQMPClient.responses["guest-exec-status"] = lambda args: asyncio.sleep(3600) write_registry(dirs, seeded_record(dirs, "vm1")) async with Client(mcp) as client: with pytest.raises(ToolError, match="stopped responding"): await asyncio.wait_for( client.call_tool("guest_exec", {"name": "vm1", "command": "sleep", "timeout": 2}), timeout=30, ) async def test_guest_file_read_gives_up_on_a_zero_progress_agent(dirs, fake_qmp): """A FIFO or tty returns count=0 without EOF forever; don't spin on it.""" FakeQMPClient.responses["guest-sync"] = lambda args: args["id"] FakeQMPClient.responses["guest-file-open"] = 3 FakeQMPClient.responses["guest-file-read"] = {"count": 0, "eof": False} FakeQMPClient.responses["guest-file-close"] = {} write_registry(dirs, seeded_record(dirs, "vm1")) async with Client(mcp) as client: data = result_data( await asyncio.wait_for( client.call_tool("guest_file_read", {"name": "vm1", "path": "/dev/fifo"}), timeout=20, ) ) assert data["bytes_read"] == 0 reads = [c for c, _ in FakeQMPClient.calls if c == "guest-file-read"] assert len(reads) < 10, "should stop after a few empty reads, not loop" async def test_guest_file_read_rejects_absurd_max_bytes(dirs, fake_qmp): FakeQMPClient.responses["guest-sync"] = lambda args: args["id"] write_registry(dirs, seeded_record(dirs, "vm1")) async with Client(mcp) as client: with pytest.raises(ToolError, match="max_bytes"): await client.call_tool( "guest_file_read", {"name": "vm1", "path": "/x", "max_bytes": 10**12} ) # --- untrusted bytes -------------------------------------------------------- async def test_screenshot_rejects_non_png_output(dirs, fake_qmp): def write_junk(args): with open(args["filename"], "wb") as f: f.write(b"not a png at all") return {} FakeQMPClient.responses["screendump"] = write_junk write_registry(dirs, seeded_record(dirs, "vm1")) async with Client(mcp) as client: with pytest.raises(ToolError, match="valid PNG"): await client.call_tool("vm_click", {"name": "vm1", "x": 1, "y": 1}) def test_record_name_follows_the_registry_key(dirs): """Tools look records up by key, so a mismatched inner name is corrected.""" rec = VMRecord(name="lies", source="spawned", qmp_socket="/tmp/q.sock") (dirs.state / "vms.json").write_text(json.dumps({"truth": rec.to_dict()})) registry = VMRegistry(config_for(dirs)) registry.load() assert registry.get("truth").name == "truth"