feat(k1): add offline control execution gate
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationBootstrapError,
|
||||
ApplicationControlAuthority,
|
||||
)
|
||||
|
||||
KEYCHAIN_SERVICE = "NODEDC Mission Core XGRIDS K1 OpenAPI"
|
||||
KEYCHAIN_ACCOUNT = "lixelgo-application-fw-3.0.2"
|
||||
KEYCHAIN_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
class ApplicationAuthorityLoadError(RuntimeError):
|
||||
"""The private application authority could not be loaded safely."""
|
||||
|
||||
|
||||
class CommandRunner(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
args: list[str],
|
||||
*,
|
||||
capture_output: bool,
|
||||
check: bool,
|
||||
timeout: float,
|
||||
) -> subprocess.CompletedProcess[bytes]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationAuthoritySourceSnapshot:
|
||||
provider: str = "macos-keychain"
|
||||
service: str = KEYCHAIN_SERVICE
|
||||
account: str = KEYCHAIN_ACCOUNT
|
||||
secret_cached: bool = False
|
||||
secret_exportable: bool = False
|
||||
file_fallback_enabled: bool = False
|
||||
environment_fallback_enabled: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"service": self.service,
|
||||
"account": self.account,
|
||||
"secret_cached": self.secret_cached,
|
||||
"secret_exportable": self.secret_exportable,
|
||||
"file_fallback_enabled": self.file_fallback_enabled,
|
||||
"environment_fallback_enabled": self.environment_fallback_enabled,
|
||||
}
|
||||
|
||||
|
||||
class MacOSKeychainApplicationAuthorityLoader:
|
||||
"""Read the exact-profile authority from the current user's Keychain.
|
||||
|
||||
There is deliberately no environment, plaintext-file, browser or API
|
||||
fallback. The secret is requested through the absolute ``security`` binary
|
||||
and is never interpolated into an exception, repr or subprocess argument.
|
||||
"""
|
||||
|
||||
def __init__(self, *, runner: CommandRunner = subprocess.run) -> None:
|
||||
self._runner = runner
|
||||
|
||||
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
|
||||
return ApplicationAuthoritySourceSnapshot()
|
||||
|
||||
def load(self) -> ApplicationControlAuthority:
|
||||
if platform.system() != "Darwin":
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"application authority loading is supported only through macOS Keychain"
|
||||
)
|
||||
security = shutil.which("security")
|
||||
if security != "/usr/bin/security":
|
||||
raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable")
|
||||
|
||||
try:
|
||||
completed = self._runner(
|
||||
[
|
||||
security,
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=KEYCHAIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||
|
||||
secret_buffer = bytearray(completed.stdout)
|
||||
try:
|
||||
while secret_buffer.endswith((b"\n", b"\r")):
|
||||
secret_buffer.pop()
|
||||
try:
|
||||
secret = secret_buffer.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"macOS Keychain authority has an invalid encoding"
|
||||
) from exc
|
||||
try:
|
||||
return ApplicationControlAuthority(openapi_key=secret)
|
||||
except ApplicationBootstrapError as exc:
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"macOS Keychain authority does not match the reviewed profile"
|
||||
) from exc
|
||||
finally:
|
||||
for index in range(len(secret_buffer)):
|
||||
secret_buffer[index] = 0
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
@@ -62,6 +63,10 @@ class ApplicationControlAuthority:
|
||||
"application authority does not match the reviewed K1 profile"
|
||||
)
|
||||
_validate_ascii_identity(self.openapi_key, "openapi_key")
|
||||
if len(self.openapi_key) != 36:
|
||||
raise ApplicationBootstrapError(
|
||||
"openapi_key must match the exact 36-byte reviewed application profile"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -133,6 +138,9 @@ class EncodedApplicationRequest:
|
||||
payload_bytes: int
|
||||
mutates_device: bool
|
||||
requires_live_binding: bool
|
||||
response_required: bool
|
||||
session_id: str = field(repr=False)
|
||||
vendor_device_id: str | None = field(repr=False)
|
||||
qos: int = 2
|
||||
retain: bool = False
|
||||
|
||||
@@ -147,6 +155,7 @@ class EncodedApplicationRequest:
|
||||
"payload_bytes": self.payload_bytes,
|
||||
"mutates_device": self.mutates_device,
|
||||
"requires_live_binding": self.requires_live_binding,
|
||||
"response_required": self.response_required,
|
||||
"qos": self.qos,
|
||||
"retain": self.retain,
|
||||
}
|
||||
@@ -181,6 +190,39 @@ class DeviceInfoResponse:
|
||||
result_code: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationResponse:
|
||||
session_id: str = field(repr=False)
|
||||
vendor_device_id: str = field(repr=False)
|
||||
openapi_key: str = field(repr=False)
|
||||
result_code: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationBootstrapSnapshot:
|
||||
current_batch: int
|
||||
total_batches: int
|
||||
batch_issued: bool
|
||||
pending_response_topics: tuple[str, ...]
|
||||
live_binding_observed: bool
|
||||
bootstrap_complete: bool
|
||||
executable: bool = False
|
||||
vendor_writes_enabled: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "shadow-only",
|
||||
"current_batch": self.current_batch,
|
||||
"total_batches": self.total_batches,
|
||||
"batch_issued": self.batch_issued,
|
||||
"pending_response_topics": list(self.pending_response_topics),
|
||||
"live_binding_observed": self.live_binding_observed,
|
||||
"bootstrap_complete": self.bootstrap_complete,
|
||||
"executable": self.executable,
|
||||
"vendor_writes_enabled": self.vendor_writes_enabled,
|
||||
}
|
||||
|
||||
|
||||
def build_shadow_bootstrap(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
@@ -213,6 +255,7 @@ def build_shadow_bootstrap(
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"identity-discovery",
|
||||
@@ -221,6 +264,7 @@ def build_shadow_bootstrap(
|
||||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"identity-discovery",
|
||||
@@ -229,6 +273,7 @@ def build_shadow_bootstrap(
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
@@ -237,6 +282,7 @@ def build_shadow_bootstrap(
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
DEVICE_CONFIG_TIME_CONTEXT,
|
||||
True,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
@@ -245,6 +291,7 @@ def build_shadow_bootstrap(
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
@@ -253,6 +300,7 @@ def build_shadow_bootstrap(
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
@@ -261,6 +309,7 @@ def build_shadow_bootstrap(
|
||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
@@ -269,6 +318,7 @@ def build_shadow_bootstrap(
|
||||
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
@@ -277,6 +327,7 @@ def build_shadow_bootstrap(
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
@@ -285,12 +336,19 @@ def build_shadow_bootstrap(
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
)
|
||||
requests: list[EncodedApplicationRequest] = []
|
||||
for ordinal, (phase, message_type, topic, response_topic, context, mutates) in enumerate(
|
||||
specs, start=1
|
||||
):
|
||||
for ordinal, (
|
||||
phase,
|
||||
message_type,
|
||||
topic,
|
||||
response_topic,
|
||||
context,
|
||||
mutates,
|
||||
response_required,
|
||||
) in enumerate(specs, start=1):
|
||||
request_binding = None if ordinal <= 3 else binding
|
||||
header = ApplicationRequestHeader(
|
||||
message_type=message_type,
|
||||
@@ -316,6 +374,11 @@ def build_shadow_bootstrap(
|
||||
payload_bytes=len(payload),
|
||||
mutates_device=mutates,
|
||||
requires_live_binding=ordinal > 3,
|
||||
response_required=response_required,
|
||||
session_id=header.session_id,
|
||||
vendor_device_id=(
|
||||
request_binding.vendor_device_id if request_binding is not None else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return ShadowApplicationBootstrap(tuple(requests))
|
||||
@@ -324,6 +387,9 @@ def build_shadow_bootstrap(
|
||||
def decode_and_bind_device_info_response(
|
||||
payload: bytes,
|
||||
authority: ApplicationControlAuthority,
|
||||
*,
|
||||
expected_session_id: str = ":DeviceInfoRequest",
|
||||
expected_vendor_device_id: str | None = None,
|
||||
) -> DeviceInfoResponse:
|
||||
"""Correlate the bootstrap response and derive a live per-device binding."""
|
||||
|
||||
@@ -333,8 +399,12 @@ def decode_and_bind_device_info_response(
|
||||
device_id = _identity_field(header, 4, "header.device_id")
|
||||
session_id = _identity_field(header, 5, "header.session_id")
|
||||
openapi_key = _identity_field(header, 6, "header.openapi_key")
|
||||
if session_id != ":DeviceInfoRequest":
|
||||
if not hmac.compare_digest(session_id, expected_session_id):
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse session correlation failed")
|
||||
if expected_vendor_device_id is not None and not hmac.compare_digest(
|
||||
device_id, expected_vendor_device_id
|
||||
):
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse vendor identity mismatch")
|
||||
if not hmac.compare_digest(openapi_key, authority.openapi_key):
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse application authority mismatch")
|
||||
|
||||
@@ -371,6 +441,221 @@ def decode_and_bind_device_info_response(
|
||||
return DeviceInfoResponse(binding, session_id, openapi_key, result_code)
|
||||
|
||||
|
||||
def correlate_application_response(
|
||||
payload: bytes,
|
||||
expected: EncodedApplicationRequest,
|
||||
authority: ApplicationControlAuthority,
|
||||
*,
|
||||
live_binding: LiveDeviceControlBinding | None = None,
|
||||
) -> ApplicationResponse:
|
||||
"""Correlate a non-DeviceInfo response to an exact emitted request."""
|
||||
|
||||
expected_device_id = expected.vendor_device_id
|
||||
if expected_device_id is None and live_binding is not None:
|
||||
expected_device_id = live_binding.vendor_device_id
|
||||
if expected_device_id is None:
|
||||
raise ApplicationBootstrapError(
|
||||
"generic response correlation requires live DeviceInfo identity"
|
||||
)
|
||||
_check_payload(payload, "application response")
|
||||
top = _selected_unique_fields(
|
||||
payload,
|
||||
"application response",
|
||||
max_fields=64,
|
||||
selected={1, 15},
|
||||
)
|
||||
header = _selected_unique_fields(
|
||||
_required_bytes(top, 1, "response.header"),
|
||||
"header",
|
||||
max_fields=32,
|
||||
selected={4, 5, 6},
|
||||
)
|
||||
device_id = _identity_field(header, 4, "header.device_id")
|
||||
session_id = _identity_field(header, 5, "header.session_id")
|
||||
openapi_key = _identity_field(header, 6, "header.openapi_key")
|
||||
if not hmac.compare_digest(device_id, expected_device_id):
|
||||
raise ApplicationBootstrapError("application response vendor identity mismatch")
|
||||
if not hmac.compare_digest(session_id, expected.session_id):
|
||||
raise ApplicationBootstrapError("application response session correlation failed")
|
||||
if not hmac.compare_digest(openapi_key, authority.openapi_key):
|
||||
raise ApplicationBootstrapError("application response authority mismatch")
|
||||
|
||||
error = _selected_unique_fields(
|
||||
_required_bytes(top, 15, "response.error"),
|
||||
"error",
|
||||
max_fields=8,
|
||||
selected={1, 2},
|
||||
)
|
||||
result_code = _required_uint(error, 1, "response.error.code")
|
||||
if result_code != OPENAPI_SUCCESS:
|
||||
raise ApplicationBootstrapError(
|
||||
f"application response rejected with result code {result_code}"
|
||||
)
|
||||
return ApplicationResponse(session_id, device_id, openapi_key, result_code)
|
||||
|
||||
|
||||
class ShadowApplicationBootstrapOrchestrator:
|
||||
"""Advance the retained dialogue only across observed response barriers.
|
||||
|
||||
The clean cycle emitted five batches. ``ModelingStatusRequest`` in the
|
||||
second batch had no required synchronous response before preparation
|
||||
continued; readiness remains a separate live DeviceStatus gate.
|
||||
"""
|
||||
|
||||
_BATCH_ORDINALS = ((1,), (2, 3), (4, 5, 6), (7,), (8, 9, 10))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: ApplicationControlAuthority,
|
||||
*,
|
||||
epoch_seconds: int,
|
||||
timezone_name: str,
|
||||
) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._authority = authority
|
||||
self._epoch_seconds = epoch_seconds
|
||||
self._timezone_name = timezone_name
|
||||
self._binding: LiveDeviceControlBinding | None = None
|
||||
self._plan: ShadowApplicationBootstrap | None = None
|
||||
self._batch_index = 0
|
||||
self._batch_issued = False
|
||||
self._pending: dict[str, EncodedApplicationRequest] = {}
|
||||
self._complete = False
|
||||
self._initial_request = _build_initial_device_info_request(authority)
|
||||
|
||||
def snapshot(self) -> ApplicationBootstrapSnapshot:
|
||||
with self._lock:
|
||||
return ApplicationBootstrapSnapshot(
|
||||
current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)),
|
||||
total_batches=len(self._BATCH_ORDINALS),
|
||||
batch_issued=self._batch_issued,
|
||||
pending_response_topics=tuple(sorted(self._pending)),
|
||||
live_binding_observed=self._binding is not None,
|
||||
bootstrap_complete=self._complete,
|
||||
)
|
||||
|
||||
@property
|
||||
def binding(self) -> LiveDeviceControlBinding | None:
|
||||
with self._lock:
|
||||
return self._binding
|
||||
|
||||
def next_batch(self) -> tuple[EncodedApplicationRequest, ...]:
|
||||
"""Return the next batch once; repeated access fails closed."""
|
||||
|
||||
with self._lock:
|
||||
if self._complete:
|
||||
raise ApplicationBootstrapError("application bootstrap is already complete")
|
||||
if self._batch_issued:
|
||||
raise ApplicationBootstrapError(
|
||||
"current application bootstrap batch was already issued"
|
||||
)
|
||||
requests: tuple[EncodedApplicationRequest, ...]
|
||||
if self._batch_index == 0:
|
||||
requests = (self._initial_request,)
|
||||
else:
|
||||
if self._plan is None:
|
||||
raise ApplicationBootstrapError("live DeviceInfo binding is missing")
|
||||
requests = tuple(
|
||||
self._plan.requests[ordinal - 1]
|
||||
for ordinal in self._BATCH_ORDINALS[self._batch_index]
|
||||
)
|
||||
pending = {
|
||||
request.response_topic: request for request in requests if request.response_required
|
||||
}
|
||||
if len(pending) != sum(request.response_required for request in requests):
|
||||
raise ApplicationBootstrapError(
|
||||
"bootstrap batch contains ambiguous required response topics"
|
||||
)
|
||||
if not pending:
|
||||
raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier")
|
||||
self._pending = pending
|
||||
self._batch_issued = True
|
||||
return requests
|
||||
|
||||
def accept_response(self, topic: str, payload: bytes) -> None:
|
||||
"""Correlate one required response and unlock only the next batch."""
|
||||
|
||||
with self._lock:
|
||||
if not self._batch_issued:
|
||||
raise ApplicationBootstrapError("no application bootstrap batch is in flight")
|
||||
expected = self._pending.get(topic)
|
||||
if expected is None:
|
||||
raise ApplicationBootstrapError(
|
||||
"application response is not required by the current batch"
|
||||
)
|
||||
|
||||
if expected.ordinal == 1:
|
||||
response = decode_and_bind_device_info_response(payload, self._authority)
|
||||
binding = response.binding
|
||||
plan = build_shadow_bootstrap(
|
||||
self._authority,
|
||||
binding,
|
||||
epoch_seconds=self._epoch_seconds,
|
||||
timezone_name=self._timezone_name,
|
||||
)
|
||||
if plan.requests[0].payload != self._initial_request.payload:
|
||||
raise ApplicationBootstrapError(
|
||||
"bound bootstrap plan changed the emitted identity request"
|
||||
)
|
||||
self._binding = binding
|
||||
self._plan = plan
|
||||
else:
|
||||
if self._binding is None:
|
||||
raise ApplicationBootstrapError("live DeviceInfo binding is missing")
|
||||
if expected.message_type == "DeviceInfoRequest":
|
||||
response = decode_and_bind_device_info_response(
|
||||
payload,
|
||||
self._authority,
|
||||
expected_session_id=expected.session_id,
|
||||
expected_vendor_device_id=self._binding.vendor_device_id,
|
||||
)
|
||||
if response.binding != self._binding:
|
||||
raise ApplicationBootstrapError(
|
||||
"live DeviceInfo facts changed during bootstrap"
|
||||
)
|
||||
else:
|
||||
correlate_application_response(
|
||||
payload,
|
||||
expected,
|
||||
self._authority,
|
||||
live_binding=self._binding,
|
||||
)
|
||||
|
||||
del self._pending[topic]
|
||||
if self._pending:
|
||||
return
|
||||
self._batch_issued = False
|
||||
self._batch_index += 1
|
||||
if self._batch_index == len(self._BATCH_ORDINALS):
|
||||
self._complete = True
|
||||
|
||||
|
||||
def _build_initial_device_info_request(
|
||||
authority: ApplicationControlAuthority,
|
||||
) -> EncodedApplicationRequest:
|
||||
header = ApplicationRequestHeader(
|
||||
message_type="DeviceInfoRequest",
|
||||
authority=authority,
|
||||
)
|
||||
payload = _bytes_field(1, _encode_header(header))
|
||||
_check_payload(payload, "application request")
|
||||
return EncodedApplicationRequest(
|
||||
ordinal=1,
|
||||
phase="identity-discovery",
|
||||
message_type="DeviceInfoRequest",
|
||||
topic=DEVICE_INFO_REQUEST_TOPIC,
|
||||
response_topic=DEVICE_INFO_RESPONSE_TOPIC,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
mutates_device=False,
|
||||
requires_live_binding=False,
|
||||
response_required=True,
|
||||
session_id=header.session_id,
|
||||
vendor_device_id=None,
|
||||
)
|
||||
|
||||
|
||||
def _encode_header(header: ApplicationRequestHeader) -> bytes:
|
||||
parts: list[bytes] = []
|
||||
if header.binding is not None:
|
||||
@@ -438,6 +723,26 @@ def _unique_fields(
|
||||
return result
|
||||
|
||||
|
||||
def _selected_unique_fields(
|
||||
payload: bytes,
|
||||
name: str,
|
||||
*,
|
||||
max_fields: int,
|
||||
selected: set[int],
|
||||
) -> dict[int, ProtoField]:
|
||||
result: dict[int, ProtoField] = {}
|
||||
try:
|
||||
for item in iter_fields(payload, max_fields=max_fields):
|
||||
if item.number not in selected:
|
||||
continue
|
||||
if item.number in result:
|
||||
raise ApplicationBootstrapError(f"{name} field {item.number} is duplicated")
|
||||
result[item.number] = item
|
||||
except ProtobufWireError as exc:
|
||||
raise ApplicationBootstrapError(f"invalid {name}: {exc}") from exc
|
||||
return result
|
||||
|
||||
|
||||
def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 2 or not isinstance(item.value, bytes):
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
EncodedApplicationRequest,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
ShadowModelingCommand,
|
||||
)
|
||||
|
||||
|
||||
class VendorWritesDisabledError(RuntimeError):
|
||||
"""The active compatibility profile cannot touch a publish transport."""
|
||||
|
||||
|
||||
class ApplicationPublishSink(Protocol):
|
||||
def publish(
|
||||
self,
|
||||
*,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OneShotPublishEnvelope:
|
||||
topic: str
|
||||
payload: bytes = field(repr=False)
|
||||
payload_sha256: str
|
||||
payload_bytes: int
|
||||
qos: int
|
||||
retain: bool
|
||||
automatic_retry: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_bootstrap_request(
|
||||
cls,
|
||||
request: EncodedApplicationRequest,
|
||||
) -> OneShotPublishEnvelope:
|
||||
return cls(
|
||||
topic=request.topic,
|
||||
payload=request.payload,
|
||||
payload_sha256=request.payload_sha256,
|
||||
payload_bytes=request.payload_bytes,
|
||||
qos=request.qos,
|
||||
retain=request.retain,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_modeling_command(
|
||||
cls,
|
||||
command: ShadowModelingCommand,
|
||||
) -> OneShotPublishEnvelope:
|
||||
return cls(
|
||||
topic=command.topic,
|
||||
payload=command.command.payload,
|
||||
payload_sha256=command.payload_sha256,
|
||||
payload_bytes=command.payload_bytes,
|
||||
qos=command.qos,
|
||||
retain=command.retain,
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.qos != 2 or self.retain or self.automatic_retry:
|
||||
raise ValueError("publish envelope differs from the reviewed one-shot contract")
|
||||
if self.payload_bytes != len(self.payload):
|
||||
raise ValueError("publish envelope payload length mismatch")
|
||||
if not hashlib.sha256(self.payload).hexdigest() == self.payload_sha256:
|
||||
raise ValueError("publish envelope payload digest mismatch")
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"topic": self.topic,
|
||||
"payload_sha256": self.payload_sha256,
|
||||
"payload_bytes": self.payload_bytes,
|
||||
"qos": self.qos,
|
||||
"retain": self.retain,
|
||||
"automatic_retry": self.automatic_retry,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WriteDisabledPublisherSnapshot:
|
||||
denied_attempts: int
|
||||
transport_calls: int = 0
|
||||
vendor_writes_enabled: bool = False
|
||||
publisher_armed: bool = False
|
||||
automatic_retry: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "write-disabled",
|
||||
"denied_attempts": self.denied_attempts,
|
||||
"transport_calls": self.transport_calls,
|
||||
"vendor_writes_enabled": self.vendor_writes_enabled,
|
||||
"publisher_armed": self.publisher_armed,
|
||||
"automatic_retry": self.automatic_retry,
|
||||
}
|
||||
|
||||
|
||||
class WriteDisabledOneShotPublisher:
|
||||
"""Current-profile publish boundary that cannot call its injected sink.
|
||||
|
||||
A future write-capable implementation must be a separate reviewed type. It
|
||||
cannot be enabled by flipping a runtime boolean on this class.
|
||||
"""
|
||||
|
||||
def __init__(self, sink: ApplicationPublishSink) -> None:
|
||||
self._sink = sink
|
||||
self._lock = threading.Lock()
|
||||
self._denied_attempts = 0
|
||||
|
||||
def publish_once(self, _envelope: OneShotPublishEnvelope) -> None:
|
||||
with self._lock:
|
||||
self._denied_attempts += 1
|
||||
raise VendorWritesDisabledError(
|
||||
"active K1 compatibility profile cannot publish vendor requests"
|
||||
)
|
||||
|
||||
def snapshot(self) -> WriteDisabledPublisherSnapshot:
|
||||
with self._lock:
|
||||
return WriteDisabledPublisherSnapshot(denied_attempts=self._denied_attempts)
|
||||
Reference in New Issue
Block a user