feat(k1): add offline control execution gate
This commit is contained in:
parent
d7749208a7
commit
ea811ff370
|
|
@ -145,8 +145,12 @@ the response supplies live vendor identity, serial, model, activation and
|
||||||
version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
|
version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
|
||||||
as one private application-level value in the observed client, not a manual
|
as one private application-level value in the observed client, not a manual
|
||||||
per-scanner profile. The whole pre-START order and START/STOP payloads are
|
per-scanner profile. The whole pre-START order and START/STOP payloads are
|
||||||
byte-matched offline, but there is no MQTT publisher, `vendor_writes_enabled`
|
byte-matched offline. A fixed macOS Keychain loader now supplies the exact
|
||||||
remains false, and no application command is sent.
|
application authority without env/file/browser fallback, and a five-batch
|
||||||
|
orchestrator passes all retained response barriers. The installed publisher
|
||||||
|
boundary is structurally write-disabled and cannot call its sink. There is no
|
||||||
|
MQTT publisher, `vendor_writes_enabled` remains false, and no application
|
||||||
|
command is sent.
|
||||||
|
|
||||||
This locked bootstrap is repeatable in the current workspace, not yet a
|
This locked bootstrap is repeatable in the current workspace, not yet a
|
||||||
standalone release install. The frontend consumes sibling `file:` packages from
|
standalone release install. The frontend consumes sibling `file:` packages from
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||||
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
|
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
|
||||||
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
|
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
|
||||||
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
||||||
| K1 application control | PAUSE (exact shadow dialogue implemented) — all 10 retained pre-START requests and START/STOP are byte-matched; BLE-selected live DeviceInfo supplies per-scanner identity while one private application-level OpenAPI authority remains outside Git/Ops; publisher, physical save acceptance and recovery policy remain closed gates |
|
| K1 application control | PAUSE (offline execution gate complete) — exact five-batch response-barrier orchestrator passes the retained 10-request dialogue; the 36-byte application authority loads only from macOS Keychain; current publisher boundary is structurally write-disabled and never touches its sink; runtime wiring, physical START/STOP and durable save remain closed gates |
|
||||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
||||||
|
|
||||||
USB project copying remains optional ground truth rather than a blocker for the
|
USB project copying remains optional ground truth rather than a blocker for the
|
||||||
|
|
@ -33,13 +33,17 @@ private application-level value in this LixelGO build, not a per-scanner
|
||||||
credential. The selected BLE peripheral returns its own LAN IP; the initial
|
credential. The selected BLE peripheral returns its own LAN IP; the initial
|
||||||
unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and
|
unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and
|
||||||
version facts for that live K1. Mission Core's shadow bootstrap reproduces the
|
version facts for that live K1. Mission Core's shadow bootstrap reproduces the
|
||||||
10 observed pre-START requests byte-for-byte: three unbound discovery reads,
|
10 observed pre-START requests byte-for-byte across five recovered batches:
|
||||||
one response-bound time/timezone sync and six further reads. A live-only safety
|
initial DeviceInfo binding; ModelingStatus plus RTK read; time sync plus
|
||||||
layer rejects identity drift or malformed status, and can form a non-executable
|
DeviceInfo/RTK; NTRIP read; then cloud/RTK/DeviceInfo reads. The first
|
||||||
START only from unbound `READY` or STOP only from bound `SCANNING`. Public output
|
ModelingStatus request has no mandatory synchronous response barrier; readiness
|
||||||
contains only digests and wire metadata. Secure application-authority loading,
|
is separately attested through live DeviceStatus. A fixed macOS Keychain loader
|
||||||
an authorized response-gated one-shot publisher and durable post-stop save are
|
now reads the exact 36-byte authority without environment/file/browser fallback.
|
||||||
not yet proven. The physical button remains the known-safe fallback.
|
The response orchestrator correlates required headers/results and rejects
|
||||||
|
identity/profile drift. The installed publish boundary is a separate
|
||||||
|
write-disabled type that cannot call its injected sink. Runtime wiring, a
|
||||||
|
reviewed write-capable type and durable post-stop save remain unproven. The
|
||||||
|
physical button remains the known-safe fallback.
|
||||||
|
|
||||||
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
|
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
|
||||||
queue cannot discard MQTT evidence. The queue holds four preview messages;
|
queue cannot discard MQTT evidence. The queue holds four preview messages;
|
||||||
|
|
@ -258,7 +262,7 @@ useful stream is decoded or structurally identified.
|
||||||
- firmware or internal-Linux analysis;
|
- firmware or internal-Linux analysis;
|
||||||
- physical end-to-end shared-timeline playback for a newly archived session
|
- physical end-to-end shared-timeline playback for a newly archived session
|
||||||
containing point cloud plus one selected K1 camera;
|
containing point cloud plus one selected K1 camera;
|
||||||
- private application-level OpenAPI authority loading, authorized command transport
|
- operator-owned Keychain provisioning/execution lease, authorized command transport
|
||||||
and stable-artifact proof after the observed stop lifecycle;
|
and stable-artifact proof after the observed stop lifecycle;
|
||||||
- long-running large-session WebViewer/WASM memory telemetry;
|
- long-running large-session WebViewer/WASM memory telemetry;
|
||||||
- production retention, replication, encryption and cross-platform packaging.
|
- production retention, replication, encryption and cross-platform packaging.
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,15 @@ repository shadow bootstrap reproduces all ten retained payloads and their
|
||||||
topic order byte-for-byte. Time sync is the only mutation before START; the
|
topic order byte-for-byte. Time sync is the only mutation before START; the
|
||||||
other nine requests are reads.
|
other nine requests are reads.
|
||||||
|
|
||||||
|
The retained request/response timing further divides the dialogue into five
|
||||||
|
bounded batches. DeviceInfo response establishes identity before later bound
|
||||||
|
requests. Required response barriers are correlated by topic, exact session,
|
||||||
|
vendor identity, application authority and numeric success. The initial
|
||||||
|
ModelingStatus request is intentionally not treated as a required synchronous
|
||||||
|
response: the clean cycle continued without one, and readiness belongs to the
|
||||||
|
live DeviceStatus gate. The offline orchestrator completes over the retained
|
||||||
|
payloads without substitution or skipped requests.
|
||||||
|
|
||||||
The repository now contains an inert bounded encoder/response parser and a
|
The repository now contains an inert bounded encoder/response parser and a
|
||||||
fail-closed device-status state machine for this exact profile. A live-only
|
fail-closed device-status state machine for this exact profile. A live-only
|
||||||
shadow planner accepts START only from `READY` with no project and STOP only
|
shadow planner accepts START only from `READY` with no project and STOP only
|
||||||
|
|
@ -195,9 +204,12 @@ from `SCANNING` with a bound project. It emits only a non-executable digest and
|
||||||
wire metadata, uses QoS 2 with retain false, and declares automatic retry
|
wire metadata, uses QoS 2 with retain false, and declares automatic retry
|
||||||
forbidden. It has no MQTT publish dependency and cannot send a command.
|
forbidden. It has no MQTT publish dependency and cannot send a command.
|
||||||
|
|
||||||
Publishing remains deliberately disabled: the private application authority
|
Publishing remains deliberately disabled. The exact 36-byte authority now has a
|
||||||
still needs a secure runtime loader and the complete stop/save sequence has not
|
read-only macOS Keychain loader with no environment, file, browser or API
|
||||||
physically proved a durable vendor project. A correlated STOP success, stream quiescence, return to
|
fallback, but it is not wired to the live facade. The current one-shot boundary
|
||||||
|
is structurally write-disabled and cannot touch its injected sink. The complete
|
||||||
|
stop/save sequence has not physically proved a durable vendor project. A
|
||||||
|
correlated STOP success, stream quiescence, return to
|
||||||
`READY`/steady-green hardware state and appearance of a reusable native project
|
`READY`/steady-green hardware state and appearance of a reusable native project
|
||||||
are separate gates. The verified physical double-click remains the acquisition
|
are separate gates. The verified physical double-click remains the acquisition
|
||||||
control until a separately reviewed one-shot write gate closes.
|
control until a separately reviewed one-shot write gate closes.
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,8 @@ observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
|
||||||
responses, and classifies live device states without importing MQTT or
|
responses, and classifies live device states without importing MQTT or
|
||||||
publishing anything. One private application-level OpenAPI authority is kept
|
publishing anything. One private application-level OpenAPI authority is kept
|
||||||
separate from the transient vendor ID/serial returned by the BLE-selected K1.
|
separate from the transient vendor ID/serial returned by the BLE-selected K1.
|
||||||
Secure authority loading and durable save completion after stop remain open, so
|
The fixed Keychain loader and offline response orchestrator now exist, but live
|
||||||
|
execution-lease wiring and durable save completion after stop remain open, so
|
||||||
`vendor_writes_enabled` is false and the physical-button workflow is canonical.
|
`vendor_writes_enabled` is false and the physical-button workflow is canonical.
|
||||||
|
|
||||||
## Automatic Rerun source and lifecycle
|
## Automatic Rerun source and lifecycle
|
||||||
|
|
@ -331,8 +332,8 @@ listener and its process memory must be closed unconditionally.
|
||||||
separate generic media path. Historical sessions predating that archive have
|
separate generic media path. Historical sessions predating that archive have
|
||||||
no recoverable video.
|
no recoverable video.
|
||||||
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
||||||
publisher needs reviewed private application-authority loading, response-gated
|
publisher needs reviewed Keychain provisioning/execution-lease wiring,
|
||||||
one-shot behavior and a proven durable-save completion gate.
|
response-gated one-shot behavior and a proven durable-save completion gate.
|
||||||
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
||||||
mission planner or vehicle control is implemented by this viewer milestone.
|
mission planner or vehicle control is implemented by this viewer milestone.
|
||||||
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
|
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,8 @@ START/STOP, correlates live DeviceInfo/modeling responses and maps bounded
|
||||||
device-status values into an observation-only state machine. The OpenAPI value
|
device-status values into an observation-only state machine. The OpenAPI value
|
||||||
is one private application-level authority; vendor ID and serial come from the
|
is one private application-level authority; vendor ID and serial come from the
|
||||||
BLE-selected live K1 and are cross-checked against status. No MQTT publisher
|
BLE-selected live K1 and are cross-checked against status. No MQTT publisher
|
||||||
exists. Secure authority loading and stable-artifact proof after STOP remain
|
exists. A fixed Keychain loader and offline response orchestrator are present;
|
||||||
|
operator execution-lease wiring and stable-artifact proof after STOP remain
|
||||||
unresolved, so `vendor_writes_enabled=false`.
|
unresolved, so `vendor_writes_enabled=false`.
|
||||||
|
|
||||||
## Semantic lifecycle
|
## Semantic lifecycle
|
||||||
|
|
@ -221,8 +222,8 @@ views.
|
||||||
4. Physically accept a newly archived left/right K1 session, then package the
|
4. Physically accept a newly archived left/right K1 session, then package the
|
||||||
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
||||||
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
||||||
plane. Keep the modeling-command publisher disabled until private application
|
plane. Keep the modeling-command publisher disabled until operator-owned
|
||||||
authority loading and durable-save confirmation close its separate safety
|
Keychain execution-lease wiring and durable-save confirmation close its safety
|
||||||
gate.
|
gate.
|
||||||
|
|
||||||
Complex equipment will likely be assembled from separately useful component
|
Complex equipment will likely be assembled from separately useful component
|
||||||
|
|
|
||||||
|
|
@ -513,5 +513,5 @@ viewer/WASM payload. That is a packaging optimization item, not a failed gate.
|
||||||
No retained physical K1 session contains the new canonical camera archive, so a
|
No retained physical K1 session contains the new canonical camera archive, so a
|
||||||
real point-cloud plus one-camera recorded playback remains an explicit hardware
|
real point-cloud plus one-camera recorded playback remains an explicit hardware
|
||||||
acceptance test. Automated protocol tests also do not authorize K1 modeling
|
acceptance test. Automated protocol tests also do not authorize K1 modeling
|
||||||
publishing: private application-authority loading and durable save completion
|
publishing: operator-owned Keychain execution lease and durable save completion
|
||||||
remain separate physical/security gates.
|
remain separate physical/security gates.
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ The operator project name is normalized and validated by the K1 contribution,
|
||||||
stored as display/catalog metadata and never used as a path component. This ADR
|
stored as display/catalog metadata and never used as a path component. This ADR
|
||||||
does not claim that it reaches the scanner: the inert application-control codec
|
does not claim that it reaches the scanner: the inert application-control codec
|
||||||
has no publisher, and automatic K1 writes remain disabled pending reviewed
|
has no publisher, and automatic K1 writes remain disabled pending reviewed
|
||||||
private application-authority loading and durable post-stop save evidence.
|
operator-owned Keychain execution-lease wiring and durable post-stop save evidence.
|
||||||
|
|
||||||
ADR 0011 subsequently places the action control plane behind a versioned
|
ADR 0011 subsequently places the action control plane behind a versioned
|
||||||
descriptor/handshake/health transport seam. Observation discovery and export
|
descriptor/handshake/health transport seam. Observation discovery and export
|
||||||
|
|
|
||||||
|
|
@ -77,24 +77,30 @@ captured special relation
|
||||||
|
|
||||||
## Current implementation boundary
|
## Current implementation boundary
|
||||||
|
|
||||||
`application_bootstrap.py` provides the bounded application authority type,
|
`application_bootstrap.py` provides DeviceInfo decoding, the exact ten-request
|
||||||
DeviceInfo response correlation/decoding and the exact non-executable ten-step
|
shadow plan and a five-batch response-barrier orchestrator. Its private retained
|
||||||
shadow plan. Its retained clean-cycle regression is 10/10 payloads and topic
|
regression completes with 10/10 request payload/topic matches and correlated
|
||||||
order. `LiveModelingControlSafety` independently binds that DeviceInfo identity
|
required responses. `ModelingStatusRequest` remains non-blocking exactly where
|
||||||
to live status and creates non-executable START/STOP plans.
|
the capture had no synchronous response. `application_authority.py` reads the
|
||||||
|
exact 36-byte authority only from a fixed macOS Keychain item and offers no
|
||||||
|
plaintext fallback. `LiveModelingControlSafety` independently binds DeviceInfo
|
||||||
|
identity to live status and creates non-executable START/STOP plans.
|
||||||
|
|
||||||
Public state contains only booleans, counts and wire metadata. Private identity,
|
Public state contains only booleans, counts and wire metadata. Private identity,
|
||||||
authority and payload bytes stay out of repr/API output. Both shadow paths are
|
authority and payload bytes stay out of repr/API output.
|
||||||
blocked by `vendor-writes-disabled` and `publisher-not-installed`; no MQTT
|
`application_publish.py` defines reviewed one-shot envelopes plus a current
|
||||||
publisher or automatic retry path exists.
|
write-disabled publisher type. That type has no enable switch and raises before
|
||||||
|
its injected transport sink can be called. Both shadow paths remain blocked by
|
||||||
|
`vendor-writes-disabled` and `publisher-not-installed`; no MQTT publisher or
|
||||||
|
automatic retry path exists.
|
||||||
|
|
||||||
## Promotion gate
|
## Promotion gate
|
||||||
|
|
||||||
A future publisher requires separate review and an operator-present physical
|
A future publisher requires separate review and an operator-present physical
|
||||||
acceptance:
|
acceptance:
|
||||||
|
|
||||||
1. load the private application authority through a reviewed local secret
|
1. provision the reviewed private authority into the fixed local Keychain item
|
||||||
mechanism;
|
and load it for one operator-owned execution lease;
|
||||||
2. confirm battery/storage and select one K1 over BLE;
|
2. confirm battery/storage and select one K1 over BLE;
|
||||||
3. run the exact response-gated bootstrap and attest activated FW 3.0.2;
|
3. run the exact response-gated bootstrap and attest activated FW 3.0.2;
|
||||||
4. confirm READY identity against DeviceInfo and review shadow metadata;
|
4. confirm READY identity against DeviceInfo and review shadow metadata;
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,10 @@ The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire
|
||||||
capture verifies the `ModelingRequest` topic, action values, field layout,
|
capture verifies the `ModelingRequest` topic, action values, field layout,
|
||||||
literal `{device_id}:ModelingRequest` session relation, retained start settings
|
literal `{device_id}:ModelingRequest` session relation, retained start settings
|
||||||
and numeric success code. Retained client/wire evidence identifies OpenAPI as
|
and numeric success code. Retained client/wire evidence identifies OpenAPI as
|
||||||
one private application-level value rather than a per-scanner profile. Its
|
one private application-level value rather than a per-scanner profile. A fixed
|
||||||
secure runtime loading remains unresolved, as do durable save completion,
|
read-only macOS Keychain loader and offline response orchestrator now exist;
|
||||||
timeout and rollback behavior. Acquisition therefore
|
operator-owned provisioning/execution-lease wiring, durable save completion,
|
||||||
|
timeout and rollback behavior remain unresolved. Acquisition therefore
|
||||||
stays `operator-manual` through the verified physical double-click.
|
stays `operator-manual` through the verified physical double-click.
|
||||||
The standalone encoder models the recovered wire schema, including enum values
|
The standalone encoder models the recovered wire schema, including enum values
|
||||||
outside the retained request. It is not an authorization policy: any future
|
outside the retained request. It is not an authorization policy: any future
|
||||||
|
|
|
||||||
|
|
@ -359,7 +359,7 @@
|
||||||
},
|
},
|
||||||
"success_result_code": 302252033,
|
"success_result_code": 302252033,
|
||||||
"required_unresolved_context": [
|
"required_unresolved_context": [
|
||||||
"secure private application-authority loading",
|
"operator-owned Keychain authority provisioning and execution-lease wiring",
|
||||||
"authorization policy for any setting outside the retained request",
|
"authorization policy for any setting outside the retained request",
|
||||||
"timeout, rejection and rollback contract"
|
"timeout, rejection and rollback contract"
|
||||||
],
|
],
|
||||||
|
|
@ -406,7 +406,7 @@
|
||||||
"request_fields": {},
|
"request_fields": {},
|
||||||
"success_result_code": 302252033,
|
"success_result_code": 302252033,
|
||||||
"required_unresolved_context": [
|
"required_unresolved_context": [
|
||||||
"secure private application-authority loading",
|
"operator-owned Keychain authority provisioning and execution-lease wiring",
|
||||||
"save-completion and final-standby state mapping",
|
"save-completion and final-standby state mapping",
|
||||||
"timeout and rollback contract"
|
"timeout and rollback contract"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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 hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
|
@ -62,6 +63,10 @@ class ApplicationControlAuthority:
|
||||||
"application authority does not match the reviewed K1 profile"
|
"application authority does not match the reviewed K1 profile"
|
||||||
)
|
)
|
||||||
_validate_ascii_identity(self.openapi_key, "openapi_key")
|
_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)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -133,6 +138,9 @@ class EncodedApplicationRequest:
|
||||||
payload_bytes: int
|
payload_bytes: int
|
||||||
mutates_device: bool
|
mutates_device: bool
|
||||||
requires_live_binding: 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
|
qos: int = 2
|
||||||
retain: bool = False
|
retain: bool = False
|
||||||
|
|
||||||
|
|
@ -147,6 +155,7 @@ class EncodedApplicationRequest:
|
||||||
"payload_bytes": self.payload_bytes,
|
"payload_bytes": self.payload_bytes,
|
||||||
"mutates_device": self.mutates_device,
|
"mutates_device": self.mutates_device,
|
||||||
"requires_live_binding": self.requires_live_binding,
|
"requires_live_binding": self.requires_live_binding,
|
||||||
|
"response_required": self.response_required,
|
||||||
"qos": self.qos,
|
"qos": self.qos,
|
||||||
"retain": self.retain,
|
"retain": self.retain,
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +190,39 @@ class DeviceInfoResponse:
|
||||||
result_code: int
|
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(
|
def build_shadow_bootstrap(
|
||||||
authority: ApplicationControlAuthority,
|
authority: ApplicationControlAuthority,
|
||||||
binding: LiveDeviceControlBinding,
|
binding: LiveDeviceControlBinding,
|
||||||
|
|
@ -213,6 +255,7 @@ def build_shadow_bootstrap(
|
||||||
DEVICE_INFO_RESPONSE_TOPIC,
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"identity-discovery",
|
"identity-discovery",
|
||||||
|
|
@ -221,6 +264,7 @@ def build_shadow_bootstrap(
|
||||||
MODELING_STATUS_RESPONSE_TOPIC,
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
|
False,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"identity-discovery",
|
"identity-discovery",
|
||||||
|
|
@ -229,6 +273,7 @@ def build_shadow_bootstrap(
|
||||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"bound-preparation",
|
"bound-preparation",
|
||||||
|
|
@ -237,6 +282,7 @@ def build_shadow_bootstrap(
|
||||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||||
DEVICE_CONFIG_TIME_CONTEXT,
|
DEVICE_CONFIG_TIME_CONTEXT,
|
||||||
True,
|
True,
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"bound-preparation",
|
"bound-preparation",
|
||||||
|
|
@ -245,6 +291,7 @@ def build_shadow_bootstrap(
|
||||||
DEVICE_INFO_RESPONSE_TOPIC,
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"bound-preparation",
|
"bound-preparation",
|
||||||
|
|
@ -253,6 +300,7 @@ def build_shadow_bootstrap(
|
||||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"bound-preparation",
|
"bound-preparation",
|
||||||
|
|
@ -261,6 +309,7 @@ def build_shadow_bootstrap(
|
||||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"bound-preparation",
|
"bound-preparation",
|
||||||
|
|
@ -269,6 +318,7 @@ def build_shadow_bootstrap(
|
||||||
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"bound-preparation",
|
"bound-preparation",
|
||||||
|
|
@ -277,6 +327,7 @@ def build_shadow_bootstrap(
|
||||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"bound-preparation",
|
"bound-preparation",
|
||||||
|
|
@ -285,12 +336,19 @@ def build_shadow_bootstrap(
|
||||||
DEVICE_INFO_RESPONSE_TOPIC,
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
requests: list[EncodedApplicationRequest] = []
|
requests: list[EncodedApplicationRequest] = []
|
||||||
for ordinal, (phase, message_type, topic, response_topic, context, mutates) in enumerate(
|
for ordinal, (
|
||||||
specs, start=1
|
phase,
|
||||||
):
|
message_type,
|
||||||
|
topic,
|
||||||
|
response_topic,
|
||||||
|
context,
|
||||||
|
mutates,
|
||||||
|
response_required,
|
||||||
|
) in enumerate(specs, start=1):
|
||||||
request_binding = None if ordinal <= 3 else binding
|
request_binding = None if ordinal <= 3 else binding
|
||||||
header = ApplicationRequestHeader(
|
header = ApplicationRequestHeader(
|
||||||
message_type=message_type,
|
message_type=message_type,
|
||||||
|
|
@ -316,6 +374,11 @@ def build_shadow_bootstrap(
|
||||||
payload_bytes=len(payload),
|
payload_bytes=len(payload),
|
||||||
mutates_device=mutates,
|
mutates_device=mutates,
|
||||||
requires_live_binding=ordinal > 3,
|
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))
|
return ShadowApplicationBootstrap(tuple(requests))
|
||||||
|
|
@ -324,6 +387,9 @@ def build_shadow_bootstrap(
|
||||||
def decode_and_bind_device_info_response(
|
def decode_and_bind_device_info_response(
|
||||||
payload: bytes,
|
payload: bytes,
|
||||||
authority: ApplicationControlAuthority,
|
authority: ApplicationControlAuthority,
|
||||||
|
*,
|
||||||
|
expected_session_id: str = ":DeviceInfoRequest",
|
||||||
|
expected_vendor_device_id: str | None = None,
|
||||||
) -> DeviceInfoResponse:
|
) -> DeviceInfoResponse:
|
||||||
"""Correlate the bootstrap response and derive a live per-device binding."""
|
"""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")
|
device_id = _identity_field(header, 4, "header.device_id")
|
||||||
session_id = _identity_field(header, 5, "header.session_id")
|
session_id = _identity_field(header, 5, "header.session_id")
|
||||||
openapi_key = _identity_field(header, 6, "header.openapi_key")
|
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")
|
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):
|
if not hmac.compare_digest(openapi_key, authority.openapi_key):
|
||||||
raise ApplicationBootstrapError("DeviceInfoResponse application authority mismatch")
|
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)
|
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:
|
def _encode_header(header: ApplicationRequestHeader) -> bytes:
|
||||||
parts: list[bytes] = []
|
parts: list[bytes] = []
|
||||||
if header.binding is not None:
|
if header.binding is not None:
|
||||||
|
|
@ -438,6 +723,26 @@ def _unique_fields(
|
||||||
return result
|
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:
|
def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes:
|
||||||
item = fields.get(number)
|
item = fields.get(number)
|
||||||
if item is None or item.wire_type != 2 or not isinstance(item.value, bytes):
|
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)
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||||
|
KEYCHAIN_ACCOUNT,
|
||||||
|
KEYCHAIN_SERVICE,
|
||||||
|
ApplicationAuthorityLoadError,
|
||||||
|
MacOSKeychainApplicationAuthorityLoader,
|
||||||
|
)
|
||||||
|
|
||||||
|
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("platform.system", return_value="Darwin")
|
||||||
|
@patch("shutil.which", return_value="/usr/bin/security")
|
||||||
|
def test_authority_loads_only_from_fixed_macos_keychain_item(
|
||||||
|
_which: object,
|
||||||
|
_system: object,
|
||||||
|
) -> None:
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def runner(
|
||||||
|
args: list[str],
|
||||||
|
*,
|
||||||
|
capture_output: bool,
|
||||||
|
check: bool,
|
||||||
|
timeout: float,
|
||||||
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
calls.append(args)
|
||||||
|
assert capture_output
|
||||||
|
assert check is False
|
||||||
|
assert timeout == 5.0
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout=PRIVATE_AUTHORITY, stderr=b"")
|
||||||
|
|
||||||
|
loader = MacOSKeychainApplicationAuthorityLoader(runner=runner)
|
||||||
|
authority = loader.load()
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
[
|
||||||
|
"/usr/bin/security",
|
||||||
|
"find-generic-password",
|
||||||
|
"-s",
|
||||||
|
KEYCHAIN_SERVICE,
|
||||||
|
"-a",
|
||||||
|
KEYCHAIN_ACCOUNT,
|
||||||
|
"-w",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
assert PRIVATE_AUTHORITY.decode().strip() not in repr(authority)
|
||||||
|
assert loader.snapshot().as_dict() == {
|
||||||
|
"provider": "macos-keychain",
|
||||||
|
"service": KEYCHAIN_SERVICE,
|
||||||
|
"account": KEYCHAIN_ACCOUNT,
|
||||||
|
"secret_cached": False,
|
||||||
|
"secret_exportable": False,
|
||||||
|
"file_fallback_enabled": False,
|
||||||
|
"environment_fallback_enabled": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("platform.system", return_value="Linux")
|
||||||
|
def test_authority_loader_has_no_non_keychain_fallback(_system: object) -> None:
|
||||||
|
loader = MacOSKeychainApplicationAuthorityLoader()
|
||||||
|
|
||||||
|
with pytest.raises(ApplicationAuthorityLoadError, match="only through macOS Keychain"):
|
||||||
|
loader.load()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("platform.system", return_value="Darwin")
|
||||||
|
@patch("shutil.which", return_value="/usr/bin/security")
|
||||||
|
def test_authority_loader_redacts_keychain_failures(
|
||||||
|
_which: object,
|
||||||
|
_system: object,
|
||||||
|
) -> None:
|
||||||
|
private_error = b"private-keychain-diagnostic"
|
||||||
|
|
||||||
|
def runner(
|
||||||
|
args: list[str],
|
||||||
|
**_kwargs: object,
|
||||||
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return subprocess.CompletedProcess(args, 44, stdout=b"", stderr=private_error)
|
||||||
|
|
||||||
|
with pytest.raises(ApplicationAuthorityLoadError) as error:
|
||||||
|
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
|
||||||
|
|
||||||
|
assert private_error.decode() not in str(error.value)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("platform.system", return_value="Darwin")
|
||||||
|
@patch("shutil.which", return_value="/usr/bin/security")
|
||||||
|
def test_authority_loader_rejects_wrong_profile_length_without_reflection(
|
||||||
|
_which: object,
|
||||||
|
_system: object,
|
||||||
|
) -> None:
|
||||||
|
invalid_secret = b"too-short-private-value"
|
||||||
|
|
||||||
|
def runner(
|
||||||
|
args: list[str],
|
||||||
|
**_kwargs: object,
|
||||||
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout=invalid_secret, stderr=b"")
|
||||||
|
|
||||||
|
with pytest.raises(ApplicationAuthorityLoadError) as error:
|
||||||
|
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
|
||||||
|
|
||||||
|
assert invalid_secret.decode() not in str(error.value)
|
||||||
|
|
@ -6,6 +6,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
ApplicationBootstrapError,
|
ApplicationBootstrapError,
|
||||||
ApplicationControlAuthority,
|
ApplicationControlAuthority,
|
||||||
LiveDeviceControlBinding,
|
LiveDeviceControlBinding,
|
||||||
|
ShadowApplicationBootstrapOrchestrator,
|
||||||
build_shadow_bootstrap,
|
build_shadow_bootstrap,
|
||||||
decode_and_bind_device_info_response,
|
decode_and_bind_device_info_response,
|
||||||
)
|
)
|
||||||
|
|
@ -75,6 +76,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
|
||||||
return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error)
|
return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error)
|
||||||
|
|
||||||
|
|
||||||
|
def _generic_response(session_id: str) -> bytes:
|
||||||
|
header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
||||||
|
error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success")
|
||||||
|
repeated_vendor_body = _text(3, "opaque-a") + _text(3, "opaque-b")
|
||||||
|
return _bytes(1, header) + repeated_vendor_body + _bytes(15, error)
|
||||||
|
|
||||||
|
|
||||||
def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None:
|
def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None:
|
||||||
plan = build_shadow_bootstrap(
|
plan = build_shadow_bootstrap(
|
||||||
_authority(),
|
_authority(),
|
||||||
|
|
@ -165,3 +173,89 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
||||||
epoch_seconds=1_752_680_000,
|
epoch_seconds=1_752_680_000,
|
||||||
timezone_name="Europe/Moscow",
|
timezone_name="Europe/Moscow",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||||
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
|
_authority(),
|
||||||
|
epoch_seconds=1_752_680_000,
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = orchestrator.next_batch()
|
||||||
|
assert [request.ordinal for request in identity] == [1]
|
||||||
|
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||||
|
orchestrator.next_batch()
|
||||||
|
orchestrator.accept_response(identity[0].response_topic, _device_info_response())
|
||||||
|
|
||||||
|
initial_reads = orchestrator.next_batch()
|
||||||
|
assert [request.ordinal for request in initial_reads] == [2, 3]
|
||||||
|
assert [request.response_required for request in initial_reads] == [False, True]
|
||||||
|
orchestrator.accept_response(
|
||||||
|
initial_reads[1].response_topic,
|
||||||
|
_generic_response(initial_reads[1].session_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
preparation = orchestrator.next_batch()
|
||||||
|
assert [request.ordinal for request in preparation] == [4, 5, 6]
|
||||||
|
device_info = preparation[1]
|
||||||
|
orchestrator.accept_response(
|
||||||
|
device_info.response_topic,
|
||||||
|
_device_info_response(session_id=device_info.session_id),
|
||||||
|
)
|
||||||
|
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||||
|
orchestrator.next_batch()
|
||||||
|
for request in (preparation[2], preparation[0]):
|
||||||
|
orchestrator.accept_response(
|
||||||
|
request.response_topic,
|
||||||
|
_generic_response(request.session_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
ntrip = orchestrator.next_batch()
|
||||||
|
assert [request.ordinal for request in ntrip] == [7]
|
||||||
|
orchestrator.accept_response(
|
||||||
|
ntrip[0].response_topic,
|
||||||
|
_generic_response(ntrip[0].session_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
final_reads = orchestrator.next_batch()
|
||||||
|
assert [request.ordinal for request in final_reads] == [8, 9, 10]
|
||||||
|
for request in final_reads:
|
||||||
|
payload = (
|
||||||
|
_device_info_response(session_id=request.session_id)
|
||||||
|
if request.message_type == "DeviceInfoRequest"
|
||||||
|
else _generic_response(request.session_id)
|
||||||
|
)
|
||||||
|
orchestrator.accept_response(request.response_topic, payload)
|
||||||
|
|
||||||
|
snapshot = orchestrator.snapshot()
|
||||||
|
assert snapshot.bootstrap_complete
|
||||||
|
assert snapshot.live_binding_observed
|
||||||
|
assert snapshot.pending_response_topics == ()
|
||||||
|
assert snapshot.executable is False
|
||||||
|
assert snapshot.vendor_writes_enabled is False
|
||||||
|
with pytest.raises(ApplicationBootstrapError, match="already complete"):
|
||||||
|
orchestrator.next_batch()
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
|
||||||
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
|
_authority(),
|
||||||
|
epoch_seconds=1_752_680_000,
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
)
|
||||||
|
orchestrator.next_batch()
|
||||||
|
|
||||||
|
with pytest.raises(ApplicationBootstrapError, match="not required"):
|
||||||
|
orchestrator.accept_response(
|
||||||
|
"lixel/application/response/get_rtk_advance",
|
||||||
|
_generic_response(":GetRtkAdvanceRequest"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert orchestrator.snapshot().current_batch == 1
|
||||||
|
assert orchestrator.snapshot().batch_issued
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_authority_requires_exact_captured_profile_length() -> None:
|
||||||
|
with pytest.raises(ApplicationBootstrapError, match="36-byte"):
|
||||||
|
ApplicationControlAuthority(openapi_key="too-short")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
ApplicationControlAuthority,
|
||||||
|
LiveDeviceControlBinding,
|
||||||
|
build_shadow_bootstrap,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
|
OneShotPublishEnvelope,
|
||||||
|
VendorWritesDisabledError,
|
||||||
|
WriteDisabledOneShotPublisher,
|
||||||
|
)
|
||||||
|
|
||||||
|
PRIVATE_AUTHORITY = "11111111-2222-3333-4444-555555555555"
|
||||||
|
|
||||||
|
|
||||||
|
class SpySink:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
topic: str,
|
||||||
|
payload: bytes,
|
||||||
|
qos: int,
|
||||||
|
retain: bool,
|
||||||
|
) -> None:
|
||||||
|
del topic, payload, qos, retain
|
||||||
|
self.calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope() -> OneShotPublishEnvelope:
|
||||||
|
authority = ApplicationControlAuthority(openapi_key=PRIVATE_AUTHORITY)
|
||||||
|
binding = LiveDeviceControlBinding(
|
||||||
|
vendor_device_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
device_serial="K1SERIAL01",
|
||||||
|
software_version="V3.0.2-build.1",
|
||||||
|
system_version="V3.0.2",
|
||||||
|
device_model="LixelKity K1",
|
||||||
|
device_type="K1",
|
||||||
|
is_activated=True,
|
||||||
|
)
|
||||||
|
request = build_shadow_bootstrap(
|
||||||
|
authority,
|
||||||
|
binding,
|
||||||
|
epoch_seconds=1_752_680_000,
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
).requests[0]
|
||||||
|
return OneShotPublishEnvelope.from_bootstrap_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_disabled_publisher_never_touches_injected_transport() -> None:
|
||||||
|
sink = SpySink()
|
||||||
|
publisher = WriteDisabledOneShotPublisher(sink)
|
||||||
|
envelope = _envelope()
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
with pytest.raises(VendorWritesDisabledError, match="cannot publish"):
|
||||||
|
publisher.publish_once(envelope)
|
||||||
|
|
||||||
|
assert sink.calls == 0
|
||||||
|
assert publisher.snapshot().as_dict() == {
|
||||||
|
"mode": "write-disabled",
|
||||||
|
"denied_attempts": 2,
|
||||||
|
"transport_calls": 0,
|
||||||
|
"vendor_writes_enabled": False,
|
||||||
|
"publisher_armed": False,
|
||||||
|
"automatic_retry": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_envelope_redacts_payload_and_rejects_retry_semantics() -> None:
|
||||||
|
envelope = _envelope()
|
||||||
|
|
||||||
|
assert PRIVATE_AUTHORITY not in repr(envelope)
|
||||||
|
assert PRIVATE_AUTHORITY not in str(envelope.as_dict())
|
||||||
|
with pytest.raises(ValueError, match="one-shot contract"):
|
||||||
|
OneShotPublishEnvelope(
|
||||||
|
topic=envelope.topic,
|
||||||
|
payload=envelope.payload,
|
||||||
|
payload_sha256=envelope.payload_sha256,
|
||||||
|
payload_bytes=envelope.payload_bytes,
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
@ -65,7 +65,7 @@ def _status_message(
|
||||||
|
|
||||||
|
|
||||||
def _authority() -> ApplicationControlAuthority:
|
def _authority() -> ApplicationControlAuthority:
|
||||||
return ApplicationControlAuthority(openapi_key="application-private-key")
|
return ApplicationControlAuthority(openapi_key="11111111-2222-3333-4444-555555555555")
|
||||||
|
|
||||||
|
|
||||||
def _binding(**changes: object) -> LiveDeviceControlBinding:
|
def _binding(**changes: object) -> LiveDeviceControlBinding:
|
||||||
|
|
@ -120,8 +120,8 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None:
|
||||||
assert plan.executable is False
|
assert plan.executable is False
|
||||||
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
|
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
|
||||||
assert plan.retry_policy == "never-automatic"
|
assert plan.retry_policy == "never-automatic"
|
||||||
assert "application-private-key" not in repr(plan)
|
assert "11111111-2222-3333-4444-555555555555" not in repr(plan)
|
||||||
assert "application-private-key" not in str(plan.as_dict())
|
assert "11111111-2222-3333-4444-555555555555" not in str(plan.as_dict())
|
||||||
|
|
||||||
|
|
||||||
def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None:
|
def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue