feat: 实现可靠事件与证据 connector (#153)
This commit is contained in:
@@ -11,6 +11,7 @@ from yovision_brain.config import ConfigError
|
||||
from yovision_brain.decode import DecoderError
|
||||
from yovision_brain.events import JsonLinesSink
|
||||
from yovision_brain.input import InputError
|
||||
from yovision_brain.integration.event_export import build_event_export_sink
|
||||
from yovision_brain.rules import RuleConfigError
|
||||
|
||||
from .runner import run_pipeline
|
||||
@@ -38,14 +39,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
stream = sys.stdout
|
||||
owned_stream = None
|
||||
try:
|
||||
if args.output != "-":
|
||||
export_sink = build_event_export_sink(raw.get("event_export"), base_dir=config_path.parent)
|
||||
if export_sink is None and args.output != "-":
|
||||
try:
|
||||
owned_stream = Path(args.output).open("w", encoding="utf-8", newline="\n")
|
||||
except OSError:
|
||||
print(json.dumps({"status": "error", "message": "event output cannot be opened"}), file=sys.stderr)
|
||||
return 2
|
||||
stream = owned_stream
|
||||
summary = run_pipeline(raw, JsonLinesSink(stream), base_dir=config_path.parent)
|
||||
sink = export_sink if export_sink is not None else JsonLinesSink(stream)
|
||||
summary = run_pipeline(raw, sink, base_dir=config_path.parent)
|
||||
except (ConfigError, DecoderError, InputError, RuleConfigError, RuntimeError, ValueError) as exc:
|
||||
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 3
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Map Brain-internal candidates to the frozen anonymous event contract."""
|
||||
|
||||
from .mapper import (
|
||||
EVENT_SCHEMA_VERSION,
|
||||
EVIDENCE_SCHEMA_VERSION,
|
||||
EventExportError,
|
||||
canonical_json,
|
||||
canonical_json_bytes,
|
||||
export_event,
|
||||
payload_sha256,
|
||||
)
|
||||
from .replay import SQLiteReplayCache
|
||||
from .client import DeliveryResult, EventDeliveryError, HTTPSMachineIdentitySender
|
||||
from .runtime import EventExportSink, build_event_export_sink
|
||||
|
||||
__all__ = [
|
||||
"EVENT_SCHEMA_VERSION",
|
||||
"EVIDENCE_SCHEMA_VERSION",
|
||||
"EventExportError",
|
||||
"EventDeliveryError",
|
||||
"DeliveryResult",
|
||||
"EventExportSink",
|
||||
"HTTPSMachineIdentitySender",
|
||||
"SQLiteReplayCache",
|
||||
"canonical_json",
|
||||
"canonical_json_bytes",
|
||||
"export_event",
|
||||
"payload_sha256",
|
||||
"build_event_export_sink",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Synchronous, request-bound HTTPS delivery for Brain event exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from yovision_brain.integration.machine_identity import Signer, TransportPolicy
|
||||
|
||||
EVENT_PATH = "/v1/events"
|
||||
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
|
||||
|
||||
|
||||
class EventDeliveryError(RuntimeError):
|
||||
"""An event was not accepted; callers must retain or reproduce the fact."""
|
||||
|
||||
def __init__(self, code: str, *, terminal: bool) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.terminal = terminal
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryResult:
|
||||
disposition: str
|
||||
request_id: str
|
||||
|
||||
|
||||
class _Headers(Protocol):
|
||||
def get(self, name: str, default: str | None = None) -> str | None: ...
|
||||
|
||||
|
||||
class _Response(Protocol):
|
||||
status: int
|
||||
headers: _Headers
|
||||
|
||||
def read(self, amount: int = -1) -> bytes: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class _Opener(Protocol):
|
||||
def open(self, request: urllib.request.Request, timeout: float) -> _Response: ...
|
||||
|
||||
|
||||
class HTTPSMachineIdentitySender:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
signer: Signer,
|
||||
policy: TransportPolicy,
|
||||
*,
|
||||
opener: _Opener | None = None,
|
||||
) -> None:
|
||||
parsed = urlsplit(endpoint)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError("event export endpoint must be an HTTPS origin")
|
||||
policy.validate()
|
||||
self._endpoint = endpoint.rstrip("/")
|
||||
self._signer = signer
|
||||
self._policy = policy
|
||||
self._opener = opener or urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=policy.ssl_context())
|
||||
)
|
||||
|
||||
def send(self, body: bytes) -> DeliveryResult:
|
||||
if len(body) > self._policy.max_request_bytes:
|
||||
raise EventDeliveryError("event_request_too_large", terminal=True)
|
||||
request_id = "req-" + secrets.token_urlsafe(16)
|
||||
if not _REQUEST_ID.fullmatch(request_id): # pragma: no cover - defensive invariant
|
||||
raise RuntimeError("generated request id is invalid")
|
||||
token = self._signer.mint(
|
||||
"yovision-sense", ("events:ingest",), "POST", EVENT_PATH, body
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
self._endpoint + EVENT_PATH,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": request_id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
response = self._opener.open(
|
||||
request, timeout=self._policy.request_timeout_ms / 1000
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
response_body = exc.read(64 * 1024 + 1)
|
||||
code = _problem_code(response_body) or "event_delivery_rejected"
|
||||
raise EventDeliveryError(
|
||||
code,
|
||||
terminal=400 <= exc.code < 500 and exc.code != 429,
|
||||
) from None
|
||||
except (OSError, TimeoutError, urllib.error.URLError):
|
||||
raise EventDeliveryError("event_delivery_unavailable", terminal=False) from None
|
||||
|
||||
try:
|
||||
response_body = response.read(64 * 1024 + 1)
|
||||
if len(response_body) > 64 * 1024:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
if response.status not in {200, 201, 202}:
|
||||
raise EventDeliveryError(
|
||||
"event_delivery_rejected",
|
||||
terminal=400 <= response.status < 500 and response.status != 429,
|
||||
)
|
||||
response_request_id = response.headers.get("X-Request-ID")
|
||||
if response_request_id != request_id:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
disposition = _disposition(response_body, response.status, body)
|
||||
return DeliveryResult(disposition=disposition, request_id=request_id)
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
def _problem_code(body: bytes) -> str | None:
|
||||
try:
|
||||
value = json.loads(body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
code = value.get("code") if isinstance(value, dict) else None
|
||||
return code if isinstance(code, str) and re.fullmatch(r"[a-z][a-z0-9_]{0,63}", code) else None
|
||||
|
||||
|
||||
def _disposition(body: bytes, status: int, request_body: bytes) -> str:
|
||||
try:
|
||||
value = json.loads(body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False) from None
|
||||
disposition = value.get("disposition") if isinstance(value, dict) else None
|
||||
allowed = {"accepted", "created", "duplicate"}
|
||||
if disposition not in allowed:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
if status == 202 and disposition != "accepted":
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
try:
|
||||
sent = json.loads(request_body)
|
||||
response_identity = (
|
||||
value["producer_id"],
|
||||
value["source_event_id"],
|
||||
value["payload_sha256"],
|
||||
)
|
||||
expected_identity = (
|
||||
sent["producer_id"],
|
||||
sent["source_event_id"],
|
||||
hashlib.sha256(request_body).hexdigest(),
|
||||
)
|
||||
except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False) from None
|
||||
if response_identity != expected_identity:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
return disposition
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Safe, deterministic Brain producer mapping for ``yovision.event/v1``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from yovision_brain.events import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
|
||||
|
||||
EVENT_SCHEMA_VERSION = "yovision.event/v1"
|
||||
EVIDENCE_SCHEMA_VERSION = "yovision.evidence-reference/v1"
|
||||
|
||||
_REFERENCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
|
||||
_WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
_SENSITIVE_NAMES = frozenset(
|
||||
{
|
||||
"path",
|
||||
"url",
|
||||
"uri",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"credential",
|
||||
"signed_url",
|
||||
"camera_url",
|
||||
"face",
|
||||
"face_id",
|
||||
"face_template",
|
||||
"alert",
|
||||
"ack",
|
||||
"close",
|
||||
"notification",
|
||||
}
|
||||
)
|
||||
_EVIDENCE_FIELDS = frozenset(
|
||||
{
|
||||
"schema_version",
|
||||
"evidence_id",
|
||||
"owner_id",
|
||||
"type",
|
||||
"status",
|
||||
"captured_at",
|
||||
"status_updated_at",
|
||||
"expires_at",
|
||||
"content_type",
|
||||
"integrity",
|
||||
"failure",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EventExportError(ValueError):
|
||||
"""The internal candidate cannot safely satisfy the frozen contract."""
|
||||
|
||||
|
||||
def export_event(
|
||||
candidate: InternalEventCandidate,
|
||||
*,
|
||||
producer_id: str,
|
||||
site_ref: str,
|
||||
device_ref: str | None = None,
|
||||
severity: str,
|
||||
evidence: Sequence[Mapping[str, object]] = (),
|
||||
region_ref: str | None = None,
|
||||
crossing_direction: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return a new closed v1 payload without mutating the internal candidate.
|
||||
|
||||
The candidate's already stable ``event_id`` is the source identity. Callers
|
||||
must persist and retry the returned payload unchanged; transport attempts do
|
||||
not participate in either identity field.
|
||||
"""
|
||||
|
||||
if candidate.schema != INTERNAL_EVENT_SCHEMA:
|
||||
raise EventExportError("unsupported internal event candidate schema")
|
||||
producer_id = _reference("producer_id", producer_id)
|
||||
source_event_id = _reference("source_event_id", candidate.event_id)
|
||||
site_ref = _reference("site_ref", site_ref)
|
||||
device_ref = _reference("device_ref", device_ref or candidate.logical_input_id)
|
||||
profile_ref = _reference("profile_ref", candidate.profile_id)
|
||||
rule_id = _reference("rule.rule_id", candidate.rule_id)
|
||||
region_id = _reference("region.region_id", region_ref or candidate.rule_id)
|
||||
track_id = _reference("observation.track_id", candidate.track_id)
|
||||
|
||||
event_type = {
|
||||
"danger_area_entered": "dangerous_area_entered",
|
||||
"dangerous_area_entered": "dangerous_area_entered",
|
||||
"directional_line_crossed": "directional_line_crossed",
|
||||
}.get(candidate.event_type)
|
||||
if event_type is None:
|
||||
raise EventExportError("unsupported event type")
|
||||
if severity not in {"low", "medium", "high", "critical"}:
|
||||
raise EventExportError("unsupported severity")
|
||||
|
||||
observation = _observation(candidate, track_id=track_id, category=category)
|
||||
region: dict[str, object] = {
|
||||
"region_id": region_id,
|
||||
"kind": "area" if event_type == "dangerous_area_entered" else "line",
|
||||
}
|
||||
if event_type == "directional_line_crossed":
|
||||
if crossing_direction not in {"a_to_b", "b_to_a"}:
|
||||
raise EventExportError("line events require a contract crossing_direction")
|
||||
region["crossing_direction"] = crossing_direction
|
||||
elif crossing_direction is not None:
|
||||
raise EventExportError("area events cannot carry crossing_direction")
|
||||
|
||||
if len(evidence) > 8:
|
||||
raise EventExportError("at most eight evidence references are allowed")
|
||||
mapped_evidence = [_evidence_reference(item) for item in evidence]
|
||||
if len({canonical_json_bytes(item) for item in mapped_evidence}) != len(mapped_evidence):
|
||||
raise EventExportError("duplicate evidence references are not allowed")
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"schema_version": EVENT_SCHEMA_VERSION,
|
||||
"producer_id": producer_id,
|
||||
"source_event_id": source_event_id,
|
||||
"site_ref": site_ref,
|
||||
"device_ref": device_ref,
|
||||
"profile_ref": profile_ref,
|
||||
"event_type": event_type,
|
||||
"occurred_at": _event_timestamp(candidate.occurred_at_ns),
|
||||
"severity": severity,
|
||||
"rule": {"rule_id": rule_id, "version": _bounded_text("rule.version", candidate.rule_version, 64)},
|
||||
"model": {
|
||||
"name": _bounded_text("model.name", candidate.model_name, 128),
|
||||
"version": _bounded_text("model.version", candidate.model_version, 64),
|
||||
},
|
||||
"observation": observation,
|
||||
"region": region,
|
||||
"evidence": mapped_evidence,
|
||||
}
|
||||
_reject_unsafe(payload)
|
||||
canonical_json_bytes(payload) # Reject non-finite or unsupported values now.
|
||||
return payload
|
||||
|
||||
|
||||
def canonical_json(value: object) -> str:
|
||||
"""Serialize the closed event-domain JCS subset used by frozen fixtures.
|
||||
|
||||
Contract values use JSON strings, containers, booleans, integers and finite
|
||||
ordinary decimals. Integer-valued floats are normalized to their JSON number
|
||||
form; the checked-in RFC 8785 vector fixes cross-language digest behavior.
|
||||
"""
|
||||
|
||||
return _encode_jcs(_normalize_numbers(value))
|
||||
|
||||
|
||||
def canonical_json_bytes(value: object) -> bytes:
|
||||
return canonical_json(value).encode("utf-8")
|
||||
|
||||
|
||||
def payload_sha256(event: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(event)).hexdigest()
|
||||
|
||||
|
||||
def _observation(
|
||||
candidate: InternalEventCandidate, *, track_id: str, category: str | None
|
||||
) -> dict[str, object]:
|
||||
internal = candidate.observation
|
||||
confidence = internal.get("confidence")
|
||||
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
|
||||
raise EventExportError("observation confidence must be numeric")
|
||||
confidence = float(confidence)
|
||||
if not math.isfinite(confidence) or not 0 <= confidence <= 1:
|
||||
raise EventExportError("observation confidence must be finite and between zero and one")
|
||||
|
||||
internal_category = internal.get("category")
|
||||
exported_category = category or {
|
||||
"anonymous_target": "person",
|
||||
"person": "person",
|
||||
"vehicle": "vehicle",
|
||||
"other": "other",
|
||||
}.get(internal_category)
|
||||
if exported_category not in {"person", "vehicle", "other"}:
|
||||
raise EventExportError("observation category requires an explicit anonymous contract mapping")
|
||||
|
||||
result: dict[str, object] = {
|
||||
"track_id": track_id,
|
||||
"category": exported_category,
|
||||
"confidence": confidence,
|
||||
}
|
||||
box = internal.get("box")
|
||||
if box is not None:
|
||||
if not isinstance(box, Mapping) or set(box) != {"left", "top", "right", "bottom"}:
|
||||
raise EventExportError("internal observation box is malformed")
|
||||
if candidate.frame_width <= 0 or candidate.frame_height <= 0:
|
||||
raise EventExportError("frame dimensions must be positive")
|
||||
coordinates = (box["left"], box["top"], box["right"], box["bottom"])
|
||||
if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in coordinates):
|
||||
raise EventExportError("box coordinates must be numeric")
|
||||
normalized = [
|
||||
float(coordinates[0]) / candidate.frame_width,
|
||||
float(coordinates[1]) / candidate.frame_height,
|
||||
float(coordinates[2]) / candidate.frame_width,
|
||||
float(coordinates[3]) / candidate.frame_height,
|
||||
]
|
||||
if any(not math.isfinite(value) or not 0 <= value <= 1 for value in normalized):
|
||||
raise EventExportError("normalized box coordinates must be finite and between zero and one")
|
||||
result["bbox_normalized"] = normalized
|
||||
return result
|
||||
|
||||
|
||||
def _evidence_reference(source: Mapping[str, object]) -> dict[str, object]:
|
||||
if not isinstance(source, Mapping):
|
||||
raise EventExportError("evidence reference must be an object")
|
||||
unknown = set(source) - _EVIDENCE_FIELDS
|
||||
if unknown:
|
||||
raise EventExportError(f"evidence reference contains forbidden fields: {sorted(unknown)!r}")
|
||||
required = {
|
||||
"schema_version",
|
||||
"evidence_id",
|
||||
"owner_id",
|
||||
"type",
|
||||
"status",
|
||||
"captured_at",
|
||||
"status_updated_at",
|
||||
}
|
||||
missing = required - set(source)
|
||||
if missing:
|
||||
raise EventExportError(f"evidence reference is missing fields: {sorted(missing)!r}")
|
||||
result = dict(source)
|
||||
if result["schema_version"] != EVIDENCE_SCHEMA_VERSION:
|
||||
raise EventExportError("unsupported evidence schema version")
|
||||
_reference("evidence.evidence_id", result["evidence_id"])
|
||||
_reference("evidence.owner_id", result["owner_id"])
|
||||
if result["type"] not in {"snapshot", "clip"}:
|
||||
raise EventExportError("unsupported evidence type")
|
||||
status = result["status"]
|
||||
if status not in {"pending", "processing", "success", "failed"}:
|
||||
raise EventExportError("unsupported evidence status")
|
||||
for field in ("captured_at", "status_updated_at", "expires_at"):
|
||||
if field in result:
|
||||
_date_time(field, result[field])
|
||||
|
||||
if status in {"pending", "processing"}:
|
||||
if any(field in result for field in ("content_type", "integrity", "failure")):
|
||||
raise EventExportError(f"{status} evidence cannot claim content or failure")
|
||||
elif status == "success":
|
||||
if "failure" in result or "content_type" not in result or "integrity" not in result:
|
||||
raise EventExportError("successful evidence requires content metadata and no failure")
|
||||
if result["content_type"] not in {"image/jpeg", "image/png", "video/mp4"}:
|
||||
raise EventExportError("unsupported evidence content type")
|
||||
integrity = result["integrity"]
|
||||
if not isinstance(integrity, Mapping) or set(integrity) != {"algorithm", "digest", "size_bytes"}:
|
||||
raise EventExportError("evidence integrity is malformed")
|
||||
if integrity["algorithm"] != "sha256" or not isinstance(integrity["digest"], str) or not _SHA256.fullmatch(integrity["digest"]):
|
||||
raise EventExportError("evidence integrity must contain a SHA-256 digest")
|
||||
if isinstance(integrity["size_bytes"], bool) or not isinstance(integrity["size_bytes"], int) or integrity["size_bytes"] < 0:
|
||||
raise EventExportError("evidence size must be a non-negative integer")
|
||||
else:
|
||||
if "content_type" in result or "integrity" in result or "failure" not in result:
|
||||
raise EventExportError("failed evidence requires only failure metadata")
|
||||
failure = result["failure"]
|
||||
if not isinstance(failure, Mapping) or set(failure) != {"code", "retryable"}:
|
||||
raise EventExportError("evidence failure is malformed")
|
||||
if failure["code"] not in {"capture_failed", "processing_failed", "expired", "unavailable"}:
|
||||
raise EventExportError("unsupported evidence failure code")
|
||||
if not isinstance(failure["retryable"], bool):
|
||||
raise EventExportError("evidence retryable must be boolean")
|
||||
_reject_unsafe(result)
|
||||
return result
|
||||
|
||||
|
||||
def _reference(name: str, value: object) -> str:
|
||||
if not isinstance(value, str) or not _REFERENCE.fullmatch(value):
|
||||
raise EventExportError(f"{name} is not a valid logical reference")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_text(name: str, value: object, maximum: int) -> str:
|
||||
if not isinstance(value, str) or not 1 <= len(value) <= maximum:
|
||||
raise EventExportError(f"{name} must be 1..{maximum} characters")
|
||||
return value
|
||||
|
||||
|
||||
def _event_timestamp(nanoseconds: int) -> str:
|
||||
if isinstance(nanoseconds, bool) or not isinstance(nanoseconds, int) or nanoseconds < 0:
|
||||
raise EventExportError("occurred_at_ns must be a non-negative integer")
|
||||
seconds, remainder = divmod(nanoseconds, 1_000_000_000)
|
||||
value = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(
|
||||
seconds=seconds, milliseconds=remainder // 1_000_000
|
||||
)
|
||||
return value.strftime("%Y-%m-%dT%H:%M:%S.") + f"{value.microsecond // 1000:03d}Z"
|
||||
|
||||
|
||||
def _date_time(name: str, value: object) -> None:
|
||||
if not isinstance(value, str):
|
||||
raise EventExportError(f"evidence {name} must be a date-time string")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise EventExportError(f"evidence {name} must be a valid date-time") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise EventExportError(f"evidence {name} must include a timezone")
|
||||
|
||||
|
||||
def _reject_unsafe(value: object, *, key: str = "") -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for child_key, child in value.items():
|
||||
lowered = str(child_key).lower()
|
||||
if lowered in _SENSITIVE_NAMES or lowered.endswith("_path") or lowered.endswith("_url"):
|
||||
raise EventExportError(f"sensitive field {child_key!r} is forbidden")
|
||||
_reject_unsafe(child, key=lowered)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for child in value:
|
||||
_reject_unsafe(child, key=key)
|
||||
elif isinstance(value, str):
|
||||
if _URL.match(value) or _WINDOWS_PATH.match(value) or value.startswith(("/", "\\\\")):
|
||||
raise EventExportError(f"path or URL value in {key or 'payload'} is forbidden")
|
||||
|
||||
|
||||
def _normalize_numbers(value: object) -> object:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
raise EventExportError("canonical JSON rejects non-finite numbers")
|
||||
if value == 0:
|
||||
return 0
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise EventExportError("canonical JSON object keys must be strings")
|
||||
return {key: _normalize_numbers(child) for key, child in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_normalize_numbers(child) for child in value]
|
||||
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
|
||||
|
||||
|
||||
def _encode_jcs(value: object) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
if value is True:
|
||||
return "true"
|
||||
if value is False:
|
||||
return "false"
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
return _jcs_float(value)
|
||||
if isinstance(value, list):
|
||||
return "[" + ",".join(_encode_jcs(item) for item in value) + "]"
|
||||
if isinstance(value, Mapping):
|
||||
# Frozen contract keys are ASCII. Sorting them is therefore identical
|
||||
# to RFC 8785's UTF-16 code-unit ordering without accepting extensions.
|
||||
return "{" + ",".join(
|
||||
_encode_jcs(key) + ":" + _encode_jcs(value[key]) for key in sorted(value)
|
||||
) + "}"
|
||||
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
|
||||
|
||||
|
||||
def _jcs_float(value: float) -> str:
|
||||
if not math.isfinite(value):
|
||||
raise EventExportError("canonical JSON rejects non-finite numbers")
|
||||
if value == 0:
|
||||
return "0"
|
||||
rendered = repr(value).lower()
|
||||
absolute = abs(value)
|
||||
if 1e-6 <= absolute < 1e21 and "e" in rendered:
|
||||
return format(Decimal(rendered), "f")
|
||||
if "e" in rendered:
|
||||
mantissa, exponent = rendered.split("e", 1)
|
||||
sign = ""
|
||||
if exponent.startswith(("+", "-")):
|
||||
sign, exponent = exponent[0], exponent[1:]
|
||||
exponent = exponent.lstrip("0") or "0"
|
||||
rendered = mantissa + "e" + sign + exponent
|
||||
return rendered[:-2] if rendered.endswith(".0") else rendered
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Durable atomic replay protection owned by the Brain connector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SQLiteReplayCache:
|
||||
"""SQLite implementation of the machine-identity ``ReplayCache`` protocol.
|
||||
|
||||
A primary key makes consumption atomic across threads and processes. Entries
|
||||
remain durable across connector restarts until their verifier expiry passes.
|
||||
"""
|
||||
|
||||
def __init__(self, database: str | Path, *, timeout_seconds: float = 5.0) -> None:
|
||||
self._database = str(Path(database))
|
||||
self._timeout_seconds = timeout_seconds
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("SQLite replay timeout must be positive")
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS machine_token_replay (
|
||||
principal TEXT NOT NULL,
|
||||
token_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (principal, token_id)
|
||||
) WITHOUT ROWID
|
||||
"""
|
||||
)
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
|
||||
if not principal or not token_id:
|
||||
raise ValueError("replay identity must be non-empty")
|
||||
if any(isinstance(value, bool) or not isinstance(value, int) for value in (expires_at, now)):
|
||||
raise ValueError("replay timestamps must be integers")
|
||||
if expires_at <= now:
|
||||
return False
|
||||
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("DELETE FROM machine_token_replay WHERE expires_at <= ?", (now,))
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO machine_token_replay (principal, token_id, expires_at) VALUES (?, ?, ?)",
|
||||
(principal, token_id, expires_at),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
connection.rollback()
|
||||
return False
|
||||
connection.commit()
|
||||
return True
|
||||
except BaseException:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(
|
||||
self._database,
|
||||
timeout=self._timeout_seconds,
|
||||
isolation_level=None,
|
||||
)
|
||||
connection.execute(f"PRAGMA busy_timeout={int(self._timeout_seconds * 1000)}")
|
||||
return connection
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Closed runtime configuration and event sink for Brain-to-Sense export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from yovision_brain.events import InternalEventCandidate
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
Signer,
|
||||
TransportPolicy,
|
||||
load_private_key,
|
||||
)
|
||||
|
||||
from .client import HTTPSMachineIdentitySender
|
||||
from .mapper import canonical_json_bytes, export_event
|
||||
|
||||
|
||||
class EventExportSink:
|
||||
def __init__(
|
||||
self,
|
||||
sender: HTTPSMachineIdentitySender,
|
||||
*,
|
||||
producer_id: str,
|
||||
site_ref: str,
|
||||
severity: str,
|
||||
region_refs: Mapping[str, str],
|
||||
crossing_directions: Mapping[str, str],
|
||||
) -> None:
|
||||
self._sender = sender
|
||||
self._producer_id = producer_id
|
||||
self._site_ref = site_ref
|
||||
self._severity = severity
|
||||
self._region_refs = dict(region_refs)
|
||||
self._crossing_directions = dict(crossing_directions)
|
||||
|
||||
def write(self, candidate: InternalEventCandidate) -> None:
|
||||
event = export_event(
|
||||
candidate,
|
||||
producer_id=self._producer_id,
|
||||
site_ref=self._site_ref,
|
||||
severity=self._severity,
|
||||
region_ref=self._region_refs.get(candidate.rule_id),
|
||||
crossing_direction=self._crossing_directions.get(candidate.rule_id),
|
||||
)
|
||||
# Mapping and serialization happen before minting, so the exact bytes are
|
||||
# bound to the machine token and remain unchanged for this delivery.
|
||||
self._sender.send(canonical_json_bytes(event))
|
||||
|
||||
|
||||
def build_event_export_sink(raw: object, *, base_dir: Path) -> EventExportSink | None:
|
||||
if raw is None:
|
||||
return None
|
||||
config = _object("event_export", raw)
|
||||
_closed(
|
||||
"event_export",
|
||||
config,
|
||||
{
|
||||
"enabled", "endpoint", "producer_id", "site_ref", "severity",
|
||||
"region_refs", "crossing_directions", "machine_identity", "transport",
|
||||
},
|
||||
)
|
||||
enabled = config.get("enabled", False)
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError("event_export.enabled must be a boolean")
|
||||
if not enabled:
|
||||
if set(config) != {"enabled"}:
|
||||
raise ValueError("disabled event_export may only contain enabled")
|
||||
return None
|
||||
|
||||
identity = _object("event_export.machine_identity", config.get("machine_identity"))
|
||||
_closed(
|
||||
"event_export.machine_identity",
|
||||
identity,
|
||||
{"principal", "key_id", "private_key_path"},
|
||||
)
|
||||
transport_raw = _object("event_export.transport", config.get("transport"))
|
||||
_closed(
|
||||
"event_export.transport",
|
||||
transport_raw,
|
||||
{
|
||||
"tls_min_version", "verify_certificate", "verify_hostname",
|
||||
"connect_timeout_ms", "response_header_timeout_ms", "request_timeout_ms",
|
||||
"max_request_bytes",
|
||||
},
|
||||
)
|
||||
policy = TransportPolicy(**transport_raw) # type: ignore[arg-type]
|
||||
policy.validate()
|
||||
key_path = _string("private_key_path", identity.get("private_key_path"))
|
||||
resolved_key_path = Path(key_path)
|
||||
if not resolved_key_path.is_absolute():
|
||||
resolved_key_path = base_dir / resolved_key_path
|
||||
signer = Signer(
|
||||
_string("principal", identity.get("principal")),
|
||||
_string("key_id", identity.get("key_id")),
|
||||
load_private_key(resolved_key_path),
|
||||
)
|
||||
return EventExportSink(
|
||||
HTTPSMachineIdentitySender(
|
||||
_string("endpoint", config.get("endpoint")), signer, policy
|
||||
),
|
||||
producer_id=_string("producer_id", config.get("producer_id")),
|
||||
site_ref=_string("site_ref", config.get("site_ref")),
|
||||
severity=_string("severity", config.get("severity")),
|
||||
region_refs=_string_map("region_refs", config.get("region_refs", {})),
|
||||
crossing_directions=_string_map(
|
||||
"crossing_directions", config.get("crossing_directions", {})
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _object(name: str, value: object) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{name} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _closed(name: str, value: Mapping[str, object], allowed: set[str]) -> None:
|
||||
unknown = set(value) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{name} contains unsupported fields")
|
||||
|
||||
|
||||
def _string(name: str, value: object) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"event_export.{name} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _string_map(name: str, value: object) -> Mapping[str, str]:
|
||||
mapping = _object(f"event_export.{name}", value)
|
||||
if any(not isinstance(item, str) or not item for item in mapping.values()):
|
||||
raise ValueError(f"event_export.{name} values must be non-empty strings")
|
||||
return mapping # type: ignore[return-value]
|
||||
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
import yovision_brain.app.__main__ as cli
|
||||
import yovision_brain.integration.event_export.runtime as export_runtime
|
||||
from yovision_brain.integration.event_export import (
|
||||
EventDeliveryError,
|
||||
HTTPSMachineIdentitySender,
|
||||
)
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
KeyRecord,
|
||||
Registry,
|
||||
ReplayStore,
|
||||
Signer,
|
||||
TransportPolicy,
|
||||
Verifier,
|
||||
bearer_token,
|
||||
)
|
||||
|
||||
FIXTURE = Path(__file__).parents[1] / "fixtures" / "events" / "area.json"
|
||||
|
||||
|
||||
def _policy() -> TransportPolicy:
|
||||
return TransportPolicy(
|
||||
tls_min_version="1.2",
|
||||
verify_certificate=True,
|
||||
verify_hostname=True,
|
||||
connect_timeout_ms=1_000,
|
||||
response_header_timeout_ms=1_000,
|
||||
request_timeout_ms=2_000,
|
||||
max_request_bytes=64 * 1024,
|
||||
)
|
||||
|
||||
|
||||
class _Response:
|
||||
status = 202
|
||||
|
||||
def __init__(self, body: bytes, request_id: str) -> None:
|
||||
self.body = body
|
||||
self.headers = {"X-Request-ID": request_id}
|
||||
self.closed = False
|
||||
|
||||
def read(self, amount: int = -1) -> bytes:
|
||||
return self.body[:amount] if amount >= 0 else self.body
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _VerifyingOpener:
|
||||
def __init__(self, verifier: Verifier) -> None:
|
||||
self.verifier = verifier
|
||||
self.requests = []
|
||||
|
||||
def open(self, request, timeout: float) -> _Response: # noqa: ANN001
|
||||
self.requests.append((request, timeout))
|
||||
body = request.data
|
||||
self.verifier.verify(
|
||||
bearer_token(request.get_header("Authorization")),
|
||||
"yovision-sense",
|
||||
"events:ingest",
|
||||
request.method,
|
||||
"/v1/events",
|
||||
body,
|
||||
)
|
||||
event = json.loads(body)
|
||||
return _Response(json.dumps({
|
||||
"producer_id": event["producer_id"],
|
||||
"source_event_id": event["source_event_id"],
|
||||
"payload_sha256": hashlib.sha256(body).hexdigest(),
|
||||
"disposition": "accepted",
|
||||
}).encode(), request.get_header("X-request-id"))
|
||||
|
||||
|
||||
def test_sender_binds_exact_path_body_and_safe_request_id() -> None:
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
signer = Signer("yv:brain:school-a", "brain-key-0001", private_key, clock=lambda: 100)
|
||||
registry = Registry(
|
||||
[
|
||||
KeyRecord(
|
||||
principal="yv:brain:school-a",
|
||||
key_id="brain-key-0001",
|
||||
public_key=private_key.public_key(),
|
||||
audience="yovision-sense",
|
||||
scopes=frozenset({"events:ingest"}),
|
||||
)
|
||||
]
|
||||
)
|
||||
opener = _VerifyingOpener(Verifier(registry, ReplayStore(), clock=lambda: 100))
|
||||
sender = HTTPSMachineIdentitySender(
|
||||
"https://sense.example:8443", signer, _policy(), opener=opener
|
||||
)
|
||||
|
||||
result = sender.send(
|
||||
b'{"producer_id":"brain-school-a","schema_version":"yovision.event/v1",'
|
||||
b'"source_event_id":"evt-1"}'
|
||||
)
|
||||
|
||||
request, timeout = opener.requests[0]
|
||||
assert request.full_url == "https://sense.example:8443/v1/events"
|
||||
assert request.method == "POST"
|
||||
assert request.get_header("Content-type") == "application/json"
|
||||
assert re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{15,127}", result.request_id)
|
||||
assert request.get_header("X-request-id") == result.request_id
|
||||
assert (result.disposition, timeout) == ("accepted", 2.0)
|
||||
|
||||
|
||||
def test_sender_rejects_plaintext_and_marks_conflict_terminal() -> None:
|
||||
key = Ed25519PrivateKey.generate()
|
||||
signer = Signer("yv:brain:school-a", "brain-key-0001", key)
|
||||
with pytest.raises(ValueError, match="HTTPS origin"):
|
||||
HTTPSMachineIdentitySender("http://sense.example", signer, _policy())
|
||||
|
||||
class ConflictOpener:
|
||||
def open(self, request, timeout: float): # noqa: ANN001, ARG002
|
||||
raise urllib.error.HTTPError(
|
||||
request.full_url,
|
||||
409,
|
||||
"Conflict",
|
||||
{},
|
||||
io.BytesIO(b'{"code":"event_identity_conflict"}'),
|
||||
)
|
||||
|
||||
sender = HTTPSMachineIdentitySender(
|
||||
"https://sense.example", signer, _policy(), opener=ConflictOpener()
|
||||
)
|
||||
with pytest.raises(EventDeliveryError) as caught:
|
||||
sender.send(b"{}")
|
||||
assert (caught.value.code, caught.value.terminal) == (
|
||||
"event_identity_conflict",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_connector_keeps_existing_json_lines_output(tmp_path: Path) -> None:
|
||||
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
raw["event_export"] = {"enabled": False}
|
||||
config = tmp_path / "brain.json"
|
||||
config.write_text(json.dumps(raw), encoding="utf-8")
|
||||
output = tmp_path / "events.jsonl"
|
||||
|
||||
assert cli.main(["--config", str(config), "--output", str(output)]) == 0
|
||||
assert json.loads(output.read_text(encoding="utf-8"))["schema"] == (
|
||||
"brain.internal.event-candidate/v1"
|
||||
)
|
||||
|
||||
|
||||
def test_delivery_failure_is_nonzero_and_not_silently_reported_as_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
class FailingSink:
|
||||
def write(self, candidate) -> None: # noqa: ANN001, ARG002
|
||||
raise EventDeliveryError("event_delivery_unavailable", terminal=False)
|
||||
|
||||
monkeypatch.setattr(cli, "build_event_export_sink", lambda raw, base_dir: FailingSink())
|
||||
unused_output = tmp_path / "disabled-json-lines-target"
|
||||
unused_output.write_text("must remain unchanged", encoding="utf-8")
|
||||
result = cli.main(
|
||||
["--config", str(FIXTURE), "--output", str(unused_output)]
|
||||
)
|
||||
|
||||
assert result == 3
|
||||
error = json.loads(capsys.readouterr().err.splitlines()[0])
|
||||
assert error == {"status": "error", "message": "event_delivery_unavailable"}
|
||||
assert unused_output.read_text(encoding="utf-8") == "must remain unchanged"
|
||||
|
||||
|
||||
def test_inline_private_key_material_is_rejected_without_echo(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
marker = "INLINE-PRIVATE-MATERIAL-MUST-NOT-LEAK"
|
||||
raw["event_export"] = {
|
||||
"enabled": True,
|
||||
"endpoint": "https://sense.example",
|
||||
"producer_id": "brain-school-a",
|
||||
"site_ref": "site-school-a",
|
||||
"severity": "high",
|
||||
"region_refs": {},
|
||||
"crossing_directions": {},
|
||||
"machine_identity": {
|
||||
"principal": "yv:brain:school-a",
|
||||
"key_id": "brain-key-0001",
|
||||
"private_key_path": "external.pem",
|
||||
"private_key": marker,
|
||||
},
|
||||
"transport": {},
|
||||
}
|
||||
config = tmp_path / "brain.json"
|
||||
config.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
assert cli.main(["--config", str(config)]) == 3
|
||||
assert marker not in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_enabled_config_loads_machine_key_only_from_external_path(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
key_path = tmp_path / "brain-machine.pem"
|
||||
key_path.write_bytes(
|
||||
Ed25519PrivateKey.generate().private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
captured = {}
|
||||
|
||||
class Sender:
|
||||
def __init__(self, endpoint, signer, policy) -> None: # noqa: ANN001
|
||||
captured.update(endpoint=endpoint, signer=signer, policy=policy)
|
||||
|
||||
monkeypatch.setattr(export_runtime, "HTTPSMachineIdentitySender", Sender)
|
||||
sink = export_runtime.build_event_export_sink(
|
||||
{
|
||||
"enabled": True,
|
||||
"endpoint": "https://sense.example",
|
||||
"producer_id": "brain-school-a",
|
||||
"site_ref": "site-school-a",
|
||||
"severity": "high",
|
||||
"region_refs": {},
|
||||
"crossing_directions": {},
|
||||
"machine_identity": {
|
||||
"principal": "yv:brain:school-a",
|
||||
"key_id": "brain-key-0001",
|
||||
"private_key_path": key_path.name,
|
||||
},
|
||||
"transport": {
|
||||
"tls_min_version": "1.2",
|
||||
"verify_certificate": True,
|
||||
"verify_hostname": True,
|
||||
"connect_timeout_ms": 1_000,
|
||||
"response_header_timeout_ms": 1_000,
|
||||
"request_timeout_ms": 2_000,
|
||||
"max_request_bytes": 64 * 1024,
|
||||
},
|
||||
},
|
||||
base_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert sink is not None
|
||||
assert captured["endpoint"] == "https://sense.example"
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from yovision_brain.events import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
|
||||
from yovision_brain.integration.event_export import EventExportError, canonical_json, export_event, payload_sha256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _candidate(**changes: object) -> InternalEventCandidate:
|
||||
values: dict[str, object] = {
|
||||
"schema": INTERNAL_EVENT_SCHEMA,
|
||||
"event_id": "evt-area-20260831-0001",
|
||||
"logical_input_id": "camera-east-gate",
|
||||
"event_type": "danger_area_entered",
|
||||
"occurred_at_ns": 1_788_134_401_125_000_000,
|
||||
"rule_id": "rule-east-danger",
|
||||
"rule_version": "3",
|
||||
"model_name": "anonymous-detector",
|
||||
"model_version": "2026.08",
|
||||
"profile_id": "profile-main-stream",
|
||||
"frame_width": 100,
|
||||
"frame_height": 100,
|
||||
"track_id": "track-0042",
|
||||
"observation": {
|
||||
"category": "anonymous_target",
|
||||
"confidence": 0.93,
|
||||
"box": {"left": 12, "top": 20, "right": 31, "bottom": 74},
|
||||
"anchor": {"x": 0.21, "y": 0.74},
|
||||
},
|
||||
"reason": "entered polygon",
|
||||
}
|
||||
values.update(changes)
|
||||
return InternalEventCandidate(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _fixture(relative: str) -> dict[str, object]:
|
||||
return json.loads((ROOT / relative).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_brain_mapper_matches_frozen_producer_fixture_and_jcs_digest() -> None:
|
||||
pending = _fixture("contracts/evidence/v1/examples/pending.json")
|
||||
expected = _fixture("contracts/events/v1/examples/dangerous-area.json")
|
||||
event = export_event(
|
||||
_candidate(),
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="high",
|
||||
evidence=[pending],
|
||||
region_ref="region-east-danger",
|
||||
)
|
||||
|
||||
assert event == expected
|
||||
assert payload_sha256(event) == "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
|
||||
|
||||
|
||||
def test_retry_mapping_preserves_original_identity_and_payload() -> None:
|
||||
candidate = _candidate()
|
||||
arguments = {
|
||||
"producer_id": "brain-school-a",
|
||||
"site_ref": "site-school-a",
|
||||
"severity": "high",
|
||||
"evidence": [_fixture("contracts/evidence/v1/examples/pending.json")],
|
||||
"region_ref": "region-east-danger",
|
||||
}
|
||||
first = export_event(candidate, **arguments)
|
||||
retry = export_event(candidate, **arguments)
|
||||
|
||||
assert (first["producer_id"], first["source_event_id"]) == (
|
||||
"brain-school-a",
|
||||
candidate.event_id,
|
||||
)
|
||||
assert retry == first
|
||||
assert payload_sha256(retry) == payload_sha256(first)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", ["pending.json", "failed.json"])
|
||||
def test_evidence_degradation_states_are_exported_unchanged(fixture: str) -> None:
|
||||
evidence = _fixture(f"contracts/evidence/v1/examples/{fixture}")
|
||||
candidate = _candidate()
|
||||
if fixture == "failed.json":
|
||||
candidate = _candidate(
|
||||
event_id="evt-line-20260831-0002",
|
||||
logical_input_id="camera-north-corridor",
|
||||
event_type="directional_line_crossed",
|
||||
occurred_at_ns=1_788_134_590_000_000_000,
|
||||
rule_id="rule-north-one-way",
|
||||
rule_version="1",
|
||||
track_id="track-0088",
|
||||
observation={"category": "anonymous_target", "confidence": 0.88},
|
||||
)
|
||||
event = export_event(
|
||||
candidate,
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="medium" if fixture == "failed.json" else "high",
|
||||
evidence=[evidence],
|
||||
region_ref="line-north-one-way" if fixture == "failed.json" else "region-east-danger",
|
||||
crossing_direction="b_to_a" if fixture == "failed.json" else None,
|
||||
)
|
||||
|
||||
assert event["evidence"] == [evidence]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe",
|
||||
[
|
||||
{"local_path": "D:/captures/frame.jpg"},
|
||||
{"url": "https://example.invalid/signed"},
|
||||
{"token": "not-a-real-token"},
|
||||
],
|
||||
)
|
||||
def test_evidence_rejects_paths_urls_and_sensitive_fields(unsafe: dict[str, object]) -> None:
|
||||
evidence = _fixture("contracts/evidence/v1/examples/pending.json")
|
||||
evidence.update(unsafe)
|
||||
with pytest.raises(EventExportError, match="forbidden"):
|
||||
export_event(
|
||||
_candidate(),
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="high",
|
||||
evidence=[evidence],
|
||||
region_ref="region-east-danger",
|
||||
)
|
||||
|
||||
|
||||
def test_failed_evidence_cannot_claim_success_content() -> None:
|
||||
evidence = _fixture("contracts/evidence/v1/examples/failed.json")
|
||||
evidence["content_type"] = "video/mp4"
|
||||
with pytest.raises(EventExportError, match="failed evidence"):
|
||||
export_event(
|
||||
_candidate(),
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="high",
|
||||
evidence=[evidence],
|
||||
region_ref="region-east-danger",
|
||||
)
|
||||
|
||||
|
||||
def test_jcs_normalizes_number_lexemes_and_negative_zero() -> None:
|
||||
assert canonical_json({"small": 1e-7, "fixed": 1e20, "zero": -0.0}) == (
|
||||
'{"fixed":100000000000000000000,"small":1e-7,"zero":0}'
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
import pytest
|
||||
|
||||
from yovision_brain.integration.event_export import SQLiteReplayCache
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
KeyRecord,
|
||||
MachineIdentityError,
|
||||
Registry,
|
||||
Signer,
|
||||
Verifier,
|
||||
)
|
||||
|
||||
|
||||
def test_replay_cache_rejects_same_jti_after_connector_restart(tmp_path) -> None:
|
||||
now = 1_800_000_000
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
registry = Registry(
|
||||
[
|
||||
KeyRecord(
|
||||
principal="yv:brain:node-a",
|
||||
key_id="brain-key-0001",
|
||||
public_key=private_key.public_key(),
|
||||
audience="yovision-sense",
|
||||
scopes=frozenset({"events:ingest"}),
|
||||
)
|
||||
]
|
||||
)
|
||||
token = Signer(
|
||||
"yv:brain:node-a",
|
||||
"brain-key-0001",
|
||||
private_key,
|
||||
clock=lambda: now,
|
||||
).mint("yovision-sense", ["events:ingest"], "POST", "/v1/events", b"{}")
|
||||
database = tmp_path / "machine-replay.sqlite3"
|
||||
|
||||
first_process = Verifier(registry, SQLiteReplayCache(database), clock=lambda: now)
|
||||
claims = first_process.verify(
|
||||
token, "yovision-sense", "events:ingest", "POST", "/v1/events", b"{}"
|
||||
)
|
||||
assert claims.iss == "yv:brain:node-a"
|
||||
|
||||
restarted_process = Verifier(registry, SQLiteReplayCache(database), clock=lambda: now)
|
||||
with pytest.raises(MachineIdentityError, match="machine_token_replayed") as caught:
|
||||
restarted_process.verify(
|
||||
token, "yovision-sense", "events:ingest", "POST", "/v1/events", b"{}"
|
||||
)
|
||||
assert caught.value.code == "machine_token_replayed"
|
||||
|
||||
|
||||
def test_replay_cache_atomically_reuses_expired_identity(tmp_path) -> None:
|
||||
cache = SQLiteReplayCache(tmp_path / "machine-replay.sqlite3")
|
||||
assert cache.consume("yv:brain:node-a", "jti-one", expires_at=110, now=100)
|
||||
assert not cache.consume("yv:brain:node-a", "jti-one", expires_at=110, now=101)
|
||||
assert cache.consume("yv:brain:node-a", "jti-one", expires_at=130, now=110)
|
||||
Reference in New Issue
Block a user