Add integration tests based on VM

This commit is contained in:
2026-10-26 22:34:00 +02:00
parent f4a6540df9
commit 7d0f006601
22 changed files with 3826 additions and 69 deletions
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""Create and validate the one approved M9 VirtualBox VM; never start it."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import selectors
import stat
import subprocess
import sys
import time
from typing import Callable, Sequence
import uuid
import guard
LAB_ROOT = guard.LAB_ROOT
ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO
VM_NAME = guard.TARGET_NAME
OS_TYPE = "Debian13_64"
DISK_SIZE_MIB = 8192
MEMORY_MIB = 1024
CPUS = 1
MAX_CAPTURE = 1024 * 1024
TIMEOUT_SECONDS = 30
class CreateError(Exception):
"""Creation could not safely complete; partial target is retained."""
Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
process = subprocess.Popen(
list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"},
)
assert process.stdout is not None and process.stderr is not None
output = {process.stdout: bytearray(), process.stderr: bytearray()}
selector = selectors.DefaultSelector()
for stream in output:
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ)
deadline = time.monotonic() + TIMEOUT_SECONDS
try:
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise subprocess.TimeoutExpired(argv, TIMEOUT_SECONDS)
for key, _ in selector.select(remaining):
stream = key.fileobj
chunk = os.read(stream.fileno(), min(65536, MAX_CAPTURE + 1 - len(output[stream])))
if not chunk:
selector.unregister(stream)
continue
output[stream].extend(chunk)
if len(output[stream]) > MAX_CAPTURE:
raise CreateError("VBoxManage output exceeded the creation limit")
returncode = process.wait(timeout=max(0.0, deadline - time.monotonic()))
except (OSError, subprocess.TimeoutExpired, CreateError):
process.kill()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
raise
finally:
selector.close()
process.stdout.close()
process.stderr.close()
return subprocess.CompletedProcess(
argv, returncode,
bytes(output[process.stdout]).decode("utf-8", errors="replace"),
bytes(output[process.stderr]).decode("utf-8", errors="replace"),
)
def _call(runner: Runner, argv: Sequence[str], *, created_uuid: str | None = None) -> str:
try:
result = runner(tuple(argv))
except (OSError, subprocess.TimeoutExpired, CreateError) as exc:
suffix = f"; retained partial VM {VM_NAME} ({created_uuid})" if created_uuid else ""
raise CreateError(f"bounded VBoxManage call failed: {argv[1]}{suffix}") from exc
if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE:
raise CreateError("VBoxManage output exceeded the creation limit")
if result.returncode != 0:
suffix = f"; retained partial VM {VM_NAME} ({created_uuid})" if created_uuid else ""
raise CreateError(f"VBoxManage {argv[1]} failed with status {result.returncode}{suffix}")
return result.stdout
def _check_lab(lab_root: Path, iso_path: Path, owner_uid: int) -> None:
try:
root_info = lab_root.lstat()
except OSError as exc:
raise CreateError("private lab root is unavailable") from exc
if (not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode)
or root_info.st_uid != owner_uid or stat.S_IMODE(root_info.st_mode) != 0o700):
raise CreateError("lab root must be a real current-user directory with mode 0700")
if iso_path.parent.resolve(strict=True) != lab_root.resolve(strict=True):
raise CreateError("installer ISO must be directly inside the exact lab root")
try:
iso_info = iso_path.lstat()
except OSError as exc:
raise CreateError("pinned installer ISO is unavailable") from exc
if not stat.S_ISREG(iso_info.st_mode) or stat.S_ISLNK(iso_info.st_mode):
raise CreateError("installer ISO must be a regular non-symlink file")
digest = hashlib.sha256()
try:
with iso_path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
except OSError as exc:
raise CreateError("pinned installer ISO cannot be read") from exc
if digest.hexdigest() != guard.EXPECTED_ISO_SHA256:
raise CreateError("installer ISO SHA-256 does not match the pinned digest")
for path, description in (
(lab_root / guard.IDENTITY_FILE, "target identity manifest"),
(lab_root / VM_NAME, "exact target VM path"),
):
try:
path.lstat()
except FileNotFoundError:
continue
except OSError as exc:
raise CreateError(f"could not inspect {description}; refusing creation") from exc
if description == "target identity manifest":
raise CreateError(f"{description} already exists; refusing overwrite")
raise CreateError(f"{description} already exists; refusing creation")
def _check_absent(runner: Runner) -> None:
output = _call(runner, ("VBoxManage", "list", "vms"))
names: set[str] = set()
uuids: set[str] = set()
for line in output.splitlines():
if not line.strip():
continue
match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line)
if not match:
raise CreateError("malformed VBoxManage inventory; refusing creation")
name, raw_uuid = match.groups()
try:
vm_uuid = str(uuid.UUID(raw_uuid))
except ValueError as exc:
raise CreateError("malformed VBoxManage inventory UUID; refusing creation") from exc
if name in names or vm_uuid in uuids:
raise CreateError("duplicate VBoxManage inventory identity; refusing creation")
names.add(name)
uuids.add(vm_uuid)
if VM_NAME in names:
raise CreateError("exact target name is already registered; refusing creation")
def _write_manifest(lab_root: Path, target_uuid: str, owner_uid: int) -> None:
marker = f"{VM_NAME}:{target_uuid}"
data = {
"schema": 1,
"name": VM_NAME,
"owner_uid": owner_uid,
"uuid": target_uuid,
"host_marker": marker,
}
path = lab_root / guard.IDENTITY_FILE
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
fd = os.open(path, flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as destination:
os.fchmod(destination.fileno(), 0o600)
json.dump(data, destination, sort_keys=True)
destination.write("\n")
destination.flush()
os.fsync(destination.fileno())
except OSError as exc:
raise CreateError(f"could not write identity manifest; retained partial VM {VM_NAME} ({target_uuid})") from exc
def create_target(
*, lab_root: Path = LAB_ROOT,
iso_path: Path = ISO_PATH,
owner_uid: int | None = None,
runner: Runner = run_command,
) -> str:
"""Preflight, create/configure the exact VM, and run the read-only guard."""
owner_uid = os.getuid() if owner_uid is None else owner_uid
try:
_check_lab(lab_root, iso_path, owner_uid)
_check_absent(runner)
except OSError as exc:
raise CreateError("creation preflight could not inspect the lab root or ISO") from exc
create_output = _call(runner, (
"VBoxManage", "createvm", "--name", VM_NAME, "--ostype", OS_TYPE,
"--basefolder", str(lab_root), "--register",
))
created_match = re.search(r"(?m)^UUID:\s*([0-9A-Fa-f-]{36})\s*$", create_output)
target_uuid: str | None = None
if created_match:
try:
target_uuid = str(uuid.UUID(created_match.group(1)))
except ValueError:
target_uuid = None
try:
info_text = _call(runner, (
"VBoxManage", "showvminfo", target_uuid or VM_NAME, "--machinereadable",
), created_uuid=target_uuid)
info = guard._machine_values(info_text)
if info.get("name") != VM_NAME or "UUID" not in info:
raise CreateError(f"created VM identity is unreadable; retained partial VM {VM_NAME} ({target_uuid or 'UUID unavailable'})")
reported_uuid = str(uuid.UUID(info["UUID"]))
if target_uuid is not None and target_uuid != reported_uuid:
raise CreateError(f"created VM UUID changed unexpectedly; retained partial VM {VM_NAME} ({target_uuid})")
target_uuid = reported_uuid
expected_cfg = lab_root / VM_NAME / f"{VM_NAME}.vbox"
if Path(info.get("CfgFile", "")) != expected_cfg:
raise CreateError(f"created VM path is unexpected; retained partial VM {VM_NAME} ({target_uuid})")
_call(runner, (
"VBoxManage", "modifyvm", target_uuid,
"--memory", str(MEMORY_MIB), "--cpus", str(CPUS),
"--nic1", "nat", "--nic2", "none", "--nic3", "none", "--nic4", "none",
"--nic5", "none", "--nic6", "none", "--nic7", "none", "--nic8", "none",
"--audio", "none", "--usb", "off",
"--boot1", "dvd", "--boot2", "disk", "--boot3", "none", "--boot4", "none",
), created_uuid=target_uuid)
disk = lab_root / VM_NAME / f"{VM_NAME}.vdi"
_call(runner, (
"VBoxManage", "createmedium", "disk", "--filename", str(disk),
"--size", str(DISK_SIZE_MIB), "--format", "VDI", "--variant", "Standard",
), created_uuid=target_uuid)
_call(runner, (
"VBoxManage", "storagectl", target_uuid, "--name", "SATA",
"--add", "sata", "--controller", "IntelAhci",
), created_uuid=target_uuid)
_call(runner, (
"VBoxManage", "storageattach", target_uuid, "--storagectl", "SATA",
"--port", "0", "--device", "0", "--type", "hdd", "--medium", str(disk),
), created_uuid=target_uuid)
_call(runner, (
"VBoxManage", "storageattach", target_uuid, "--storagectl", "SATA",
"--port", "1", "--device", "0", "--type", "dvddrive", "--medium", str(iso_path),
), created_uuid=target_uuid)
_write_manifest(lab_root, target_uuid, owner_uid)
_call(runner, (
"VBoxManage", "modifyvm", target_uuid,
"--description", f"{VM_NAME}:{target_uuid}",
), created_uuid=target_uuid)
try:
guard.inspect_target(lab_root=lab_root, owner_uid=owner_uid, runner=runner)
except (guard.GuardError, OSError) as exc:
raise CreateError(f"final read-only guard failed; retained partial VM {VM_NAME} ({target_uuid})") from exc
return target_uuid
except CreateError as exc:
if "retained partial" in str(exc):
raise
raise CreateError(f"{exc}; retained partial VM {VM_NAME} ({locals().get('target_uuid', 'UUID unavailable')})") from exc
except (ValueError, KeyError, OSError) as exc:
raise CreateError(f"creation identity/configuration failed; retained partial VM {VM_NAME} ({locals().get('target_uuid', 'UUID unavailable')})") from exc
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.parse_args(argv)
try:
target_uuid = create_target()
except CreateError as exc:
print(f"VM creation refused/stopped: {exc}", file=sys.stderr)
return 2
print(f"Created and guarded {VM_NAME} ({target_uuid}); VM remains powered off")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""Fail-closed host guard for the single M9 VirtualBox target.
This is deliberately not a general VirtualBox wrapper. Its only possible
state-changing action is starting the one named lab VM after the full host
identity check. Guest privileged actions remain an explicit later step and
must be preceded by ``authorize-guest`` after the guest marker is installed.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import selectors
import shlex
import stat
import subprocess
import sys
import time
from typing import Callable, Sequence
import uuid
LAB_ROOT = Path("/media/data/wm/Virtualbox/wga-m9-lab")
TARGET_NAME = "wga-m9-debian137-20261025"
IDENTITY_FILE = "target-identity.json"
EXPECTED_ISO = "debian-13.7.0-amd64-netinst.iso"
EXPECTED_ISO_SHA256 = "a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355"
EXPECTED_GUEST_MARKER = "wga-m9-debian137-20261025"
GUEST_MARKER_PATH = "/etc/wga-m9-lab-marker"
MAX_CAPTURE = 1024 * 1024
class GuardError(Exception):
"""A target failed one of the explicit identity/isolation checks."""
Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
process = subprocess.Popen(
list(argv),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"},
)
assert process.stdout is not None and process.stderr is not None
output = {process.stdout: bytearray(), process.stderr: bytearray()}
selector = selectors.DefaultSelector()
for stream in output:
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ)
deadline = time.monotonic() + 15
try:
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise subprocess.TimeoutExpired(argv, 15)
for key, _ in selector.select(remaining):
stream = key.fileobj
chunk = os.read(stream.fileno(), min(65536, MAX_CAPTURE + 1 - len(output[stream])))
if not chunk:
selector.unregister(stream)
continue
output[stream].extend(chunk)
if len(output[stream]) > MAX_CAPTURE:
raise GuardError("VBoxManage output exceeded the guard limit")
returncode = process.wait(timeout=max(0.0, deadline - time.monotonic()))
except (OSError, subprocess.TimeoutExpired, GuardError):
process.kill()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
raise
finally:
selector.close()
process.stdout.close()
process.stderr.close()
return subprocess.CompletedProcess(
argv,
returncode,
bytes(output[process.stdout]).decode("utf-8", errors="replace"),
bytes(output[process.stderr]).decode("utf-8", errors="replace"),
)
def _machine_values(output: str) -> dict[str, str]:
"""Parse VBoxManage key=value output without evaluating its values."""
values: dict[str, str] = {}
for line in output.splitlines():
if not line.strip():
continue
# VBoxManage groups recording settings under section markers in
# machine-readable output. These are the only non-key/value rows we
# accept; keep rejecting all other unexpected text.
if re.fullmatch(r" rec_screen[0-9]+", line):
continue
if "=" not in line:
raise GuardError("malformed VBoxManage machine-readable line")
raw_key, raw = line.split("=", 1)
raw_key = raw_key.strip()
if raw_key.startswith('"') and raw_key.endswith('"'):
key = raw_key[1:-1]
elif '"' in raw_key:
raise GuardError("unrecognized VBoxManage machine-readable key")
else:
key = raw_key
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_()\-]*", key):
raise GuardError("unrecognized VBoxManage machine-readable key")
if key in values:
raise GuardError(f"duplicate VBoxManage machine-readable key: {key}")
try:
value = shlex.split(raw, posix=True)
except ValueError as exc:
raise GuardError(f"malformed VBoxManage value for {key}") from exc
if len(value) != 1:
raise GuardError(f"ambiguous VBoxManage value for {key}")
values[key] = value[0]
return values
def _owned_private_directory(path: Path, owner_uid: int) -> None:
try:
info = path.lstat()
except OSError as exc:
raise GuardError(f"lab directory unavailable: {path}") from exc
if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise GuardError("lab root must be a real directory")
if info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o700:
raise GuardError("lab root must be owned by the current user and mode 0700")
def _read_manifest(path: Path, owner_uid: int) -> dict[str, object]:
try:
info = path.lstat()
except OSError as exc:
raise GuardError("target identity manifest is missing") from exc
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise GuardError("target identity manifest must be a regular file")
if info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o600:
raise GuardError("target identity manifest must be current-user-owned mode 0600")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise GuardError("target identity manifest is unreadable or invalid") from exc
if not isinstance(raw, dict) or raw.get("schema") != 1:
raise GuardError("unsupported target identity manifest")
if raw.get("name") != TARGET_NAME or raw.get("owner_uid") != owner_uid:
raise GuardError("manifest owner or exact VM name does not match")
try:
target_uuid = str(uuid.UUID(str(raw.get("uuid"))))
except (ValueError, AttributeError) as exc:
raise GuardError("manifest UUID is missing or invalid") from exc
expected_marker = f"{TARGET_NAME}:{target_uuid}"
if raw.get("host_marker") != expected_marker:
raise GuardError("manifest host marker does not match its UUID")
return {"uuid": target_uuid, "host_marker": expected_marker}
def _invoke(runner: Runner, argv: Sequence[str]) -> str:
try:
result = runner(argv)
except (OSError, subprocess.TimeoutExpired) as exc:
raise GuardError(f"bounded VBoxManage call failed: {argv[1]}") from exc
if result.returncode != 0:
raise GuardError(f"VBoxManage {argv[1]} failed with status {result.returncode}")
if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE:
raise GuardError("VBoxManage output exceeded the guard limit")
return result.stdout
def _check_attachment_path(raw_path: str, lab_root: Path, suffix: str) -> Path:
if raw_path in {"", "emptydrive", "none"}:
return Path()
path = Path(raw_path)
if not path.is_absolute() or path.is_symlink():
raise GuardError("attached storage path must be absolute and not a symlink")
try:
resolved_root = lab_root.resolve(strict=True)
resolved = path.resolve(strict=True)
except OSError as exc:
raise GuardError("attached storage path is missing or unreadable") from exc
try:
resolved.relative_to(resolved_root)
except ValueError as exc:
raise GuardError("attached storage is outside the exact lab root") from exc
if resolved.suffix.lower() != suffix:
raise GuardError("attached storage is outside the exact lab root or has wrong type")
if resolved.stat().st_uid != os.getuid() or not resolved.is_file():
raise GuardError("attached storage must be a current-user-owned regular file")
return resolved
def inspect_target(
*,
lab_root: Path = LAB_ROOT,
owner_uid: int | None = None,
runner: Runner = run_command,
) -> tuple[str, dict[str, str]]:
owner_uid = os.getuid() if owner_uid is None else owner_uid
if lab_root != LAB_ROOT and owner_uid == os.getuid():
# The alternate root is only for unit tests; the CLI never accepts it.
pass
_owned_private_directory(lab_root, owner_uid)
manifest = _read_manifest(lab_root / IDENTITY_FILE, owner_uid)
target_uuid = str(manifest["uuid"])
listed = _invoke(runner, ("VBoxManage", "list", "vms"))
rows: list[tuple[str, str]] = []
for line in listed.splitlines():
if not line.strip():
continue
match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line)
if match is None:
raise GuardError("malformed VBoxManage VM inventory row")
name, raw_uuid = match.groups()
try:
listed_uuid = str(uuid.UUID(raw_uuid))
except ValueError as exc:
raise GuardError("malformed VBoxManage VM inventory UUID") from exc
rows.append((name, listed_uuid))
if len({name for name, _ in rows}) != len(rows) or len({uid for _, uid in rows}) != len(rows):
raise GuardError("duplicate VBoxManage VM inventory name or UUID")
matching = [(name, listed_uuid) for name, listed_uuid in rows if name == TARGET_NAME]
uuid_matches = [(name, listed_uuid) for name, listed_uuid in rows if listed_uuid.lower() == target_uuid]
if matching != [(TARGET_NAME, target_uuid)] or uuid_matches != [(TARGET_NAME, target_uuid)]:
raise GuardError("exact VM name is missing, duplicated, or has a different UUID")
output = _invoke(runner, ("VBoxManage", "showvminfo", target_uuid, "--machinereadable"))
info = _machine_values(output)
if info.get("name") != TARGET_NAME or info.get("UUID", "").lower() != target_uuid:
raise GuardError("showvminfo identity does not match the selected target")
marker = str(manifest["host_marker"])
if info.get("description") != marker:
raise GuardError("VirtualBox host marker is missing or mismatched")
cfg_file = Path(info.get("CfgFile", ""))
if cfg_file.is_symlink() or cfg_file.name != f"{TARGET_NAME}.vbox":
raise GuardError("VM configuration file is missing or unexpected")
target_folder = lab_root / TARGET_NAME
try:
resolved_lab_root = lab_root.resolve(strict=True)
target_folder_info = target_folder.lstat()
if not stat.S_ISDIR(target_folder_info.st_mode) or stat.S_ISLNK(target_folder_info.st_mode):
raise GuardError("exact target folder must be a real directory")
resolved_target_folder = target_folder.resolve(strict=True)
vm_folder = cfg_file.parent.resolve(strict=True)
config_file = cfg_file.resolve(strict=True)
except OSError as exc:
raise GuardError("VM configuration file or folder is missing") from exc
try:
resolved_target_folder.relative_to(resolved_lab_root)
config_file.relative_to(resolved_lab_root)
except ValueError as exc:
raise GuardError("VM configuration is outside the exact target folder") from exc
if (resolved_target_folder != resolved_lab_root / TARGET_NAME
or vm_folder != resolved_target_folder
or config_file.parent != resolved_target_folder):
raise GuardError("VM configuration is outside the exact target folder")
config_info = config_file.stat()
if not stat.S_ISREG(config_info.st_mode) or config_info.st_uid != owner_uid:
raise GuardError("VM configuration must be a current-user-owned regular file")
controllers = {
int(match.group(1)): value
for key, value in info.items()
if (match := re.fullmatch(r"storagecontrollername([0-9]+)", key))
}
if controllers != {0: "SATA"}:
raise GuardError("unexpected storage controller inventory")
disks: list[Path] = []
isos: list[Path] = []
# VirtualBox emits attachment rows as Controller-port-device. Its other
# per-port rows (for example SATA-ImageUUID-0-0 and
# SATA-hot-pluggable-0-0) look similar but are metadata, not media.
metadata_fields = {
"ImageUUID",
"IsEjected",
"hot-pluggable",
"nonrotational",
"discard",
"tempeject",
}
attachment_keys: set[str] = set()
for key, value in info.items():
if key.startswith("SATA-"):
suffix = key[len("SATA-"):]
if re.fullmatch(r"[0-9]+-[0-9]+", suffix):
attachment_keys.add(key)
continue
metadata = re.fullmatch(r"([A-Za-z][A-Za-z0-9_-]*)-[0-9]+-[0-9]+", suffix)
if metadata and metadata.group(1) in metadata_fields:
continue
# Do not let a disk on an undeclared controller (including a name
# containing hyphens) disappear from the attachment inventory.
if re.fullmatch(r".+-[0-9]+-[0-9]+", key):
raise GuardError("unrecognized attached storage key or controller")
for key in attachment_keys:
value = info[key]
if value.lower().endswith(".vdi"):
disks.append(_check_attachment_path(value, lab_root, ".vdi"))
elif value.lower().endswith(".iso"):
isos.append(_check_attachment_path(value, lab_root, ".iso"))
elif value not in {"emptydrive", "none"}:
raise GuardError("unexpected attached storage type")
expected_disk = (lab_root / TARGET_NAME / f"{TARGET_NAME}.vdi").resolve(strict=True)
if len(disks) != 1 or disks[0] != expected_disk:
raise GuardError("expected exactly one target VDI in the lab root")
expected_iso = (lab_root / EXPECTED_ISO).resolve(strict=True)
if len(isos) > 1 or (isos and isos[0] != expected_iso):
raise GuardError("unexpected attached ISO")
nics = {int(match.group(1)): value for key, value in info.items()
if (match := re.fullmatch(r"nic([1-8])", key))}
if nics.get(1) != "nat" or any(mode not in {"none", "null"} for slot, mode in nics.items() if slot != 1):
raise GuardError("target must have only NIC 1 in NAT mode")
if len(nics) != 8:
raise GuardError("NIC inventory is incomplete; refusing ambiguous network state")
if any((key.lower().startswith("natpf") or key.lower().startswith("forwarding(")) and value
for key, value in info.items()):
raise GuardError("NAT port forwarding is prohibited")
if any(key.startswith("SharedFolder") and value for key, value in info.items()):
raise GuardError("shared folders are prohibited")
return target_uuid, info
def start_target(*, lab_root: Path = LAB_ROOT, runner: Runner = run_command) -> str:
target_uuid, _ = inspect_target(lab_root=lab_root, runner=runner)
_invoke(runner, ("VBoxManage", "startvm", target_uuid, "--type=gui"))
return target_uuid
def authorize_guest(*, password_file: Path, lab_root: Path = LAB_ROOT, runner: Runner = run_command) -> str:
target_uuid, _ = inspect_target(lab_root=lab_root, runner=runner)
try:
info = password_file.lstat()
except OSError as exc:
raise GuardError("guest password file is unavailable") from exc
if (not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600):
raise GuardError("guest password file must be current-user-owned mode 0600")
output = _invoke(runner, (
"VBoxManage", "guestcontrol", target_uuid, "run", "--username", "wga",
"--timeout", "15000", "--wait-stdout",
"--passwordfile", str(password_file), "--exe", "/usr/bin/cat", "--",
GUEST_MARKER_PATH,
))
if output.strip() != EXPECTED_GUEST_MARKER:
raise GuardError("guest-local marker is missing or mismatched")
return target_uuid
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="action", required=True)
sub.add_parser("check", help="inspect the selected VM without changing it")
sub.add_parser("start", help="start only the selected VM after host identity checks")
guest = sub.add_parser("authorize-guest", help="check the guest marker using a password file")
guest.add_argument("--password-file", required=True, type=Path)
args = parser.parse_args(argv)
try:
if args.action == "check":
target_uuid, _ = inspect_target()
elif args.action == "start":
target_uuid = start_target()
else:
target_uuid = authorize_guest(password_file=args.password_file)
except GuardError as exc:
print(f"VM guard refused: {exc}", file=sys.stderr)
return 2
print(f"{args.action} authorized for {TARGET_NAME} ({target_uuid})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Check and make the bounded first headless start of the pinned M9 installer VM."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
from typing import Sequence
import uuid
import guard
TARGET_UUID = "b14c850b-a21e-4a56-acca-785590f0c4ac"
TARGET_NAME = guard.TARGET_NAME
LAB_ROOT = guard.LAB_ROOT
TARGET_FOLDER = LAB_ROOT / TARGET_NAME
EXPECTED_VDI = TARGET_FOLDER / f"{TARGET_NAME}.vdi"
EXPECTED_VISO = TARGET_FOLDER / (
f"Unattended-{TARGET_UUID}-aux-iso.viso"
)
MAX_CAPTURE = guard.MAX_CAPTURE
class InstallBootError(Exception):
"""Pinned installer state failed validation or first start was uncertain."""
def _regular_owned_file(
path: Path, *, owner_uid: int, mode: int | None, label: str,
) -> Path:
try:
info = path.lstat()
except OSError as exc:
raise InstallBootError(f"{label} is unavailable") from exc
if (not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != owner_uid
or (mode is not None and stat.S_IMODE(info.st_mode) != mode)):
permission = "a regular current-user-owned file" if mode is None else (
f"current-user-owned mode {mode:04o}"
)
raise InstallBootError(f"{label} must be {permission}")
if info.st_nlink != 1:
raise InstallBootError(f"{label} must have exactly one hard link")
try:
return path.resolve(strict=True)
except OSError as exc:
raise InstallBootError(f"{label} is unavailable") from exc
def _check_installer_private_files(target_folder: Path, owner_uid: int) -> None:
"""Check installer material permissions without opening or displaying contents."""
def walk_error(error: OSError) -> None:
raise InstallBootError("private target folder cannot be inspected") from error
try:
walk = tuple(os.walk(target_folder, followlinks=False, onerror=walk_error))
except OSError as exc:
raise InstallBootError("private target folder cannot be inspected") from exc
for directory, subdirectories, filenames in walk:
directory_path = Path(directory)
if any((directory_path / name).is_symlink() for name in subdirectories):
raise InstallBootError("symlink in private target folder is prohibited")
for name in subdirectories:
child = directory_path / name
child_info = child.lstat()
if (not stat.S_ISDIR(child_info.st_mode) or child_info.st_uid != owner_uid
or stat.S_IMODE(child_info.st_mode) != 0o700):
raise InstallBootError("nested target directories must be current-user-owned mode 0700")
for filename in filenames:
entry = directory_path / filename
if entry.is_symlink():
raise InstallBootError("symlink in private target folder is prohibited")
# VirtualBox's unattended installer generates the sensitive auxiliary
# media and installer scripts here. Match names only; never read content.
if re.search(r"(?:viso|isolinux|preseed|postinstall)", entry.name, re.IGNORECASE):
_regular_owned_file(
entry, owner_uid=owner_uid, mode=0o600,
label="generated unattended installer file",
)
def _machine_info(*, lab_root: Path, runner: guard.Runner, owner_uid: int) -> tuple[str, dict[str, str]]:
"""Apply strict host identity checks while allowing only the known installer .viso."""
try:
guard._owned_private_directory(lab_root, owner_uid)
manifest = guard._read_manifest(lab_root / guard.IDENTITY_FILE, owner_uid)
selected_uuid = str(manifest["uuid"])
if selected_uuid != TARGET_UUID:
raise InstallBootError("identity manifest UUID is not the pinned installer target")
listed = guard._invoke(runner, ("VBoxManage", "list", "vms"))
rows: list[tuple[str, str]] = []
for line in listed.splitlines():
if not line.strip():
continue
match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line)
if match is None:
raise InstallBootError("malformed VirtualBox VM inventory")
name, raw_uuid = match.groups()
try:
vm_uuid = str(uuid.UUID(raw_uuid))
except ValueError as exc:
raise InstallBootError("malformed VirtualBox VM UUID") from exc
rows.append((name, vm_uuid))
if (len({name for name, _ in rows}) != len(rows)
or len({vm_uuid for _, vm_uuid in rows}) != len(rows)
or [(name, vm_uuid) for name, vm_uuid in rows
if name == TARGET_NAME or vm_uuid == TARGET_UUID]
!= [(TARGET_NAME, TARGET_UUID)]):
raise InstallBootError("pinned VM name and UUID are missing, duplicated, or mismatched")
info = guard._machine_values(guard._invoke(
runner, ("VBoxManage", "showvminfo", TARGET_UUID, "--machinereadable"),
))
except guard.GuardError as exc:
raise InstallBootError(f"strict host identity check failed: {exc}") from None
if info.get("name") != TARGET_NAME or info.get("UUID", "").lower() != TARGET_UUID:
raise InstallBootError("showvminfo identity does not match the pinned target")
marker = f"{TARGET_NAME}:{TARGET_UUID}"
if manifest.get("host_marker") != marker or info.get("description") != marker:
raise InstallBootError("manifest and VirtualBox host marker do not match")
if info.get("VMState") != "poweroff":
raise InstallBootError("pinned target must be powered off before first start")
folder = lab_root / TARGET_NAME
try:
folder_info = folder.lstat()
except OSError as exc:
raise InstallBootError("exact target folder is unavailable") from exc
if (not stat.S_ISDIR(folder_info.st_mode) or stat.S_ISLNK(folder_info.st_mode)
or folder_info.st_uid != owner_uid or stat.S_IMODE(folder_info.st_mode) != 0o700):
raise InstallBootError("exact target folder must be current-user-owned mode 0700")
resolved_root = lab_root.resolve(strict=True)
resolved_folder = folder.resolve(strict=True)
if resolved_folder != resolved_root / TARGET_NAME:
raise InstallBootError("exact target folder resolves outside the lab root")
cfg_file = Path(info.get("CfgFile", ""))
if cfg_file.is_symlink() or cfg_file.name != f"{TARGET_NAME}.vbox":
raise InstallBootError("VM configuration path is unexpected")
config = _regular_owned_file(
cfg_file, owner_uid=owner_uid, mode=None,
label="VM configuration file",
)
if config.parent != resolved_folder:
raise InstallBootError("VM configuration is outside the exact target folder")
expected_disk = _regular_owned_file(
EXPECTED_VDI, owner_uid=owner_uid, mode=None,
label="pinned target VDI",
)
expected_viso = _regular_owned_file(
EXPECTED_VISO, owner_uid=owner_uid, mode=0o600, label="generated auxiliary .viso",
)
if expected_disk.parent != resolved_folder or expected_viso.parent != resolved_folder:
raise InstallBootError("expected VM media resolves outside the exact target folder")
if info.get("storagecontrollername0") != "SATA" or any(
key.startswith("storagecontrollername") and key != "storagecontrollername0"
for key in info
):
raise InstallBootError("storage controller inventory is not the pinned SATA controller")
attachments = {
key: value for key, value in info.items()
if re.fullmatch(r"SATA-[0-9]+-[0-9]+", key)
}
if (attachments.get("SATA-0-0") != str(expected_disk)
or attachments.get("SATA-1-0") != str(expected_viso)
or any(attachments.get(f"SATA-{port}-0") != "none" for port in range(2, 30))
or set(attachments) != {f"SATA-{port}-0" for port in range(30)}):
raise InstallBootError("SATA media do not exactly match the pinned installer state")
for key, value in info.items():
if (key.startswith("SATA-") and not re.fullmatch(
r"SATA-(?:[0-9]+-[0-9]+|(?:ImageUUID|IsEjected|hot-pluggable|nonrotational|discard|tempeject)-[0-9]+-[0-9]+)", key)):
raise InstallBootError("unrecognized SATA attachment metadata")
if (not key.startswith("SATA-")
and re.fullmatch(r".+-[0-9]+-[0-9]+", key)):
raise InstallBootError("unexpected attached storage controller or media")
if re.fullmatch(r"SATA-[0-9]+-[0-9]+", key) and value not in {"none", str(expected_disk), str(expected_viso)}:
raise InstallBootError("unexpected attached media")
if {key for key in info if re.fullmatch(r"nic[0-9]+", key)} != {
f"nic{slot}" for slot in range(1, 9)
}:
raise InstallBootError("network adapter inventory is incomplete or has extra devices")
nics = {f"nic{slot}": info.get(f"nic{slot}") for slot in range(1, 9)}
if nics != {"nic1": "nat", **{f"nic{slot}": "none" for slot in range(2, 9)}}:
raise InstallBootError("network inventory is not the pinned NAT-only configuration")
if any(key.lower().startswith(("natpf", "forwarding(")) and value
for key, value in info.items()):
raise InstallBootError("NAT port forwarding is prohibited")
if any(key.startswith("SharedFolder") and value for key, value in info.items()):
raise InstallBootError("VirtualBox shared folders are prohibited")
_check_installer_private_files(resolved_folder, owner_uid)
return TARGET_UUID, info
def inspect_installer_state(
*, lab_root: Path = LAB_ROOT,
owner_uid: int | None = None,
runner: guard.Runner = guard.run_command,
) -> tuple[str, dict[str, str]]:
owner_uid = os.getuid() if owner_uid is None else owner_uid
return _machine_info(lab_root=lab_root, runner=runner, owner_uid=owner_uid)
def start_installer(
*, lab_root: Path = LAB_ROOT,
owner_uid: int | None = None,
runner: guard.Runner = guard.run_command,
) -> str:
target_uuid, _ = inspect_installer_state(
lab_root=lab_root, owner_uid=owner_uid, runner=runner,
)
try:
guard._invoke(runner, ("VBoxManage", "startvm", target_uuid, "--type=headless"))
except guard.GuardError:
raise InstallBootError(
"bounded first-start request failed or timed out; the VM may have started; "
"run read-only inspection before any further action"
) from None
return target_uuid
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("check", "start"))
args = parser.parse_args(argv)
try:
if args.action == "check":
target_uuid, _ = inspect_installer_state()
else:
target_uuid = start_installer()
except (InstallBootError, guard.GuardError) as exc:
print(f"Installer-state guard refused: {exc}", file=sys.stderr)
return 2
if args.action == "check":
print(f"installer state checked for {TARGET_NAME} ({target_uuid}); VM remains powered off")
else:
print(
f"bounded first-start request completed for {TARGET_NAME} ({target_uuid}); "
"guest installation and boot are not verified"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Prepare the one pinned M9 VM for unattended installation; never start it."""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import re
import selectors
import stat
import subprocess
import sys
import time
from typing import Callable, Sequence
import guard
TARGET_UUID = "b14c850b-a21e-4a56-acca-785590f0c4ac"
LAB_ROOT = guard.LAB_ROOT
ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO
ISO_SHA256 = guard.EXPECTED_ISO_SHA256
ADDITIONS_ISO = Path("/usr/share/virtualbox/VBoxGuestAdditions.iso")
ADDITIONS_SHA256 = "4f51a073296de31cce53924860549149be5dc339f65dcd1dbf34fd7accefe8fb"
PASSWORD_DIR = Path("/tmp/wga-m9-private")
USER_PASSWORD_FILE = PASSWORD_DIR / "wga-password"
ROOT_PASSWORD_FILE = PASSWORD_DIR / "root-password"
MAX_CAPTURE = 1024 * 1024
TIMEOUT_SECONDS = 120
class PrepareError(Exception):
"""Preflight refused or unattended preparation stopped; output is redacted."""
Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
"""Run bounded VBoxManage while retaining its potentially secret output privately."""
process = subprocess.Popen(
list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"},
)
assert process.stdout is not None and process.stderr is not None
output = {process.stdout: bytearray(), process.stderr: bytearray()}
selector = selectors.DefaultSelector()
for stream in output:
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ)
deadline = time.monotonic() + TIMEOUT_SECONDS
try:
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise subprocess.TimeoutExpired(argv, TIMEOUT_SECONDS)
for key, _ in selector.select(remaining):
stream = key.fileobj
chunk = os.read(stream.fileno(), min(65536, MAX_CAPTURE + 1 - len(output[stream])))
if not chunk:
selector.unregister(stream)
continue
output[stream].extend(chunk)
if len(output[stream]) > MAX_CAPTURE:
raise PrepareError("VBoxManage output exceeded the preparation limit")
returncode = process.wait(timeout=max(0.0, deadline - time.monotonic()))
except (OSError, subprocess.TimeoutExpired, PrepareError):
process.kill()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
raise
finally:
selector.close()
process.stdout.close()
process.stderr.close()
return subprocess.CompletedProcess(
argv, returncode,
bytes(output[process.stdout]).decode("utf-8", errors="replace"),
bytes(output[process.stderr]).decode("utf-8", errors="replace"),
)
def _check_private_file(path: Path, owner_uid: int) -> None:
try:
info = path.lstat()
except OSError as exc:
raise PrepareError("private synthetic password file is unavailable") from exc
if (not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o600
or info.st_size == 0):
raise PrepareError("synthetic password files must be nonempty current-user files with mode 0600")
def _check_private_credentials(owner_uid: int) -> None:
try:
info = PASSWORD_DIR.lstat()
except OSError as exc:
raise PrepareError("private synthetic password directory is unavailable") from exc
if (not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o700):
raise PrepareError("synthetic password directory must be current-user-owned mode 0700")
_check_private_file(USER_PASSWORD_FILE, owner_uid)
_check_private_file(ROOT_PASSWORD_FILE, owner_uid)
def _check_digest(path: Path, expected: str, label: str) -> None:
try:
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise PrepareError(f"{label} must be a regular non-symlink file")
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
except OSError as exc:
raise PrepareError(f"{label} is unavailable") from exc
if digest.hexdigest() != expected:
raise PrepareError(f"{label} SHA-256 does not match the pinned value")
def _validate_preinstall_state(info: dict[str, str]) -> None:
if info.get("VMState") != "poweroff":
raise PrepareError("exact target must be powered off before preparation")
expected_disk = str((LAB_ROOT / guard.TARGET_NAME / f"{guard.TARGET_NAME}.vdi").resolve(strict=True))
expected_iso = str(ISO_PATH.resolve(strict=True))
attachments = {key: value for key, value in info.items()
if re.fullmatch(r"SATA-[0-9]+-[0-9]+", key)}
if (attachments.get("SATA-0-0") != expected_disk
or attachments.get("SATA-1-0") != expected_iso):
raise PrepareError("target media do not match the expected VDI and pinned installer ISO")
extra_attachments = set(attachments) - {"SATA-0-0", "SATA-1-0"}
if any(attachments[key] not in {"none", "emptydrive"} for key in extra_attachments):
raise PrepareError("target media do not match the expected VDI and pinned installer ISO")
def _arguments(*, dry_run: bool) -> tuple[str, ...]:
args = [
"VBoxManage", "unattended", "install", TARGET_UUID,
f"--iso={ISO_PATH}",
"--user=wga", "--full-user-name=WireGuard Admin Lab",
f"--user-password-file={USER_PASSWORD_FILE}",
f"--admin-password-file={ROOT_PASSWORD_FILE}",
"--hostname=wga-m9-debian137.invalid", "--locale=en_US", "--time-zone=UTC",
"--country=US", "--package-selection-adjustment=minimal",
"--install-additions", f"--additions-iso={ADDITIONS_ISO}",
"--start-vm=none",
]
if dry_run:
args.append("--dry-run")
return tuple(args)
def _invoke(runner: Runner, argv: Sequence[str], stage: str) -> None:
try:
result = runner(argv)
except PrepareError as exc:
if str(exc) == "VBoxManage output exceeded the preparation limit":
raise PrepareError(f"{stage} output exceeded the preparation limit; output was suppressed") from None
raise PrepareError(f"{stage} failed or timed out; VBoxManage output was suppressed") from None
except Exception:
raise PrepareError(f"{stage} failed or timed out; VBoxManage output was suppressed") from None
if result.returncode != 0:
raise PrepareError(f"{stage} failed with status {result.returncode}; VBoxManage output was suppressed")
if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE:
raise PrepareError(f"{stage} output exceeded the preparation limit; output was suppressed")
def prepare_target(*, runner: Runner = run_command, owner_uid: int | None = None) -> str:
owner_uid = os.getuid() if owner_uid is None else owner_uid
_check_private_credentials(owner_uid)
if guard.EXPECTED_ISO_SHA256 != ISO_SHA256:
raise PrepareError("configured installer digest differs from the pinned digest")
_check_digest(ISO_PATH, ISO_SHA256, "installer ISO")
_check_digest(ADDITIONS_ISO, ADDITIONS_SHA256, "Guest Additions ISO")
try:
target_uuid, info = guard.inspect_target(runner=runner)
except (guard.GuardError, OSError, subprocess.TimeoutExpired):
raise PrepareError("read-only exact-target guard failed; refusing preparation") from None
if target_uuid != TARGET_UUID:
raise PrepareError("read-only guard returned an unexpected target UUID")
_validate_preinstall_state(info)
_invoke(runner, _arguments(dry_run=True), "unattended dry run")
try:
_invoke(runner, _arguments(dry_run=False), "unattended preparation")
except PrepareError:
raise PrepareError(
f"unattended preparation stopped or partially completed for {TARGET_UUID}; "
"do not rerun; perform separate read-only inspection before any separately reviewed start"
) from None
return TARGET_UUID
def main() -> int:
try:
target_uuid = prepare_target()
except PrepareError as exc:
print(f"Unattended preparation refused/stopped: {exc}", file=sys.stderr)
return 2
print(
f"Unattended preparation completed for the pinned target ({target_uuid}); "
"VM remains unstarted. Perform separate read-only media inspection before any separately reviewed start."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Resume configuration of the one reviewed, powered-off partial M9 VM."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
from typing import Sequence
import uuid
import guard
import create
LAB_ROOT = guard.LAB_ROOT
ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO
VM_NAME = "wga-m9-debian137-20261025"
TARGET_UUID = "b14c850b-a21e-4a56-acca-785590f0c4ac"
PINNED_ISO_SHA256 = "a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355"
class ResumeError(Exception):
"""The partial VM could not safely be resumed or configured."""
Runner = create.Runner
def _require_absent(path: Path, description: str) -> None:
try:
path.lstat()
except FileNotFoundError:
return
except OSError as exc:
raise ResumeError(f"could not inspect {description}; refusing resume") from exc
raise ResumeError(f"{description} already exists; refusing resume")
def _check_iso(iso_path: Path) -> None:
if guard.EXPECTED_ISO_SHA256 != PINNED_ISO_SHA256:
raise ResumeError("configured ISO digest differs from the pinned digest")
if iso_path != ISO_PATH:
raise ResumeError("installer ISO path is not the pinned lab path")
try:
if iso_path.parent.resolve(strict=True) != LAB_ROOT.resolve(strict=True):
raise ResumeError("installer ISO must be directly inside the exact lab root")
info = iso_path.lstat()
except OSError as exc:
raise ResumeError("pinned installer ISO is unavailable") from exc
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise ResumeError("installer ISO must be a regular non-symlink file")
digest = hashlib.sha256()
try:
with iso_path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
except OSError as exc:
raise ResumeError("pinned installer ISO cannot be read") from exc
if digest.hexdigest() != PINNED_ISO_SHA256:
raise ResumeError("installer ISO SHA-256 does not match the pinned digest")
def _inventory(runner: Runner) -> None:
output = guard._invoke(runner, ("VBoxManage", "list", "vms"))
names: set[str] = set()
uuids: set[str] = set()
matches: list[tuple[str, str]] = []
for line in output.splitlines():
if not line.strip():
continue
match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line)
if not match:
raise ResumeError("malformed VBoxManage inventory; refusing resume")
name, raw_uuid = match.groups()
try:
vm_uuid = str(uuid.UUID(raw_uuid))
except ValueError as exc:
raise ResumeError("malformed VBoxManage inventory UUID; refusing resume") from exc
if name in names or vm_uuid in uuids:
raise ResumeError("duplicate VBoxManage inventory identity; refusing resume")
names.add(name)
uuids.add(vm_uuid)
if name == VM_NAME or vm_uuid == TARGET_UUID:
matches.append((name, vm_uuid))
if matches != [(VM_NAME, TARGET_UUID)]:
raise ResumeError("registered VM inventory does not match the pinned partial target")
def _preflight_info(info: dict[str, str], lab_root: Path, owner_uid: int) -> None:
if info.get("name") != VM_NAME or info.get("UUID", "").lower() != TARGET_UUID:
raise ResumeError("showvminfo identity does not match the pinned partial target")
if info.get("VMState") != "poweroff":
raise ResumeError("partial target must be powered off")
if info.get("description", ""):
raise ResumeError("partial target already has a host marker; refusing resume")
if info.get("memory") != "1024" or info.get("cpus") != "1":
raise ResumeError("partial target default memory or CPU state is unexpected")
cfg_file = Path(info.get("CfgFile", ""))
if cfg_file != lab_root / VM_NAME / f"{VM_NAME}.vbox" or cfg_file.is_symlink():
raise ResumeError("partial target configuration path is not the exact lab path")
try:
folder_info = (lab_root / VM_NAME).lstat()
config_info = cfg_file.lstat()
except OSError as exc:
raise ResumeError("partial target configuration file is unavailable") from exc
if (not stat.S_ISDIR(folder_info.st_mode) or stat.S_ISLNK(folder_info.st_mode)
or folder_info.st_uid != owner_uid
or (lab_root / VM_NAME).resolve(strict=True) != lab_root / VM_NAME):
raise ResumeError("partial target folder must be a real directory at the exact lab path")
if (not stat.S_ISREG(config_info.st_mode) or stat.S_ISLNK(config_info.st_mode)
or config_info.st_uid != owner_uid):
raise ResumeError("partial target configuration must be a current-user-owned regular file")
nics = {int(match.group(1)): value for key, value in info.items()
if (match := re.fullmatch(r"nic([1-8])", key))}
if (len(nics) != 8 or nics.get(1) != "nat"
or any(nics.get(slot) not in {"none", "null"} for slot in range(2, 9))):
raise ResumeError("partial target NIC state must be NIC 1 in NAT with NICs 2-8 disabled")
if any((key.lower().startswith("natpf") or key.lower().startswith("forwarding(")) and value
for key, value in info.items()):
raise ResumeError("partial target has NAT forwarding; refusing resume")
if any(key.startswith("SharedFolder") and value for key, value in info.items()):
raise ResumeError("partial target has shared folders; refusing resume")
storage_rows = [
(key, value) for key, value in info.items()
if re.fullmatch(r"[A-Za-z0-9_-]+-[0-9]+-[0-9]+", key)
]
if any(value not in {"", "emptydrive", "none"} for _, value in storage_rows):
raise ResumeError("partial target has attached storage; refusing resume")
if any(key.startswith("storagecontroller") for key in info):
raise ResumeError("partial target has a storage controller; refusing resume")
def _preflight(
*, lab_root: Path, iso_path: Path, owner_uid: int, runner: Runner,
) -> None:
if lab_root != LAB_ROOT:
raise ResumeError("resume is restricted to the pinned lab root")
try:
root_info = lab_root.lstat()
except OSError as exc:
raise ResumeError("private lab root is unavailable") from exc
if (not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode)
or root_info.st_uid != owner_uid or stat.S_IMODE(root_info.st_mode) != 0o700):
raise ResumeError("lab root must be a real current-user directory with mode 0700")
_check_iso(iso_path)
_require_absent(lab_root / guard.IDENTITY_FILE, "target identity manifest")
_require_absent(lab_root / VM_NAME / f"{VM_NAME}.vdi", "target VDI")
_inventory(runner)
info_text = guard._invoke(runner, (
"VBoxManage", "showvminfo", TARGET_UUID, "--machinereadable",
))
try:
info = guard._machine_values(info_text)
except guard.GuardError as exc:
raise ResumeError("partial target machine-readable state is invalid") from exc
_preflight_info(info, lab_root, owner_uid)
def _write_manifest(lab_root: Path, owner_uid: int) -> None:
marker = f"{VM_NAME}:{TARGET_UUID}"
data = {
"schema": 1,
"name": VM_NAME,
"owner_uid": owner_uid,
"uuid": TARGET_UUID,
"host_marker": marker,
}
path = lab_root / guard.IDENTITY_FILE
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
fd = os.open(path, flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as destination:
os.fchmod(destination.fileno(), 0o600)
json.dump(data, destination, sort_keys=True)
destination.write("\n")
destination.flush()
os.fsync(destination.fileno())
except OSError as exc:
raise ResumeError(f"could not write identity manifest; retained partial VM {VM_NAME} ({TARGET_UUID})") from exc
def _call(runner: Runner, argv: Sequence[str]) -> str:
try:
result = runner(tuple(argv))
except (OSError, subprocess.TimeoutExpired, create.CreateError, guard.GuardError) as exc:
raise ResumeError(f"bounded VBoxManage call failed: {argv[1]}") from exc
if len(result.stdout) > create.MAX_CAPTURE or len(result.stderr) > create.MAX_CAPTURE:
raise ResumeError("VBoxManage output exceeded the resume limit")
if result.returncode != 0:
raise ResumeError(f"VBoxManage {argv[1]} failed with status {result.returncode}")
return result.stdout
def resume_target(
*, lab_root: Path = LAB_ROOT,
iso_path: Path = ISO_PATH,
owner_uid: int | None = None,
runner: Runner = create.run_command,
) -> str:
"""Preflight the exact partial VM, finish setup, and run the read-only guard."""
owner_uid = os.getuid() if owner_uid is None else owner_uid
try:
_preflight(lab_root=lab_root, iso_path=iso_path, owner_uid=owner_uid, runner=runner)
except (OSError, guard.GuardError) as exc:
raise ResumeError("resume preflight failed closed") from exc
disk_path = lab_root / VM_NAME / f"{VM_NAME}.vdi"
try:
_call(runner, (
"VBoxManage", "modifyvm", TARGET_UUID,
"--memory", str(create.MEMORY_MIB), "--cpus", str(create.CPUS),
"--nic1", "nat", "--nic2", "none", "--nic3", "none", "--nic4", "none",
"--nic5", "none", "--nic6", "none", "--nic7", "none", "--nic8", "none",
"--audio", "none", "--usb", "off",
"--boot1", "dvd", "--boot2", "disk", "--boot3", "none", "--boot4", "none",
))
_call(runner, (
"VBoxManage", "createmedium", "disk", "--filename", str(disk_path),
"--size", str(create.DISK_SIZE_MIB), "--format", "VDI", "--variant", "Standard",
))
_call(runner, (
"VBoxManage", "storagectl", TARGET_UUID, "--name", "SATA",
"--add", "sata", "--controller", "IntelAhci",
))
_call(runner, (
"VBoxManage", "storageattach", TARGET_UUID, "--storagectl", "SATA",
"--port", "0", "--device", "0", "--type", "hdd", "--medium", str(disk_path),
))
_call(runner, (
"VBoxManage", "storageattach", TARGET_UUID, "--storagectl", "SATA",
"--port", "1", "--device", "0", "--type", "dvddrive", "--medium", str(iso_path),
))
_write_manifest(lab_root, owner_uid)
_call(runner, (
"VBoxManage", "modifyvm", TARGET_UUID,
"--description", f"{VM_NAME}:{TARGET_UUID}",
))
try:
selected_uuid, _ = guard.inspect_target(
lab_root=lab_root, owner_uid=owner_uid, runner=runner,
)
except (guard.GuardError, OSError) as exc:
raise ResumeError("final read-only guard failed") from exc
if selected_uuid != TARGET_UUID:
raise ResumeError("final guard returned an unexpected UUID")
return TARGET_UUID
except ResumeError as exc:
if "retained partial VM" in str(exc):
raise
raise ResumeError(f"{exc}; retained partial VM {VM_NAME} ({TARGET_UUID})") from exc
except (OSError, ValueError, KeyError, create.CreateError, guard.GuardError) as exc:
raise ResumeError(f"resume configuration failed; retained partial VM {VM_NAME} ({TARGET_UUID})") from exc
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.parse_args(argv)
try:
target_uuid = resume_target()
except ResumeError as exc:
print(f"VM resume refused/stopped: {exc}", file=sys.stderr)
return 2
print(f"Resumed and guarded {VM_NAME} ({target_uuid}); VM remains powered off")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+209
View File
@@ -0,0 +1,209 @@
"""Behavioral tests for the M9 create-only VirtualBox procedure."""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
import uuid
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
SPEC = importlib.util.spec_from_file_location("vm_create", HERE / "create.py")
assert SPEC and SPEC.loader
create = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(create)
class FakeVBox:
def __init__(self, root: Path, iso: Path, *, inventory: str = ""):
self.root = root
self.iso = iso
self.inventory = inventory
self.target_uuid = "1fd553db-3b0e-43e8-b407-244ea5f8c5c8"
self.description = ""
self.storage_controllers: dict[str, str] = {}
self.calls: list[tuple[str, ...]] = []
self.mutations: list[tuple[str, ...]] = []
def __call__(self, argv):
args = tuple(argv)
self.calls.append(args)
command = args[1]
if command not in {"list", "showvminfo"}:
self.mutations.append(args)
if args[1:3] == ("list", "vms"):
return subprocess.CompletedProcess(args, 0, self.inventory, "")
if command == "createvm":
folder = self.root / create.VM_NAME
folder.mkdir()
(folder / f"{create.VM_NAME}.vbox").write_text("synthetic", encoding="ascii")
self.inventory = f'"{create.VM_NAME}" {{{self.target_uuid}}}\n'
return subprocess.CompletedProcess(args, 0, f"VM created.\nUUID: {self.target_uuid}\n", "")
if command == "createmedium":
Path(args[args.index("--filename") + 1]).write_bytes(b"synthetic disk")
return subprocess.CompletedProcess(args, 0, "", "")
if command == "storagectl":
self.storage_controllers["storagecontrollername0"] = args[args.index("--name") + 1]
return subprocess.CompletedProcess(args, 0, "", "")
if command == "modifyvm" and "--description" in args:
self.description = args[args.index("--description") + 1]
return subprocess.CompletedProcess(args, 0, "", "")
if command == "showvminfo":
info = {
"name": create.VM_NAME,
"UUID": self.target_uuid,
"description": self.description,
"CfgFile": str(self.root / create.VM_NAME / f"{create.VM_NAME}.vbox"),
"nic1": "nat",
**{f"nic{slot}": "none" for slot in range(2, 9)},
"natpf1": "",
"SATA-0-0": str(self.root / create.VM_NAME / f"{create.VM_NAME}.vdi"),
"SATA-1-0": str(self.iso),
**self.storage_controllers,
}
output = "\n".join(f"{key}={json.dumps(value)}" for key, value in info.items()) + "\n"
return subprocess.CompletedProcess(args, 0, output, "")
return subprocess.CompletedProcess(args, 0, "", "")
class CreateTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name) / "lab"
self.root.mkdir(mode=0o700)
os.chmod(self.root, 0o700)
self.iso = self.root / create.guard.EXPECTED_ISO
self.iso.write_bytes(b"synthetic ISO")
self.digest = __import__("hashlib").sha256(self.iso.read_bytes()).hexdigest()
self.vbox = FakeVBox(self.root, self.iso)
def tearDown(self):
self.temp.cleanup()
def run_create(self, runner=None):
with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", self.digest):
return create.create_target(
lab_root=self.root, iso_path=self.iso, runner=runner or self.vbox,
)
def test_create_is_scoped_and_guarded_with_mode_0600_manifest(self):
target_uuid = self.run_create()
self.assertEqual(target_uuid, self.vbox.target_uuid)
self.assertEqual(self.vbox.calls[2][1:4], ("showvminfo", target_uuid, "--machinereadable"))
manifest_path = self.root / create.guard.IDENTITY_FILE
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["name"], create.VM_NAME)
self.assertEqual(manifest["uuid"], target_uuid)
self.assertEqual(manifest["host_marker"], f"{create.VM_NAME}:{target_uuid}")
self.assertEqual(manifest_path.stat().st_mode & 0o777, 0o600)
createvm = self.vbox.mutations[0]
self.assertEqual(createvm[:2], ("VBoxManage", "createvm"))
self.assertEqual(createvm[createvm.index("--name") + 1], create.VM_NAME)
self.assertEqual(createvm[createvm.index("--ostype") + 1], "Debian13_64")
self.assertEqual(createvm[createvm.index("--basefolder") + 1], str(self.root))
modify = next(call for call in self.vbox.mutations if call[1] == "modifyvm" and "--memory" in call)
self.assertEqual(modify[modify.index("--memory") + 1], "1024")
self.assertEqual(modify[modify.index("--cpus") + 1], "1")
self.assertEqual(modify[modify.index("--nic1") + 1], "nat")
for slot in range(2, 9):
self.assertEqual(modify[modify.index(f"--nic{slot}") + 1], "none")
self.assertEqual(modify[modify.index("--audio") + 1], "none")
self.assertEqual(modify[modify.index("--usb") + 1], "off")
self.assertEqual(modify[modify.index("--boot1") + 1], "dvd")
self.assertEqual(modify[modify.index("--boot2") + 1], "disk")
disk_call = next(call for call in self.vbox.mutations if call[1] == "createmedium")
self.assertEqual(disk_call[disk_call.index("--size") + 1], "8192")
self.assertFalse(any(call[1] in {"startvm", "unregistervm", "snapshot"} for call in self.vbox.calls))
self.assertEqual(self.vbox.calls[-2][1:], ("list", "vms"))
self.assertEqual(self.vbox.calls[-1][1], "showvminfo")
def test_preflight_failures_make_no_mutating_calls(self):
cases = (
("bad-digest", "ISO SHA-256"),
("registered", "already registered"),
("malformed", "malformed VBoxManage inventory"),
("manifest", "manifest already exists"),
)
for case, message in cases:
with self.subTest(case=case):
fake = FakeVBox(self.root, self.iso)
iso_path = self.iso
if case == "bad-digest":
with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", "0" * 64):
with self.assertRaisesRegex(create.CreateError, message):
create.create_target(lab_root=self.root, iso_path=iso_path, runner=fake)
self.assertEqual(fake.calls, [])
continue
if case == "registered":
fake.inventory = f'"{create.VM_NAME}" {{{fake.target_uuid}}}\n'
elif case == "malformed":
fake.inventory = "not a machine-readable row\n"
else:
(self.root / create.guard.IDENTITY_FILE).write_text("exists", encoding="ascii")
with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", self.digest):
with self.assertRaisesRegex(create.CreateError, message):
create.create_target(lab_root=self.root, iso_path=iso_path, runner=fake)
self.assertEqual(fake.mutations, [])
def test_occupied_manifest_and_target_paths_make_no_vbox_calls(self):
cases = (
("manifest-file", "manifest already exists", "manifest", False),
("manifest-dangling-symlink", "manifest already exists", "manifest", True),
("target-directory", "exact target VM path already exists", "target", False),
("target-dangling-symlink", "exact target VM path already exists", "target", True),
)
for case, message, occupied_kind, dangling in cases:
with self.subTest(case=case):
fake = FakeVBox(self.root, self.iso)
occupied_path = (
self.root / create.guard.IDENTITY_FILE
if occupied_kind == "manifest"
else self.root / create.VM_NAME
)
if dangling:
occupied_path.symlink_to(self.root / "missing-target")
elif occupied_kind == "manifest":
occupied_path.write_text("occupied", encoding="ascii")
else:
occupied_path.mkdir()
with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", self.digest):
with self.assertRaisesRegex(create.CreateError, message):
create.create_target(lab_root=self.root, iso_path=self.iso, runner=fake)
self.assertEqual(fake.calls, [])
if dangling or occupied_kind == "manifest":
occupied_path.unlink()
else:
occupied_path.rmdir()
def test_failed_configuration_retains_partial_identity_and_does_not_cleanup(self):
class FailingVBox(FakeVBox):
def __call__(inner, argv):
if len(argv) > 1 and argv[1] == "storagectl":
args = tuple(argv)
inner.calls.append(args)
inner.mutations.append(args)
return subprocess.CompletedProcess(args, 1, "", "sensitive detail omitted")
return super(FailingVBox, inner).__call__(argv)
fake = FailingVBox(self.root, self.iso)
with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", self.digest):
with self.assertRaisesRegex(create.CreateError, self.vbox.target_uuid):
create.create_target(lab_root=self.root, iso_path=self.iso, runner=fake)
self.assertTrue((self.root / create.VM_NAME / f"{create.VM_NAME}.vbox").exists())
self.assertFalse(any(call[1] in {"unregistervm", "closevm", "deletevm"} for call in fake.calls))
self.assertNotIn("sensitive detail omitted", str(fake.calls))
if __name__ == "__main__":
unittest.main()
+365
View File
@@ -0,0 +1,365 @@
"""Behavioral tests for the fail-closed VirtualBox target guard.
These tests use a temporary synthetic VM inventory and a recording fake CLI.
They never invoke VBoxManage, start a VM, or contact a host service.
"""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
import uuid
SPEC = importlib.util.spec_from_file_location("vm_guard", Path(__file__).with_name("guard.py"))
assert SPEC and SPEC.loader
guard = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(guard)
class FakeVBox:
def __init__(self, root: Path, *, mutate: dict[str, str] | None = None,
listed_uuid: str | None = None, duplicate: bool = False,
extra_list_rows: tuple[str, ...] = (),
guest_marker: str = guard.EXPECTED_GUEST_MARKER):
self.root = root
self.mutate = mutate or {}
self.listed_uuid = listed_uuid
self.duplicate = duplicate
self.extra_list_rows = extra_list_rows
self.guest_marker = guest_marker
self.calls: list[tuple[str, ...]] = []
self.target_uuid = str(uuid.UUID("1fd553db-3b0e-43e8-b407-244ea5f8c5c8"))
def __call__(self, argv):
args = tuple(argv)
self.calls.append(args)
if args[1:3] == ("list", "vms"):
uid = self.listed_uuid or self.target_uuid
rows = [f'"{guard.TARGET_NAME}" {{{uid}}}']
if self.duplicate:
rows.append(f'"{guard.TARGET_NAME}" {{{self.target_uuid}}}')
rows.extend(self.extra_list_rows)
return subprocess.CompletedProcess(args, 0, "\n".join(rows) + "\n", "")
if args[1] == "showvminfo":
lines = {
"name": guard.TARGET_NAME,
"UUID": self.target_uuid,
"description": f"{guard.TARGET_NAME}:{self.target_uuid}",
"CfgFile": str(self.root / guard.TARGET_NAME / f"{guard.TARGET_NAME}.vbox"),
"nic1": "nat",
**{f"nic{n}": "none" for n in range(2, 9)},
"natpf1": "",
"storagecontrollername0": "SATA",
"SATA-0-0": str(self.root / guard.TARGET_NAME / f"{guard.TARGET_NAME}.vdi"),
"SATA-1-0": str(self.root / guard.EXPECTED_ISO),
"SATA-2-0": "none",
"SATA-ImageUUID-0-0": "{synthetic-disk-uuid}",
"SATA-hot-pluggable-0-0": "off",
"SATA-nonrotational-0-0": "off",
"SATA-discard-0-0": "off",
"SATA-IsEjected-1-0": "off",
"SATA-tempeject-1-0": "off",
}
lines.update(self.mutate)
out = "\n".join(
f'{json.dumps(key) if "-" in key else key}={json.dumps(value)}'
for key, value in lines.items()
) + "\n"
return subprocess.CompletedProcess(args, 0, out, "")
if args[1:3] == ("guestcontrol", self.target_uuid):
return subprocess.CompletedProcess(args, 0, self.guest_marker + "\n", "")
return subprocess.CompletedProcess(args, 0, "", "")
class GuardTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name) / "lab"
self.root.mkdir(mode=0o700)
os.chmod(self.root, 0o700)
self.vm_dir = self.root / guard.TARGET_NAME
self.vm_dir.mkdir()
(self.vm_dir / f"{guard.TARGET_NAME}.vbox").write_text("synthetic", encoding="ascii")
(self.vm_dir / f"{guard.TARGET_NAME}.vdi").write_bytes(b"synthetic disk")
(self.root / guard.EXPECTED_ISO).write_bytes(b"synthetic ISO marker")
self.vbox = FakeVBox(self.root)
marker = f"{guard.TARGET_NAME}:{self.vbox.target_uuid}"
manifest = {
"schema": 1,
"name": guard.TARGET_NAME,
"owner_uid": os.getuid(),
"uuid": self.vbox.target_uuid,
"host_marker": marker,
}
manifest_path = self.root / guard.IDENTITY_FILE
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
os.chmod(manifest_path, 0o600)
def tearDown(self):
self.temp.cleanup()
def inspect(self, runner=None):
return guard.inspect_target(lab_root=self.root, runner=runner or self.vbox)
def test_normal_selected_identity_is_allowed(self):
target_uuid, info = self.inspect()
self.assertEqual(target_uuid, self.vbox.target_uuid)
self.assertEqual(info["nic1"], "nat")
self.assertEqual(self.vbox.calls, [
("VBoxManage", "list", "vms"),
("VBoxManage", "showvminfo", self.vbox.target_uuid, "--machinereadable"),
])
def test_missing_manifest_fails_closed_before_inventory_or_start(self):
(self.root / guard.IDENTITY_FILE).unlink()
with self.assertRaisesRegex(guard.GuardError, "manifest is missing"):
guard.start_target(lab_root=self.root, runner=self.vbox)
self.assertEqual(self.vbox.calls, [])
def test_duplicate_or_mismatched_name_uuid_fails_before_start(self):
for fake in (FakeVBox(self.root, duplicate=True),
FakeVBox(self.root, listed_uuid=str(uuid.uuid4()))):
with self.subTest(duplicate=fake.duplicate, listed_uuid=fake.listed_uuid):
with self.assertRaisesRegex(guard.GuardError, "duplicate|missing, duplicated"):
guard.start_target(lab_root=self.root, runner=fake)
self.assertEqual([call[1] for call in fake.calls], ["list"])
def test_malformed_or_ambiguous_vm_inventory_fails_before_start(self):
cases = (
(FakeVBox(self.root, extra_list_rows=("not a VM row",)), "malformed"),
(FakeVBox(self.root, extra_list_rows=(f'"other" {{{uuid.uuid4()}}}',
f'"other" {{{uuid.uuid4()}}}')), "duplicate"),
(FakeVBox(self.root, extra_list_rows=(f'"other" {{{self.vbox.target_uuid}}}',)), "duplicate"),
)
for fake, expected in cases:
with self.subTest(rows=fake.extra_list_rows):
with self.assertRaisesRegex(guard.GuardError, expected):
guard.start_target(lab_root=self.root, runner=fake)
self.assertEqual([call[1] for call in fake.calls], ["list"])
def test_malformed_or_duplicate_machine_readable_values_refuse_before_start(self):
for bad_line, expected in (("not-a-key-value", "malformed"),
('name="duplicate"', "duplicate")):
class MalformedMachine(FakeVBox):
def __call__(inner_self, argv):
result = super(MalformedMachine, inner_self).__call__(argv)
if argv[1] == "showvminfo":
result.stdout += bad_line + "\n"
return result
fake = MalformedMachine(self.root)
with self.subTest(line=bad_line):
with self.assertRaisesRegex(guard.GuardError, expected):
guard.start_target(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_quoted_storage_keys_and_port_metadata_match_observed_vbox_output(self):
fake = FakeVBox(self.root)
_, info = self.inspect(fake)
self.assertEqual(info['SATA-0-0'], str(self.vm_dir / f"{guard.TARGET_NAME}.vdi"))
self.assertEqual(info['SATA-2-0'], "none")
self.assertEqual(
{key for key in info if key.startswith("SATA-") and key not in {
"SATA-0-0", "SATA-1-0", "SATA-2-0"
}},
{
"SATA-ImageUUID-0-0",
"SATA-hot-pluggable-0-0",
"SATA-nonrotational-0-0",
"SATA-discard-0-0",
"SATA-IsEjected-1-0",
"SATA-tempeject-1-0",
},
)
self.assertEqual(info["SATA-ImageUUID-0-0"], "{synthetic-disk-uuid}")
def test_unknown_attachment_shaped_key_and_extra_media_are_rejected(self):
cases = (
({"SATA-unknown-property-1-0": "off"}, "unrecognized attached storage"),
({"SATA-3-0": str(self.vm_dir / f"{guard.TARGET_NAME}.vdi")},
"expected exactly one target VDI"),
)
for mutations, expected in cases:
with self.subTest(mutations=mutations):
fake = FakeVBox(self.root, mutate=mutations)
with self.assertRaisesRegex(guard.GuardError, expected):
guard.start_target(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_extra_or_undeclared_hyphenated_controller_storage_fails_closed(self):
cases = (
({"storagecontrollername1": "IDE-Extra", "IDE-Extra-0-0": "/tmp/other.iso"},
"storage controller inventory"),
({"IDE-Extra-0-0": "/tmp/other.iso"}, "unrecognized attached storage"),
)
for mutations, expected in cases:
with self.subTest(mutations=mutations):
fake = FakeVBox(self.root, mutate=mutations)
with self.assertRaisesRegex(guard.GuardError, expected):
guard.start_target(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_missing_storage_controller_inventory_fails_closed(self):
class MissingController(FakeVBox):
def __call__(inner_self, argv):
result = super(MissingController, inner_self).__call__(argv)
if argv[1] == "showvminfo":
result.stdout = "\n".join(
line for line in result.stdout.splitlines()
if not line.startswith("storagecontrollername")
) + "\n"
return result
fake = MissingController(self.root)
with self.assertRaisesRegex(guard.GuardError, "storage controller inventory"):
guard.start_target(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_target_folder_symlink_outside_lab_fails_configuration_guard(self):
outside_folder = Path(self.temp.name) / "outside-target"
self.vm_dir.rename(outside_folder)
self.vm_dir.symlink_to(outside_folder, target_is_directory=True)
fake = FakeVBox(self.root)
with self.assertRaisesRegex(guard.GuardError, "exact target folder must be a real directory"):
guard.start_target(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_quoted_and_unquoted_duplicate_keys_are_rejected(self):
class DuplicateQuotedKey(FakeVBox):
def __call__(inner_self, argv):
result = super(DuplicateQuotedKey, inner_self).__call__(argv)
if argv[1] == "showvminfo":
result.stdout += 'SATA-0-0="duplicate.vdi"\n'
return result
fake = DuplicateQuotedKey(self.root)
with self.assertRaisesRegex(guard.GuardError, "duplicate.*key"):
guard.start_target(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_malformed_quoted_key_is_rejected(self):
class MalformedQuotedKey(FakeVBox):
def __call__(inner_self, argv):
result = super(MalformedQuotedKey, inner_self).__call__(argv)
if argv[1] == "showvminfo":
result.stdout += '"SATA-0-1=value\n'
return result
fake = MalformedQuotedKey(self.root)
with self.assertRaisesRegex(guard.GuardError, "unrecognized.*key"):
guard.start_target(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_virtualbox_recording_section_marker_is_ignored(self):
class RecordingSection(FakeVBox):
def __call__(inner_self, argv):
result = super(RecordingSection, inner_self).__call__(argv)
if argv[1] == "showvminfo":
result.stdout = result.stdout.replace(
'name="' + guard.TARGET_NAME + '"\n',
'name="' + guard.TARGET_NAME + '"\n rec_screen0\n',
)
return result
fake = RecordingSection(self.root)
target_uuid, info = self.inspect(fake)
self.assertEqual(target_uuid, fake.target_uuid)
self.assertEqual(info["name"], guard.TARGET_NAME)
class UnknownSection(FakeVBox):
def __call__(inner_self, argv):
result = super(UnknownSection, inner_self).__call__(argv)
if argv[1] == "showvminfo":
result.stdout = result.stdout.replace(
'name="' + guard.TARGET_NAME + '"\n',
'name="' + guard.TARGET_NAME + '"\n rec_unknown0\n',
)
return result
unknown = UnknownSection(self.root)
with self.assertRaisesRegex(guard.GuardError, "malformed VBoxManage"):
self.inspect(unknown)
def test_mismatched_identity_path_network_and_marker_all_refuse_before_start(self):
cases = [
({"name": "wga-other"}, "showvminfo identity"),
({"description": "wrong-marker"}, "host marker"),
({"CfgFile": "/tmp/not-the-lab/other.vbox"}, "configuration"),
({"nic1": "bridged"}, "only NIC 1 in NAT"),
({"nic2": "hostonly"}, "only NIC 1 in NAT"),
({"natpf1": "ssh,tcp,,2222,,22"}, "port forwarding"),
({"Forwarding(0)": "ssh,tcp,,2222,,22"}, "port forwarding"),
({"SharedFolderPathMachineMapping1": "/"}, "shared folders"),
({"SATA-0-0": "/tmp/other.vdi"}, "attached storage path"),
]
for mutations, expected in cases:
with self.subTest(mutations=mutations):
fake = FakeVBox(self.root, mutate=mutations)
with self.assertRaisesRegex(guard.GuardError, expected):
guard.start_target(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_incomplete_nic_inventory_refuses_ambiguous_state(self):
class MissingNic(FakeVBox):
def __call__(self, argv):
result = super().__call__(argv)
if argv[1] == "showvminfo":
result.stdout = "\n".join(
line for line in result.stdout.splitlines() if not line.startswith('nic8=')
) + "\n"
return result
fake = MissingNic(self.root)
with self.assertRaisesRegex(guard.GuardError, "inventory is incomplete"):
guard.start_target(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_valid_start_is_scoped_to_exact_uuid_after_checks(self):
result = guard.start_target(lab_root=self.root, runner=self.vbox)
self.assertEqual(result, self.vbox.target_uuid)
self.assertEqual(self.vbox.calls[-1], (
"VBoxManage", "startvm", self.vbox.target_uuid, "--type=gui"))
def test_guest_marker_is_read_only_and_must_match_before_authorization(self):
password = self.root / "guest-password"
password.write_text("synthetic\n", encoding="ascii")
os.chmod(password, 0o600)
self.assertEqual(guard.authorize_guest(
password_file=password, lab_root=self.root, runner=self.vbox), self.vbox.target_uuid)
self.assertIn("--passwordfile", self.vbox.calls[-1])
self.assertIn("--wait-stdout", self.vbox.calls[-1])
self.assertEqual(self.vbox.calls[-1][self.vbox.calls[-1].index("--timeout") + 1], "15000")
self.assertNotIn("synthetic", " ".join(self.vbox.calls[-1]))
fake = FakeVBox(self.root, guest_marker="wrong")
with self.assertRaisesRegex(guard.GuardError, "guest-local marker"):
guard.authorize_guest(password_file=password, lab_root=self.root, runner=fake)
self.assertEqual(fake.calls[-1][1], "guestcontrol")
self.assertEqual(len(fake.calls), 3)
def test_subprocess_capture_stops_at_limit(self):
for stream in ("stdout", "stderr"):
script = f"import sys; sys.{stream}.write('x' * {guard.MAX_CAPTURE + 1})"
with self.subTest(stream=stream), self.assertRaisesRegex(guard.GuardError, "output exceeded"):
guard.run_command((sys.executable, "-c", script))
def test_unsafe_lab_or_manifest_permissions_refuse(self):
os.chmod(self.root, 0o755)
with self.assertRaisesRegex(guard.GuardError, "mode 0700"):
self.inspect()
os.chmod(self.root, 0o700)
os.chmod(self.root / guard.IDENTITY_FILE, 0o644)
with self.assertRaisesRegex(guard.GuardError, "mode 0600"):
self.inspect()
if __name__ == "__main__":
unittest.main()
+209
View File
@@ -0,0 +1,209 @@
"""Synthetic behavioral checks for the pinned installer-state guard."""
from __future__ import annotations
import importlib.util
import json
import os
import contextlib
import io
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
SPEC = importlib.util.spec_from_file_location("vm_install_boot", HERE / "install_boot.py")
assert SPEC and SPEC.loader
boot = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(boot)
class FakeVBox:
def __init__(self, root: Path, *, mutations: dict[str, str] | None = None,
extra_vms: tuple[str, ...] = (), start_result: int = 0):
self.root = root
self.mutations = mutations or {}
self.extra_vms = extra_vms
self.start_result = start_result
self.calls: list[tuple[str, ...]] = []
def __call__(self, argv):
args = tuple(argv)
self.calls.append(args)
if args[1:3] == ("list", "vms"):
rows = [f'"{boot.TARGET_NAME}" {{{boot.TARGET_UUID}}}', *self.extra_vms]
return subprocess.CompletedProcess(args, 0, "\n".join(rows) + "\n", "")
if args[1] == "showvminfo":
values = {
"name": boot.TARGET_NAME,
"UUID": boot.TARGET_UUID,
"description": f"{boot.TARGET_NAME}:{boot.TARGET_UUID}",
"CfgFile": str(self.root / boot.TARGET_NAME / f"{boot.TARGET_NAME}.vbox"),
"VMState": "poweroff",
"storagecontrollername0": "SATA",
"SATA-0-0": str(self.root / boot.TARGET_NAME / f"{boot.TARGET_NAME}.vdi"),
"SATA-1-0": str(self.root / boot.TARGET_NAME / f"Unattended-{boot.TARGET_UUID}-aux-iso.viso"),
**{f"SATA-{port}-0": "none" for port in range(2, 30)},
**{f"nic{slot}": "nat" if slot == 1 else "none" for slot in range(1, 9)},
"natpf1": "",
"SharedFolderNameMachineMapping1": "",
"SATA-ImageUUID-0-0": "{synthetic-disk-uuid}",
"SATA-IsEjected-1-0": "off",
}
values.update(self.mutations)
out = "\n".join(
f'{json.dumps(key) if "-" in key else key}={json.dumps(value)}'
for key, value in values.items()
) + "\n"
return subprocess.CompletedProcess(args, 0, out, "")
if args[1] == "startvm":
return subprocess.CompletedProcess(args, self.start_result, "", "")
return subprocess.CompletedProcess(args, 0, "", "")
class InstallBootTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name) / "lab"
self.root.mkdir(mode=0o700)
os.chmod(self.root, 0o700)
self.folder = self.root / boot.TARGET_NAME
self.folder.mkdir(mode=0o700)
os.chmod(self.folder, 0o700)
self.cfg = self.folder / f"{boot.TARGET_NAME}.vbox"
self.disk = self.folder / f"{boot.TARGET_NAME}.vdi"
self.viso = self.folder / f"Unattended-{boot.TARGET_UUID}-aux-iso.viso"
self.cfg.write_text("synthetic vbox", encoding="ascii")
self.disk.write_bytes(b"synthetic disk")
self.viso.write_bytes(b"synthetic viso; contents intentionally never read")
os.chmod(self.viso, 0o600)
manifest = {
"schema": 1,
"name": boot.TARGET_NAME,
"owner_uid": os.getuid(),
"uuid": boot.TARGET_UUID,
"host_marker": f"{boot.TARGET_NAME}:{boot.TARGET_UUID}",
}
(self.root / "target-identity.json").write_text(json.dumps(manifest), encoding="utf-8")
os.chmod(self.root / "target-identity.json", 0o600)
self.constants = mock.patch.multiple(
boot, LAB_ROOT=self.root, TARGET_FOLDER=self.folder,
EXPECTED_VDI=self.disk, EXPECTED_VISO=self.viso,
)
self.constants.start()
self.addCleanup(self.constants.stop)
self.fake = FakeVBox(self.root)
def tearDown(self):
self.temp.cleanup()
def test_exact_installer_state_checks_read_only(self):
uid, info = boot.inspect_installer_state(lab_root=self.root, runner=self.fake)
self.assertEqual(uid, boot.TARGET_UUID)
self.assertEqual(info["SATA-1-0"], str(self.viso))
self.assertEqual([call[1] for call in self.fake.calls], ["list", "showvminfo"])
def test_start_uses_only_pinned_headless_target_after_inspection(self):
uid = boot.start_installer(lab_root=self.root, runner=self.fake)
self.assertEqual(uid, boot.TARGET_UUID)
self.assertEqual(self.fake.calls[-1], (
"VBoxManage", "startvm", boot.TARGET_UUID, "--type=headless",
))
def test_identity_media_power_network_and_forwarding_mismatches_refuse_before_start(self):
cases = (
({"description": "wrong marker"}, "host marker"),
({"SATA-2-0": "/tmp/extra.iso"}, "SATA media"),
({"SATA-1-0": "/tmp/elsewhere.viso"}, "SATA media"),
({"VMState": "running"}, "powered off"),
({"nic2": "nat"}, "NAT-only"),
({"nic9": "nat"}, "extra devices"),
({"natpf1": "guest,tcp,,2222,,22"}, "forwarding"),
({"SharedFolderNameMachineMapping1": "hostshare"}, "shared folders"),
({"IDE-0-0": "/tmp/extra.iso"}, "storage controller or media"),
)
for mutations, message in cases:
with self.subTest(mutations=mutations):
fake = FakeVBox(self.root, mutations=mutations)
with self.assertRaisesRegex(boot.InstallBootError, message):
boot.start_installer(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_extra_registered_vm_row_is_rejected_without_start(self):
fake = FakeVBox(self.root, extra_vms=(
'"wga-m9-debian137-20261025" {12345678-1234-1234-1234-123456789012}',
))
with self.assertRaisesRegex(boot.InstallBootError, "missing, duplicated, or mismatched"):
boot.start_installer(lab_root=self.root, runner=fake)
self.assertEqual([call[1] for call in fake.calls], ["list"])
def test_target_folder_and_attached_viso_symlinks_refuse(self):
outside = Path(self.temp.name) / "outside"
self.folder.rename(outside)
self.folder.symlink_to(outside, target_is_directory=True)
fake = FakeVBox(self.root)
with self.assertRaisesRegex(boot.InstallBootError, "target folder"):
boot.start_installer(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_viso_mode_and_missing_media_are_checked_without_reading_contents(self):
os.chmod(self.viso, 0o644)
fake = FakeVBox(self.root)
with self.assertRaisesRegex(boot.InstallBootError, "mode 0600"):
boot.start_installer(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_target_vdi_hard_link_refuses_before_start(self):
other_vm_disk = Path(self.temp.name) / "other-vm.vdi"
os.link(self.disk, other_vm_disk)
fake = FakeVBox(self.root)
with self.assertRaisesRegex(boot.InstallBootError, "one hard link"):
boot.start_installer(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_generated_private_file_symlink_is_rejected(self):
secret = self.folder / "preseed.cfg"
secret.symlink_to(self.viso)
fake = FakeVBox(self.root)
with self.assertRaisesRegex(boot.InstallBootError, "symlink"):
boot.start_installer(lab_root=self.root, runner=fake)
self.assertNotIn("startvm", [call[1] for call in fake.calls])
def test_failed_start_reports_may_have_started_and_never_retries(self):
fake = FakeVBox(self.root, start_result=1)
with self.assertRaisesRegex(boot.InstallBootError, "may have started"):
boot.start_installer(lab_root=self.root, runner=fake)
self.assertEqual([call[1] for call in fake.calls].count("startvm"), 1)
def test_start_timeout_is_bounded_and_reported_as_ambiguous(self):
fake = FakeVBox(self.root)
start_attempts = []
def timed_out(argv):
if tuple(argv)[1] == "startvm":
start_attempts.append(tuple(argv))
raise subprocess.TimeoutExpired(argv, 15)
return fake(argv)
with self.assertRaisesRegex(boot.InstallBootError, "may have started"):
boot.start_installer(lab_root=self.root, runner=timed_out)
self.assertEqual(start_attempts, [(
"VBoxManage", "startvm", boot.TARGET_UUID, "--type=headless",
)])
def test_cli_has_no_target_or_path_override(self):
with mock.patch.object(boot, "inspect_installer_state") as inspect:
with contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as raised:
boot.main(["check", "--lab-root", "/tmp/other"])
self.assertEqual(raised.exception.code, 2)
inspect.assert_not_called()
if __name__ == "__main__":
unittest.main()
+175
View File
@@ -0,0 +1,175 @@
"""Behavioral tests for the credential-redacting M9 unattended preparation."""
from __future__ import annotations
import hashlib
import importlib.util
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
SPEC = importlib.util.spec_from_file_location("vm_install_prepare", HERE / "install_prepare.py")
assert SPEC and SPEC.loader
prepare = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(prepare)
class PrepareTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.lab = self.root / "lab"
self.vm_dir = self.lab / prepare.guard.TARGET_NAME
self.vm_dir.mkdir(parents=True)
os.chmod(self.lab, 0o700)
self.disk = self.vm_dir / f"{prepare.guard.TARGET_NAME}.vdi"
self.disk.write_bytes(b"synthetic VDI")
self.iso = self.lab / prepare.guard.EXPECTED_ISO
self.iso.write_bytes(b"synthetic installer")
self.additions = self.root / "VBoxGuestAdditions.iso"
self.additions.write_bytes(b"synthetic additions")
self.password_dir = self.root / "private"
self.password_dir.mkdir(mode=0o700)
os.chmod(self.password_dir, 0o700)
self.user_password = self.password_dir / "wga-password"
self.root_password = self.password_dir / "root-password"
self.user_password.write_text("synthetic-user-secret", encoding="ascii")
self.root_password.write_text("synthetic-root-secret", encoding="ascii")
os.chmod(self.user_password, 0o600)
os.chmod(self.root_password, 0o600)
self.iso_digest = hashlib.sha256(self.iso.read_bytes()).hexdigest()
self.additions_digest = hashlib.sha256(self.additions.read_bytes()).hexdigest()
self.info = {
"VMState": "poweroff",
"SATA-0-0": str(self.disk),
"SATA-1-0": str(self.iso),
}
self.info.update({f"SATA-{port}-0": "none" for port in range(2, 30)})
self.calls: list[tuple[str, ...]] = []
self.constants = mock.patch.multiple(
prepare, LAB_ROOT=self.lab, ISO_PATH=self.iso, ISO_SHA256=self.iso_digest,
ADDITIONS_ISO=self.additions, ADDITIONS_SHA256=self.additions_digest,
PASSWORD_DIR=self.password_dir, USER_PASSWORD_FILE=self.user_password,
ROOT_PASSWORD_FILE=self.root_password,
)
self.constants.start()
self.addCleanup(self.constants.stop)
self.guard_digest = mock.patch.object(prepare.guard, "EXPECTED_ISO_SHA256", self.iso_digest)
self.guard_digest.start()
self.addCleanup(self.guard_digest.stop)
self.guard_inspect = mock.patch.object(
prepare.guard, "inspect_target",
return_value=(prepare.TARGET_UUID, self.info),
)
self.guard_inspect.start()
self.addCleanup(self.guard_inspect.stop)
def tearDown(self):
self.temp.cleanup()
def runner(self, argv):
args = tuple(argv)
self.calls.append(args)
return subprocess.CompletedProcess(args, 0, "synthetic output", "")
def test_runs_dry_run_then_exact_target_prepare_without_start(self):
selected = prepare.prepare_target(runner=self.runner)
self.assertEqual(selected, prepare.TARGET_UUID)
self.assertEqual(len(self.calls), 2)
dry, actual = self.calls
self.assertEqual(dry[:4], ("VBoxManage", "unattended", "install", prepare.TARGET_UUID))
self.assertIn("--dry-run", dry)
self.assertNotIn("--dry-run", actual)
for args in (dry, actual):
self.assertIn(f"--iso={self.iso}", args)
self.assertIn("--user=wga", args)
self.assertIn("--hostname=wga-m9-debian137.invalid", args)
self.assertIn("--locale=en_US", args)
self.assertIn("--time-zone=UTC", args)
self.assertIn("--country=US", args)
self.assertIn("--package-selection-adjustment=minimal", args)
self.assertIn("--install-additions", args)
self.assertIn(f"--additions-iso={self.additions}", args)
self.assertIn("--start-vm=none", args)
self.assertIn(f"--user-password-file={self.user_password}", args)
self.assertIn(f"--admin-password-file={self.root_password}", args)
self.assertNotIn(f"--root-password-file={self.root_password}", args)
self.assertFalse(any("startvm" in value for value in args))
self.assertNotIn("synthetic-user-secret", args)
self.assertNotIn("synthetic-root-secret", args)
def test_bad_private_credentials_refuse_before_guard_or_vbox(self):
os.chmod(self.user_password, 0o644)
with self.assertRaisesRegex(prepare.PrepareError, "mode 0600"):
prepare.prepare_target(runner=self.runner)
self.assertEqual(self.calls, [])
def test_guard_rejects_extra_nonempty_sata_media_before_vbox(self):
self.info["SATA-2-0"] = str(self.additions)
with self.assertRaisesRegex(prepare.PrepareError, "target media"):
prepare.prepare_target(runner=self.runner)
self.assertEqual(self.calls, [])
def test_guard_accepts_emptydrive_placeholders_for_extra_sata_slots(self):
self.info["SATA-2-0"] = "emptydrive"
self.info["SATA-3-0"] = "none"
prepare.prepare_target(runner=self.runner)
self.assertEqual(len(self.calls), 2)
def test_guard_power_media_or_digest_refusal_makes_no_mutating_calls(self):
failures = (
("running", {"VMState": "running"}, "powered off"),
("media", {"SATA-1-0": str(self.additions)}, "target media"),
)
for name, update, expected in failures:
with self.subTest(name=name):
self.info.update(update)
with self.assertRaisesRegex(prepare.PrepareError, expected):
prepare.prepare_target(runner=self.runner)
self.assertEqual(self.calls, [])
self.info.update({"VMState": "poweroff", "SATA-1-0": str(self.iso)})
with mock.patch.object(prepare, "ADDITIONS_SHA256", "0" * 64):
with self.assertRaisesRegex(prepare.PrepareError, "Guest Additions ISO SHA-256"):
prepare.prepare_target(runner=self.runner)
self.assertEqual(self.calls, [])
def test_dry_run_failure_redacts_output_and_does_not_prepare(self):
secret = "SENSITIVE-PLAINTEXT-DRY-RUN"
def failing(argv):
self.calls.append(tuple(argv))
return subprocess.CompletedProcess(argv, 1, secret, secret)
with self.assertRaises(prepare.PrepareError) as raised:
prepare.prepare_target(runner=failing)
self.assertNotIn(secret, str(raised.exception))
self.assertEqual(len(self.calls), 1)
self.assertIn("unattended dry run", str(raised.exception))
def test_actual_failure_reports_partial_target_and_suppresses_output(self):
secret = "SENSITIVE-PLAINTEXT-ACTUAL"
def failing(argv):
self.calls.append(tuple(argv))
code = 0 if "--dry-run" in argv else 9
return subprocess.CompletedProcess(argv, code, secret, secret)
with self.assertRaises(prepare.PrepareError) as raised:
prepare.prepare_target(runner=failing)
self.assertNotIn(secret, str(raised.exception))
self.assertIn("partially completed", str(raised.exception))
self.assertIn(prepare.TARGET_UUID, str(raised.exception))
self.assertEqual(len(self.calls), 2)
if __name__ == "__main__":
unittest.main()
+211
View File
@@ -0,0 +1,211 @@
"""Behavioral tests for the UUID-pinned M9 resume-only procedure."""
from __future__ import annotations
import importlib.util
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
SPEC = importlib.util.spec_from_file_location("vm_resume", HERE / "resume.py")
assert SPEC and SPEC.loader
resume = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(resume)
class FakeVBox:
def __init__(self, root: Path, iso: Path, *, mutate: dict[str, str] | None = None):
self.root = root
self.iso = iso
self.target_uuid = resume.TARGET_UUID
self.description = ""
self.machine_mutations = dict(mutate or {})
self.calls: list[tuple[str, ...]] = []
self.mutations: list[tuple[str, ...]] = []
self.inventory = f'"{resume.VM_NAME}" {{{self.target_uuid}}}\n'
self.fail_command: str | None = None
def __call__(self, argv):
args = tuple(argv)
self.calls.append(args)
command = args[1]
if command not in {"list", "showvminfo"}:
self.mutations.append(args)
if command == self.fail_command:
return subprocess.CompletedProcess(args, 1, "", "synthetic failure detail")
if args[1:3] == ("list", "vms"):
return subprocess.CompletedProcess(args, 0, self.inventory, "")
if command == "modifyvm" and "--description" in args:
self.description = args[args.index("--description") + 1]
if command == "modifyvm" and "--memory" in args:
self.machine_mutations.update({
"memory": "1024", "cpus": "1", "nic1": "nat",
**{f"nic{slot}": "none" for slot in range(2, 9)},
})
if command == "createmedium":
Path(args[args.index("--filename") + 1]).write_bytes(b"synthetic VDI")
if command == "storagectl":
self.machine_mutations["storagecontrollername0"] = args[args.index("--name") + 1]
if command == "storageattach":
port = args[args.index("--port") + 1]
medium = args[args.index("--medium") + 1]
self.machine_mutations[f"SATA-{port}-0"] = medium
if command == "showvminfo":
info = {
"name": resume.VM_NAME,
"UUID": self.target_uuid,
"description": self.description,
"CfgFile": str(self.root / resume.VM_NAME / f"{resume.VM_NAME}.vbox"),
"VMState": "poweroff",
"memory": "1024",
"cpus": "1",
"nic1": "nat",
**{f"nic{slot}": "none" for slot in range(2, 9)},
**self.machine_mutations,
}
output = "\n".join(f"{key}={json.dumps(value)}" for key, value in info.items()) + "\n"
return subprocess.CompletedProcess(args, 0, output, "")
return subprocess.CompletedProcess(args, 0, "", "")
class ResumeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name) / "lab"
self.root.mkdir(mode=0o700)
os.chmod(self.root, 0o700)
self.vm_dir = self.root / resume.VM_NAME
self.vm_dir.mkdir()
(self.vm_dir / f"{resume.VM_NAME}.vbox").write_text("synthetic", encoding="ascii")
self.iso = self.root / "debian-13.7.0-amd64-netinst.iso"
self.iso.write_bytes(b"synthetic ISO")
self.digest = hashlib.sha256(self.iso.read_bytes()).hexdigest()
self.vbox = FakeVBox(self.root, self.iso)
self.constants = mock.patch.multiple(
resume,
LAB_ROOT=self.root,
ISO_PATH=self.iso,
PINNED_ISO_SHA256=self.digest,
)
self.constants.start()
self.guard_digest = mock.patch.object(resume.guard, "EXPECTED_ISO_SHA256", self.digest)
self.guard_digest.start()
self.addCleanup(self.constants.stop)
self.addCleanup(self.guard_digest.stop)
def tearDown(self):
self.temp.cleanup()
def run_resume(self, runner=None):
return resume.resume_target(
lab_root=self.root, iso_path=self.iso, runner=runner or self.vbox,
)
def test_resume_configures_only_pinned_partial_vm_and_finishes_guard(self):
selected = self.run_resume()
self.assertEqual(selected, resume.TARGET_UUID)
self.assertEqual(self.vbox.calls[0][1:3], ("list", "vms"))
self.assertEqual(self.vbox.calls[1][1:4], (
"showvminfo", resume.TARGET_UUID, "--machinereadable"))
modify = next(call for call in self.vbox.mutations
if call[1] == "modifyvm" and "--memory" in call)
self.assertEqual(modify[modify.index("--nic1") + 1], "nat")
for slot in range(2, 9):
self.assertEqual(modify[modify.index(f"--nic{slot}") + 1], "none")
disk_call = next(call for call in self.vbox.mutations if call[1] == "createmedium")
self.assertEqual(disk_call[disk_call.index("--size") + 1], "8192")
self.assertTrue((self.vm_dir / f"{resume.VM_NAME}.vdi").is_file())
manifest_path = self.root / resume.guard.IDENTITY_FILE
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["uuid"], resume.TARGET_UUID)
self.assertEqual(manifest["host_marker"], f"{resume.VM_NAME}:{resume.TARGET_UUID}")
self.assertEqual(manifest_path.stat().st_mode & 0o777, 0o600)
self.assertFalse(any(call[1] in {
"createvm", "startvm", "unregistervm", "deletevm", "closevm",
} for call in self.vbox.calls))
self.assertEqual(self.vbox.calls[-2][1:], ("list", "vms"))
self.assertEqual(self.vbox.calls[-1][1], "showvminfo")
def test_preflight_failures_make_no_mutating_calls(self):
cases = (
("digest", "ISO SHA-256"),
("inventory", "inventory does not match"),
("manifest", "manifest already exists"),
("disk", "VDI already exists"),
("running", "powered off"),
("network", "NIC state"),
("primary_nic", "NIC state"),
("forwarding", "NAT forwarding"),
("forwarding_record", "NAT forwarding"),
("shared", "shared folders"),
("storage", "attached storage"),
("controller", "storage controller"),
("description", "host marker"),
("memory", "default memory or CPU"),
)
for case, message in cases:
with self.subTest(case=case):
for stale in (
self.root / resume.guard.IDENTITY_FILE,
self.vm_dir / f"{resume.VM_NAME}.vdi",
):
if stale.exists():
stale.unlink()
fake = FakeVBox(self.root, self.iso)
if case == "inventory":
fake.inventory = '"other" {00000000-0000-0000-0000-000000000001}\n'
elif case == "manifest":
(self.root / resume.guard.IDENTITY_FILE).write_text("occupied", encoding="ascii")
elif case == "disk":
(self.vm_dir / f"{resume.VM_NAME}.vdi").write_bytes(b"occupied")
elif case == "running":
fake.machine_mutations["VMState"] = "running"
elif case == "network":
fake.machine_mutations["nic2"] = "hostonly"
elif case == "primary_nic":
fake.machine_mutations["nic1"] = "none"
elif case == "forwarding":
fake.machine_mutations["natpf1"] = "ssh,tcp,,2222,,22"
elif case == "forwarding_record":
fake.machine_mutations["Forwarding(0)"] = "ssh,tcp,,2222,,22"
elif case == "shared":
fake.machine_mutations["SharedFolderPathMachineMapping1"] = "/"
elif case == "storage":
fake.machine_mutations["SATA-0-0"] = str(self.root / "foreign.vdi")
elif case == "controller":
fake.machine_mutations["storagecontrollername1"] = "SATA"
elif case == "description":
fake.description = "unreviewed marker"
elif case == "memory":
fake.machine_mutations["memory"] = "2048"
with self.subTest(case=case), self.assertRaisesRegex(resume.ResumeError, message):
if case == "digest":
with (mock.patch.object(resume, "PINNED_ISO_SHA256", "0" * 64),
mock.patch.object(resume.guard, "EXPECTED_ISO_SHA256", "0" * 64)):
self.run_resume(fake)
else:
self.run_resume(fake)
self.assertEqual(fake.mutations, [])
def test_mutation_failure_reports_exact_partial_uuid_and_does_not_clean_up(self):
fake = FakeVBox(self.root, self.iso)
fake.fail_command = "storagectl"
with self.assertRaisesRegex(resume.ResumeError, resume.TARGET_UUID):
self.run_resume(fake)
self.assertFalse((self.root / resume.guard.IDENTITY_FILE).exists())
self.assertFalse(any(call[1] in {"unregistervm", "deletevm", "closevm"}
for call in fake.calls))
self.assertNotIn("synthetic failure detail", str(fake.calls))
if __name__ == "__main__":
unittest.main()