760 lines
29 KiB
Python
760 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""Validate the offline-only Phase-0.9D readback and recovery audit."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
from typing import Any
|
|
|
|
|
|
EXPECTED_BASELINE = "e0e68829ab76977c845e7106ef93e6c01fbc966e"
|
|
EXPECTED_BRANCH = "codex/chimera-gfx-phase09d-existing-stack-readback"
|
|
EXPECTED_PHASE = "PHASE_0_9D_EXISTING_STACK_READBACK"
|
|
EXPECTED_STATUS = "DESIGN_ONLY"
|
|
BLOCKED_HASH = (
|
|
"4be1c17b4964f2b68c39b5145bc4af4619c32512d60269ecf5c39728b390fa63"
|
|
)
|
|
DENYLIST_HASH = (
|
|
"e9603b0e3792781ad5b511afb22ef61e3d4fd4c5a16bf928f8609193bcd97783"
|
|
)
|
|
|
|
AUTHORIZATION_FIELDS = (
|
|
"installation_authorized",
|
|
"execution_authorized",
|
|
"lifecycle_authorized",
|
|
"automatic_retry",
|
|
"autoload_authorized",
|
|
"device_write_authorized",
|
|
"transfer_authorized",
|
|
"observer_build_authorized",
|
|
"backup_creation_authorized",
|
|
)
|
|
|
|
ACTION_FIELDS = (
|
|
"ps5_connected",
|
|
"device_request_performed",
|
|
"files_transferred",
|
|
"device_write_performed",
|
|
"target_execution_performed",
|
|
"target_artifact_created",
|
|
"target_build_performed",
|
|
"observer_created",
|
|
"device_client_created",
|
|
"backup_created",
|
|
"staging_performed",
|
|
)
|
|
|
|
SOURCE_COMMITS = {
|
|
"hardened_elfldr": (
|
|
"../chimera-elfldr",
|
|
"197623058f509eddde18868dafcb92fdcac66464",
|
|
),
|
|
"controlled_payload_manager": (
|
|
"../chimera-ps5-payload-manager",
|
|
"e23d94ff91233aa770e2342800c1467875bdef44",
|
|
),
|
|
"elfldr_public_base": (
|
|
"work/upstream/elfldr-v0.23",
|
|
"699e8bcff03e91e8d6ca6eba281af25c5a58d8c2",
|
|
),
|
|
"payload_manager_public_base": (
|
|
"work/upstream/pldmgr-v0.3.1",
|
|
"cfbc70f30f419b09bf2b52283f7409e2d3117ee1",
|
|
),
|
|
"ps5_payload_sdk_v0_41": (
|
|
"work/upstream/sdk",
|
|
"d2e2e585740362976a39fdd5ccf390f199a7bc37",
|
|
),
|
|
}
|
|
|
|
SOURCE_HASHES = {
|
|
"../chimera-elfldr/README.md": (
|
|
"372aeb28dc971b2bd98093a47fdaf77c32f75bbdc3b3d7e8678900744b91eadb"
|
|
),
|
|
"../chimera-elfldr/main.c": (
|
|
"876389a26999073994e63ca29926982280d9594a1ee941244205b54f57e2b4d1"
|
|
),
|
|
"../chimera-elfldr/bootstrap.c": (
|
|
"5a8072ec0d6db919cb3a81a7028dc91fd8e2c3a0d69b0fa7e84836fa93b45381"
|
|
),
|
|
"../chimera-elfldr/socksrv.c": (
|
|
"d642ced3e9b4a296dd15e355050ebe956f53a6dfdaa6ac10109cd067a3bba3d7"
|
|
),
|
|
"../chimera-ps5-payload-manager/README.md": (
|
|
"a00277252da46c701326ef66e5ca0d13cadffd50b3c8adf6da89cd1948c97718"
|
|
),
|
|
"../chimera-ps5-payload-manager/DEVELOPMENT.md": (
|
|
"0c17bed71b07c9aadf31c47625bbe1ccb42e670e594fb64a1e07aa782d5eec31"
|
|
),
|
|
"../chimera-ps5-payload-manager/deploy.sh": (
|
|
"2facdc1ca70db57ba258265c07a8ae1d30d3d2fb429d6718d5d5088300788e52"
|
|
),
|
|
"../chimera-ps5-payload-manager/include/pldmgr.h": (
|
|
"8603b8338364cea60ffaf94f985f112fb2ddfda2f44b1ccc9a2bd7a15d1b229a"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/http_server.c": (
|
|
"2c8ff2a4bc1028d71e3cc342839584d425b6e7502e55e18e2762cdbf62c59d40"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/log_server.c": (
|
|
"659095f43df1bbe8eb24acb165f027edc277af1e60aabb26ba9e3920b233d6f1"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/autoload.c": (
|
|
"7051cab3ee1a3e0b9f6498000565eb9e160b9c63efa1771f250e98ec3aa4ae67"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/main.c": (
|
|
"b2374e8fb101587b15c8261c58cb4f0573d88c490214365051eb9facd60f6eed"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/controlled_manager.c": (
|
|
"042b55b2cece32effed636529249fd18061a2fe3c5e70c7f755f2817cfb84b99"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/verified_launcher.c": (
|
|
"066100ca4917c7acc560e2e85666ca136cd7ccfd9094417377048f41106dd56e"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/ps5_launcher.c": (
|
|
"29c1a5fd01784a59e88b3698940f120cb03020071bc2b7d74a1da1a51524ef59"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/payload_mgr.c": (
|
|
"d67e9ba33edc8ca3a45aae07923d4c4790348b5f8570307e581e16780abafcba"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/repository.c": (
|
|
"8ba694ae6d4813573752acd82ce2dbfb715b51d4ec6155f783904d76dd115adb"
|
|
),
|
|
"../chimera-ps5-payload-manager/src/sources.c": (
|
|
"a7a4a5cafccfba74902d6ed21ba001e6a9f62d4dff840ac38882d04827162c96"
|
|
),
|
|
"work/upstream/elfldr-v0.23/socksrv.c": (
|
|
"500d3c7df7ed5eac1adc925c89344d75c469651b71143716fdb77bfb2209a40c"
|
|
),
|
|
"work/upstream/pldmgr-v0.3.1/include/pldmgr.h": (
|
|
"01c693a3248dce7a663dd4ed9c73ce5f3a4443b5f2bd210746d94993dee27b91"
|
|
),
|
|
"work/upstream/pldmgr-v0.3.1/src/http_server.c": (
|
|
"35cf5d8f0dd44cf64ceab5e4b0ecc09413c82d7e9946ba9de2ca4b1898631fdd"
|
|
),
|
|
"samples/lifecycle_probe/main.c": (
|
|
"1ae7df1fe921ccab2a252f77975d3d441ef7725e34535b024580c0d4a242d766"
|
|
),
|
|
}
|
|
|
|
IMMUTABLE_HASHES = {
|
|
"manifests/runtime/phase-0.8-read-only-preflight.json": (
|
|
"47d7f452f8799979fe99b3e6d56859f03544112725bf7e5b349eba5ed81b3322"
|
|
),
|
|
"manifests/runtime/phase-0.8-remediation.json": (
|
|
"a9dafed8c83722c43709dcf90ea117c21d6f996ff8fe233810ed08aa20cbe071"
|
|
),
|
|
"manifests/runtime/phase-0.9-anti-brick-design.json": (
|
|
"39fd7c70cae998d9d74e7caf1ff3c19f9f76f7de2e5a50bc09baede4ba53e9e9"
|
|
),
|
|
"manifests/runtime/phase-0.9b-observer.json": (
|
|
"104c4a667ad17f9827fc7276852c6faeef96effb8e3561a5ebc19a62c7d51634"
|
|
),
|
|
"manifests/runtime/phase-0.9c-feasibility.json": (
|
|
"84eb737ff3486d0c5d8b5ecf06809f93bc573497da4919595b4c1817fd34247c"
|
|
),
|
|
}
|
|
|
|
DELIVERABLES = (
|
|
"docs/runtime/phase-0.9d-bootstrap-recovery-chain.md",
|
|
"docs/runtime/phase-0.9d-existing-stack-endpoint-matrix.md",
|
|
"docs/runtime/phase-0.9d-readback-feasibility.md",
|
|
"docs/runtime/phase-0.9d-off-device-backup-contract.md",
|
|
"docs/runtime/phase-0.9d-independent-recovery-analysis.md",
|
|
"docs/runtime/phase-0.9d-operational-windows.md",
|
|
"manifests/runtime/phase-0.9d-existing-stack-readback.json",
|
|
"manifests/runtime/phase-0.9d-existing-stack-readback.schema.json",
|
|
"tools/validate_phase09d_readback.py",
|
|
"tests/test_phase09d_readback.py",
|
|
)
|
|
|
|
EXPECTED_DECISIONS = {
|
|
"new_observer_feasibility": "BLOCKED",
|
|
"existing_stack_manual_fact_collection": "PARTIAL",
|
|
"existing_stack_single_readback": "BLOCKED_NO_READBACK_PATH",
|
|
"existing_stack_repeat_readback": "BLOCKED",
|
|
"elfldr_independent_recovery": "PARTIAL",
|
|
"payload_manager_independent_recovery": "PARTIAL",
|
|
"side_by_side_feasibility": "BLOCKED",
|
|
"device_write": "NOT_AUTHORIZED",
|
|
"target_execution": "NOT_AUTHORIZED",
|
|
"installation": "NOT_AUTHORIZED",
|
|
}
|
|
|
|
REQUIRED_EFFECTIVE_ROUTE_FIELDS = (
|
|
"method",
|
|
"endpoint",
|
|
"handler",
|
|
"source",
|
|
"lines",
|
|
"parameters",
|
|
"authentication",
|
|
"response",
|
|
"open_flags",
|
|
"reads_bytes",
|
|
"writes_bytes",
|
|
"creates_file",
|
|
"removes_file",
|
|
"renames_file",
|
|
"reads_directory",
|
|
"reads_metadata",
|
|
"calculates_hash",
|
|
"modifies_configuration",
|
|
"writes_server_active_flag",
|
|
"writes_autoload_triggered",
|
|
"writes_log_ring",
|
|
"launches_payload",
|
|
"process_or_service_action",
|
|
"network_behavior",
|
|
"timeout_behavior",
|
|
"retry_behavior",
|
|
"maximum_size",
|
|
"short_read_behavior",
|
|
"error_behavior",
|
|
"effect_class",
|
|
"readback_candidate",
|
|
"observation_candidate",
|
|
"binary_safe_file_response",
|
|
"exact_returned_byte_count",
|
|
"partial_result_rejected",
|
|
"forbidden_reason",
|
|
)
|
|
|
|
BACKUP_STATES = (
|
|
"TRANSFER_NOT_STARTED",
|
|
"TRANSFER_INCOMPLETE",
|
|
"HOST_COPY_RECEIVED",
|
|
"HOST_COPY_REOPENED",
|
|
"HOST_COPY_HASHED",
|
|
"SECOND_COPY_CREATED",
|
|
"SECOND_COPY_REOPENED",
|
|
"SECOND_COPY_HASHED",
|
|
"COPIES_MATCH",
|
|
"SOURCE_MAPPING_PARTIAL",
|
|
"SOURCE_MAPPING_VERIFIED",
|
|
"INVALID",
|
|
)
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
with path.open("r", encoding="utf-8") as stream:
|
|
value = json.load(stream)
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{path} does not contain an object")
|
|
return value
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def git(root: Path, *args: str) -> str:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=root,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr.strip() or "git command failed")
|
|
return result.stdout.strip()
|
|
|
|
|
|
def effective_route(
|
|
route: dict[str, Any], defaults: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
merged = dict(defaults)
|
|
merged.update(route)
|
|
return merged
|
|
|
|
|
|
def route_readback_errors(
|
|
route: dict[str, Any], defaults: dict[str, Any] | None = None
|
|
) -> list[str]:
|
|
merged = effective_route(route, defaults or {})
|
|
errors: list[str] = []
|
|
flags = {str(flag).lower() for flag in merged.get("open_flags", [])}
|
|
mutating_open = any(
|
|
token in flag
|
|
for flag in flags
|
|
for token in ("o_wronly", "o_rdwr", "o_creat", "o_append", "wb", "(w)")
|
|
)
|
|
mutating = any(
|
|
bool(merged.get(field))
|
|
for field in (
|
|
"writes_bytes",
|
|
"creates_file",
|
|
"removes_file",
|
|
"renames_file",
|
|
"modifies_configuration",
|
|
)
|
|
) or mutating_open
|
|
|
|
if merged.get("readback_candidate"):
|
|
if mutating:
|
|
errors.append("readback candidate mutates a device file or configuration")
|
|
if merged.get("launches_payload"):
|
|
errors.append("readback candidate launches a payload")
|
|
if merged.get("process_or_service_action"):
|
|
errors.append("readback candidate performs a process/service action")
|
|
if merged.get("writes_autoload_triggered"):
|
|
errors.append("readback candidate writes autoload_triggered")
|
|
if not merged.get("binary_safe_file_response"):
|
|
errors.append("readback candidate lacks binary-safe framing")
|
|
if not merged.get("exact_returned_byte_count"):
|
|
errors.append("readback candidate lacks an exact byte count")
|
|
if not merged.get("partial_result_rejected"):
|
|
errors.append("readback candidate does not reject partial output")
|
|
if str(merged.get("short_read_behavior", "")).upper() in {
|
|
"",
|
|
"ABSENT",
|
|
"UNPROVEN",
|
|
"NO_HOST_FILE_READBACK_CONTRACT",
|
|
"NO_FILE_RESPONSE",
|
|
}:
|
|
errors.append("readback candidate lacks short-read detection")
|
|
if merged.get("automatic_retry") is True:
|
|
errors.append("readback candidate enables automatic retry")
|
|
if merged.get("automatic_resume") is True:
|
|
errors.append("readback candidate enables automatic resume")
|
|
return errors
|
|
|
|
|
|
def backup_record_errors(record: dict[str, Any]) -> list[str]:
|
|
errors: list[str] = []
|
|
status = record.get("status")
|
|
if status not in BACKUP_STATES:
|
|
errors.append("unknown backup status")
|
|
if record.get("automatic_resume"):
|
|
errors.append("automatic resume is forbidden")
|
|
if record.get("automatic_retry"):
|
|
errors.append("automatic retry is forbidden")
|
|
if status in {"HOST_COPY_HASHED", "SECOND_COPY_HASHED", "COPIES_MATCH"}:
|
|
if not isinstance(record.get("exact_byte_count"), int):
|
|
errors.append("a hash requires an exact byte count")
|
|
if not record.get("closed_and_reopened"):
|
|
errors.append("hash requires close and reopen")
|
|
if not record.get("sha256"):
|
|
errors.append("hashed state requires SHA-256")
|
|
if status == "COPIES_MATCH":
|
|
if not all(
|
|
record.get(field)
|
|
for field in ("sizes_match", "hashes_match", "bytes_match")
|
|
):
|
|
errors.append("COPIES_MATCH requires size, hash, and byte equality")
|
|
if record.get("transfer_complete") is False and status != "INVALID":
|
|
errors.append("partial transfer must be INVALID")
|
|
if record.get("recovery_proven"):
|
|
errors.append("host backup cannot prove recovery")
|
|
return errors
|
|
|
|
|
|
def server_active_observation_status(semantics: dict[str, Any]) -> str:
|
|
if (
|
|
not semantics.get("fully_documented")
|
|
or semantics.get("reset_path") in {None, "UNPROVEN"}
|
|
):
|
|
return "BLOCKED"
|
|
if semantics.get("reset_path") == "NONE_IN_PROCESS":
|
|
return "PARTIAL"
|
|
return "READY_FOR_REVIEW"
|
|
|
|
|
|
def recovery_dependency_classification(
|
|
component: str, recovery_executor: str
|
|
) -> str:
|
|
if component == recovery_executor:
|
|
return "SELF_DEPENDENT"
|
|
if recovery_executor in {"ABSENT", "UNPROVEN", ""}:
|
|
return recovery_executor
|
|
return "CROSS_DEPENDENT"
|
|
|
|
|
|
def _type_matches(value: Any, expected: str) -> bool:
|
|
return {
|
|
"object": isinstance(value, dict),
|
|
"array": isinstance(value, list),
|
|
"string": isinstance(value, str),
|
|
"boolean": isinstance(value, bool),
|
|
"integer": isinstance(value, int) and not isinstance(value, bool),
|
|
"number": isinstance(value, (int, float)) and not isinstance(value, bool),
|
|
"null": value is None,
|
|
}.get(expected, True)
|
|
|
|
|
|
def validate_schema_instance(
|
|
schema: dict[str, Any], instance: Any, path: str = "$"
|
|
) -> list[str]:
|
|
"""Small offline validator for the schema features used by this record."""
|
|
errors: list[str] = []
|
|
expected_type = schema.get("type")
|
|
if expected_type and not _type_matches(instance, expected_type):
|
|
return [f"{path}: expected {expected_type}"]
|
|
if "const" in schema and instance != schema["const"]:
|
|
errors.append(f"{path}: expected constant {schema['const']!r}")
|
|
if "enum" in schema and instance not in schema["enum"]:
|
|
errors.append(f"{path}: value is outside enum")
|
|
|
|
if isinstance(instance, dict):
|
|
required = schema.get("required", [])
|
|
for key in required:
|
|
if key not in instance:
|
|
errors.append(f"{path}: missing {key}")
|
|
if len(instance) < schema.get("minProperties", 0):
|
|
errors.append(f"{path}: too few properties")
|
|
properties = schema.get("properties", {})
|
|
for key, value in instance.items():
|
|
if key in properties:
|
|
errors.extend(
|
|
validate_schema_instance(properties[key], value, f"{path}.{key}")
|
|
)
|
|
elif schema.get("additionalProperties") is False:
|
|
errors.append(f"{path}: unexpected property {key}")
|
|
elif isinstance(schema.get("additionalProperties"), dict):
|
|
errors.extend(
|
|
validate_schema_instance(
|
|
schema["additionalProperties"], value, f"{path}.{key}"
|
|
)
|
|
)
|
|
|
|
if isinstance(instance, list):
|
|
if len(instance) < schema.get("minItems", 0):
|
|
errors.append(f"{path}: too few items")
|
|
if "maxItems" in schema and len(instance) > schema["maxItems"]:
|
|
errors.append(f"{path}: too many items")
|
|
item_schema = schema.get("items")
|
|
if isinstance(item_schema, dict):
|
|
for index, item in enumerate(instance):
|
|
errors.extend(
|
|
validate_schema_instance(item_schema, item, f"{path}[{index}]")
|
|
)
|
|
return errors
|
|
|
|
|
|
def validate_manifest(manifest: dict[str, Any]) -> list[str]:
|
|
errors: list[str] = []
|
|
if manifest.get("phase") != EXPECTED_PHASE:
|
|
errors.append("wrong phase")
|
|
if manifest.get("status") != EXPECTED_STATUS:
|
|
errors.append("status must remain DESIGN_ONLY")
|
|
if manifest.get("baseline_commit") != EXPECTED_BASELINE:
|
|
errors.append("wrong baseline")
|
|
if manifest.get("branch") != EXPECTED_BRANCH:
|
|
errors.append("wrong branch")
|
|
|
|
for field in AUTHORIZATION_FIELDS:
|
|
if manifest.get("authorization", {}).get(field) is not False:
|
|
errors.append(f"authorization.{field} must be false")
|
|
for field in ACTION_FIELDS:
|
|
if manifest.get("actions", {}).get(field) is not False:
|
|
errors.append(f"actions.{field} must be false")
|
|
|
|
canonical = manifest.get("canonical_state", {})
|
|
expected_canonical = {
|
|
"phase08_status": "READ_ONLY_PREFLIGHT_BLOCKED",
|
|
"phase09a_status": "DESIGN_ONLY",
|
|
"phase09b_status": "BLOCKED",
|
|
"phase09c_classification": "BLOCKED_MULTIPLE_FOUNDATIONAL_CONTRACTS",
|
|
"firmware_runtime_behavior": "UNPROVEN",
|
|
"stock_identities": "reference_only",
|
|
"payload_manager_backup": "HARD_BLOCKER_FOR_INSTALLATION",
|
|
"independent_recovery": "UNPROVEN",
|
|
"permanent_denylist_sha256": DENYLIST_HASH,
|
|
"permanently_blocked_artifact_sha256": BLOCKED_HASH,
|
|
}
|
|
for field, expected in expected_canonical.items():
|
|
if canonical.get(field) != expected:
|
|
errors.append(f"canonical_state.{field} changed")
|
|
|
|
if manifest.get("decisions") != EXPECTED_DECISIONS:
|
|
errors.append("decision matrix changed")
|
|
if manifest.get("source_commits", {}).get("lifecycle_source") != (
|
|
"fe08300339a13f899fb78ea404ada381a5cba87c"
|
|
):
|
|
errors.append("lifecycle source binding changed")
|
|
for name, (_, expected) in SOURCE_COMMITS.items():
|
|
if manifest.get("source_commits", {}).get(name) != expected:
|
|
errors.append(f"source commit changed: {name}")
|
|
if manifest.get("source_tree_status", {}).get(name) != "clean":
|
|
errors.append(f"source tree is not recorded clean: {name}")
|
|
|
|
routes = manifest.get("endpoint_matrix", [])
|
|
if len(routes) != 40:
|
|
errors.append("endpoint matrix must contain all 40 audited route records")
|
|
defaults = manifest.get("endpoint_defaults", {})
|
|
identities: set[tuple[str, str]] = set()
|
|
for route in routes:
|
|
effective = effective_route(route, defaults)
|
|
identity = (str(route.get("profile")), str(route.get("endpoint")))
|
|
if identity in identities:
|
|
errors.append(f"duplicate route identity: {identity}")
|
|
identities.add(identity)
|
|
for field in REQUIRED_EFFECTIVE_ROUTE_FIELDS:
|
|
if field not in effective:
|
|
errors.append(f"{identity}: missing effective field {field}")
|
|
errors.extend(
|
|
f"{identity}: {error}"
|
|
for error in route_readback_errors(route, defaults)
|
|
)
|
|
if effective.get("readback_candidate") is not False:
|
|
errors.append(f"{identity}: no audited route may be a readback candidate")
|
|
|
|
autoload = next(
|
|
(
|
|
effective_route(route, defaults)
|
|
for route in routes
|
|
if route.get("profile") == "full"
|
|
and route.get("endpoint") == "/autoload_status"
|
|
),
|
|
None,
|
|
)
|
|
if not autoload or not autoload.get("writes_autoload_triggered"):
|
|
errors.append("/autoload_status mutation is not recorded")
|
|
|
|
flags = manifest.get("flag_semantics", {})
|
|
server_active = flags.get("server_active_flag", {})
|
|
if server_active.get("classification") == "ANTI_BRICK_CRITICAL":
|
|
errors.append("server_active_flag is incorrectly anti-brick critical")
|
|
if (
|
|
not server_active.get("fully_documented")
|
|
or server_active.get("reset_path") != "NONE_IN_PROCESS"
|
|
or server_active.get("lifetime") != "PROCESS_LOCAL"
|
|
):
|
|
errors.append("server_active_flag semantics are incomplete")
|
|
autoload_flag = flags.get("autoload_triggered", {})
|
|
if autoload_flag.get("excluded_windows") != [1, 2]:
|
|
errors.append("autoload_triggered must be excluded from Windows 1 and 2")
|
|
|
|
if len(manifest.get("readback_routes", [])) != 7:
|
|
errors.append("readback route search is incomplete")
|
|
for route in manifest.get("readback_routes", []):
|
|
if route.get("usable_once") or route.get("usable_twice"):
|
|
errors.append("a rejected readback route is marked usable")
|
|
if manifest.get("path_classification") != "PATH_CONFLICT":
|
|
errors.append("path conflict was removed")
|
|
if manifest.get("runtime_observed_live_paths") != []:
|
|
errors.append("offline package/config paths cannot become live paths")
|
|
|
|
contract = manifest.get("host_backup_contract", {})
|
|
if contract.get("statuses") != list(BACKUP_STATES):
|
|
errors.append("backup status vocabulary changed")
|
|
required_true = (
|
|
"one_component_per_session",
|
|
"new_exclusive_local_output",
|
|
"binary_mode",
|
|
"exact_received_byte_count_required",
|
|
"close_reopen_before_hash",
|
|
"sha256_required",
|
|
"size_required",
|
|
"second_independent_connection",
|
|
"second_new_output",
|
|
"compare_size",
|
|
"compare_sha256",
|
|
"compare_every_byte",
|
|
"capture_raw_protocol_metadata",
|
|
"capture_literal_source_path",
|
|
"capture_device_and_session",
|
|
"capture_client_commit",
|
|
"off_device_backup_valid_requires_copies_match",
|
|
)
|
|
for field in required_true:
|
|
if contract.get(field) is not True:
|
|
errors.append(f"host_backup_contract.{field} must be true")
|
|
required_false = (
|
|
"overwrite_existing_output",
|
|
"automatic_resume",
|
|
"automatic_retry",
|
|
"contains_device_write_command",
|
|
"recovery_proven_allowed",
|
|
)
|
|
for field in required_false:
|
|
if contract.get(field) is not False:
|
|
errors.append(f"host_backup_contract.{field} must be false")
|
|
if contract.get("partial_transfer_status") != "INVALID":
|
|
errors.append("partial host transfer must be INVALID")
|
|
|
|
recovery = manifest.get("recovery_dependencies", {})
|
|
if recovery.get("elfldr", {}).get("classification") != "PARTIAL":
|
|
errors.append("elfldr recovery must remain PARTIAL")
|
|
manager = recovery.get("payload_manager", {})
|
|
if manager.get("classification") != "PARTIAL":
|
|
errors.append("Payload Manager recovery must remain PARTIAL")
|
|
if "CROSS_DEPENDENT" not in manager.get("detailed", []):
|
|
errors.append("Payload Manager cross-dependence is missing")
|
|
if manifest.get("side_by_side", {}).get("classification") != "BLOCKED":
|
|
errors.append("side-by-side must remain blocked")
|
|
if manifest.get("side_by_side", {}).get(
|
|
"grants_installation_authorization"
|
|
):
|
|
errors.append("side-by-side cannot authorize installation")
|
|
|
|
windows = manifest.get("operational_windows", [])
|
|
if [window.get("window") for window in windows] != list(range(1, 8)):
|
|
errors.append("operational windows are incomplete or reordered")
|
|
for window in windows:
|
|
if window.get("device_write") is not False:
|
|
errors.append(f"Window {window.get('window')} permits device write")
|
|
if window.get("payload_launch") is not False:
|
|
errors.append(f"Window {window.get('window')} permits payload launch")
|
|
if window.get("autoload_status_route") is not False:
|
|
errors.append(f"Window {window.get('window')} permits /autoload_status")
|
|
if window.get("automatic_retry") is not False:
|
|
errors.append(f"Window {window.get('window')} permits retry")
|
|
if windows and windows[0].get("file_transfer") is not False:
|
|
errors.append("Window 1 contains file transfer")
|
|
if len(windows) >= 3 and windows[2].get("automatic_third_attempt") is not False:
|
|
errors.append("Window 3 permits an automatic third attempt")
|
|
if len(windows) >= 4 and windows[3].get("component_session_separate") is not True:
|
|
errors.append("components do not have separate windows")
|
|
|
|
final = manifest.get("final_decision", {})
|
|
if (
|
|
final.get("classification") != "BLOCKED"
|
|
or final.get("hardware_evidence_claimed") is not False
|
|
or final.get("device_action_authorized") is not False
|
|
):
|
|
errors.append("final decision must remain an offline-only block")
|
|
return errors
|
|
|
|
|
|
def _phase09d_paths(root: Path) -> list[str]:
|
|
paths: set[str] = set()
|
|
for candidate in git(root, "ls-files").splitlines():
|
|
normalized = candidate.replace("\\", "/")
|
|
lowered = normalized.lower()
|
|
if "phase-0.9d" in lowered or "phase09d" in lowered:
|
|
paths.add(normalized)
|
|
|
|
output = git(root, "status", "--porcelain=v1", "--untracked-files=all")
|
|
for line in output.splitlines():
|
|
if len(line) < 4:
|
|
continue
|
|
candidate = line[3:].replace("\\", "/")
|
|
if " -> " in candidate:
|
|
candidate = candidate.rsplit(" -> ", 1)[1]
|
|
lowered = candidate.lower()
|
|
if "phase-0.9d" in lowered or "phase09d" in lowered:
|
|
paths.add(candidate)
|
|
return sorted(paths)
|
|
|
|
|
|
def collect_errors(root: Path) -> list[str]:
|
|
errors: list[str] = []
|
|
manifest_path = (
|
|
root / "manifests/runtime/phase-0.9d-existing-stack-readback.json"
|
|
)
|
|
schema_path = (
|
|
root
|
|
/ "manifests/runtime/phase-0.9d-existing-stack-readback.schema.json"
|
|
)
|
|
try:
|
|
manifest = load_json(manifest_path)
|
|
schema = load_json(schema_path)
|
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
return [str(error)]
|
|
|
|
errors.extend(validate_manifest(manifest))
|
|
errors.extend(validate_schema_instance(schema, manifest))
|
|
|
|
for relative in DELIVERABLES:
|
|
if not (root / relative).is_file():
|
|
errors.append(f"missing deliverable: {relative}")
|
|
|
|
for relative, expected in SOURCE_HASHES.items():
|
|
path = (root / relative).resolve()
|
|
if not path.is_file():
|
|
errors.append(f"missing source evidence: {relative}")
|
|
elif sha256_file(path) != expected:
|
|
errors.append(f"source evidence changed: {relative}")
|
|
if manifest.get("source_evidence", {}).get(relative) != expected:
|
|
errors.append(f"manifest source hash mismatch: {relative}")
|
|
|
|
for relative, expected in IMMUTABLE_HASHES.items():
|
|
path = root / relative
|
|
if not path.is_file() or sha256_file(path) != expected:
|
|
errors.append(f"immutable evidence changed: {relative}")
|
|
if manifest.get("immutable_evidence", {}).get(relative) != expected:
|
|
errors.append(f"manifest immutable hash mismatch: {relative}")
|
|
|
|
denylist = root / "manifests/artifact-denylist.json"
|
|
if not denylist.is_file() or sha256_file(denylist) != DENYLIST_HASH:
|
|
errors.append("permanent denylist changed")
|
|
else:
|
|
denylist_data = load_json(denylist)
|
|
entries = json.dumps(denylist_data, sort_keys=True)
|
|
if BLOCKED_HASH not in entries:
|
|
errors.append("permanently blocked artifact is absent from denylist")
|
|
|
|
for name, (relative, expected) in SOURCE_COMMITS.items():
|
|
source_root = (root / relative).resolve()
|
|
try:
|
|
if git(source_root, "rev-parse", "HEAD") != expected:
|
|
errors.append(f"source HEAD changed: {name}")
|
|
if git(source_root, "status", "--porcelain=v1"):
|
|
errors.append(f"source tree is dirty: {name}")
|
|
except RuntimeError as error:
|
|
errors.append(f"{name}: {error}")
|
|
|
|
try:
|
|
if git(root, "branch", "--show-current") != EXPECTED_BRANCH:
|
|
errors.append("current branch is not the Phase-0.9D branch")
|
|
except RuntimeError as error:
|
|
errors.append(str(error))
|
|
|
|
allowed_suffixes = {".md", ".json", ".py"}
|
|
forbidden_roots = ("samples/", "src/", "include/", "packaging/", "adapters/")
|
|
for relative in _phase09d_paths(root):
|
|
lowered = relative.lower()
|
|
if Path(relative).suffix.lower() not in allowed_suffixes:
|
|
errors.append(f"forbidden Phase-0.9D artifact/source suffix: {relative}")
|
|
if lowered.startswith(forbidden_roots):
|
|
errors.append(f"forbidden Phase-0.9D target/product path: {relative}")
|
|
if lowered.endswith((".elf", ".o", ".a", ".so", ".map", ".s", ".asm", ".ld")):
|
|
errors.append(f"forbidden Phase-0.9D target artifact: {relative}")
|
|
|
|
docs = "\n".join(
|
|
(root / relative).read_text(encoding="utf-8")
|
|
for relative in DELIVERABLES
|
|
if relative.endswith(".md") and (root / relative).is_file()
|
|
)
|
|
for marker in (
|
|
"BLOCKED_NO_READBACK_PATH",
|
|
"PATH_CONFLICT",
|
|
"server_active_flag",
|
|
"autoload_triggered",
|
|
"OFF_DEVICE_BACKUP_VALID",
|
|
"RECOVERY_PROVEN",
|
|
"No PS5 was contacted",
|
|
):
|
|
if marker not in docs:
|
|
errors.append(f"documentation marker missing: {marker}")
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--root", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
errors = collect_errors(args.root.resolve())
|
|
if errors:
|
|
for error in errors:
|
|
print(f"ERROR: {error}")
|
|
return 1
|
|
print("Phase-0.9D existing-stack readback audit: PASS")
|
|
print("Decision: BLOCKED_NO_READBACK_PATH")
|
|
print("Device contact/write/execution/artifact creation: false")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|