blob: b154540810878acbe464ae23c98b8161c5433113 [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Plan validation, canonicalization, and digests.
Plans are data, not code: no expressions, no arbitrary driver URLs,
component monikers, or protocol paths. Validation rejects unknown keys
everywhere so nothing can be smuggled past review. Canonicalization
normalizes numeric representations and key order -- operation order is
semantic and preserved -- so any change to an executable field changes the
digest and invalidates approval.
The schema carries the phase 2 (managed takeover) keys as reserved:
setting one fails with a distinct reserved-key error, so phase 2 can
adopt them without a schema-shape change.
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from typing import Any
SCHEMA_VERSION = 1
_TOP_KEYS = {
"schema_version",
"run_id",
"case_id",
"target",
"node",
"access",
"operations",
}
_TARGET_KEYS = {
"selector",
"expected_boot_id",
"requires_recovery",
"requires_serial",
}
_NODE_KEYS = {
"id",
"driver_moniker",
"expected_unclaimed",
"expected_resource_digest",
} | {
"expected_bound_driver_url",
"expected_topology_generation",
}
_ACCESS_KEYS = {
"mode",
"activation",
"requires_target_policy",
"requires_target_audit",
"requires_target_local_timing",
"restoration",
}
# Keys the schema carries for phase 2 (managed takeover) so their later
# arrival changes no schema shape. A phase 1 plan that sets one fails
# with a reserved-key error, never an unknown-key error.
_RESERVED_PHASE2_KEYS = {
"node.expected_bound_driver_url",
"node.expected_topology_generation",
"access.restoration",
}
_MAX_STRING = 128
class PlanError(Exception):
"""The plan is structurally invalid or requests something unsupported."""
def _require_str(value: object, what: str) -> str:
if not isinstance(value, str) or not value or len(value) > _MAX_STRING:
raise PlanError(
f"{what} must be a non-empty string of at most {_MAX_STRING} characters"
)
return value
def _require_bool(value: object, what: str) -> bool:
if not isinstance(value, bool):
raise PlanError(f"{what} must be a boolean")
return value
def _require_keys(
mapping: Mapping[str, object], allowed: set[str], what: str
) -> None:
if not isinstance(mapping, Mapping):
raise PlanError(f"{what} must be an object")
unknown = set(mapping) - allowed
if unknown:
raise PlanError(f"{what} has unknown keys: {sorted(unknown)}")
reserved = {
key for key in mapping if f"{what}.{key}" in _RESERVED_PHASE2_KEYS
}
if reserved:
raise PlanError(
f"{what} keys {sorted(reserved)} are reserved until phase 2 (managed takeover)"
)
def _parse_offset(value: object, what: str) -> int:
if isinstance(value, bool):
raise PlanError(f"{what} must be an integer or hex string")
if isinstance(value, int):
offset = value
elif isinstance(value, str):
text = value.strip().lower()
try:
offset = int(text, 16) if text.startswith("0x") else int(text, 10)
except ValueError as error:
raise PlanError(
f"{what} is not a valid offset: {value!r}"
) from error
else:
raise PlanError(f"{what} must be an integer or hex string")
if offset < 0:
raise PlanError(f"{what} must be non-negative")
return offset
def _require_uint32(value: object, what: str) -> int:
if isinstance(value, bool):
raise PlanError(f"{what} must be an integer or hex string")
if isinstance(value, int):
val = value
elif isinstance(value, str):
text = value.strip().lower()
try:
val = int(text, 16) if text.startswith("0x") else int(text, 10)
except ValueError as error:
raise PlanError(
f"{what} is not a valid integer: {value!r}"
) from error
else:
raise PlanError(f"{what} must be an integer or hex string")
if not (0 <= val <= 0xFFFF_FFFF):
raise PlanError(f"{what} must be in range [0, 0xffffffff]")
return val
_READ32_KINDS = ("mmio_read32", "state_read32")
_WRITE32_KINDS = ("mmio_write32", "knob_write32", "trigger_write32")
_POLL32_KINDS = ("mmio_poll32", "state_poll32")
def _resolve_resource(
mapping: Mapping[str, object],
what: str,
default_state0: bool,
kind: str = "",
) -> str:
if default_state0 and "resource" not in mapping:
resource = "state0"
else:
resource = _require_str(mapping.get("resource"), f"{what}.resource")
if default_state0 and resource.startswith("mmio"):
raise PlanError(
f"{what}.resource {resource!r} is an MMIO resource; use mmio_read32, mmio_write32, or mmio_poll32 instead of {kind}"
)
if not default_state0 and resource.startswith("state"):
raise PlanError(
f"{what}.resource {resource!r} is a StateBank resource; use state_read32, state_poll32, knob_write32, or trigger_write32 instead of {kind}"
)
return resource
def _validate_read32_op(
mapping: Mapping[str, Any], kind: str, what: str
) -> dict[str, Any]:
_require_keys(mapping, {"kind", "resource", "offset"}, what)
resource = _resolve_resource(
mapping, what, default_state0=(kind == "state_read32"), kind=kind
)
return {
"kind": kind,
"resource": resource,
"offset": _parse_offset(mapping.get("offset"), f"{what}.offset"),
}
def _validate_write32_op(
mapping: Mapping[str, Any], kind: str, what: str
) -> dict[str, Any]:
allowed_keys = {
"kind",
"resource",
"offset",
"value",
"write_mask",
"precondition",
"readback",
}
if kind == "trigger_write32":
allowed_keys = allowed_keys | {"arg"}
_require_keys(mapping, allowed_keys, what)
resource = _resolve_resource(
mapping,
what,
default_state0=(kind in ("knob_write32", "trigger_write32")),
kind=kind,
)
offset = _parse_offset(mapping.get("offset"), f"{what}.offset")
if kind == "trigger_write32":
if "value" in mapping and "arg" in mapping:
raise PlanError(f"{what} cannot specify both value and arg")
if "value" in mapping:
value = _require_uint32(mapping.get("value"), f"{what}.value")
elif "arg" in mapping:
value = _require_uint32(mapping.get("arg"), f"{what}.arg")
else:
value = 1
else:
if "value" not in mapping:
raise PlanError(f"{what}.value is required")
value = _require_uint32(mapping.get("value"), f"{what}.value")
write_mask = (
_require_uint32(mapping.get("write_mask"), f"{what}.write_mask")
if "write_mask" in mapping
else 0xFFFF_FFFF
)
precondition = None
if "precondition" in mapping and mapping["precondition"] is not None:
prec = mapping["precondition"]
prec_what = f"{what}.precondition"
if not isinstance(prec, Mapping):
raise PlanError(f"{prec_what} must be an object")
_require_keys(prec, {"expected", "mask"}, prec_what)
if "expected" not in prec:
raise PlanError(f"{prec_what}.expected is required")
p_expected = _require_uint32(
prec.get("expected"), f"{prec_what}.expected"
)
p_mask = (
_require_uint32(prec.get("mask"), f"{prec_what}.mask")
if "mask" in prec
else 0xFFFF_FFFF
)
precondition = {"expected": p_expected, "mask": p_mask}
readback = (
_require_bool(mapping.get("readback"), f"{what}.readback")
if "readback" in mapping
else (False if kind == "trigger_write32" else True)
)
res_write: dict[str, Any] = {
"kind": kind,
"resource": resource,
"offset": offset,
"value": value,
"write_mask": write_mask,
"readback": readback,
}
if precondition is not None:
res_write["precondition"] = precondition
return res_write
def _validate_poll32_op(
mapping: Mapping[str, Any], kind: str, what: str, *, in_sequence: bool
) -> dict[str, Any]:
_require_keys(
mapping,
{
"kind",
"resource",
"offset",
"expected",
"mask",
"interval_ns",
"timeout_ns",
"interval_s",
"timeout_s",
},
what,
)
resource = _resolve_resource(
mapping, what, default_state0=(kind == "state_poll32"), kind=kind
)
offset = _parse_offset(mapping.get("offset"), f"{what}.offset")
if "expected" not in mapping:
raise PlanError(f"{what}.expected is required")
expected = _require_uint32(mapping.get("expected"), f"{what}.expected")
mask = (
_require_uint32(mapping.get("mask"), f"{what}.mask")
if "mask" in mapping
else 0xFFFF_FFFF
)
if "interval_ns" in mapping:
interval_ns = int(mapping["interval_ns"])
elif "interval_s" in mapping:
interval_ns = int(float(mapping["interval_s"]) * 1e9)
else:
interval_ns = 1_000_000
if not in_sequence and interval_ns < 0:
raise PlanError(f"{what}.interval_ns must be non-negative")
if "timeout_ns" in mapping:
timeout_ns = int(mapping["timeout_ns"])
elif "timeout_s" in mapping:
timeout_ns = int(float(mapping["timeout_s"]) * 1e9)
else:
if kind == "state_poll32":
timeout_ns = 100_000_000
else:
raise PlanError(f"{what} requires timeout_ns or timeout_s")
if in_sequence:
if timeout_ns <= 0 or timeout_ns > 10_000_000_000:
raise PlanError(f"{what}.timeout_ns out of range")
else:
if timeout_ns <= 0:
raise PlanError(f"{what}.timeout_ns must be positive")
if timeout_ns > 10_000_000_000:
raise PlanError(f"{what}.timeout_ns exceeds 10s ceiling")
return {
"kind": kind,
"resource": resource,
"offset": offset,
"expected": expected,
"mask": mask,
"interval_ns": interval_ns,
"timeout_ns": timeout_ns,
}
def _validate_operation(operation: object, index: int) -> dict[str, Any]:
what = f"operations[{index}]"
if not isinstance(operation, Mapping):
raise PlanError(f"{what} must be an object")
kind = operation.get("kind")
if isinstance(kind, str) and kind in _READ32_KINDS:
return _validate_read32_op(operation, kind, what)
if kind == "mmio_snapshot32":
_require_keys(operation, {"kind", "items"}, what)
items = operation.get("items")
if not isinstance(items, list) or not items:
raise PlanError(f"{what}.items must be a non-empty list")
canonical_items = []
for item_index, item in enumerate(items):
item_what = f"{what}.items[{item_index}]"
if not isinstance(item, Mapping):
raise PlanError(f"{item_what} must be an object")
_require_keys(item, {"resource", "offset"}, item_what)
canonical_items.append(
{
"resource": _require_str(
item.get("resource"), f"{item_what}.resource"
),
"offset": _parse_offset(
item.get("offset"), f"{item_what}.offset"
),
}
)
return {"kind": "mmio_snapshot32", "items": canonical_items}
if isinstance(kind, str) and kind in _WRITE32_KINDS:
return _validate_write32_op(operation, kind, what)
if isinstance(kind, str) and kind in _POLL32_KINDS:
return _validate_poll32_op(operation, kind, what, in_sequence=False)
if kind == "sequence":
_require_keys(operation, {"kind", "items"}, what)
items = operation.get("items")
if not isinstance(items, list) or not items:
raise PlanError(f"{what}.items must be a non-empty list")
if len(items) > 64:
raise PlanError(f"{what}.items exceeds maximum of 64 items")
canonical_items = []
for item_index, item in enumerate(items):
item_what = f"{what}.items[{item_index}]"
if not isinstance(item, Mapping):
raise PlanError(f"{item_what} must be an object")
item_kind = item.get("kind")
if isinstance(item_kind, str) and item_kind in _READ32_KINDS:
canonical_items.append(
_validate_read32_op(item, item_kind, item_what)
)
elif isinstance(item_kind, str) and item_kind in _WRITE32_KINDS:
canonical_items.append(
_validate_write32_op(item, item_kind, item_what)
)
elif isinstance(item_kind, str) and item_kind in _POLL32_KINDS:
canonical_items.append(
_validate_poll32_op(
item, item_kind, item_what, in_sequence=True
)
)
elif item_kind == "delay_ns":
_require_keys(item, {"kind", "duration_ns"}, item_what)
duration = int(item.get("duration_ns", 0))
if duration < 0 or duration > 1_000_000_000:
raise PlanError(
f"{item_what}.duration_ns must be between 0 and 1s (1_000_000_000 ns)"
)
canonical_items.append(
{"kind": "delay_ns", "duration_ns": duration}
)
elif item_kind == "barrier":
_require_keys(item, {"kind", "variant"}, item_what)
variant = item.get("variant", "memory")
if variant != "memory":
raise PlanError(
f"{item_what}.variant {variant!r} is unsupported"
)
canonical_items.append({"kind": "barrier", "variant": variant})
else:
raise PlanError(
f"{item_what}.kind is unsupported: {item_kind!r}"
)
return {"kind": "sequence", "items": canonical_items}
if kind == "delay_ns":
_require_keys(operation, {"kind", "duration_ns"}, what)
duration = int(operation.get("duration_ns", 0))
if duration < 0 or duration > 1_000_000_000:
raise PlanError(
f"{what}.duration_ns must be between 0 and 1s (1_000_000_000 ns)"
)
return {"kind": "delay_ns", "duration_ns": duration}
if kind == "barrier":
_require_keys(operation, {"kind", "variant"}, what)
variant = operation.get("variant", "memory")
if variant != "memory":
raise PlanError(f"{what}.variant {variant!r} is unsupported")
return {"kind": "barrier", "variant": variant}
if kind == "fidl_call":
_require_keys(operation, {"kind", "method", "args"}, what)
method = _require_str(operation.get("method"), f"{what}.method")
args = operation.get("args")
if args is not None and not isinstance(args, Mapping):
raise PlanError(f"{what}.args must be an object")
canonical_args: dict[str, Any] = {}
if isinstance(args, Mapping):
for k, v in args.items():
if not isinstance(k, str):
raise PlanError(f"{what}.args keys must be strings")
canonical_args[k] = v
return {
"kind": "fidl_call",
"method": method,
"args": canonical_args,
}
raise PlanError(f"{what}.kind is unsupported: {kind!r}")
def validate_plan(plan: Mapping[str, object]) -> dict[str, Any]:
"""Validates `plan` and returns its canonical structure.
Rejects unknown keys at every level, unsupported access modes and
activations (managed takeover is phase 2), and malformed operations.
"""
_require_keys(plan, _TOP_KEYS, "plan")
if plan.get("schema_version") != SCHEMA_VERSION:
raise PlanError("schema_version must be 1")
run_id = _require_str(plan.get("run_id"), "run_id")
case_id = _require_str(plan.get("case_id"), "case_id")
target_in = plan.get("target")
_require_keys(target_in, _TARGET_KEYS, "target") # type: ignore[arg-type]
assert isinstance(target_in, Mapping)
target: dict[str, Any] = {
"selector": _require_str(target_in.get("selector"), "target.selector")
}
if "expected_boot_id" in target_in:
target["expected_boot_id"] = _require_str(
target_in.get("expected_boot_id"), "target.expected_boot_id"
)
if "requires_recovery" in target_in:
target["requires_recovery"] = _require_bool(
target_in.get("requires_recovery"), "target.requires_recovery"
)
if "requires_serial" in target_in:
target["requires_serial"] = _require_bool(
target_in.get("requires_serial"), "target.requires_serial"
)
node_in = plan.get("node")
_require_keys(node_in, _NODE_KEYS, "node") # type: ignore[arg-type]
assert isinstance(node_in, Mapping)
node: dict[str, Any] = {"id": _require_str(node_in.get("id"), "node.id")}
if "driver_moniker" in node_in:
node["driver_moniker"] = _require_str(
node_in.get("driver_moniker"), "node.driver_moniker"
)
if "expected_unclaimed" in node_in:
node["expected_unclaimed"] = _require_bool(
node_in.get("expected_unclaimed"), "node.expected_unclaimed"
)
if "expected_resource_digest" in node_in:
node["expected_resource_digest"] = _require_str(
node_in.get("expected_resource_digest"),
"node.expected_resource_digest",
)
access_in = plan.get("access")
_require_keys(access_in, _ACCESS_KEYS, "access") # type: ignore[arg-type]
assert isinstance(access_in, Mapping)
mode = access_in.get("mode")
if mode not in ("direct", "proxy", "in-situ"):
raise PlanError(f"access.mode is unsupported: {mode!r}")
access: dict[str, Any] = {"mode": mode}
activation = access_in.get("activation")
if mode == "proxy":
if activation == "takeover":
raise PlanError(
"access.activation 'takeover' is not supported until phase 2"
)
if activation not in (None, "bind-unclaimed", "in-situ"):
raise PlanError(f"access.activation is unsupported: {activation!r}")
access["activation"] = activation or "bind-unclaimed"
elif mode == "in-situ":
if activation == "takeover":
raise PlanError(
"access.activation 'takeover' is not supported until phase 2"
)
if activation not in (None, "in-situ"):
raise PlanError(f"access.activation is unsupported: {activation!r}")
access["activation"] = "in-situ"
elif activation is not None:
raise PlanError(
"access.activation is only valid in proxy or in-situ mode"
)
for flag in (
"requires_target_policy",
"requires_target_audit",
"requires_target_local_timing",
):
value = access_in.get(flag, False)
access[flag] = _require_bool(value, f"access.{flag}")
operations_in = plan.get("operations")
if not isinstance(operations_in, list) or not operations_in:
raise PlanError("operations must be a non-empty list")
operations = [
_validate_operation(operation, index)
for index, operation in enumerate(operations_in)
]
return {
"schema_version": SCHEMA_VERSION,
"run_id": run_id,
"case_id": case_id,
"target": target,
"node": node,
"access": access,
"operations": operations,
}
def canonical_json(plan: Mapping[str, object]) -> bytes:
"""The canonical byte form of `plan`: validated, keys sorted, numbers
normalized, operation order preserved."""
canonical = validate_plan(plan)
return json.dumps(
canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode()
def plan_digest(plan: Mapping[str, object]) -> str:
"""The canonical plan digest, as `sha256:<hex>`."""
return "sha256:" + hashlib.sha256(canonical_json(plan)).hexdigest()
def is_mutating_plan(plan: Mapping[str, object]) -> bool:
"""Whether `plan` contains any mutating operations."""
operations = plan.get("operations")
if not isinstance(operations, list):
return False
for op in operations:
if not isinstance(op, Mapping):
continue
kind = op.get("kind")
if kind in _WRITE32_KINDS:
return True
if kind == "sequence":
items = op.get("items")
if isinstance(items, list):
for item in items:
if (
isinstance(item, Mapping)
and item.get("kind") in _WRITE32_KINDS
):
return True
return False