blob: 08a249b9200a104d76f0bb913ea3bcd1c986ec5e [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.
"""Frozen data models for the driver-lab host tooling.
Models are immutable and validated at construction. Conversions to and
from serialized forms happen in explicit functions; unvalidated
dictionaries are never passed through the system.
"""
from __future__ import annotations
import dataclasses
import enum
import hashlib
class AccessClass(enum.Enum):
"""Access classes; a grant for one class never authorizes another."""
READ_ONCE = "read_once"
SNAPSHOT = "snapshot"
POLL = "poll"
WRITE = "write"
SEQUENCE = "sequence"
PROTOCOL = "protocol"
PROTOCOL_TRANSACTION = "protocol_transaction"
INTERRUPT = "interrupt"
class Decision(enum.Enum):
"""Persistent grant decision."""
ALLOW = "allow"
DENY = "deny"
def _check_common(resource_digest: str, offset: int, width: int) -> None:
if not resource_digest.startswith("sha256:"):
raise ValueError("resource_digest must be a sha256:<hex> digest")
if offset < 0:
raise ValueError("offset must be non-negative")
if width <= 0:
raise ValueError("width must be positive")
@dataclasses.dataclass(frozen=True)
class AccessRequest:
"""One register access whose consent must be resolved."""
target_scope: str
node_id: str
resource_digest: str
resource: str
offset: int
width: int
access: AccessClass
def __post_init__(self) -> None:
_check_common(self.resource_digest, self.offset, self.width)
@property
def match_key(self) -> tuple[str, str, str, str, int, int, AccessClass]:
"""The exact identity a grant must match."""
return (
self.target_scope,
self.node_id,
self.resource_digest,
self.resource,
self.offset,
self.width,
self.access,
)
@dataclasses.dataclass(frozen=True)
class ReadGrant:
"""A persistent operator consent rule.
A grant matches on stable identity criteria only: target scope, node
identity criteria, resource-description digest, logical resource,
offset, width, and access class. It never matches solely by nodename,
boot ID, or devfs path. A boot change alone does not invalidate a
grant (no boot identity is recorded); a resource-description or
per-resource policy change does, because the digest changes.
"""
schema_version: int
target_scope: str
node_id: str
resource_digest: str
resource: str
offset: int
width: int
access: AccessClass
decision: Decision
approved_at: str
approval_source: str | None = None
reason: str | None = None
max_poll_hz: float | None = None
max_poll_timeout_s: float | None = None
def __post_init__(self) -> None:
if self.schema_version != 1:
raise ValueError(
f"unsupported grant schema version {self.schema_version}"
)
_check_common(self.resource_digest, self.offset, self.width)
if self.access in (AccessClass.WRITE, AccessClass.SEQUENCE):
raise ValueError(
f"persistent {self.access.value} grants are not supported"
)
has_poll_limits = (
self.max_poll_hz is not None or self.max_poll_timeout_s is not None
)
if self.access is not AccessClass.POLL and has_poll_limits:
raise ValueError("poll limits are only valid on poll grants")
if (
self.access is AccessClass.POLL
and self.decision is Decision.ALLOW
and (self.max_poll_hz is None or self.max_poll_timeout_s is None)
):
raise ValueError(
"a poll allow grant requires max_poll_hz and max_poll_timeout_s"
)
@property
def match_key(self) -> tuple[str, str, str, str, int, int, AccessClass]:
"""The exact identity this grant matches."""
return (
self.target_scope,
self.node_id,
self.resource_digest,
self.resource,
self.offset,
self.width,
self.access,
)
@property
def grant_id(self) -> str:
"""Stable identifier for list/explain/revoke operations."""
material = "|".join(
[
self.target_scope,
self.node_id,
self.resource_digest,
self.resource,
f"{self.offset:#x}",
str(self.width),
self.access.value,
self.decision.value,
]
)
return "grant-" + hashlib.sha256(material.encode()).hexdigest()[:16]
def matches(self, request: AccessRequest) -> bool:
"""Whether this grant exactly matches `request`."""
return self.match_key == request.match_key
@dataclasses.dataclass(frozen=True)
class WritePrecondition:
"""A required 32-bit register value prior to a write."""
expected: int
mask: int = 0xFFFF_FFFF
def __post_init__(self) -> None:
if not (0 <= self.expected <= 0xFFFF_FFFF):
raise ValueError("expected must be a 32-bit unsigned integer")
if not (0 <= self.mask <= 0xFFFF_FFFF):
raise ValueError("mask must be a 32-bit unsigned integer")
class ResourceKind(str, enum.Enum):
"""Resource kind identifying MMIO registers or peripheral protocol endpoints."""
MMIO = "mmio"
GPIO = "gpio"
I2C = "i2c"
SPI = "spi"
INTERRUPT = "interrupt"
CLOCK = "clock"
RESET = "reset"
SERIAL = "serial"
@dataclasses.dataclass(frozen=True)
class GpioReadOutcome:
"""The outcome of a policy-checked, audited GPIO read."""
value: bool
audit_seq: int
timestamp_ns: int
@dataclasses.dataclass(frozen=True)
class GpioWriteOutcome:
"""The outcome of a policy-checked, audited GPIO write."""
audit_seq: int
timestamp_ns: int
@dataclasses.dataclass(frozen=True)
class I2cTransferOutcome:
"""The outcome of a policy-checked, audited I2C transfer."""
read_data: bytes
audit_seq: int
timestamp_ns: int
@dataclasses.dataclass(frozen=True)
class SpiTransmitOutcome:
"""The outcome of a policy-checked, audited SPI transmit."""
rx_data: bytes
audit_seq: int
timestamp_ns: int
@dataclasses.dataclass(frozen=True)
class InterruptOutcome:
"""The outcome of an interrupt observation."""
resource: int
sequence: int
count: int
timestamp_ns: int
coalesced_count: int = 0
def __eq__(self, other: object) -> bool:
if isinstance(other, int):
return self.timestamp_ns == other
if isinstance(other, InterruptOutcome):
return (
self.resource == other.resource
and self.sequence == other.sequence
and self.count == other.count
and self.timestamp_ns == other.timestamp_ns
and self.coalesced_count == other.coalesced_count
)
return False
def __int__(self) -> int:
return self.timestamp_ns
def __index__(self) -> int:
return self.timestamp_ns