feat: 实现可靠事件与证据 connector (#153)

This commit is contained in:
QiuSW
2026-08-31 14:57:48 +08:00
parent cf00d73436
commit 04c5deecfb
36 changed files with 4102 additions and 14 deletions
+5 -2
View File
@@ -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)