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()