NODEDC_MISSION_CORE/src/k1link/simulation/s0.py

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