Add integration tests based on VM
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user