1007 lines
35 KiB
Python
1007 lines
35 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import platform
|
|
import re
|
|
import shutil
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
from typing import Final
|
|
|
|
import yaml
|
|
|
|
PROFILE_SCHEMA: Final = "missioncore.simulation-s0-profile/v1"
|
|
EVIDENCE_SCHEMA: Final = "missioncore.simulation-s0-evidence/v1"
|
|
DOCTOR_SCHEMA: Final = "missioncore.simulation-s0-doctor/v1"
|
|
MAX_MANIFEST_BYTES: Final = 256 * 1024
|
|
|
|
_SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
_SAFE_TOOL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
_GIT_COMMIT = re.compile(r"^[a-f0-9]{40}$")
|
|
|
|
|
|
class S0ProfileError(ValueError):
|
|
"""The SIM S0 qualification contract is absent, unsafe, or inconsistent."""
|
|
|
|
|
|
class CheckStatus(StrEnum):
|
|
PASS = "pass"
|
|
FAIL = "fail"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
class DoctorVerdict(StrEnum):
|
|
GO = "go"
|
|
INCOMPLETE = "incomplete"
|
|
BLOCKED = "blocked"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TargetHost:
|
|
windows_release: str
|
|
virtualization: str
|
|
linux_distribution: str
|
|
architecture: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class MutablePath:
|
|
identifier: str
|
|
windows_path: PureWindowsPath
|
|
wsl_path: PurePosixPath
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class StoragePolicy:
|
|
windows_root: PureWindowsPath
|
|
wsl_root: PurePosixPath
|
|
minimum_free_gib: int
|
|
stop_below_gib: int
|
|
forbid_windows_system_drive: bool
|
|
mutable_paths: tuple[MutablePath, ...]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ClockPolicy:
|
|
authority: str
|
|
ros_use_sim_time: bool
|
|
px4_uxrce_dds_sync_enabled: bool
|
|
canonical_unit: str
|
|
utc_role: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AuthorityPolicy:
|
|
simulation_or_shadow_only: bool
|
|
actuator_authority: bool
|
|
navigation_or_safety_accepted: bool
|
|
direct_actuator_setpoints_allowed: bool
|
|
allowed_setpoint_profiles: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RosProfile:
|
|
domain_id: int
|
|
namespace: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ComponentPin:
|
|
identifier: str
|
|
source_url: str
|
|
requested_ref: str
|
|
resolved_version: str | None
|
|
resolved_commit: str | None
|
|
license: str
|
|
qualification_state: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PortBinding:
|
|
identifier: str
|
|
host_scope: str
|
|
protocol: str
|
|
bind: str
|
|
port: int
|
|
owner: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ProcessOwnership:
|
|
identifier: str
|
|
parent: str | None
|
|
shutdown_order: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class S0Profile:
|
|
profile_id: str
|
|
target_host: TargetHost
|
|
storage: StoragePolicy
|
|
clock: ClockPolicy
|
|
authority: AuthorityPolicy
|
|
ros: RosProfile
|
|
components: tuple[ComponentPin, ...]
|
|
ports: tuple[PortBinding, ...]
|
|
processes: tuple[ProcessOwnership, ...]
|
|
required_tools: tuple[str, ...]
|
|
required_evidence: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DoctorCheck:
|
|
identifier: str
|
|
status: CheckStatus
|
|
detail: str
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return {
|
|
"id": self.identifier,
|
|
"status": self.status.value,
|
|
"detail": self.detail,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class S0DoctorReport:
|
|
profile_id: str
|
|
profile_sha256: str
|
|
verdict: DoctorVerdict
|
|
target_host_match: bool
|
|
checks: tuple[DoctorCheck, ...]
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return {
|
|
"schema_version": DOCTOR_SCHEMA,
|
|
"profile_id": self.profile_id,
|
|
"profile_sha256": self.profile_sha256,
|
|
"verdict": self.verdict.value,
|
|
"target_host_match": self.target_host_match,
|
|
"authority": {
|
|
"simulation_or_shadow_only": True,
|
|
"actuator_authority": False,
|
|
"navigation_or_safety_accepted": False,
|
|
"direct_actuator_setpoints_allowed": False,
|
|
},
|
|
"host": {
|
|
"system": platform.system(),
|
|
"release": platform.release(),
|
|
"machine": platform.machine(),
|
|
},
|
|
"checks": [check.to_dict() for check in self.checks],
|
|
}
|
|
|
|
|
|
def load_s0_profile(path: Path) -> S0Profile:
|
|
"""Load and fail closed on an unsafe or incomplete S0 profile."""
|
|
|
|
document = _read_yaml_mapping(path)
|
|
_expect_keys(
|
|
document,
|
|
required={
|
|
"schema_version",
|
|
"profile_id",
|
|
"target_host",
|
|
"storage",
|
|
"clock",
|
|
"authority",
|
|
"ros",
|
|
"components",
|
|
"ports",
|
|
"processes",
|
|
"required_tools",
|
|
"required_evidence",
|
|
},
|
|
context="profile",
|
|
)
|
|
if _string(document["schema_version"], "profile.schema_version") != PROFILE_SCHEMA:
|
|
raise S0ProfileError("profile schema_version is incompatible")
|
|
profile_id = _safe_id(document["profile_id"], "profile.profile_id")
|
|
|
|
target_host = _parse_target_host(document["target_host"])
|
|
storage = _parse_storage(document["storage"])
|
|
clock = _parse_clock(document["clock"])
|
|
authority = _parse_authority(document["authority"])
|
|
ros = _parse_ros(document["ros"])
|
|
components = _parse_components(document["components"])
|
|
ports = _parse_ports(document["ports"])
|
|
processes = _parse_processes(document["processes"])
|
|
required_tools = _tool_list(document["required_tools"], "profile.required_tools")
|
|
required_evidence = _safe_id_list(
|
|
document["required_evidence"],
|
|
"profile.required_evidence",
|
|
)
|
|
|
|
_validate_port_owners(ports, processes)
|
|
_validate_required_processes(processes)
|
|
if len(required_evidence) < 1:
|
|
raise S0ProfileError("at least one factual S0 evidence requirement is mandatory")
|
|
|
|
return S0Profile(
|
|
profile_id=profile_id,
|
|
target_host=target_host,
|
|
storage=storage,
|
|
clock=clock,
|
|
authority=authority,
|
|
ros=ros,
|
|
components=components,
|
|
ports=ports,
|
|
processes=processes,
|
|
required_tools=required_tools,
|
|
required_evidence=required_evidence,
|
|
)
|
|
|
|
|
|
def run_s0_doctor(
|
|
profile_path: Path,
|
|
*,
|
|
evidence_path: Path | None = None,
|
|
) -> S0DoctorReport:
|
|
"""Inspect the local host without installing, starting, or mutating anything."""
|
|
|
|
profile_bytes = _read_bounded_file(profile_path)
|
|
profile = load_s0_profile(profile_path)
|
|
profile_sha256 = hashlib.sha256(profile_bytes).hexdigest()
|
|
target_match, target_detail = _target_host_match(profile.target_host)
|
|
|
|
checks: list[DoctorCheck] = [
|
|
DoctorCheck("profile-contract", CheckStatus.PASS, "strict profile validation passed"),
|
|
DoctorCheck(
|
|
"authority-contract",
|
|
CheckStatus.PASS,
|
|
"simulation/shadow only; real and direct actuator authority disabled",
|
|
),
|
|
DoctorCheck(
|
|
"clock-contract",
|
|
CheckStatus.PASS,
|
|
"Gazebo /clock, ROS use_sim_time and disabled PX4 DDS time sync are explicit",
|
|
),
|
|
DoctorCheck(
|
|
"port-registry",
|
|
CheckStatus.PASS,
|
|
f"{len(profile.ports)} unique scoped bindings validated",
|
|
),
|
|
DoctorCheck(
|
|
"process-registry",
|
|
CheckStatus.PASS,
|
|
f"{len(profile.processes)} owned processes have deterministic shutdown order",
|
|
),
|
|
DoctorCheck(
|
|
"target-host",
|
|
CheckStatus.PASS if target_match else CheckStatus.UNKNOWN,
|
|
target_detail,
|
|
),
|
|
]
|
|
checks.extend(_storage_checks(profile.storage, target_match))
|
|
checks.append(_component_check(profile.components))
|
|
checks.append(_tool_check(profile.required_tools, target_match))
|
|
checks.append(
|
|
_evidence_check(
|
|
profile,
|
|
profile_sha256=profile_sha256,
|
|
evidence_path=evidence_path,
|
|
)
|
|
)
|
|
|
|
statuses = {check.status for check in checks}
|
|
if CheckStatus.FAIL in statuses:
|
|
verdict = DoctorVerdict.BLOCKED
|
|
elif target_match and statuses == {CheckStatus.PASS}:
|
|
verdict = DoctorVerdict.GO
|
|
else:
|
|
verdict = DoctorVerdict.INCOMPLETE
|
|
return S0DoctorReport(
|
|
profile_id=profile.profile_id,
|
|
profile_sha256=profile_sha256,
|
|
verdict=verdict,
|
|
target_host_match=target_match,
|
|
checks=tuple(checks),
|
|
)
|
|
|
|
|
|
def _parse_target_host(value: object) -> TargetHost:
|
|
data = _mapping(value, "target_host")
|
|
_expect_keys(
|
|
data,
|
|
required={
|
|
"windows_release",
|
|
"virtualization",
|
|
"linux_distribution",
|
|
"architecture",
|
|
},
|
|
context="target_host",
|
|
)
|
|
target = TargetHost(
|
|
windows_release=_string(data["windows_release"], "target_host.windows_release"),
|
|
virtualization=_string(data["virtualization"], "target_host.virtualization"),
|
|
linux_distribution=_string(
|
|
data["linux_distribution"],
|
|
"target_host.linux_distribution",
|
|
),
|
|
architecture=_string(data["architecture"], "target_host.architecture"),
|
|
)
|
|
if target.windows_release != "windows-11" or target.virtualization != "wsl2":
|
|
raise S0ProfileError("S0 target must remain the reviewed Windows 11 WSL2 worker")
|
|
if target.linux_distribution != "ubuntu-24.04" or target.architecture != "x86_64":
|
|
raise S0ProfileError("S0 target Linux distribution or architecture drifted")
|
|
return target
|
|
|
|
|
|
def _parse_storage(value: object) -> StoragePolicy:
|
|
data = _mapping(value, "storage")
|
|
_expect_keys(
|
|
data,
|
|
required={
|
|
"windows_root",
|
|
"wsl_root",
|
|
"minimum_free_gib",
|
|
"stop_below_gib",
|
|
"forbid_windows_system_drive",
|
|
"mutable_paths",
|
|
},
|
|
context="storage",
|
|
)
|
|
windows_root = PureWindowsPath(_string(data["windows_root"], "storage.windows_root"))
|
|
wsl_root = PurePosixPath(_string(data["wsl_root"], "storage.wsl_root"))
|
|
minimum_free_gib = _integer(data["minimum_free_gib"], "storage.minimum_free_gib")
|
|
stop_below_gib = _integer(data["stop_below_gib"], "storage.stop_below_gib")
|
|
forbid_system = _boolean(
|
|
data["forbid_windows_system_drive"],
|
|
"storage.forbid_windows_system_drive",
|
|
)
|
|
if windows_root != PureWindowsPath(r"D:\NDC_MISSIONCORE"):
|
|
raise S0ProfileError("storage.windows_root must be D:\\NDC_MISSIONCORE")
|
|
if wsl_root != PurePosixPath("/mnt/d/NDC_MISSIONCORE"):
|
|
raise S0ProfileError("storage.wsl_root must be /mnt/d/NDC_MISSIONCORE")
|
|
if not forbid_system:
|
|
raise S0ProfileError("Windows system-drive mutation must remain forbidden")
|
|
if minimum_free_gib <= stop_below_gib or stop_below_gib < 1:
|
|
raise S0ProfileError("storage free-space thresholds are inconsistent")
|
|
|
|
mutable: list[MutablePath] = []
|
|
seen: set[str] = set()
|
|
for index, item in enumerate(_sequence(data["mutable_paths"], "storage.mutable_paths")):
|
|
entry = _mapping(item, f"storage.mutable_paths[{index}]")
|
|
_expect_keys(
|
|
entry,
|
|
required={"id", "windows_path", "wsl_path"},
|
|
context=f"storage.mutable_paths[{index}]",
|
|
)
|
|
identifier = _safe_id(entry["id"], f"storage.mutable_paths[{index}].id")
|
|
if identifier in seen:
|
|
raise S0ProfileError("storage mutable path ids must be unique")
|
|
seen.add(identifier)
|
|
windows_path = PureWindowsPath(
|
|
_string(
|
|
entry["windows_path"],
|
|
f"storage.mutable_paths[{index}].windows_path",
|
|
)
|
|
)
|
|
wsl_path = PurePosixPath(
|
|
_string(entry["wsl_path"], f"storage.mutable_paths[{index}].wsl_path")
|
|
)
|
|
if (
|
|
not windows_path.is_absolute()
|
|
or windows_path == windows_root
|
|
or not windows_path.is_relative_to(windows_root)
|
|
or windows_path.drive.upper() != "D:"
|
|
or ".." in windows_path.parts
|
|
):
|
|
raise S0ProfileError(f"mutable Windows path {identifier!r} escapes D-only root")
|
|
if (
|
|
not wsl_path.is_absolute()
|
|
or wsl_path == wsl_root
|
|
or not wsl_path.is_relative_to(wsl_root)
|
|
or ".." in wsl_path.parts
|
|
):
|
|
raise S0ProfileError(f"mutable WSL path {identifier!r} escapes D-only root")
|
|
mutable.append(MutablePath(identifier, windows_path, wsl_path))
|
|
if len(mutable) < 1:
|
|
raise S0ProfileError("storage.mutable_paths must not be empty")
|
|
return StoragePolicy(
|
|
windows_root=windows_root,
|
|
wsl_root=wsl_root,
|
|
minimum_free_gib=minimum_free_gib,
|
|
stop_below_gib=stop_below_gib,
|
|
forbid_windows_system_drive=forbid_system,
|
|
mutable_paths=tuple(mutable),
|
|
)
|
|
|
|
|
|
def _parse_clock(value: object) -> ClockPolicy:
|
|
data = _mapping(value, "clock")
|
|
_expect_keys(
|
|
data,
|
|
required={
|
|
"authority",
|
|
"ros_use_sim_time",
|
|
"px4_uxrce_dds_sync_enabled",
|
|
"canonical_unit",
|
|
"utc_role",
|
|
},
|
|
context="clock",
|
|
)
|
|
clock = ClockPolicy(
|
|
authority=_string(data["authority"], "clock.authority"),
|
|
ros_use_sim_time=_boolean(data["ros_use_sim_time"], "clock.ros_use_sim_time"),
|
|
px4_uxrce_dds_sync_enabled=_boolean(
|
|
data["px4_uxrce_dds_sync_enabled"],
|
|
"clock.px4_uxrce_dds_sync_enabled",
|
|
),
|
|
canonical_unit=_string(data["canonical_unit"], "clock.canonical_unit"),
|
|
utc_role=_string(data["utc_role"], "clock.utc_role"),
|
|
)
|
|
if (
|
|
clock.authority != "gazebo:/clock"
|
|
or not clock.ros_use_sim_time
|
|
or clock.px4_uxrce_dds_sync_enabled
|
|
or clock.canonical_unit != "nanoseconds"
|
|
or clock.utc_role != "provenance-only"
|
|
):
|
|
raise S0ProfileError("clock policy violates the canonical simulation-time contract")
|
|
return clock
|
|
|
|
|
|
def _parse_authority(value: object) -> AuthorityPolicy:
|
|
data = _mapping(value, "authority")
|
|
_expect_keys(
|
|
data,
|
|
required={
|
|
"simulation_or_shadow_only",
|
|
"actuator_authority",
|
|
"navigation_or_safety_accepted",
|
|
"direct_actuator_setpoints_allowed",
|
|
"allowed_setpoint_profiles",
|
|
},
|
|
context="authority",
|
|
)
|
|
authority = AuthorityPolicy(
|
|
simulation_or_shadow_only=_boolean(
|
|
data["simulation_or_shadow_only"],
|
|
"authority.simulation_or_shadow_only",
|
|
),
|
|
actuator_authority=_boolean(
|
|
data["actuator_authority"],
|
|
"authority.actuator_authority",
|
|
),
|
|
navigation_or_safety_accepted=_boolean(
|
|
data["navigation_or_safety_accepted"],
|
|
"authority.navigation_or_safety_accepted",
|
|
),
|
|
direct_actuator_setpoints_allowed=_boolean(
|
|
data["direct_actuator_setpoints_allowed"],
|
|
"authority.direct_actuator_setpoints_allowed",
|
|
),
|
|
allowed_setpoint_profiles=tuple(
|
|
_string_list(
|
|
data["allowed_setpoint_profiles"],
|
|
"authority.allowed_setpoint_profiles",
|
|
)
|
|
),
|
|
)
|
|
if (
|
|
not authority.simulation_or_shadow_only
|
|
or authority.actuator_authority
|
|
or authority.navigation_or_safety_accepted
|
|
or authority.direct_actuator_setpoints_allowed
|
|
):
|
|
raise S0ProfileError("SIM S0 cannot grant real, safety, or direct actuator authority")
|
|
if set(authority.allowed_setpoint_profiles) != {
|
|
"rover-speed-steering/v1",
|
|
"rover-speed-yaw-rate/v1",
|
|
}:
|
|
raise S0ProfileError("SIM S0 setpoint profiles drifted from the reviewed rover boundary")
|
|
return authority
|
|
|
|
|
|
def _parse_ros(value: object) -> RosProfile:
|
|
data = _mapping(value, "ros")
|
|
_expect_keys(data, required={"domain_id", "namespace"}, context="ros")
|
|
domain_id = _integer(data["domain_id"], "ros.domain_id")
|
|
namespace = _string(data["namespace"], "ros.namespace")
|
|
if not 0 <= domain_id <= 232:
|
|
raise S0ProfileError("ROS domain id must be in the DDS-safe range 0..232")
|
|
if not namespace.startswith("/") or "//" in namespace or namespace.endswith("/"):
|
|
raise S0ProfileError("ROS namespace must be canonical and absolute")
|
|
return RosProfile(domain_id, namespace)
|
|
|
|
|
|
def _parse_components(value: object) -> tuple[ComponentPin, ...]:
|
|
items: list[ComponentPin] = []
|
|
seen: set[str] = set()
|
|
for index, item in enumerate(_sequence(value, "components")):
|
|
data = _mapping(item, f"components[{index}]")
|
|
_expect_keys(
|
|
data,
|
|
required={
|
|
"id",
|
|
"source_url",
|
|
"requested_ref",
|
|
"resolved_version",
|
|
"resolved_commit",
|
|
"license",
|
|
"qualification_state",
|
|
},
|
|
context=f"components[{index}]",
|
|
)
|
|
identifier = _safe_id(data["id"], f"components[{index}].id")
|
|
if identifier in seen:
|
|
raise S0ProfileError("component ids must be unique")
|
|
seen.add(identifier)
|
|
resolved_commit = _optional_string(
|
|
data["resolved_commit"],
|
|
f"components[{index}].resolved_commit",
|
|
)
|
|
if resolved_commit is not None and _GIT_COMMIT.fullmatch(resolved_commit) is None:
|
|
raise S0ProfileError(f"component {identifier!r} has an invalid Git commit")
|
|
state = _string(data["qualification_state"], f"components[{index}].qualification_state")
|
|
if state not in {"candidate", "pending-worker-resolution", "accepted"}:
|
|
raise S0ProfileError(f"component {identifier!r} has an invalid qualification state")
|
|
items.append(
|
|
ComponentPin(
|
|
identifier=identifier,
|
|
source_url=_https_url(data["source_url"], f"components[{index}].source_url"),
|
|
requested_ref=_string(
|
|
data["requested_ref"],
|
|
f"components[{index}].requested_ref",
|
|
),
|
|
resolved_version=_optional_string(
|
|
data["resolved_version"],
|
|
f"components[{index}].resolved_version",
|
|
),
|
|
resolved_commit=resolved_commit,
|
|
license=_string(data["license"], f"components[{index}].license"),
|
|
qualification_state=state,
|
|
)
|
|
)
|
|
if len(items) < 1:
|
|
raise S0ProfileError("components must not be empty")
|
|
return tuple(items)
|
|
|
|
|
|
def _parse_ports(value: object) -> tuple[PortBinding, ...]:
|
|
items: list[PortBinding] = []
|
|
identities: set[str] = set()
|
|
bindings: set[tuple[str, str, str, int]] = set()
|
|
for index, item in enumerate(_sequence(value, "ports")):
|
|
data = _mapping(item, f"ports[{index}]")
|
|
_expect_keys(
|
|
data,
|
|
required={"id", "host_scope", "protocol", "bind", "port", "owner"},
|
|
context=f"ports[{index}]",
|
|
)
|
|
identifier = _safe_id(data["id"], f"ports[{index}].id")
|
|
if identifier in identities:
|
|
raise S0ProfileError("port ids must be unique")
|
|
identities.add(identifier)
|
|
host_scope = _safe_id(data["host_scope"], f"ports[{index}].host_scope")
|
|
protocol = _string(data["protocol"], f"ports[{index}].protocol")
|
|
bind = _string(data["bind"], f"ports[{index}].bind")
|
|
port = _integer(data["port"], f"ports[{index}].port")
|
|
owner = _safe_id(data["owner"], f"ports[{index}].owner")
|
|
if protocol not in {"tcp", "udp"}:
|
|
raise S0ProfileError(f"port {identifier!r} has unsupported protocol")
|
|
if bind != "127.0.0.1":
|
|
raise S0ProfileError(f"port {identifier!r} must remain loopback-only in S0")
|
|
if not 1 <= port <= 65535:
|
|
raise S0ProfileError(f"port {identifier!r} is outside 1..65535")
|
|
binding = (host_scope, protocol, bind, port)
|
|
if binding in bindings:
|
|
raise S0ProfileError(f"port {identifier!r} duplicates a scoped binding")
|
|
bindings.add(binding)
|
|
items.append(PortBinding(identifier, host_scope, protocol, bind, port, owner))
|
|
if len(items) < 1:
|
|
raise S0ProfileError("ports must not be empty")
|
|
return tuple(items)
|
|
|
|
|
|
def _parse_processes(value: object) -> tuple[ProcessOwnership, ...]:
|
|
items: list[ProcessOwnership] = []
|
|
identities: set[str] = set()
|
|
orders: set[int] = set()
|
|
for index, item in enumerate(_sequence(value, "processes")):
|
|
data = _mapping(item, f"processes[{index}]")
|
|
_expect_keys(
|
|
data,
|
|
required={"id", "parent", "shutdown_order"},
|
|
context=f"processes[{index}]",
|
|
)
|
|
identifier = _safe_id(data["id"], f"processes[{index}].id")
|
|
parent = _optional_safe_id(data["parent"], f"processes[{index}].parent")
|
|
shutdown_order = _integer(
|
|
data["shutdown_order"],
|
|
f"processes[{index}].shutdown_order",
|
|
)
|
|
if identifier in identities or shutdown_order in orders:
|
|
raise S0ProfileError("process ids and shutdown_order values must be unique")
|
|
identities.add(identifier)
|
|
orders.add(shutdown_order)
|
|
items.append(ProcessOwnership(identifier, parent, shutdown_order))
|
|
roots = [item for item in items if item.parent is None]
|
|
if len(roots) != 1 or roots[0].identifier != "simulation-orchestrator":
|
|
raise S0ProfileError("simulation-orchestrator must be the only process root")
|
|
process_ids = {item.identifier for item in items}
|
|
for item in items:
|
|
if item.parent is not None and item.parent not in process_ids:
|
|
raise S0ProfileError(f"process {item.identifier!r} has an unknown parent")
|
|
root_order = roots[0].shutdown_order
|
|
if any(item.shutdown_order >= root_order for item in items if item.parent is not None):
|
|
raise S0ProfileError("the orchestrator must be the final process in shutdown order")
|
|
return tuple(items)
|
|
|
|
|
|
def _validate_port_owners(
|
|
ports: tuple[PortBinding, ...],
|
|
processes: tuple[ProcessOwnership, ...],
|
|
) -> None:
|
|
owners = {process.identifier for process in processes}
|
|
for binding in ports:
|
|
if binding.owner not in owners:
|
|
raise S0ProfileError(f"port {binding.identifier!r} has an unowned process")
|
|
|
|
|
|
def _validate_required_processes(processes: tuple[ProcessOwnership, ...]) -> None:
|
|
present = {process.identifier for process in processes}
|
|
required = {
|
|
"simulation-orchestrator",
|
|
"gazebo-server",
|
|
"px4-sitl",
|
|
"micro-xrce-dds-agent",
|
|
"ros2-bridge",
|
|
"nav2",
|
|
}
|
|
if present != required:
|
|
raise S0ProfileError("process registry must enumerate the complete reviewed S0 graph")
|
|
|
|
|
|
def _target_host_match(target: TargetHost) -> tuple[bool, str]:
|
|
system = platform.system().lower()
|
|
machine = platform.machine().lower()
|
|
release = _read_optional_text(Path("/proc/sys/kernel/osrelease")).lower()
|
|
os_release = _linux_os_release()
|
|
is_wsl2 = system == "linux" and "microsoft" in release and "wsl2" in release
|
|
distribution_match = (
|
|
os_release.get("id") == "ubuntu" and os_release.get("version_id") == "24.04"
|
|
)
|
|
architecture_match = machine in {"x86_64", "amd64"}
|
|
matched = is_wsl2 and distribution_match and architecture_match
|
|
if matched:
|
|
return True, (
|
|
f"matched {target.windows_release}/{target.virtualization}/"
|
|
f"{target.linux_distribution}/{target.architecture}"
|
|
)
|
|
return False, (
|
|
"doctor is not running inside the reviewed Windows 11 WSL2 Ubuntu 24.04 "
|
|
f"x86_64 target (observed system={system!r}, machine={machine!r})"
|
|
)
|
|
|
|
|
|
def _storage_checks(policy: StoragePolicy, target_match: bool) -> list[DoctorCheck]:
|
|
if not target_match:
|
|
return [
|
|
DoctorCheck(
|
|
"d-only-storage",
|
|
CheckStatus.UNKNOWN,
|
|
"physical D-drive placement can only be attested on the target worker",
|
|
),
|
|
DoctorCheck(
|
|
"disk-budget",
|
|
CheckStatus.UNKNOWN,
|
|
"target D-drive free space was not inspected on this host",
|
|
),
|
|
]
|
|
root = Path(policy.wsl_root.as_posix())
|
|
if not root.is_dir():
|
|
return [
|
|
DoctorCheck(
|
|
"d-only-storage",
|
|
CheckStatus.FAIL,
|
|
f"required WSL D-drive root is absent: {root}",
|
|
),
|
|
DoctorCheck(
|
|
"disk-budget",
|
|
CheckStatus.FAIL,
|
|
"disk budget cannot be measured without the D-drive root",
|
|
),
|
|
]
|
|
missing = [
|
|
item.identifier
|
|
for item in policy.mutable_paths
|
|
if not Path(item.wsl_path.as_posix()).is_dir()
|
|
]
|
|
storage_check = DoctorCheck(
|
|
"d-only-storage",
|
|
CheckStatus.PASS if not missing else CheckStatus.FAIL,
|
|
(
|
|
f"all {len(policy.mutable_paths)} mutable roots exist under {root}"
|
|
if not missing
|
|
else f"missing D-only mutable roots: {', '.join(missing)}"
|
|
),
|
|
)
|
|
free_gib = shutil.disk_usage(root).free / (1024**3)
|
|
disk_check = DoctorCheck(
|
|
"disk-budget",
|
|
CheckStatus.PASS if free_gib >= policy.minimum_free_gib else CheckStatus.FAIL,
|
|
(
|
|
f"{free_gib:.2f} GiB free; minimum={policy.minimum_free_gib} GiB; "
|
|
f"stop-below={policy.stop_below_gib} GiB"
|
|
),
|
|
)
|
|
return [storage_check, disk_check]
|
|
|
|
|
|
def _component_check(components: tuple[ComponentPin, ...]) -> DoctorCheck:
|
|
unresolved = [
|
|
item.identifier
|
|
for item in components
|
|
if item.qualification_state != "accepted"
|
|
or (item.resolved_version is None and item.resolved_commit is None)
|
|
]
|
|
if unresolved:
|
|
return DoctorCheck(
|
|
"version-lock",
|
|
CheckStatus.UNKNOWN,
|
|
"worker qualification still required for: " + ", ".join(unresolved),
|
|
)
|
|
return DoctorCheck(
|
|
"version-lock",
|
|
CheckStatus.PASS,
|
|
f"all {len(components)} component pins are resolved and accepted",
|
|
)
|
|
|
|
|
|
def _tool_check(required_tools: tuple[str, ...], target_match: bool) -> DoctorCheck:
|
|
if not target_match:
|
|
return DoctorCheck(
|
|
"required-tools",
|
|
CheckStatus.UNKNOWN,
|
|
"target worker executables were not inspected on this host",
|
|
)
|
|
missing = [name for name in required_tools if shutil.which(name) is None]
|
|
if missing:
|
|
return DoctorCheck(
|
|
"required-tools",
|
|
CheckStatus.FAIL,
|
|
"missing target executables: " + ", ".join(missing),
|
|
)
|
|
return DoctorCheck(
|
|
"required-tools",
|
|
CheckStatus.PASS,
|
|
f"all {len(required_tools)} target executables are discoverable",
|
|
)
|
|
|
|
|
|
def _evidence_check(
|
|
profile: S0Profile,
|
|
*,
|
|
profile_sha256: str,
|
|
evidence_path: Path | None,
|
|
) -> DoctorCheck:
|
|
if evidence_path is None:
|
|
return DoctorCheck(
|
|
"factual-evidence",
|
|
CheckStatus.UNKNOWN,
|
|
f"{len(profile.required_evidence)} target-run evidence claims are required",
|
|
)
|
|
try:
|
|
data = _read_yaml_mapping(evidence_path)
|
|
_expect_keys(
|
|
data,
|
|
required={"schema_version", "profile_sha256", "checks"},
|
|
context="evidence",
|
|
)
|
|
if _string(data["schema_version"], "evidence.schema_version") != EVIDENCE_SCHEMA:
|
|
raise S0ProfileError("evidence schema_version is incompatible")
|
|
if _string(data["profile_sha256"], "evidence.profile_sha256") != profile_sha256:
|
|
raise S0ProfileError("evidence targets a different S0 profile generation")
|
|
evidence_root = evidence_path.expanduser().resolve(strict=True).parent
|
|
observed: set[str] = set()
|
|
for index, item in enumerate(_sequence(data["checks"], "evidence.checks")):
|
|
entry = _mapping(item, f"evidence.checks[{index}]")
|
|
_expect_keys(
|
|
entry,
|
|
required={"id", "status", "artifact", "sha256", "note"},
|
|
context=f"evidence.checks[{index}]",
|
|
)
|
|
identifier = _safe_id(entry["id"], f"evidence.checks[{index}].id")
|
|
if identifier in observed:
|
|
raise S0ProfileError("evidence check ids must be unique")
|
|
observed.add(identifier)
|
|
if _string(entry["status"], f"evidence.checks[{index}].status") != "pass":
|
|
raise S0ProfileError(f"evidence check {identifier!r} is not a pass")
|
|
artifact_text = _string(
|
|
entry["artifact"],
|
|
f"evidence.checks[{index}].artifact",
|
|
)
|
|
artifact_relative = PurePosixPath(artifact_text)
|
|
if artifact_relative.is_absolute() or ".." in artifact_relative.parts:
|
|
raise S0ProfileError(f"evidence artifact {identifier!r} is not confined")
|
|
artifact_candidate = evidence_root / Path(*artifact_relative.parts)
|
|
if artifact_candidate.is_symlink():
|
|
raise S0ProfileError(f"evidence artifact {identifier!r} cannot be a symlink")
|
|
artifact = artifact_candidate.resolve(strict=True)
|
|
if (
|
|
not artifact.is_relative_to(evidence_root)
|
|
or not artifact.is_file()
|
|
):
|
|
raise S0ProfileError(f"evidence artifact {identifier!r} is not a regular file")
|
|
expected_sha256 = _string(
|
|
entry["sha256"],
|
|
f"evidence.checks[{index}].sha256",
|
|
)
|
|
if (
|
|
_SHA256.fullmatch(expected_sha256) is None
|
|
or _sha256_file(artifact) != expected_sha256
|
|
):
|
|
raise S0ProfileError(f"evidence artifact {identifier!r} digest changed")
|
|
_string(entry["note"], f"evidence.checks[{index}].note")
|
|
if observed != set(profile.required_evidence):
|
|
missing = sorted(set(profile.required_evidence) - observed)
|
|
extra = sorted(observed - set(profile.required_evidence))
|
|
raise S0ProfileError(
|
|
f"evidence coverage mismatch; missing={missing!r}; extra={extra!r}"
|
|
)
|
|
except (OSError, S0ProfileError) as exc:
|
|
return DoctorCheck("factual-evidence", CheckStatus.FAIL, str(exc))
|
|
return DoctorCheck(
|
|
"factual-evidence",
|
|
CheckStatus.PASS,
|
|
f"all {len(profile.required_evidence)} digest-bound evidence claims validated",
|
|
)
|
|
|
|
|
|
def _read_yaml_mapping(path: Path) -> dict[str, object]:
|
|
payload = _read_bounded_file(path)
|
|
try:
|
|
loaded: object = yaml.safe_load(payload)
|
|
except yaml.YAMLError as exc:
|
|
raise S0ProfileError(f"invalid YAML in {path}") from exc
|
|
return _mapping(loaded, str(path))
|
|
|
|
|
|
def _read_bounded_file(path: Path) -> bytes:
|
|
candidate = path.expanduser()
|
|
if candidate.is_symlink():
|
|
raise S0ProfileError(f"manifest cannot be a symlink: {path}")
|
|
resolved = candidate.resolve(strict=True)
|
|
if not resolved.is_file():
|
|
raise S0ProfileError(f"manifest is not a regular file: {path}")
|
|
size = resolved.stat().st_size
|
|
if size < 1 or size > MAX_MANIFEST_BYTES:
|
|
raise S0ProfileError(f"manifest size is outside 1..{MAX_MANIFEST_BYTES} bytes")
|
|
return resolved.read_bytes()
|
|
|
|
|
|
def _mapping(value: object, context: str) -> dict[str, object]:
|
|
if not isinstance(value, dict):
|
|
raise S0ProfileError(f"{context} must be a mapping")
|
|
result: dict[str, object] = {}
|
|
for key, item in value.items():
|
|
if not isinstance(key, str):
|
|
raise S0ProfileError(f"{context} keys must be strings")
|
|
result[key] = item
|
|
return result
|
|
|
|
|
|
def _sequence(value: object, context: str) -> list[object]:
|
|
if not isinstance(value, list):
|
|
raise S0ProfileError(f"{context} must be a list")
|
|
return list(value)
|
|
|
|
|
|
def _expect_keys(
|
|
data: dict[str, object],
|
|
*,
|
|
required: set[str],
|
|
context: str,
|
|
) -> None:
|
|
present = set(data)
|
|
if present != required:
|
|
raise S0ProfileError(
|
|
f"{context} keys mismatch; missing={sorted(required - present)!r}; "
|
|
f"extra={sorted(present - required)!r}"
|
|
)
|
|
|
|
|
|
def _string(value: object, context: str) -> str:
|
|
if not isinstance(value, str) or not value.strip() or value != value.strip():
|
|
raise S0ProfileError(f"{context} must be a non-empty trimmed string")
|
|
return value
|
|
|
|
|
|
def _optional_string(value: object, context: str) -> str | None:
|
|
if value is None:
|
|
return None
|
|
return _string(value, context)
|
|
|
|
|
|
def _boolean(value: object, context: str) -> bool:
|
|
if not isinstance(value, bool):
|
|
raise S0ProfileError(f"{context} must be a boolean")
|
|
return value
|
|
|
|
|
|
def _integer(value: object, context: str) -> int:
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise S0ProfileError(f"{context} must be an integer")
|
|
return value
|
|
|
|
|
|
def _safe_id(value: object, context: str) -> str:
|
|
text = _string(value, context)
|
|
if _SAFE_ID.fullmatch(text) is None:
|
|
raise S0ProfileError(f"{context} is not a safe id")
|
|
return text
|
|
|
|
|
|
def _optional_safe_id(value: object, context: str) -> str | None:
|
|
if value is None:
|
|
return None
|
|
return _safe_id(value, context)
|
|
|
|
|
|
def _safe_id_list(value: object, context: str) -> tuple[str, ...]:
|
|
result = tuple(
|
|
_safe_id(item, f"{context}[{index}]")
|
|
for index, item in enumerate(_sequence(value, context))
|
|
)
|
|
if len(set(result)) != len(result):
|
|
raise S0ProfileError(f"{context} values must be unique")
|
|
return result
|
|
|
|
|
|
def _tool_list(value: object, context: str) -> tuple[str, ...]:
|
|
result: list[str] = []
|
|
for index, item in enumerate(_sequence(value, context)):
|
|
tool = _string(item, f"{context}[{index}]")
|
|
if _SAFE_TOOL.fullmatch(tool) is None:
|
|
raise S0ProfileError(f"{context}[{index}] is not a safe executable name")
|
|
result.append(tool)
|
|
if len(set(result)) != len(result):
|
|
raise S0ProfileError(f"{context} values must be unique")
|
|
return tuple(result)
|
|
|
|
|
|
def _string_list(value: object, context: str) -> tuple[str, ...]:
|
|
result = tuple(
|
|
_string(item, f"{context}[{index}]")
|
|
for index, item in enumerate(_sequence(value, context))
|
|
)
|
|
if len(set(result)) != len(result):
|
|
raise S0ProfileError(f"{context} values must be unique")
|
|
return result
|
|
|
|
|
|
def _https_url(value: object, context: str) -> str:
|
|
text = _string(value, context)
|
|
if not text.startswith("https://"):
|
|
raise S0ProfileError(f"{context} must use HTTPS")
|
|
return text
|
|
|
|
|
|
def _read_optional_text(path: Path) -> str:
|
|
try:
|
|
return path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def _linux_os_release() -> dict[str, str]:
|
|
result: dict[str, str] = {}
|
|
for line in _read_optional_text(Path("/etc/os-release")).splitlines():
|
|
key, separator, raw_value = line.partition("=")
|
|
if not separator:
|
|
continue
|
|
result[key.lower()] = raw_value.strip().strip('"')
|
|
return result
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
while chunk := stream.read(1024 * 1024):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|