diff --git a/.fusa-dfmea.json b/.fusa-dfmea.json index b1c6028..d2e1032 100644 --- a/.fusa-dfmea.json +++ b/.fusa-dfmea.json @@ -1,6 +1,6 @@ { "component": "rust-RCP", - "spec_version": "1.10", + "spec_version": "1.11", "standard": "ISO 26262 ASIL-B", "created": "2026-06-19", "failure_modes": [ diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 8160b85..fd52c77 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -1,2394 +1,3218 @@ -[ - { - "id": "REQ-SPEC-001", - "title": "RELAY spec version", - "description": "The crate must implement RELAY specification version 1.6", - "asil": "ASIL-B", - "category": "spec", - "source": "relay-spec-1.10" - }, - { - "id": "REQ-ZONE-001", - "title": "Zone string representation", - "description": "Each zone must have a unique non-empty PascalCase string name", - "asil": "ASIL-B", - "category": "zone" - }, - { - "id": "REQ-ZONE-002", - "title": "Zone inner value", - "description": "Zone is a newtype over u8; inner value is stable across versions", - "asil": "ASIL-B", - "category": "zone" - }, - { - "id": "REQ-ZONE-003", - "title": "FRONT_LEFT is 1", - "description": "Zone::FRONT_LEFT.0 == 1", - "asil": "ASIL-B", - "category": "zone" - }, - { - "id": "REQ-ZONE-004", - "title": "FRONT_RIGHT is 2", - "description": "Zone::FRONT_RIGHT.0 == 2", - "asil": "ASIL-B", - "category": "zone" - }, - { - "id": "REQ-ZONE-005", - "title": "REAR_LEFT is 3", - "description": "Zone::REAR_LEFT.0 == 3", - "asil": "ASIL-B", - "category": "zone" - }, - { - "id": "REQ-ZONE-006", - "title": "REAR_RIGHT is 4", - "description": "Zone::REAR_RIGHT.0 == 4", - "asil": "ASIL-B", - "category": "zone" - }, - { - "id": "REQ-ZONE-007", - "title": "CENTRAL is 5", - "description": "Zone::CENTRAL.0 == 5", - "asil": "ASIL-B", - "category": "zone" - }, - { - "id": "REQ-ZONE-008", - "title": "Zone constants are distinct", - "description": "All zone constants must have distinct inner values", - "asil": "ASIL-B", - "category": "zone" - }, - { - "id": "REQ-PRI-001", - "title": "NORMAL priority", - "description": "Priority::NORMAL.0 == 0", - "asil": "ASIL-B", - "category": "priority" - }, - { - "id": "REQ-PRI-002", - "title": "HIGH > NORMAL", - "description": "Priority::HIGH > Priority::NORMAL", - "asil": "ASIL-B", - "category": "priority" - }, - { - "id": "REQ-PRI-003", - "title": "CRITICAL > HIGH", - "description": "Priority::CRITICAL > Priority::HIGH", - "asil": "ASIL-B", - "category": "priority" - }, - { - "id": "REQ-CMD-001", - "title": "NOOP is 0", - "description": "CommandType::NOOP.0 == 0", - "asil": "ASIL-B", - "category": "cmd" - }, - { - "id": "REQ-CMD-002", - "title": "SET is 1", - "description": "CommandType::SET.0 == 1", - "asil": "ASIL-B", - "category": "cmd" - }, - { - "id": "REQ-CMD-003", - "title": "GET is 2", - "description": "CommandType::GET.0 == 2", - "asil": "ASIL-B", - "category": "cmd" - }, - { - "id": "REQ-CMD-004", - "title": "RESET is 3", - "description": "CommandType::RESET.0 == 3", - "asil": "ASIL-B", - "category": "cmd" - }, - { - "id": "REQ-CMD-005", - "title": "WATCHDOG is 4", - "description": "CommandType::WATCHDOG.0 == 4", - "asil": "ASIL-B", - "category": "cmd" - }, - { - "id": "REQ-CMD-006", - "title": "Command types distinct", - "description": "All command type constants must have distinct inner values", - "asil": "ASIL-B", - "category": "cmd" - }, - { - "id": "REQ-STATUS-001", - "title": "ResponseStatus strings unique", - "description": "All response status values must have unique non-empty string representations", - "asil": "ASIL-B", - "category": "status" - }, - { - "id": "REQ-STATUS-002", - "title": "OK is 0", - "description": "ResponseStatus::OK.0 == 0", - "asil": "ASIL-B", - "category": "status" - }, - { - "id": "REQ-STATUS-003", - "title": "ERROR is 1", - "description": "ResponseStatus::ERROR.0 == 1", - "asil": "ASIL-B", - "category": "status" - }, - { - "id": "REQ-STATUS-004", - "title": "TIMEOUT is 2", - "description": "ResponseStatus::TIMEOUT.0 == 2", - "asil": "ASIL-B", - "category": "status" - }, - { - "id": "REQ-STATUS-005", - "title": "BUSY is 3", - "description": "ResponseStatus::BUSY.0 == 3", - "asil": "ASIL-B", - "category": "status" - }, - { - "id": "REQ-STATUS-006", - "title": "Status constants distinct", - "description": "All response status constants must have distinct inner values", - "asil": "ASIL-B", - "category": "status" - }, - { - "id": "REQ-CMDSTRUCT-001", - "title": "Zero Command is safe NOOP", - "description": "Command::default() must have zone=UNKNOWN, cmd_type=NOOP, priority=NORMAL, payload=None", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-CMDSTRUCT-002", - "title": "Command payload optional", - "description": "Command.payload may be None", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-RESP-001", - "title": "Response carries command ID", - "description": "Response.command_id echoes the dispatched command ID", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-RESP-002", - "title": "Response carries zone", - "description": "Response.zone identifies the responding zone", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-RESP-003", - "title": "Zero Response has status OK", - "description": "Response::default().status == ResponseStatus::OK", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-STAT-001", - "title": "Status carries zone", - "description": "Status.zone identifies the publishing zone controller", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-STAT-002", - "title": "Status carries seq", - "description": "Status.seq is a monotonically increasing sequence number", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-STAT-003", - "title": "Status healthy flag", - "description": "Status.healthy reports whether the controller is operational", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-STAT-004", - "title": "Status seq per controller", - "description": "Each zone controller maintains its own seq counter", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-STAT-005", - "title": "Status payload optional", - "description": "Status.payload may be None", - "asil": "ASIL-B", - "category": "struct" - }, - { - "id": "REQ-ERR-001", - "title": "Closed error distinct", - "description": "RcpError::Closed must be a distinct variant", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-002", - "title": "NotFound error distinct", - "description": "RcpError::NotFound must be a distinct variant", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-003", - "title": "AlreadyExists error distinct", - "description": "RcpError::AlreadyExists must be a distinct variant", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-004", - "title": "Timeout error distinct", - "description": "RcpError::Timeout must be a distinct variant", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-005", - "title": "Busy error distinct", - "description": "RcpError::Busy must be a distinct variant", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-006", - "title": "Sentinel errors are mutually distinct", - "description": "All RELAY sentinel error variants must be pairwise distinct", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-007", - "title": "Closed is relay-closed", - "description": "RcpError::Closed.is_relay_closed() == true", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-008", - "title": "NotConnected is relay-not-connected", - "description": "RcpError::NotConnected.is_relay_not_connected() == true", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-009", - "title": "AlreadyExists is standalone", - "description": "RcpError::AlreadyExists.is_already_exists() == true and is not relay-closed/timeout/not-connected", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-010", - "title": "Timeout is relay-timeout", - "description": "RcpError::Timeout.is_relay_timeout() == true", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-011", - "title": "ZoneMismatch is distinct", - "description": "RcpError::ZoneMismatch.is_zone_mismatch() == true and is also is_relay_not_connected()", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-012", - "title": "NotConnected test", - "description": "Direct test of NotConnected is_relay_not_connected", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-013", - "title": "PayloadTooLarge test", - "description": "Direct test of PayloadTooLarge is_relay_payload_too_large", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-014", - "title": "Only Closed is relay-closed", - "description": "is_relay_closed() returns false for non-Closed variants", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-015", - "title": "is_relay_not_connected coverage", - "description": "is_relay_not_connected returns false for Closed and Timeout", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-016", - "title": "Timeout is relay-timeout", - "description": "RcpError::Timeout.is_relay_timeout() == true", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-017", - "title": "PayloadTooLarge not relay-closed", - "description": "PayloadTooLarge.is_relay_closed() == false", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-018", - "title": "ZoneMismatch is relay-not-connected", - "description": "ZoneMismatch wraps NotConnected per RELAY spec", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-019", - "title": "AlreadyExists is not relay-timeout", - "description": "AlreadyExists.is_relay_timeout() == false", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-020", - "title": "Busy is relay-timeout", - "description": "Busy.is_relay_timeout() == true; Busy wraps Timeout", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-ERR-021", - "title": "ZoneMismatch is relay-not-connected (named)", - "description": "ZoneMismatch.is_relay_not_connected() == true", - "asil": "ASIL-B", - "category": "error" - }, - { - "id": "REQ-MSG-001", - "title": "zone_from_str PascalCase and kebab-case", - "description": "zone_from_str accepts both PascalCase and kebab-case zone names", - "asil": "ASIL-B", - "category": "parse" - }, - { - "id": "REQ-MSG-002", - "title": "zone_from_str unknown returns NotFound", - "description": "zone_from_str returns Err(RcpError::NotFound) for unknown strings", - "asil": "ASIL-B", - "category": "parse" - }, - { - "id": "REQ-PWR-001", - "title": "SLEEP and WAKE command types distinct", - "description": "CommandType::SLEEP != CommandType::WAKE; both differ from other command types", - "asil": "ASIL-B", - "category": "power" - }, - { - "id": "REQ-PWR-002", - "title": "PowerState variants distinct", - "description": "PowerState::Active, Sleep, Standby are pairwise distinct", - "asil": "ASIL-B", - "category": "power" - }, - { - "id": "REQ-PWR-003", - "title": "Initial state is Active", - "description": "PowerStateController starts in Active state", - "asil": "ASIL-B", - "category": "power" - }, - { - "id": "REQ-PWR-004", - "title": "power_state query", - "description": "PowerStateController.power_state() returns current state", - "asil": "ASIL-B", - "category": "power" - }, - { - "id": "REQ-PWR-005", - "title": "SLEEP transitions to Sleep", - "description": "Sending SLEEP command transitions to PowerState::Sleep", - "asil": "ASIL-B", - "category": "power" - }, - { - "id": "REQ-PWR-006", - "title": "Commands during Sleep return BUSY", - "description": "Non-WAKE commands while in Sleep state return ResponseStatus::BUSY", - "asil": "ASIL-B", - "category": "power" - }, - { - "id": "REQ-PWR-007", - "title": "WAKE transitions to Active", - "description": "Sending WAKE command from Sleep transitions to PowerState::Active", - "asil": "ASIL-B", - "category": "power" - }, - { - "id": "REQ-PWR-008", - "title": "Close returns Closed", - "description": "PowerStateController.send() after close() returns Err(Closed)", - "asil": "ASIL-B", - "category": "power" - }, - { - "id": "REQ-CTRL-001", - "title": "Controller is a trait", - "description": "Controller is a trait with zone/send/subscribe/close methods", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-002", - "title": "send invokes handler", - "description": "MockController.send() invokes the registered handler and returns its result", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-003", - "title": "send returns Result", - "description": "Controller.send() returns Result", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-004", - "title": "subscribe returns Subscription", - "description": "Controller.subscribe() returns Result", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-005", - "title": "close safe to call multiple times", - "description": "Controller.close() must be idempotent", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-006", - "title": "send after close returns Closed", - "description": "Controller.send() after close() returns Err(RcpError::Closed)", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-007", - "title": "zero timeout returns Timeout", - "description": "send() with timeout=Some(Duration::ZERO) returns Err(RcpError::Timeout)", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-008", - "title": "zone mismatch returns ZoneMismatch", - "description": "send() with cmd.zone != ctrl.zone() returns Err(RcpError::ZoneMismatch)", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-009", - "title": "zone getter stable", - "description": "Controller.zone() returns the same value across calls", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-010", - "title": "subscribe seq strictly increasing", - "description": "Each published Status carries a seq number strictly greater than the previous", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-011", - "title": "subscribe on closed returns Closed", - "description": "subscribe() after close() returns Err(Closed)", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-012", - "title": "multiple concurrent subscribers", - "description": "Multiple concurrent subscribers each receive their own copy of published Status", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-013", - "title": "NOOP command accepted", - "description": "Controller accepts NOOP command type without error", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-014", - "title": "WATCHDOG command accepted", - "description": "Controller accepts WATCHDOG command type without error", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-015", - "title": "RESET command accepted", - "description": "Controller accepts RESET command type without error", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-016", - "title": "handler response returned verbatim", - "description": "The Response returned by the handler is returned unchanged from send()", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-017", - "title": "publish on closed controller is safe", - "description": "Calling publish() on a closed controller must not panic", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-018", - "title": "concurrent sends are race-free", - "description": "Multiple concurrent send() calls from different threads are safe", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-019", - "title": "concurrent publish and subscribe are race-free", - "description": "Simultaneous publish() and subscribe() from different threads are safe", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-020", - "title": "status carries correct zone", - "description": "Published Status.zone matches the controller's zone", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-021", - "title": "status carries correct payload", - "description": "Published Status.payload matches the payload passed to publish()", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-022", - "title": "status healthy while open", - "description": "Status.healthy == true while the controller is open", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-023", - "title": "zero timeout is pre-cancelled", - "description": "Timeout=Some(ZERO) is treated as pre-cancelled context (no handler invoked)", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-024", - "title": "nil payload does not panic", - "description": "send() with payload=None must not panic", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-025", - "title": "Controller is Send+Sync", - "description": "Controller implementations must be Send + Sync", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-026", - "title": "Payload copied before handler", - "description": "MockController copies payload before passing to handler", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-CTRL-027", - "title": "Publish copies payload", - "description": "MockController.publish() copies the payload for each subscriber", - "asil": "ASIL-B", - "category": "ctrl" - }, - { - "id": "REQ-REG-001", - "title": "Registry is a trait", - "description": "Registry is a trait with register/deregister/lookup/controllers/close", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-002", - "title": "register returns AlreadyExists on duplicate", - "description": "Registering the same zone twice returns Err(AlreadyExists)", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-003", - "title": "deregister returns NotFound for absent zone", - "description": "Deregistering an unregistered zone returns Err(NotFound)", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-004", - "title": "lookup returns NotFound for absent zone", - "description": "Looking up an unregistered zone returns Err(NotFound)", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-005", - "title": "lookup returns Closed when registry is closed", - "description": "lookup() after registry.close() returns Err(Closed)", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-006", - "title": "controllers returns all registered", - "description": "controllers() returns one entry per registered zone", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-007", - "title": "close closes all controllers", - "description": "registry.close() propagates close to all registered controllers", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-008", - "title": "Registry is Send+Sync", - "description": "Registry implementations must be Send + Sync", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-009", - "title": "deregister closes the controller", - "description": "deregister() calls close() on the removed controller", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-010", - "title": "MockRegistry pre-populates 5 zones", - "description": "MockRegistry.new() registers controllers for all 5 standard zones", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-011", - "title": "register after close returns Closed", - "description": "registry.register() after registry.close() returns Err(Closed)", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-012", - "title": "deregister after close returns Closed", - "description": "registry.deregister() after registry.close() returns Err(Closed)", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-REG-013", - "title": "close is idempotent", - "description": "Calling registry.close() multiple times is safe", - "asil": "ASIL-B", - "category": "registry" - }, - { - "id": "REQ-WIRE-001", - "title": "Magic bytes", - "description": "Wire frames start with 0x52 0x43", - "asil": "ASIL-B", - "category": "wire" - }, - { - "id": "REQ-WIRE-002", - "title": "Protocol version byte", - "description": "Wire frames include PROTO_VER=0x01 at byte offset 2", - "asil": "ASIL-B", - "category": "wire" - }, - { - "id": "REQ-WIRE-003", - "title": "Message type byte", - "description": "Wire frame includes message type byte (Command=0x01, Response=0x02, Status=0x03)", - "asil": "ASIL-B", - "category": "wire" - }, - { - "id": "REQ-WIRE-004", - "title": "Header length", - "description": "Wire header is exactly 16 bytes", - "asil": "ASIL-B", - "category": "wire" - }, - { - "id": "REQ-WIRE-005", - "title": "Command encode/decode round-trip", - "description": "encode_command(cmd) followed by decode_command() recovers the original command", - "asil": "ASIL-B", - "category": "wire" - }, - { - "id": "REQ-WIRE-006", - "title": "Response encode/decode round-trip", - "description": "encode_response(resp) followed by decode_response() recovers the original response", - "asil": "ASIL-B", - "category": "wire" - }, - { - "id": "REQ-WIRE-007", - "title": "Payload size limit", - "description": "Payloads exceeding MAX_PAYLOAD=65491 bytes are rejected with PayloadTooLarge", - "asil": "ASIL-B", - "category": "wire" - }, - { - "id": "REQ-WIRE-008", - "title": "Bad magic rejected", - "description": "Frames with wrong magic bytes return Err(BadMagic)", - "asil": "ASIL-B", - "category": "wire" - }, - { - "id": "REQ-WIRE-009", - "title": "Bad version rejected", - "description": "Frames with wrong protocol version return Err(BadVersion)", - "asil": "ASIL-B", - "category": "wire" - }, - { - "id": "REQ-E2E-001", - "title": "E2E wrap prepends seqNum", - "description": "wrap() prepends a 4-byte big-endian sequence number", - "asil": "ASIL-B", - "category": "e2e" - }, - { - "id": "REQ-E2E-002", - "title": "CRC covers seqNum and payload", - "description": "CRC-16/CCITT-FALSE is computed over [seqNum bytes ++ payload]", - "asil": "ASIL-B", - "category": "e2e" - }, - { - "id": "REQ-E2E-003", - "title": "CRC mismatch rejected", - "description": "unwrap() returns Err(CrcMismatch) when CRC does not match", - "asil": "ASIL-B", - "category": "e2e" - }, - { - "id": "REQ-E2E-004", - "title": "E2eController auto-increments seq", - "description": "E2eController atomically increments sequence number per send", - "asil": "ASIL-B", - "category": "e2e" - }, - { - "id": "REQ-E2E-005", - "title": "ReplayGuard rejects replayed seq", - "description": "ReplayGuard.check() returns Err(Replay) for previously seen seq numbers", - "asil": "ASIL-B", - "category": "e2e" - }, - { - "id": "REQ-E2E-006", - "title": "E2E header is 6 bytes", - "description": "E2E header length is 4 (seqNum) + 2 (CRC) = 6 bytes", - "asil": "ASIL-B", - "category": "e2e" - }, - { - "id": "REQ-E2E-007", - "title": "wrap/unwrap are inverse", - "description": "unwrap(wrap(seq, payload)) == Ok((seq, payload))", - "asil": "ASIL-B", - "category": "e2e" - }, - { - "id": "REQ-E2E-008", - "title": "Single-bit corruption detected", - "description": "Flipping any bit in payload or seqNum causes CRC mismatch", - "asil": "ASIL-B", - "category": "e2e" - }, - { - "id": "REQ-PQ-001", - "title": "PrioController wraps inner", - "description": "PrioController delegates commands to an inner Controller", - "asil": "ASIL-B", - "category": "prioqueue" - }, - { - "id": "REQ-PQ-002", - "title": "Dispatch thread spawned on new", - "description": "PrioController.new() spawns a dispatch thread immediately", - "asil": "ASIL-B", - "category": "prioqueue" - }, - { - "id": "REQ-PQ-003", - "title": "Zone matches inner", - "description": "PrioController.zone() returns the inner controller's zone", - "asil": "ASIL-B", - "category": "prioqueue" - }, - { - "id": "REQ-PQ-004", - "title": "Critical dispatched before High/Normal", - "description": "Critical-priority commands are dequeued before High and Normal", - "asil": "ASIL-B", - "category": "prioqueue" - }, - { - "id": "REQ-PQ-005", - "title": "FIFO within priority level", - "description": "Commands at the same priority level are dispatched in submission order", - "asil": "ASIL-B", - "category": "prioqueue" - }, - { - "id": "REQ-PQ-006", - "title": "Payload forwarded unchanged", - "description": "PrioController forwards payloads to inner without modification", - "asil": "ASIL-B", - "category": "prioqueue" - }, - { - "id": "REQ-PQ-007", - "title": "All command types routed", - "description": "PrioController handles all CommandType variants", - "asil": "ASIL-B", - "category": "prioqueue" - }, - { - "id": "REQ-PQ-008", - "title": "Close drains queue with Closed error", - "description": "close() returns Err(Closed) to all pending callers", - "asil": "ASIL-B", - "category": "prioqueue" - }, - { - "id": "REQ-RL-001", - "title": "Token bucket burst capacity", - "description": "Commands up to burst capacity succeed without delay", - "asil": "ASIL-B", - "category": "ratelimit" - }, - { - "id": "REQ-RL-002", - "title": "Default config values", - "description": "DefaultConfig() returns rate=100, burst=20, exempt_critical=true", - "asil": "ASIL-B", - "category": "ratelimit" - }, - { - "id": "REQ-RL-003", - "title": "Zone matches inner", - "description": "RateLimitController.zone() returns inner controller's zone", - "asil": "ASIL-B", - "category": "ratelimit" - }, - { - "id": "REQ-RL-004", - "title": "Tokens replenish over time", - "description": "Token bucket refills at rate tokens/second", - "asil": "ASIL-B", - "category": "ratelimit" - }, - { - "id": "REQ-RL-005", - "title": "Normal and High obey bucket", - "description": "NORMAL and HIGH priority commands consume tokens from the bucket", - "asil": "ASIL-B", - "category": "ratelimit" - }, - { - "id": "REQ-RL-006", - "title": "Empty bucket returns Busy", - "description": "When bucket is empty, send() returns Err(RcpError::Busy) immediately", - "asil": "ASIL-B", - "category": "ratelimit" - }, - { - "id": "REQ-RL-007", - "title": "Critical exempt from rate limit", - "description": "When exempt_critical=true, CRITICAL commands bypass the token bucket", - "asil": "ASIL-B", - "category": "ratelimit" - }, - { - "id": "REQ-RL-008", - "title": "Busy is relay-timeout sentinel", - "description": "RcpError::Busy.is_relay_timeout() == true (Busy wraps Timeout)", - "asil": "ASIL-B", - "category": "ratelimit" - }, - { - "id": "REQ-SIM-001", - "title": "SimController accepts commands", - "description": "SimController processes commands and returns responses", - "asil": "ASIL-B", - "category": "sim" - }, - { - "id": "REQ-SIM-002", - "title": "Queued responses delivered in order", - "description": "queue_response() pre-programs responses consumed FIFO", - "asil": "ASIL-B", - "category": "sim" - }, - { - "id": "REQ-SIM-003", - "title": "Commands recorded", - "description": "SimController records all dispatched commands for inspection", - "asil": "ASIL-B", - "category": "sim" - }, - { - "id": "REQ-SIM-004", - "title": "Zero timeout returns Timeout", - "description": "send() with timeout=Some(ZERO) returns Err(Timeout)", - "asil": "ASIL-B", - "category": "sim" - }, - { - "id": "REQ-SIM-005", - "title": "Zone mismatch returns ZoneMismatch", - "description": "send() with wrong zone returns Err(ZoneMismatch)", - "asil": "ASIL-B", - "category": "sim" - }, - { - "id": "REQ-SIM-006", - "title": "subscribe receives publish", - "description": "Subscription receives Status updates from publish()", - "asil": "ASIL-B", - "category": "sim" - }, - { - "id": "REQ-SIM-007", - "title": "Publish increments seq", - "description": "Each publish() call increments the seq counter", - "asil": "ASIL-B", - "category": "sim" - }, - { - "id": "REQ-SIM-008", - "title": "Close returns Closed", - "description": "send() after close() returns Err(Closed)", - "asil": "ASIL-B", - "category": "sim" - }, - { - "id": "REQ-WDG-001", - "title": "Watchdog default config", - "description": "WatchdogConfig::default() returns interval=1s, miss_window=3, close_on_miss=false", - "asil": "ASIL-B", - "category": "watchdog" - }, - { - "id": "REQ-WDG-002", - "title": "Closed controller accumulates misses", - "description": "WatchdogMonitor accumulates miss_count when controller is unresponsive", - "asil": "ASIL-B", - "category": "watchdog" - }, - { - "id": "REQ-WDG-003", - "title": "Background thread starts on new", - "description": "WatchdogMonitor::start() spawns a background thread immediately", - "asil": "ASIL-B", - "category": "watchdog" - }, - { - "id": "REQ-WDG-004", - "title": "Healthy when responsive", - "description": "is_healthy() returns true when controller responds to WATCHDOG commands", - "asil": "ASIL-B", - "category": "watchdog" - }, - { - "id": "REQ-WDG-005", - "title": "Miss count is zero on responsive controller", - "description": "miss_count() returns 0 when controller is responsive", - "asil": "ASIL-B", - "category": "watchdog" - }, - { - "id": "REQ-WDG-006", - "title": "stop terminates monitor", - "description": "stop() terminates the watchdog thread without deadlock", - "asil": "ASIL-B", - "category": "watchdog" - }, - { - "id": "REQ-WDG-007", - "title": "Zone getter", - "description": "WatchdogMonitor.zone() returns the monitored zone", - "asil": "ASIL-B", - "category": "watchdog" - }, - { - "id": "REQ-WDG-008", - "title": "Unhealthy after miss window exceeded", - "description": "is_healthy() returns false after miss_window consecutive misses", - "asil": "ASIL-B", - "category": "watchdog" - }, - { - "id": "REQ-DL-001", - "title": "DeadlineController enforces latency", - "description": "DeadlineController sends with a maximum response deadline", - "asil": "ASIL-B", - "category": "deadline" - }, - { - "id": "REQ-DL-002", - "title": "Deadline getter", - "description": "DeadlineController.deadline() returns configured deadline", - "asil": "ASIL-B", - "category": "deadline" - }, - { - "id": "REQ-DL-003", - "title": "Commands forwarded to inner", - "description": "DeadlineController forwards commands to inner controller", - "asil": "ASIL-B", - "category": "deadline" - }, - { - "id": "REQ-DL-004", - "title": "Zero timeout returns Timeout", - "description": "send() with timeout=Some(ZERO) returns Err(Timeout)", - "asil": "ASIL-B", - "category": "deadline" - }, - { - "id": "REQ-DL-005", - "title": "Shorter of deadline and caller timeout wins", - "description": "Effective timeout = min(caller_timeout, deadline)", - "asil": "ASIL-B", - "category": "deadline" - }, - { - "id": "REQ-DL-006", - "title": "Close forwarded", - "description": "DeadlineController.close() is forwarded to inner", - "asil": "ASIL-B", - "category": "deadline" - }, - { - "id": "REQ-LOAN-001", - "title": "LoanPool pre-allocates buffers", - "description": "LoanPool.new(count, size) pre-allocates count buffers of size bytes", - "asil": "ASIL-B", - "category": "loan" - }, - { - "id": "REQ-LOAN-002", - "title": "Correct pool count", - "description": "LoanPool.available() returns count immediately after creation", - "asil": "ASIL-B", - "category": "loan" - }, - { - "id": "REQ-LOAN-003", - "title": "Buffer returned on drop", - "description": "Dropping a Loan returns the buffer to the pool", - "asil": "ASIL-B", - "category": "loan" - }, - { - "id": "REQ-LOAN-004", - "title": "try_acquire returns None when empty", - "description": "try_acquire() returns None when no buffers are available", - "asil": "ASIL-B", - "category": "loan" - }, - { - "id": "REQ-LOAN-005", - "title": "Zone matches inner", - "description": "LoanPoolController.zone() returns inner controller's zone", - "asil": "ASIL-B", - "category": "loan" - }, - { - "id": "REQ-LOAN-006", - "title": "Oversized loan request rejected", - "description": "loan(size > buffer_size) returns Err(PayloadTooLarge)", - "asil": "ASIL-B", - "category": "loan" - }, - { - "id": "REQ-LOAN-007", - "title": "send_loaned forwards payload", - "description": "send_loaned() copies loan payload into command and forwards to inner", - "asil": "ASIL-B", - "category": "loan" - }, - { - "id": "REQ-ZG-001", - "title": "ZoneGroup wraps multiple controllers", - "description": "ZoneGroup holds a Vec of zone controllers", - "asil": "ASIL-B", - "category": "zonegroup" - }, - { - "id": "REQ-ZG-002", - "title": "Zone is logical zone ID", - "description": "ZoneGroup.zone() returns the logical group zone, not a member zone", - "asil": "ASIL-B", - "category": "zonegroup" - }, - { - "id": "REQ-ZG-003", - "title": "Send broadcasts to all members", - "description": "send() dispatches the command to every member controller", - "asil": "ASIL-B", - "category": "zonegroup" - }, - { - "id": "REQ-ZG-004", - "title": "First member error propagated", - "description": "If any member returns an error, send() returns that error", - "asil": "ASIL-B", - "category": "zonegroup" - }, - { - "id": "REQ-ZG-005", - "title": "Subscribe via first member", - "description": "subscribe() delegates to the first member controller", - "asil": "ASIL-B", - "category": "zonegroup" - }, - { - "id": "REQ-ZG-006", - "title": "Close closes all members", - "description": "close() closes all member controllers", - "asil": "ASIL-B", - "category": "zonegroup" - }, - { - "id": "REQ-ZG-007", - "title": "Empty group send returns default OK", - "description": "send() on an empty group returns Ok(Response::default())", - "asil": "ASIL-B", - "category": "zonegroup" - }, - { - "id": "REQ-PROXY-001", - "title": "ProxyController wraps inner", - "description": "ProxyController forwards all calls to an interchangeable inner controller", - "asil": "ASIL-B", - "category": "proxy" - }, - { - "id": "REQ-PROXY-002", - "title": "Zone set from original inner", - "description": "ProxyController.zone() is set from the original inner controller at construction", - "asil": "ASIL-B", - "category": "proxy" - }, - { - "id": "REQ-PROXY-003", - "title": "send forwarded", - "description": "ProxyController.send() forwards to the current inner controller", - "asil": "ASIL-B", - "category": "proxy" - }, - { - "id": "REQ-PROXY-004", - "title": "subscribe forwarded", - "description": "ProxyController.subscribe() forwards to the current inner controller", - "asil": "ASIL-B", - "category": "proxy" - }, - { - "id": "REQ-PROXY-005", - "title": "swap replaces inner atomically", - "description": "swap() atomically replaces the inner controller", - "asil": "ASIL-B", - "category": "proxy" - }, - { - "id": "REQ-PROXY-006", - "title": "detach returns NotConnected", - "description": "After detach(), send() and subscribe() return Err(NotConnected)", - "asil": "ASIL-B", - "category": "proxy" - }, - { - "id": "REQ-RED-001", - "title": "RedundancyController provides hot standby", - "description": "RedundancyController implements 1-of-2 hot standby", - "asil": "ASIL-B", - "category": "redundancy" - }, - { - "id": "REQ-RED-002", - "title": "Zone matches primary", - "description": "RedundancyController.zone() matches the primary controller's zone", - "asil": "ASIL-B", - "category": "redundancy" - }, - { - "id": "REQ-RED-003", - "title": "Primary success no failover", - "description": "When primary succeeds, no failover is triggered", - "asil": "ASIL-B", - "category": "redundancy" - }, - { - "id": "REQ-RED-004", - "title": "Primary failure triggers failover", - "description": "On primary error, secondary is promoted and command is retried", - "asil": "ASIL-B", - "category": "redundancy" - }, - { - "id": "REQ-RED-005", - "title": "Both failed returns error", - "description": "When both primary and secondary fail, the error is returned to caller", - "asil": "ASIL-B", - "category": "redundancy" - }, - { - "id": "REQ-RED-006", - "title": "Failover count increments", - "description": "failover_count() increments each time a failover occurs", - "asil": "ASIL-B", - "category": "redundancy" - }, - { - "id": "REQ-RED-007", - "title": "No secondary after failover", - "description": "After one failover, has_secondary() returns false", - "asil": "ASIL-B", - "category": "redundancy" - }, - { - "id": "REQ-RED-008", - "title": "subscribe forwarded to primary", - "description": "subscribe() delegates to the current primary", - "asil": "ASIL-B", - "category": "redundancy" - }, - { - "id": "REQ-FI-001", - "title": "FaultSpec defines rule and error", - "description": "FaultSpec holds a FaultRule and the error to inject", - "asil": "ASIL-B", - "category": "faultinject" - }, - { - "id": "REQ-FI-002", - "title": "FaultRule variants", - "description": "FaultRule supports Always, OnNthCall, and AfterNthCall", - "asil": "ASIL-B", - "category": "faultinject" - }, - { - "id": "REQ-FI-003", - "title": "No fault passes through", - "description": "Without fault rules, commands are forwarded to inner unchanged", - "asil": "ASIL-B", - "category": "faultinject" - }, - { - "id": "REQ-FI-004", - "title": "Multiple rules first match wins", - "description": "When multiple rules match, the first one installed takes effect", - "asil": "ASIL-B", - "category": "faultinject" - }, - { - "id": "REQ-FI-005", - "title": "Clear removes all rules", - "description": "clear() removes all installed fault rules", - "asil": "ASIL-B", - "category": "faultinject" - }, - { - "id": "REQ-FI-006", - "title": "Fault rule checked on each send", - "description": "Each send() call evaluates all installed fault rules in order", - "asil": "ASIL-B", - "category": "faultinject" - }, - { - "id": "REQ-FI-007", - "title": "Close forwarded", - "description": "FaultInjectController.close() is forwarded to inner", - "asil": "ASIL-B", - "category": "faultinject" - }, - { - "id": "REQ-OBS-001", - "title": "Metrics struct", - "description": "ObserveController maintains Metrics with call count, error count, latency", - "asil": "ASIL-B", - "category": "observe" - }, - { - "id": "REQ-OBS-002", - "title": "ObserveController wraps inner", - "description": "ObserveController wraps an inner controller", - "asil": "ASIL-B", - "category": "observe" - }, - { - "id": "REQ-OBS-003", - "title": "Metrics updated on each send", - "description": "call count and error count are updated on every send()", - "asil": "ASIL-B", - "category": "observe" - }, - { - "id": "REQ-OBS-004", - "title": "Hook registered via add_hook", - "description": "add_hook() registers a callback invoked after each send()", - "asil": "ASIL-B", - "category": "observe" - }, - { - "id": "REQ-OBS-005", - "title": "Hook receives command, result, and latency", - "description": "Hook function receives (&Command, &Result, Duration)", - "asil": "ASIL-B", - "category": "observe" - }, - { - "id": "REQ-OBS-006", - "title": "Close forwarded", - "description": "ObserveController.close() forwards to inner", - "asil": "ASIL-B", - "category": "observe" - }, - { - "id": "REQ-TSN-001", - "title": "TrafficClass type", - "description": "TrafficClass is a newtype over u8 with BEST_EFFORT(0), CONTROL(5), CRITICAL(7)", - "asil": "ASIL-B", - "category": "tsn" - }, - { - "id": "REQ-TSN-002", - "title": "Priority to traffic class mapping", - "description": "TrafficClass::from_priority() maps NORMAL\u2192BEST_EFFORT, HIGH\u2192CONTROL, CRITICAL\u2192CRITICAL", - "asil": "ASIL-B", - "category": "tsn" - }, - { - "id": "REQ-TSN-003", - "title": "Zone forwarded", - "description": "TsnController.zone() returns the inner controller's zone", - "asil": "ASIL-B", - "category": "tsn" - }, - { - "id": "REQ-TSN-004", - "title": "TSN byte prepended to payload", - "description": "TsnController prepends traffic class byte to command payload", - "asil": "ASIL-B", - "category": "tsn" - }, - { - "id": "REQ-TSN-005", - "title": "Close forwarded", - "description": "TsnController.close() forwards to inner", - "asil": "ASIL-B", - "category": "tsn" - }, - { - "id": "REQ-AUTHZ-001", - "title": "Policy defines allowed cmd types", - "description": "Policy.allowed_cmd_types controls which command types are permitted", - "asil": "ASIL-B", - "category": "authz" - }, - { - "id": "REQ-AUTHZ-002", - "title": "allow_all permits any command", - "description": "Policy::allow_all() permits all command types", - "asil": "ASIL-B", - "category": "authz" - }, - { - "id": "REQ-AUTHZ-003", - "title": "deny_all blocks every command", - "description": "Policy::deny_all() blocks all command types", - "asil": "ASIL-B", - "category": "authz" - }, - { - "id": "REQ-AUTHZ-004", - "title": "AuthzController wraps inner", - "description": "AuthzController wraps an inner controller with a policy", - "asil": "ASIL-B", - "category": "authz" - }, - { - "id": "REQ-AUTHZ-005", - "title": "Policy enforced on send", - "description": "send() returns Err(NotFound) when command type is not in allowlist", - "asil": "ASIL-B", - "category": "authz" - }, - { - "id": "REQ-AUTHZ-006", - "title": "Policy hot-swappable", - "description": "set_policy() atomically replaces the active policy", - "asil": "ASIL-B", - "category": "authz" - }, - { - "id": "REQ-AUTHZ-007", - "title": "Close forwarded", - "description": "AuthzController.close() forwards to inner", - "asil": "ASIL-B", - "category": "authz" - }, - { - "id": "REQ-FW-001", - "title": "MAX_CHUNK constant", - "description": "MAX_CHUNK == 512 bytes per firmware chunk", - "asil": "ASIL-B", - "category": "firmware" - }, - { - "id": "REQ-FW-002", - "title": "Empty image rejected", - "description": "flash() with empty image returns Err(InvalidSize)", - "asil": "ASIL-B", - "category": "firmware" - }, - { - "id": "REQ-FW-003", - "title": "Single chunk flash", - "description": "flash() with image <= chunk_size sends exactly one chunk", - "asil": "ASIL-B", - "category": "firmware" - }, - { - "id": "REQ-FW-004", - "title": "Multiple chunks", - "description": "flash() sends ceil(image_len/chunk_size) SET commands", - "asil": "ASIL-B", - "category": "firmware" - }, - { - "id": "REQ-FW-005", - "title": "Chunk error aborts", - "description": "Non-OK response from any chunk aborts flash and returns error", - "asil": "ASIL-B", - "category": "firmware" - }, - { - "id": "REQ-FW-006", - "title": "Chunk size capped at MAX_CHUNK", - "description": "Chunk sizes larger than MAX_CHUNK are silently capped", - "asil": "ASIL-B", - "category": "firmware" - }, - { - "id": "REQ-REC-001", - "title": "Entry carries timestamp", - "description": "Record Entry includes SystemTime timestamp", - "asil": "ASIL-B", - "category": "record" - }, - { - "id": "REQ-REC-002", - "title": "RecordController wraps inner", - "description": "RecordController wraps an inner controller and logs interactions", - "asil": "ASIL-B", - "category": "record" - }, - { - "id": "REQ-REC-003", - "title": "Entries in chronological order", - "description": "entries() returns interactions in submission order", - "asil": "ASIL-B", - "category": "record" - }, - { - "id": "REQ-REC-004", - "title": "Clear empties log", - "description": "clear() removes all recorded entries", - "asil": "ASIL-B", - "category": "record" - }, - { - "id": "REQ-REC-005", - "title": "Errors recorded", - "description": "RecordController records both successful and error results", - "asil": "ASIL-B", - "category": "record" - }, - { - "id": "REQ-FED-001", - "title": "FederationRouter routes by vehicle ID", - "description": "FederationRouter maps vehicle IDs to remote registries", - "asil": "ASIL-B", - "category": "federation" - }, - { - "id": "REQ-FED-002", - "title": "add_peer registers a remote registry", - "description": "add_peer() registers a registry under a vehicle ID string", - "asil": "ASIL-B", - "category": "federation" - }, - { - "id": "REQ-FED-003", - "title": "remove_peer deregisters", - "description": "remove_peer() removes a registered vehicle registry", - "asil": "ASIL-B", - "category": "federation" - }, - { - "id": "REQ-FED-004", - "title": "peer_ids lists all", - "description": "peer_ids() returns all registered vehicle ID strings", - "asil": "ASIL-B", - "category": "federation" - }, - { - "id": "REQ-FED-005", - "title": "lookup_peer resolves zone", - "description": "lookup_peer(vehicle_id, zone) returns the controller from the peer registry", - "asil": "ASIL-B", - "category": "federation" - }, - { - "id": "REQ-DYN-001", - "title": "DynStore is a thread-safe KV store", - "description": "DynStore provides a thread-safe key/value store", - "asil": "ASIL-B", - "category": "dyndata" - }, - { - "id": "REQ-DYN-002", - "title": "set inserts or replaces", - "description": "set() inserts a new key or replaces an existing value", - "asil": "ASIL-B", - "category": "dyndata" - }, - { - "id": "REQ-DYN-003", - "title": "get returns value or None", - "description": "get() returns Some(value) for existing keys, None otherwise", - "asil": "ASIL-B", - "category": "dyndata" - }, - { - "id": "REQ-DYN-004", - "title": "delete removes key", - "description": "delete() removes a key; returns true if it existed", - "asil": "ASIL-B", - "category": "dyndata" - }, - { - "id": "REQ-DYN-005", - "title": "keys lists all", - "description": "keys() returns all parameter names currently in the store", - "asil": "ASIL-B", - "category": "dyndata" - }, - { - "id": "REQ-CFG-001", - "title": "RcpConfig default is valid", - "description": "RcpConfig::default() passes validate()", - "asil": "ASIL-B", - "category": "config" - }, - { - "id": "REQ-CFG-002", - "title": "ControllerConfig per-controller", - "description": "ControllerConfig holds zone, timeout_ms, max_payload_bytes", - "asil": "ASIL-B", - "category": "config" - }, - { - "id": "REQ-CFG-003", - "title": "WatchdogConfig defaults", - "description": "WatchdogConfig::default() returns interval_ms=1000, window=3", - "asil": "ASIL-B", - "category": "config" - }, - { - "id": "REQ-CFG-004", - "title": "RateLimitConfig defaults", - "description": "RateLimitConfig::default() returns rate=100, burst=20, exempt_critical=true", - "asil": "ASIL-B", - "category": "config" - }, - { - "id": "REQ-CFG-005", - "title": "JSON and YAML parsing", - "description": "from_json() and from_yaml() parse RcpConfig from text", - "asil": "ASIL-B", - "category": "config" - }, - { - "id": "REQ-CFG-006", - "title": "Validation rejects invalid values", - "description": "validate() returns Err for zone > 5, negative rate, zero watchdog window, oversized payload", - "asil": "ASIL-B", - "category": "config" - }, - { - "id": "REQ-CG-001", - "title": "FieldType maps to Rust types", - "description": "FieldType::rust_type() returns correct Rust primitive type names", - "asil": "ASIL-B", - "category": "codegen" - }, - { - "id": "REQ-CG-002", - "title": "StructSchema defines name and fields", - "description": "StructSchema holds name and Vec with optional flag", - "asil": "ASIL-B", - "category": "codegen" - }, - { - "id": "REQ-CG-003", - "title": "generate_structs emits Rust code", - "description": "generate_structs() emits valid Rust struct definitions", - "asil": "ASIL-B", - "category": "codegen" - }, - { - "id": "REQ-CG-004", - "title": "parse_schema converts JSON schema", - "description": "parse_schema() converts a HashMap schema definition into StructSchemas", - "asil": "ASIL-B", - "category": "codegen" - }, - { - "id": "REQ-CYB-001", - "title": "Feasibility variants", - "description": "Feasibility has Low, Medium, High, Critical variants with Display", - "asil": "ASIL-B", - "category": "iso21434" - }, - { - "id": "REQ-CYB-002", - "title": "Impact variants", - "description": "Impact has Negligible, Moderate, Major, Severe variants with Display", - "asil": "ASIL-B", - "category": "iso21434" - }, - { - "id": "REQ-CYB-003", - "title": "RiskLevel variants", - "description": "RiskLevel has Low, Medium, High, Critical variants", - "asil": "ASIL-B", - "category": "iso21434" - }, - { - "id": "REQ-CYB-004", - "title": "risk_level function", - "description": "risk_level(f, i) returns RiskLevel per ISO 21434 Table 14", - "asil": "ASIL-B", - "category": "iso21434" - }, - { - "id": "REQ-CYB-005", - "title": "Threat.risk_level()", - "description": "Threat.risk_level() computes risk from its feasibility and impact", - "asil": "ASIL-B", - "category": "iso21434" - }, - { - "id": "REQ-CYB-006", - "title": "filter_by_risk", - "description": "filter_by_risk() returns threats at or above the minimum risk level", - "asil": "ASIL-B", - "category": "iso21434" - }, - { - "id": "REQ-GAP-001", - "title": "GapReport has_gaps flag", - "description": "GapReport.has_gaps() returns true when any gap list is non-empty", - "asil": "ASIL-B", - "category": "certgap" - }, - { - "id": "REQ-GAP-002", - "title": "analyse detects gaps", - "description": "analyse() identifies unimplemented, untested, and undeclared requirements", - "asil": "ASIL-B", - "category": "certgap" - }, - { - "id": "REQ-GAP-003", - "title": "coverage ratio", - "description": "coverage() returns ratio of covered to declared requirements", - "asil": "ASIL-B", - "category": "certgap" - }, - { - "id": "REQ-GAP-004", - "title": "coverage_by_prefix", - "description": "coverage_by_prefix() groups coverage statistics by requirement prefix", - "asil": "ASIL-B", - "category": "certgap" - }, - { - "id": "REQ-GAP-005", - "title": "zero coverage baseline", - "description": "coverage() returns 0.0 when no requirements are covered; coverage_by_prefix() returns 0.0 per prefix", - "asil": "ASIL-B", - "category": "certgap" - }, - { - "id": "REQ-FORMAL-001", - "title": "Invariant type", - "description": "Invariant holds a name and a checkable predicate", - "asil": "ASIL-B", - "category": "formal" - }, - { - "id": "REQ-FORMAL-002", - "title": "CheckResult tracks pass/fail", - "description": "CheckResult records passed and failed invariant names", - "asil": "ASIL-B", - "category": "formal" - }, - { - "id": "REQ-FORMAL-003", - "title": "check_all evaluates all invariants", - "description": "check_all() checks every invariant against the state", - "asil": "ASIL-B", - "category": "formal" - }, - { - "id": "REQ-FORMAL-004", - "title": "witness finds first violation", - "description": "witness() returns the first state that violates the invariant", - "asil": "ASIL-B", - "category": "formal" - }, - { - "id": "REQ-ADMIN-001", - "title": "AdminServer health check", - "description": "AdminServer.is_healthy() returns true when all controllers respond", - "asil": "ASIL-B", - "category": "admin" - }, - { - "id": "REQ-ADMIN-002", - "title": "Request count", - "description": "record_request() and request_count() track admin endpoint hits", - "asil": "ASIL-B", - "category": "admin" - }, - { - "id": "REQ-ADMIN-003", - "title": "Uptime", - "description": "uptime() returns non-negative duration since construction", - "asil": "ASIL-B", - "category": "admin" - }, - { - "id": "REQ-ADMIN-004", - "title": "Controller count", - "description": "controller_count() returns number of registered controllers", - "asil": "ASIL-B", - "category": "admin" - }, - { - "id": "REQ-ADMIN-005", - "title": "Shutdown closes registry", - "description": "shutdown() closes the registry and sets is_shutting_down()", - "asil": "ASIL-B", - "category": "admin" - }, - { - "id": "REQ-CANBR-001", - "title": "CAN FD max payload", - "description": "CAN_FD_MAX_PAYLOAD == 64 bytes", - "asil": "ASIL-B", - "category": "canbr" - }, - { - "id": "REQ-CANBR-002", - "title": "CanSocket trait", - "description": "CanSocket defines send_frame and recv_frame", - "asil": "ASIL-B", - "category": "canbr" - }, - { - "id": "REQ-CANBR-003", - "title": "CanBridge sends CAN frame", - "description": "CanBridge.send() encodes and transmits a CAN frame", - "asil": "ASIL-B", - "category": "canbr" - }, - { - "id": "REQ-CANBR-004", - "title": "Zone mismatch and payload size checks", - "description": "CanBridge rejects wrong zone and oversized payloads", - "asil": "ASIL-B", - "category": "canbr" - }, - { - "id": "REQ-CANBR-005", - "title": "Close is no-op", - "description": "CanBridge.close() returns Ok(())", - "asil": "ASIL-B", - "category": "canbr" - }, - { - "id": "REQ-LINBR-001", - "title": "LIN max data", - "description": "LIN_MAX_DATA == 8 bytes", - "asil": "ASIL-B", - "category": "linbr" - }, - { - "id": "REQ-LINBR-002", - "title": "LinMaster trait", - "description": "LinMaster defines send_frame and recv_frame", - "asil": "ASIL-B", - "category": "linbr" - }, - { - "id": "REQ-LINBR-003", - "title": "LinBridge wraps master", - "description": "LinBridge delegates to a LinMaster", - "asil": "ASIL-B", - "category": "linbr" - }, - { - "id": "REQ-LINBR-004", - "title": "Payload, zone, and timeout checks", - "description": "LinBridge rejects oversized payload, wrong zone, zero timeout", - "asil": "ASIL-B", - "category": "linbr" - }, - { - "id": "REQ-SOMEIP-001", - "title": "SOME/IP header length", - "description": "SOMEIP_HEADER_LEN == 16 bytes", - "asil": "ASIL-B", - "category": "someip" - }, - { - "id": "REQ-SOMEIP-002", - "title": "encode_request", - "description": "encode_request() produces a valid SOME/IP header + payload", - "asil": "ASIL-B", - "category": "someip" - }, - { - "id": "REQ-SOMEIP-003", - "title": "decode_response", - "description": "decode_response() parses SOME/IP response and maps status codes", - "asil": "ASIL-B", - "category": "someip" - }, - { - "id": "REQ-SOMEIP-004", - "title": "SomeIpSocket trait", - "description": "SomeIpSocket defines send and recv", - "asil": "ASIL-B", - "category": "someip" - }, - { - "id": "REQ-SOMEIP-005", - "title": "SomeIpBridge delegates", - "description": "SomeIpBridge.send() uses encode_request/decode_response cycle", - "asil": "ASIL-B", - "category": "someip" - }, - { - "id": "REQ-MQTT-001", - "title": "MqttClient trait", - "description": "MqttClient defines publish, subscribe_topic, recv_message", - "asil": "ASIL-B", - "category": "mqttbr" - }, - { - "id": "REQ-MQTT-002", - "title": "MqttBridge wraps client", - "description": "MqttBridge delegates to a MqttClient", - "asil": "ASIL-B", - "category": "mqttbr" - }, - { - "id": "REQ-MQTT-003", - "title": "Topic scheme", - "description": "MqttBridge publishes to rcp/{zone}/cmd/{id}", - "asil": "ASIL-B", - "category": "mqttbr" - }, - { - "id": "REQ-MQTT-004", - "title": "Zone mismatch rejected", - "description": "MqttBridge rejects commands with wrong zone", - "asil": "ASIL-B", - "category": "mqttbr" - }, - { - "id": "REQ-MQTT-005", - "title": "subscribe returns NotFound", - "description": "MqttBridge.subscribe() returns Err(NotFound)", - "asil": "ASIL-B", - "category": "mqttbr" - }, - { - "id": "REQ-DDS-001", - "title": "DdsParticipant trait", - "description": "DdsParticipant defines write and take", - "asil": "ASIL-B", - "category": "ddsbr" - }, - { - "id": "REQ-DDS-002", - "title": "DdsBridge wraps participant", - "description": "DdsBridge delegates to a DdsParticipant", - "asil": "ASIL-B", - "category": "ddsbr" - }, - { - "id": "REQ-DDS-003", - "title": "Commands mapped to DDS topics", - "description": "DdsBridge.send() writes to rcp.zone{N}.cmd and reads from rcp.zone{N}.resp", - "asil": "ASIL-B", - "category": "ddsbr" - }, - { - "id": "REQ-DDS-004", - "title": "Close is no-op", - "description": "DdsBridge.close() returns Ok(())", - "asil": "ASIL-B", - "category": "ddsbr" - }, - { - "id": "REQ-UDP-001", - "title": "UdpSocket trait", - "description": "UdpSocket defines send_to and recv_from", - "asil": "ASIL-B", - "category": "udp" - }, - { - "id": "REQ-UDP-002", - "title": "UdpBridge wraps socket", - "description": "UdpBridge delegates to a UdpSocket and a remote SocketAddr", - "asil": "ASIL-B", - "category": "udp" - }, - { - "id": "REQ-UDP-003", - "title": "Commands encoded as wire frames", - "description": "UdpBridge.send() uses wire::encode_command and decode_response", - "asil": "ASIL-B", - "category": "udp" - }, - { - "id": "REQ-UDP-004", - "title": "Zone mismatch rejected", - "description": "UdpBridge rejects commands with wrong zone", - "asil": "ASIL-B", - "category": "udp" - }, - { - "id": "REQ-UDP-005", - "title": "Close is no-op", - "description": "UdpBridge.close() returns Ok(())", - "asil": "ASIL-B", - "category": "udp" - }, - { - "id": "REQ-SHM-001", - "title": "ShmChannel trait", - "description": "ShmChannel defines write and read", - "asil": "ASIL-B", - "category": "shmem" - }, - { - "id": "REQ-SHM-002", - "title": "InProcChannel is FIFO", - "description": "InProcChannel maintains FIFO ordering", - "asil": "ASIL-B", - "category": "shmem" - }, - { - "id": "REQ-SHM-003", - "title": "ShmBridge wraps tx/rx channels", - "description": "ShmBridge holds a write channel (tx) and read channel (rx)", - "asil": "ASIL-B", - "category": "shmem" - }, - { - "id": "REQ-SHM-004", - "title": "Zone mismatch and timeout checks", - "description": "ShmBridge rejects wrong zone and zero timeout", - "asil": "ASIL-B", - "category": "shmem" - }, - { - "id": "REQ-SHM-005", - "title": "Close is no-op", - "description": "ShmBridge.close() returns Ok(())", - "asil": "ASIL-B", - "category": "shmem" - }, - { - "id": "REQ-MDNS-001", - "title": "ServiceRecord type", - "description": "ServiceRecord holds host, port, zone, and txt fields", - "asil": "ASIL-B", - "category": "mdns" - }, - { - "id": "REQ-MDNS-002", - "title": "MdnsRegistry in-process impl", - "description": "MdnsRegistry provides announce, withdraw, resolve, names", - "asil": "ASIL-B", - "category": "mdns" - }, - { - "id": "REQ-MDNS-003", - "title": "announce registers service", - "description": "announce() registers a ServiceRecord under a name", - "asil": "ASIL-B", - "category": "mdns" - }, - { - "id": "REQ-MDNS-004", - "title": "resolve returns record", - "description": "resolve() returns Some(record) for announced names, None otherwise", - "asil": "ASIL-B", - "category": "mdns" - }, - { - "id": "REQ-TLS-001", - "title": "MIN_TLS_VERSION constant", - "description": "MIN_TLS_VERSION == \"TLSv1.2\"", - "asil": "ASIL-B", - "category": "tls" - }, - { - "id": "REQ-TLS-002", - "title": "Mutual auth required", - "description": "REQUIRE_MUTUAL_AUTH == true; unverified peer returns Err(NotConnected)", - "asil": "ASIL-B", - "category": "tls" - }, - { - "id": "REQ-TLS-003", - "title": "TlsStream trait", - "description": "TlsStream defines write_all, read_to_vec, peer_verified", - "asil": "ASIL-B", - "category": "tls" - }, - { - "id": "REQ-TLS-004", - "title": "TlsBridge uses wire encoding", - "description": "TlsBridge.send() uses wire::encode_command and decode_response", - "asil": "ASIL-B", - "category": "tls" - }, - { - "id": "REQ-TLS-005", - "title": "Close is no-op", - "description": "TlsBridge.close() returns Ok(())", - "asil": "ASIL-B", - "category": "tls" - }, - { - "id": "REQ-GRPC-001", - "title": "GrpcRequest/Response types", - "description": "GrpcRequest and GrpcResponse are Vec newtypes", - "asil": "ASIL-B", - "category": "grpcbridge" - }, - { - "id": "REQ-GRPC-002", - "title": "Encode/decode helpers", - "description": "encode_grpc_request and decode_grpc_response marshal RCP types", - "asil": "ASIL-B", - "category": "grpcbridge" - }, - { - "id": "REQ-GRPC-003", - "title": "GrpcStub trait", - "description": "GrpcStub defines unary_call", - "asil": "ASIL-B", - "category": "grpcbridge" - }, - { - "id": "REQ-GRPC-004", - "title": "GrpcBridge delegates", - "description": "GrpcBridge.send() uses encode/decode cycle via GrpcStub", - "asil": "ASIL-B", - "category": "grpcbridge" - }, - { - "id": "REQ-REST-001", - "title": "HttpClient trait", - "description": "HttpClient defines post", - "asil": "ASIL-B", - "category": "restbridge" - }, - { - "id": "REQ-REST-002", - "title": "RestBridge wraps client", - "description": "RestBridge delegates to an HttpClient", - "asil": "ASIL-B", - "category": "restbridge" - }, - { - "id": "REQ-REST-003", - "title": "HTTP status codes mapped", - "description": "200\u2192OK, 408/504\u2192TIMEOUT, 429\u2192BUSY, other\u2192ERROR", - "asil": "ASIL-B", - "category": "restbridge" - }, - { - "id": "REQ-REST-004", - "title": "Close is no-op", - "description": "RestBridge.close() returns Ok(())", - "asil": "ASIL-B", - "category": "restbridge" - }, - { - "id": "REQ-UDS-001", - "title": "UDS service IDs", - "description": "READ_DATA=0x22, WRITE_DATA=0x2E, ECU_RESET=0x11", - "asil": "ASIL-B", - "category": "udsbr" - }, - { - "id": "REQ-UDS-002", - "title": "cmd_to_uds_sid mapping", - "description": "GET\u2192READ_DATA, SET\u2192WRITE_DATA, RESET\u2192ECU_RESET", - "asil": "ASIL-B", - "category": "udsbr" - }, - { - "id": "REQ-UDS-003", - "title": "UdsTransport trait", - "description": "UdsTransport defines request()", - "asil": "ASIL-B", - "category": "udsbr" - }, - { - "id": "REQ-UDS-004", - "title": "UdsBridge delegates", - "description": "UdsBridge.send() maps to UDS service and checks positive response SID", - "asil": "ASIL-B", - "category": "udsbr" - }, - { - "id": "REQ-UDS-005", - "title": "Close is no-op", - "description": "UdsBridge.close() returns Ok(())", - "asil": "ASIL-B", - "category": "udsbr" - }, - { - "id": "REQ-DOIP-001", - "title": "DoIP protocol version", - "description": "DOIP_PROTO_VER == 0x02; DOIP_HEADER_LEN == 8", - "asil": "ASIL-B", - "category": "doipbr" - }, - { - "id": "REQ-DOIP-002", - "title": "DoipSocket trait and frame encoding", - "description": "DoipSocket defines send/recv; encode_doip_frame produces 8-byte header", - "asil": "ASIL-B", - "category": "doipbr" - }, - { - "id": "REQ-DOIP-003", - "title": "DoipBridge delegates", - "description": "DoipBridge.send() encodes via DoIP and checks response", - "asil": "ASIL-B", - "category": "doipbr" - }, - { - "id": "REQ-DOIP-004", - "title": "Close is no-op", - "description": "DoipBridge.close() returns Ok(())", - "asil": "ASIL-B", - "category": "doipbr" - }, - { - "id": "REQ-CAPI-001", - "title": "CCommand is repr(C)", - "description": "CCommand uses #[repr(C)] with C-compatible field types", - "asil": "ASIL-B", - "category": "capi" - }, - { - "id": "REQ-CAPI-002", - "title": "CResponse is repr(C)", - "description": "CResponse uses #[repr(C)] with C-compatible field types", - "asil": "ASIL-B", - "category": "capi" - }, - { - "id": "REQ-CAPI-003", - "title": "Command/CCommand conversion", - "description": "From<&Command> for CCommand and From<&CCommand> for Command are inverses", - "asil": "ASIL-B", - "category": "capi" - }, - { - "id": "REQ-CAPI-004", - "title": "CError maps RcpError", - "description": "From<&RcpError> for CError maps all RcpError variants to CError codes", - "asil": "ASIL-B", - "category": "capi" - }, - { - "id": "REQ-ADAPT-001", - "title": "Adapter trait", - "description": "Adapter defines to_command and to_message", - "asil": "ASIL-B", - "category": "adapt" - }, - { - "id": "REQ-ADAPT-002", - "title": "AdaptController wraps inner", - "description": "AdaptController delegates sends via Adapter conversion", - "asil": "ASIL-B", - "category": "adapt" - }, - { - "id": "REQ-ADAPT-003", - "title": "send_msg uses adapter", - "description": "send_msg() converts M to Command, sends, and converts Response back to M", - "asil": "ASIL-B", - "category": "adapt" - }, - { - "id": "REQ-ADAPT-004", - "title": "PassthroughAdapter identity", - "description": "PassthroughAdapter for Command implements identity conversion", - "asil": "ASIL-B", - "category": "adapt" - }, - { - "id": "REQ-ADAPT-005", - "title": "Payload preserved", - "description": "PassthroughAdapter preserves payload through round-trip", - "asil": "ASIL-B", - "category": "adapt" - }, - { - "id": "REQ-CLI-001", - "title": "send command", - "description": "rcp send dispatches a command to a zone controller", - "asil": "QM", - "category": "cli" - }, - { - "id": "REQ-CLI-002", - "title": "send options", - "description": "rcp send accepts --zone, --type, --priority, --payload flags", - "asil": "QM", - "category": "cli" - }, - { - "id": "REQ-CLI-003", - "title": "version command", - "description": "rcp version prints crate version and RELAY spec version", - "asil": "QM", - "category": "cli" - }, - { - "id": "REQ-CLI-004", - "title": "zones command", - "description": "rcp zones lists all standard zone IDs and names", - "asil": "QM", - "category": "cli" - }, - { - "id": "REQ-CLI-005", - "title": "status command", - "description": "rcp status subscribes to a zone and prints the first Status", - "asil": "QM", - "category": "cli" - }, - { - "id": "REQ-CLI-006", - "title": "version --format json", - "description": "The CLI must emit a valid \u00a712.1 JSON document when invoked with version --format json containing tool, version, spec_version, language, runtime fields", - "asil": "ASIL-B", - "category": "cli", - "source": "relay-spec-1.10" - }, - { - "id": "REQ-CLI-007", - "title": "capabilities subcommand", - "description": "The CLI must emit a valid \u00a712.2 capabilities JSON document when invoked with capabilities", - "asil": "ASIL-B", - "category": "cli", - "source": "relay-spec-1.10" - }, - { - "id": "REQ-CLI-008", - "title": "status --format json", - "description": "The CLI must emit a valid \u00a712.3 status JSON document when invoked with status --format json (no --zone) containing protocol, tool, version, healthy, connected, endpoint, details fields", - "asil": "ASIL-B", - "category": "cli", - "source": "relay-spec-1.10" - }, - { - "id": "REQ-CLI-009", - "title": "convert --protocol RCP", - "description": "The CLI must accept a relay.Status JSON on stdin and emit a relay.Message JSON on stdout when invoked with convert --protocol RCP; exit 1 on invalid input, exit 2 on wrong/missing --protocol", - "asil": "ASIL-B", - "category": "cli", - "source": "relay-spec-1.10" - } -] \ No newline at end of file +{ + "schemaVersion": "1.0", + "requirements": [ + { + "id": "REQ-SPEC-001", + "title": "RELAY spec version", + "text": "The crate must implement RELAY specification version 1.11 and export it as both SPEC_VERSION and RELAY_SPEC_VERSION from the crate root", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZONE-001", + "title": "Zone string representation", + "text": "Each zone must have a unique non-empty PascalCase string name", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZONE-002", + "title": "Zone inner value", + "text": "Zone is a newtype over u8; inner value is stable across versions", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZONE-003", + "title": "FRONT_LEFT is 1", + "text": "Zone::FRONT_LEFT.0 == 1", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZONE-004", + "title": "FRONT_RIGHT is 2", + "text": "Zone::FRONT_RIGHT.0 == 2", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZONE-005", + "title": "REAR_LEFT is 3", + "text": "Zone::REAR_LEFT.0 == 3", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZONE-006", + "title": "REAR_RIGHT is 4", + "text": "Zone::REAR_RIGHT.0 == 4", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZONE-007", + "title": "CENTRAL is 5", + "text": "Zone::CENTRAL.0 == 5", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZONE-008", + "title": "Zone constants are distinct", + "text": "All zone constants must have distinct inner values", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PRI-001", + "title": "NORMAL priority", + "text": "Priority::NORMAL.0 == 0", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PRI-002", + "title": "HIGH > NORMAL", + "text": "Priority::HIGH > Priority::NORMAL", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PRI-003", + "title": "CRITICAL > HIGH", + "text": "Priority::CRITICAL > Priority::HIGH", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CMD-001", + "title": "NOOP is 0", + "text": "CommandType::NOOP.0 == 0", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CMD-002", + "title": "SET is 1", + "text": "CommandType::SET.0 == 1", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CMD-003", + "title": "GET is 2", + "text": "CommandType::GET.0 == 2", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CMD-004", + "title": "RESET is 3", + "text": "CommandType::RESET.0 == 3", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CMD-005", + "title": "WATCHDOG is 4", + "text": "CommandType::WATCHDOG.0 == 4", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CMD-006", + "title": "Command types distinct", + "text": "All command type constants must have distinct inner values", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STATUS-001", + "title": "ResponseStatus strings unique", + "text": "All response status values must have unique non-empty string representations", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STATUS-002", + "title": "OK is 0", + "text": "ResponseStatus::OK.0 == 0", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STATUS-003", + "title": "ERROR is 1", + "text": "ResponseStatus::ERROR.0 == 1", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STATUS-004", + "title": "TIMEOUT is 2", + "text": "ResponseStatus::TIMEOUT.0 == 2", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STATUS-005", + "title": "BUSY is 3", + "text": "ResponseStatus::BUSY.0 == 3", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STATUS-006", + "title": "Status constants distinct", + "text": "All response status constants must have distinct inner values", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CMDSTRUCT-001", + "title": "Zero Command is safe NOOP", + "text": "Command::default() must have zone=UNKNOWN, cmd_type=NOOP, priority=NORMAL, payload=None", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CMDSTRUCT-002", + "title": "Command payload optional", + "text": "Command.payload may be None", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RESP-001", + "title": "Response carries command ID", + "text": "Response.command_id echoes the dispatched command ID", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RESP-002", + "title": "Response carries zone", + "text": "Response.zone identifies the responding zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RESP-003", + "title": "Zero Response has status OK", + "text": "Response::default().status == ResponseStatus::OK", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STAT-001", + "title": "Status carries zone", + "text": "Status.zone identifies the publishing zone controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STAT-002", + "title": "Status carries seq", + "text": "Status.seq is a monotonically increasing sequence number", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STAT-003", + "title": "Status healthy flag", + "text": "Status.healthy reports whether the controller is operational", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STAT-004", + "title": "Status seq per controller", + "text": "Each zone controller maintains its own seq counter", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-STAT-005", + "title": "Status payload optional", + "text": "Status.payload may be None", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-001", + "title": "Closed error distinct", + "text": "RcpError::Closed must be a distinct variant", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-002", + "title": "NotFound error distinct", + "text": "RcpError::NotFound must be a distinct variant", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-003", + "title": "AlreadyExists error distinct", + "text": "RcpError::AlreadyExists must be a distinct variant", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-004", + "title": "Timeout error distinct", + "text": "RcpError::Timeout must be a distinct variant", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-005", + "title": "Busy error distinct", + "text": "RcpError::Busy must be a distinct variant", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-006", + "title": "Sentinel errors are mutually distinct", + "text": "All RELAY sentinel error variants must be pairwise distinct", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-007", + "title": "Closed is relay-closed", + "text": "RcpError::Closed.is_relay_closed() == true", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-008", + "title": "NotConnected is relay-not-connected", + "text": "RcpError::NotConnected.is_relay_not_connected() == true", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-009", + "title": "AlreadyExists is standalone", + "text": "RcpError::AlreadyExists.is_already_exists() == true and is not relay-closed/timeout/not-connected", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-010", + "title": "Timeout is relay-timeout", + "text": "RcpError::Timeout.is_relay_timeout() == true", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-011", + "title": "ZoneMismatch is distinct", + "text": "RcpError::ZoneMismatch.is_zone_mismatch() == true and is also is_relay_not_connected()", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-012", + "title": "NotConnected test", + "text": "Direct test of NotConnected is_relay_not_connected", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-013", + "title": "PayloadTooLarge test", + "text": "Direct test of PayloadTooLarge is_relay_payload_too_large", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-014", + "title": "Only Closed is relay-closed", + "text": "is_relay_closed() returns false for non-Closed variants", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-015", + "title": "is_relay_not_connected coverage", + "text": "is_relay_not_connected returns false for Closed and Timeout", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-016", + "title": "Timeout is relay-timeout", + "text": "RcpError::Timeout.is_relay_timeout() == true", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-017", + "title": "PayloadTooLarge not relay-closed", + "text": "PayloadTooLarge.is_relay_closed() == false", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-018", + "title": "ZoneMismatch is relay-not-connected", + "text": "ZoneMismatch wraps NotConnected per RELAY spec", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-019", + "title": "AlreadyExists is not relay-timeout", + "text": "AlreadyExists.is_relay_timeout() == false", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-020", + "title": "Busy is relay-timeout", + "text": "Busy.is_relay_timeout() == true; Busy wraps Timeout", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ERR-021", + "title": "ZoneMismatch is relay-not-connected (named)", + "text": "ZoneMismatch.is_relay_not_connected() == true", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MSG-001", + "title": "zone_from_str PascalCase and kebab-case", + "text": "zone_from_str accepts both PascalCase and kebab-case zone names", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MSG-002", + "title": "zone_from_str unknown returns NotFound", + "text": "zone_from_str returns Err(RcpError::NotFound) for unknown strings", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PWR-001", + "title": "SLEEP and WAKE command types distinct", + "text": "CommandType::SLEEP != CommandType::WAKE; both differ from other command types", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PWR-002", + "title": "PowerState variants distinct", + "text": "PowerState::Active, Sleep, Standby are pairwise distinct", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PWR-003", + "title": "Initial state is Active", + "text": "PowerStateController starts in Active state", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PWR-004", + "title": "power_state query", + "text": "PowerStateController.power_state() returns current state", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PWR-005", + "title": "SLEEP transitions to Sleep", + "text": "Sending SLEEP command transitions to PowerState::Sleep", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PWR-006", + "title": "Commands during Sleep return BUSY", + "text": "Non-WAKE commands while in Sleep state return ResponseStatus::BUSY", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PWR-007", + "title": "WAKE transitions to Active", + "text": "Sending WAKE command from Sleep transitions to PowerState::Active", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PWR-008", + "title": "Close returns Closed", + "text": "PowerStateController.send() after close() returns Err(Closed)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-001", + "title": "Controller is a trait", + "text": "Controller is a trait with zone/send/subscribe/close methods", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-002", + "title": "send invokes handler", + "text": "MockController.send() invokes the registered handler and returns its result", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-003", + "title": "send returns Result", + "text": "Controller.send() returns Result", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-004", + "title": "subscribe returns Subscription", + "text": "Controller.subscribe() returns Result", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-005", + "title": "close safe to call multiple times", + "text": "Controller.close() must be idempotent", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-006", + "title": "send after close returns Closed", + "text": "Controller.send() after close() returns Err(RcpError::Closed)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-007", + "title": "zero timeout returns Timeout", + "text": "send() with timeout=Some(Duration::ZERO) returns Err(RcpError::Timeout)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-008", + "title": "zone mismatch returns ZoneMismatch", + "text": "send() with cmd.zone != ctrl.zone() returns Err(RcpError::ZoneMismatch)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-009", + "title": "zone getter stable", + "text": "Controller.zone() returns the same value across calls", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-010", + "title": "subscribe seq strictly increasing", + "text": "Each published Status carries a seq number strictly greater than the previous", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-011", + "title": "subscribe on closed returns Closed", + "text": "subscribe() after close() returns Err(Closed)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-012", + "title": "multiple concurrent subscribers", + "text": "Multiple concurrent subscribers each receive their own copy of published Status", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-013", + "title": "NOOP command accepted", + "text": "Controller accepts NOOP command type without error", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-014", + "title": "WATCHDOG command accepted", + "text": "Controller accepts WATCHDOG command type without error", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-015", + "title": "RESET command accepted", + "text": "Controller accepts RESET command type without error", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-016", + "title": "handler response returned verbatim", + "text": "The Response returned by the handler is returned unchanged from send()", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-017", + "title": "publish on closed controller is safe", + "text": "Calling publish() on a closed controller must not panic", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-018", + "title": "concurrent sends are race-free", + "text": "Multiple concurrent send() calls from different threads are safe", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-019", + "title": "concurrent publish and subscribe are race-free", + "text": "Simultaneous publish() and subscribe() from different threads are safe", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-020", + "title": "status carries correct zone", + "text": "Published Status.zone matches the controller's zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-021", + "title": "status carries correct payload", + "text": "Published Status.payload matches the payload passed to publish()", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-022", + "title": "status healthy while open", + "text": "Status.healthy == true while the controller is open", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-023", + "title": "zero timeout is pre-cancelled", + "text": "Timeout=Some(ZERO) is treated as pre-cancelled context (no handler invoked)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-024", + "title": "nil payload does not panic", + "text": "send() with payload=None must not panic", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-025", + "title": "Controller is Send+Sync", + "text": "Controller implementations must be Send + Sync", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-026", + "title": "Payload copied before handler", + "text": "MockController copies payload before passing to handler", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CTRL-027", + "title": "Publish copies payload", + "text": "MockController.publish() copies the payload for each subscriber", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-001", + "title": "Registry is a trait", + "text": "Registry is a trait with register/deregister/lookup/controllers/close", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-002", + "title": "register returns AlreadyExists on duplicate", + "text": "Registering the same zone twice returns Err(AlreadyExists)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-003", + "title": "deregister returns NotFound for absent zone", + "text": "Deregistering an unregistered zone returns Err(NotFound)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-004", + "title": "lookup returns NotFound for absent zone", + "text": "Looking up an unregistered zone returns Err(NotFound)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-005", + "title": "lookup returns Closed when registry is closed", + "text": "lookup() after registry.close() returns Err(Closed)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-006", + "title": "controllers returns all registered", + "text": "controllers() returns one entry per registered zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-007", + "title": "close closes all controllers", + "text": "registry.close() propagates close to all registered controllers", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-008", + "title": "Registry is Send+Sync", + "text": "Registry implementations must be Send + Sync", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-009", + "title": "deregister closes the controller", + "text": "deregister() calls close() on the removed controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-010", + "title": "MockRegistry pre-populates 5 zones", + "text": "MockRegistry.new() registers controllers for all 5 standard zones", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-011", + "title": "register after close returns Closed", + "text": "registry.register() after registry.close() returns Err(Closed)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-012", + "title": "deregister after close returns Closed", + "text": "registry.deregister() after registry.close() returns Err(Closed)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REG-013", + "title": "close is idempotent", + "text": "Calling registry.close() multiple times is safe", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WIRE-001", + "title": "Magic bytes", + "text": "Wire frames start with 0x52 0x43", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WIRE-002", + "title": "Protocol version byte", + "text": "Wire frames include PROTO_VER=0x01 at byte offset 2", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WIRE-003", + "title": "Message type byte", + "text": "Wire frame includes message type byte (Command=0x01, Response=0x02, Status=0x03)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WIRE-004", + "title": "Header length", + "text": "Wire header is exactly 16 bytes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WIRE-005", + "title": "Command encode/decode round-trip", + "text": "encode_command(cmd) followed by decode_command() recovers the original command", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WIRE-006", + "title": "Response encode/decode round-trip", + "text": "encode_response(resp) followed by decode_response() recovers the original response", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WIRE-007", + "title": "Payload size limit", + "text": "Payloads exceeding MAX_PAYLOAD=65491 bytes are rejected with PayloadTooLarge", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WIRE-008", + "title": "Bad magic rejected", + "text": "Frames with wrong magic bytes return Err(BadMagic)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WIRE-009", + "title": "Bad version rejected", + "text": "Frames with wrong protocol version return Err(BadVersion)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-E2E-001", + "title": "E2E wrap prepends seqNum", + "text": "wrap() prepends a 4-byte big-endian sequence number", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-E2E-002", + "title": "CRC covers seqNum and payload", + "text": "CRC-16/CCITT-FALSE is computed over [seqNum bytes ++ payload]", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-E2E-003", + "title": "CRC mismatch rejected", + "text": "unwrap() returns Err(CrcMismatch) when CRC does not match", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-E2E-004", + "title": "E2eController auto-increments seq", + "text": "E2eController atomically increments sequence number per send", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-E2E-005", + "title": "ReplayGuard rejects replayed seq", + "text": "ReplayGuard.check() returns Err(Replay) for previously seen seq numbers", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-E2E-006", + "title": "E2E header is 6 bytes", + "text": "E2E header length is 4 (seqNum) + 2 (CRC) = 6 bytes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-E2E-007", + "title": "wrap/unwrap are inverse", + "text": "unwrap(wrap(seq, payload)) == Ok((seq, payload))", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-E2E-008", + "title": "Single-bit corruption detected", + "text": "Flipping any bit in payload or seqNum causes CRC mismatch", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PQ-001", + "title": "PrioController wraps inner", + "text": "PrioController delegates commands to an inner Controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PQ-002", + "title": "Dispatch thread spawned on new", + "text": "PrioController.new() spawns a dispatch thread immediately", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PQ-003", + "title": "Zone matches inner", + "text": "PrioController.zone() returns the inner controller's zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PQ-004", + "title": "Critical dispatched before High/Normal", + "text": "Critical-priority commands are dequeued before High and Normal", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PQ-005", + "title": "FIFO within priority level", + "text": "Commands at the same priority level are dispatched in submission order", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PQ-006", + "title": "Payload forwarded unchanged", + "text": "PrioController forwards payloads to inner without modification", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PQ-007", + "title": "All command types routed", + "text": "PrioController handles all CommandType variants", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PQ-008", + "title": "Close drains queue with Closed error", + "text": "close() returns Err(Closed) to all pending callers", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RL-001", + "title": "Token bucket burst capacity", + "text": "Commands up to burst capacity succeed without delay", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RL-002", + "title": "Default config values", + "text": "DefaultConfig() returns rate=100, burst=20, exempt_critical=true", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RL-003", + "title": "Zone matches inner", + "text": "RateLimitController.zone() returns inner controller's zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RL-004", + "title": "Tokens replenish over time", + "text": "Token bucket refills at rate tokens/second", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RL-005", + "title": "Normal and High obey bucket", + "text": "NORMAL and HIGH priority commands consume tokens from the bucket", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RL-006", + "title": "Empty bucket returns Busy", + "text": "When bucket is empty, send() returns Err(RcpError::Busy) immediately", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RL-007", + "title": "Critical exempt from rate limit", + "text": "When exempt_critical=true, CRITICAL commands bypass the token bucket", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RL-008", + "title": "Busy is relay-timeout sentinel", + "text": "RcpError::Busy.is_relay_timeout() == true (Busy wraps Timeout)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SIM-001", + "title": "SimController accepts commands", + "text": "SimController processes commands and returns responses", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SIM-002", + "title": "Queued responses delivered in order", + "text": "queue_response() pre-programs responses consumed FIFO", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SIM-003", + "title": "Commands recorded", + "text": "SimController records all dispatched commands for inspection", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SIM-004", + "title": "Zero timeout returns Timeout", + "text": "send() with timeout=Some(ZERO) returns Err(Timeout)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SIM-005", + "title": "Zone mismatch returns ZoneMismatch", + "text": "send() with wrong zone returns Err(ZoneMismatch)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SIM-006", + "title": "subscribe receives publish", + "text": "Subscription receives Status updates from publish()", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SIM-007", + "title": "Publish increments seq", + "text": "Each publish() call increments the seq counter", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SIM-008", + "title": "Close returns Closed", + "text": "send() after close() returns Err(Closed)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WDG-001", + "title": "Watchdog default config", + "text": "WatchdogConfig::default() returns interval=1s, miss_window=3, close_on_miss=false", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WDG-002", + "title": "Closed controller accumulates misses", + "text": "WatchdogMonitor accumulates miss_count when controller is unresponsive", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WDG-003", + "title": "Background thread starts on new", + "text": "WatchdogMonitor::start() spawns a background thread immediately", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WDG-004", + "title": "Healthy when responsive", + "text": "is_healthy() returns true when controller responds to WATCHDOG commands", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WDG-005", + "title": "Miss count is zero on responsive controller", + "text": "miss_count() returns 0 when controller is responsive", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WDG-006", + "title": "stop terminates monitor", + "text": "stop() terminates the watchdog thread without deadlock", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WDG-007", + "title": "Zone getter", + "text": "WatchdogMonitor.zone() returns the monitored zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-WDG-008", + "title": "Unhealthy after miss window exceeded", + "text": "is_healthy() returns false after miss_window consecutive misses", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DL-001", + "title": "DeadlineController enforces latency", + "text": "DeadlineController sends with a maximum response deadline", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DL-002", + "title": "Deadline getter", + "text": "DeadlineController.deadline() returns configured deadline", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DL-003", + "title": "Commands forwarded to inner", + "text": "DeadlineController forwards commands to inner controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DL-004", + "title": "Zero timeout returns Timeout", + "text": "send() with timeout=Some(ZERO) returns Err(Timeout)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DL-005", + "title": "Shorter of deadline and caller timeout wins", + "text": "Effective timeout = min(caller_timeout, deadline)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DL-006", + "title": "Close forwarded", + "text": "DeadlineController.close() is forwarded to inner", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LOAN-001", + "title": "LoanPool pre-allocates buffers", + "text": "LoanPool.new(count, size) pre-allocates count buffers of size bytes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LOAN-002", + "title": "Correct pool count", + "text": "LoanPool.available() returns count immediately after creation", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LOAN-003", + "title": "Buffer returned on drop", + "text": "Dropping a Loan returns the buffer to the pool", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LOAN-004", + "title": "try_acquire returns None when empty", + "text": "try_acquire() returns None when no buffers are available", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LOAN-005", + "title": "Zone matches inner", + "text": "LoanPoolController.zone() returns inner controller's zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LOAN-006", + "title": "Oversized loan request rejected", + "text": "loan(size > buffer_size) returns Err(PayloadTooLarge)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LOAN-007", + "title": "send_loaned forwards payload", + "text": "send_loaned() copies loan payload into command and forwards to inner", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZG-001", + "title": "ZoneGroup wraps multiple controllers", + "text": "ZoneGroup holds a Vec of zone controllers", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZG-002", + "title": "Zone is logical zone ID", + "text": "ZoneGroup.zone() returns the logical group zone, not a member zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZG-003", + "title": "Send broadcasts to all members", + "text": "send() dispatches the command to every member controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZG-004", + "title": "First member error propagated", + "text": "If any member returns an error, send() returns that error", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZG-005", + "title": "Subscribe via first member", + "text": "subscribe() delegates to the first member controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZG-006", + "title": "Close closes all members", + "text": "close() closes all member controllers", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ZG-007", + "title": "Empty group send returns default OK", + "text": "send() on an empty group returns Ok(Response::default())", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PROXY-001", + "title": "ProxyController wraps inner", + "text": "ProxyController forwards all calls to an interchangeable inner controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PROXY-002", + "title": "Zone set from original inner", + "text": "ProxyController.zone() is set from the original inner controller at construction", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PROXY-003", + "title": "send forwarded", + "text": "ProxyController.send() forwards to the current inner controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PROXY-004", + "title": "subscribe forwarded", + "text": "ProxyController.subscribe() forwards to the current inner controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PROXY-005", + "title": "swap replaces inner atomically", + "text": "swap() atomically replaces the inner controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-PROXY-006", + "title": "detach returns NotConnected", + "text": "After detach(), send() and subscribe() return Err(NotConnected)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RED-001", + "title": "RedundancyController provides hot standby", + "text": "RedundancyController implements 1-of-2 hot standby", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RED-002", + "title": "Zone matches primary", + "text": "RedundancyController.zone() matches the primary controller's zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RED-003", + "title": "Primary success no failover", + "text": "When primary succeeds, no failover is triggered", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RED-004", + "title": "Primary failure triggers failover", + "text": "On primary error, secondary is promoted and command is retried", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RED-005", + "title": "Both failed returns error", + "text": "When both primary and secondary fail, the error is returned to caller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RED-006", + "title": "Failover count increments", + "text": "failover_count() increments each time a failover occurs", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RED-007", + "title": "No secondary after failover", + "text": "After one failover, has_secondary() returns false", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RED-008", + "title": "subscribe forwarded to primary", + "text": "subscribe() delegates to the current primary", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FI-001", + "title": "FaultSpec defines rule and error", + "text": "FaultSpec holds a FaultRule and the error to inject", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FI-002", + "title": "FaultRule variants", + "text": "FaultRule supports Always, OnNthCall, and AfterNthCall", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FI-003", + "title": "No fault passes through", + "text": "Without fault rules, commands are forwarded to inner unchanged", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FI-004", + "title": "Multiple rules first match wins", + "text": "When multiple rules match, the first one installed takes effect", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FI-005", + "title": "Clear removes all rules", + "text": "clear() removes all installed fault rules", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FI-006", + "title": "Fault rule checked on each send", + "text": "Each send() call evaluates all installed fault rules in order", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FI-007", + "title": "Close forwarded", + "text": "FaultInjectController.close() is forwarded to inner", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-OBS-001", + "title": "Metrics struct", + "text": "ObserveController maintains Metrics with call count, error count, latency", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-OBS-002", + "title": "ObserveController wraps inner", + "text": "ObserveController wraps an inner controller", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-OBS-003", + "title": "Metrics updated on each send", + "text": "call count and error count are updated on every send()", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-OBS-004", + "title": "Hook registered via add_hook", + "text": "add_hook() registers a callback invoked after each send()", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-OBS-005", + "title": "Hook receives command, result, and latency", + "text": "Hook function receives (&Command, &Result, Duration)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-OBS-006", + "title": "Close forwarded", + "text": "ObserveController.close() forwards to inner", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TSN-001", + "title": "TrafficClass type", + "text": "TrafficClass is a newtype over u8 with BEST_EFFORT(0), CONTROL(5), CRITICAL(7)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TSN-002", + "title": "Priority to traffic class mapping", + "text": "TrafficClass::from_priority() maps NORMAL\u2192BEST_EFFORT, HIGH\u2192CONTROL, CRITICAL\u2192CRITICAL", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TSN-003", + "title": "Zone forwarded", + "text": "TsnController.zone() returns the inner controller's zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TSN-004", + "title": "TSN byte prepended to payload", + "text": "TsnController prepends traffic class byte to command payload", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TSN-005", + "title": "Close forwarded", + "text": "TsnController.close() forwards to inner", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-AUTHZ-001", + "title": "Policy defines allowed cmd types", + "text": "Policy.allowed_cmd_types controls which command types are permitted", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-AUTHZ-002", + "title": "allow_all permits any command", + "text": "Policy::allow_all() permits all command types", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-AUTHZ-003", + "title": "deny_all blocks every command", + "text": "Policy::deny_all() blocks all command types", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-AUTHZ-004", + "title": "AuthzController wraps inner", + "text": "AuthzController wraps an inner controller with a policy", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-AUTHZ-005", + "title": "Policy enforced on send", + "text": "send() returns Err(NotFound) when command type is not in allowlist", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-AUTHZ-006", + "title": "Policy hot-swappable", + "text": "set_policy() atomically replaces the active policy", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-AUTHZ-007", + "title": "Close forwarded", + "text": "AuthzController.close() forwards to inner", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FW-001", + "title": "MAX_CHUNK constant", + "text": "MAX_CHUNK == 512 bytes per firmware chunk", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FW-002", + "title": "Empty image rejected", + "text": "flash() with empty image returns Err(InvalidSize)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FW-003", + "title": "Single chunk flash", + "text": "flash() with image <= chunk_size sends exactly one chunk", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FW-004", + "title": "Multiple chunks", + "text": "flash() sends ceil(image_len/chunk_size) SET commands", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FW-005", + "title": "Chunk error aborts", + "text": "Non-OK response from any chunk aborts flash and returns error", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FW-006", + "title": "Chunk size capped at MAX_CHUNK", + "text": "Chunk sizes larger than MAX_CHUNK are silently capped", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REC-001", + "title": "Entry carries timestamp", + "text": "Record Entry includes SystemTime timestamp", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REC-002", + "title": "RecordController wraps inner", + "text": "RecordController wraps an inner controller and logs interactions", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REC-003", + "title": "Entries in chronological order", + "text": "entries() returns interactions in submission order", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REC-004", + "title": "Clear empties log", + "text": "clear() removes all recorded entries", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REC-005", + "title": "Errors recorded", + "text": "RecordController records both successful and error results", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FED-001", + "title": "FederationRouter routes by vehicle ID", + "text": "FederationRouter maps vehicle IDs to remote registries", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FED-002", + "title": "add_peer registers a remote registry", + "text": "add_peer() registers a registry under a vehicle ID string", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FED-003", + "title": "remove_peer deregisters", + "text": "remove_peer() removes a registered vehicle registry", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FED-004", + "title": "peer_ids lists all", + "text": "peer_ids() returns all registered vehicle ID strings", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FED-005", + "title": "lookup_peer resolves zone", + "text": "lookup_peer(vehicle_id, zone) returns the controller from the peer registry", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DYN-001", + "title": "DynStore is a thread-safe KV store", + "text": "DynStore provides a thread-safe key/value store", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DYN-002", + "title": "set inserts or replaces", + "text": "set() inserts a new key or replaces an existing value", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DYN-003", + "title": "get returns value or None", + "text": "get() returns Some(value) for existing keys, None otherwise", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DYN-004", + "title": "delete removes key", + "text": "delete() removes a key; returns true if it existed", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DYN-005", + "title": "keys lists all", + "text": "keys() returns all parameter names currently in the store", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CFG-001", + "title": "RcpConfig default is valid", + "text": "RcpConfig::default() passes validate()", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CFG-002", + "title": "ControllerConfig per-controller", + "text": "ControllerConfig holds zone, timeout_ms, max_payload_bytes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CFG-003", + "title": "WatchdogConfig defaults", + "text": "WatchdogConfig::default() returns interval_ms=1000, window=3", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CFG-004", + "title": "RateLimitConfig defaults", + "text": "RateLimitConfig::default() returns rate=100, burst=20, exempt_critical=true", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CFG-005", + "title": "JSON and YAML parsing", + "text": "from_json() and from_yaml() parse RcpConfig from text", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CFG-006", + "title": "Validation rejects invalid values", + "text": "validate() returns Err for zone > 5, negative rate, zero watchdog window, oversized payload", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CG-001", + "title": "FieldType maps to Rust types", + "text": "FieldType::rust_type() returns correct Rust primitive type names", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CG-002", + "title": "StructSchema defines name and fields", + "text": "StructSchema holds name and Vec with optional flag", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CG-003", + "title": "generate_structs emits Rust code", + "text": "generate_structs() emits valid Rust struct definitions", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CG-004", + "title": "parse_schema converts JSON schema", + "text": "parse_schema() converts a HashMap schema definition into StructSchemas", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CYB-001", + "title": "Feasibility variants", + "text": "Feasibility has Low, Medium, High, Critical variants with Display", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CYB-002", + "title": "Impact variants", + "text": "Impact has Negligible, Moderate, Major, Severe variants with Display", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CYB-003", + "title": "RiskLevel variants", + "text": "RiskLevel has Low, Medium, High, Critical variants", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CYB-004", + "title": "risk_level function", + "text": "risk_level(f, i) returns RiskLevel per ISO 21434 Table 14", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CYB-005", + "title": "Threat.risk_level()", + "text": "Threat.risk_level() computes risk from its feasibility and impact", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CYB-006", + "title": "filter_by_risk", + "text": "filter_by_risk() returns threats at or above the minimum risk level", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-GAP-001", + "title": "GapReport has_gaps flag", + "text": "GapReport.has_gaps() returns true when any gap list is non-empty", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-GAP-002", + "title": "analyse detects gaps", + "text": "analyse() identifies unimplemented, untested, and undeclared requirements", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-GAP-003", + "title": "coverage ratio", + "text": "coverage() returns ratio of covered to declared requirements", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-GAP-004", + "title": "coverage_by_prefix", + "text": "coverage_by_prefix() groups coverage statistics by requirement prefix", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-GAP-005", + "title": "zero coverage baseline", + "text": "coverage() returns 0.0 when no requirements are covered; coverage_by_prefix() returns 0.0 per prefix", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FORMAL-001", + "title": "Invariant type", + "text": "Invariant holds a name and a checkable predicate", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FORMAL-002", + "title": "CheckResult tracks pass/fail", + "text": "CheckResult records passed and failed invariant names", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FORMAL-003", + "title": "check_all evaluates all invariants", + "text": "check_all() checks every invariant against the state", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-FORMAL-004", + "title": "witness finds first violation", + "text": "witness() returns the first state that violates the invariant", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADMIN-001", + "title": "AdminServer health check", + "text": "AdminServer.is_healthy() returns true when all controllers respond", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADMIN-002", + "title": "Request count", + "text": "record_request() and request_count() track admin endpoint hits", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADMIN-003", + "title": "Uptime", + "text": "uptime() returns non-negative duration since construction", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADMIN-004", + "title": "Controller count", + "text": "controller_count() returns number of registered controllers", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADMIN-005", + "title": "Shutdown closes registry", + "text": "shutdown() closes the registry and sets is_shutting_down()", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CANBR-001", + "title": "CAN FD max payload", + "text": "CAN_FD_MAX_PAYLOAD == 64 bytes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CANBR-002", + "title": "CanSocket trait", + "text": "CanSocket defines send_frame and recv_frame", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CANBR-003", + "title": "CanBridge sends CAN frame", + "text": "CanBridge.send() encodes and transmits a CAN frame", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CANBR-004", + "title": "Zone mismatch and payload size checks", + "text": "CanBridge rejects wrong zone and oversized payloads", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CANBR-005", + "title": "Close is no-op", + "text": "CanBridge.close() returns Ok(())", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LINBR-001", + "title": "LIN max data", + "text": "LIN_MAX_DATA == 8 bytes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LINBR-002", + "title": "LinMaster trait", + "text": "LinMaster defines send_frame and recv_frame", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LINBR-003", + "title": "LinBridge wraps master", + "text": "LinBridge delegates to a LinMaster", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-LINBR-004", + "title": "Payload, zone, and timeout checks", + "text": "LinBridge rejects oversized payload, wrong zone, zero timeout", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SOMEIP-001", + "title": "SOME/IP header length", + "text": "SOMEIP_HEADER_LEN == 16 bytes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SOMEIP-002", + "title": "encode_request", + "text": "encode_request() produces a valid SOME/IP header + payload", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SOMEIP-003", + "title": "decode_response", + "text": "decode_response() parses SOME/IP response and maps status codes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SOMEIP-004", + "title": "SomeIpSocket trait", + "text": "SomeIpSocket defines send and recv", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SOMEIP-005", + "title": "SomeIpBridge delegates", + "text": "SomeIpBridge.send() uses encode_request/decode_response cycle", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MQTT-001", + "title": "MqttClient trait", + "text": "MqttClient defines publish, subscribe_topic, recv_message", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MQTT-002", + "title": "MqttBridge wraps client", + "text": "MqttBridge delegates to a MqttClient", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MQTT-003", + "title": "Topic scheme", + "text": "MqttBridge publishes to rcp/{zone}/cmd/{id}", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MQTT-004", + "title": "Zone mismatch rejected", + "text": "MqttBridge rejects commands with wrong zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MQTT-005", + "title": "subscribe returns NotFound", + "text": "MqttBridge.subscribe() returns Err(NotFound)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DDS-001", + "title": "DdsParticipant trait", + "text": "DdsParticipant defines write and take", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DDS-002", + "title": "DdsBridge wraps participant", + "text": "DdsBridge delegates to a DdsParticipant", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DDS-003", + "title": "Commands mapped to DDS topics", + "text": "DdsBridge.send() writes to rcp.zone{N}.cmd and reads from rcp.zone{N}.resp", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DDS-004", + "title": "Close is no-op", + "text": "DdsBridge.close() returns Ok(())", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDP-001", + "title": "UdpSocket trait", + "text": "UdpSocket defines send_to and recv_from", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDP-002", + "title": "UdpBridge wraps socket", + "text": "UdpBridge delegates to a UdpSocket and a remote SocketAddr", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDP-003", + "title": "Commands encoded as wire frames", + "text": "UdpBridge.send() uses wire::encode_command and decode_response", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDP-004", + "title": "Zone mismatch rejected", + "text": "UdpBridge rejects commands with wrong zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDP-005", + "title": "Close is no-op", + "text": "UdpBridge.close() returns Ok(())", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SHM-001", + "title": "ShmChannel trait", + "text": "ShmChannel defines write and read", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SHM-002", + "title": "InProcChannel is FIFO", + "text": "InProcChannel maintains FIFO ordering", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SHM-003", + "title": "ShmBridge wraps tx/rx channels", + "text": "ShmBridge holds a write channel (tx) and read channel (rx)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SHM-004", + "title": "Zone mismatch and timeout checks", + "text": "ShmBridge rejects wrong zone and zero timeout", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-SHM-005", + "title": "Close is no-op", + "text": "ShmBridge.close() returns Ok(())", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MDNS-001", + "title": "ServiceRecord type", + "text": "ServiceRecord holds host, port, zone, and txt fields", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MDNS-002", + "title": "MdnsRegistry in-process impl", + "text": "MdnsRegistry provides announce, withdraw, resolve, names", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MDNS-003", + "title": "announce registers service", + "text": "announce() registers a ServiceRecord under a name", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-MDNS-004", + "title": "resolve returns record", + "text": "resolve() returns Some(record) for announced names, None otherwise", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TLS-001", + "title": "MIN_TLS_VERSION constant", + "text": "MIN_TLS_VERSION == \"TLSv1.2\"", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TLS-002", + "title": "Mutual auth required", + "text": "REQUIRE_MUTUAL_AUTH == true; unverified peer returns Err(NotConnected)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TLS-003", + "title": "TlsStream trait", + "text": "TlsStream defines write_all, read_to_vec, peer_verified", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TLS-004", + "title": "TlsBridge uses wire encoding", + "text": "TlsBridge.send() uses wire::encode_command and decode_response", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-TLS-005", + "title": "Close is no-op", + "text": "TlsBridge.close() returns Ok(())", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-GRPC-001", + "title": "GrpcRequest/Response types", + "text": "GrpcRequest and GrpcResponse are Vec newtypes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-GRPC-002", + "title": "Encode/decode helpers", + "text": "encode_grpc_request and decode_grpc_response marshal RCP types", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-GRPC-003", + "title": "GrpcStub trait", + "text": "GrpcStub defines unary_call", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-GRPC-004", + "title": "GrpcBridge delegates", + "text": "GrpcBridge.send() uses encode/decode cycle via GrpcStub", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REST-001", + "title": "HttpClient trait", + "text": "HttpClient defines post", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REST-002", + "title": "RestBridge wraps client", + "text": "RestBridge delegates to an HttpClient", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REST-003", + "title": "HTTP status codes mapped", + "text": "200\u2192OK, 408/504\u2192TIMEOUT, 429\u2192BUSY, other\u2192ERROR", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-REST-004", + "title": "Close is no-op", + "text": "RestBridge.close() returns Ok(())", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDS-001", + "title": "UDS service IDs", + "text": "READ_DATA=0x22, WRITE_DATA=0x2E, ECU_RESET=0x11", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDS-002", + "title": "cmd_to_uds_sid mapping", + "text": "GET\u2192READ_DATA, SET\u2192WRITE_DATA, RESET\u2192ECU_RESET", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDS-003", + "title": "UdsTransport trait", + "text": "UdsTransport defines request()", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDS-004", + "title": "UdsBridge delegates", + "text": "UdsBridge.send() maps to UDS service and checks positive response SID", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-UDS-005", + "title": "Close is no-op", + "text": "UdsBridge.close() returns Ok(())", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DOIP-001", + "title": "DoIP protocol version", + "text": "DOIP_PROTO_VER == 0x02; DOIP_HEADER_LEN == 8", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DOIP-002", + "title": "DoipSocket trait and frame encoding", + "text": "DoipSocket defines send/recv; encode_doip_frame produces 8-byte header", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DOIP-003", + "title": "DoipBridge delegates", + "text": "DoipBridge.send() encodes via DoIP and checks response", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-DOIP-004", + "title": "Close is no-op", + "text": "DoipBridge.close() returns Ok(())", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CAPI-001", + "title": "CCommand is repr(C)", + "text": "CCommand uses #[repr(C)] with C-compatible field types", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CAPI-002", + "title": "CResponse is repr(C)", + "text": "CResponse uses #[repr(C)] with C-compatible field types", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CAPI-003", + "title": "Command/CCommand conversion", + "text": "From<&Command> for CCommand and From<&CCommand> for Command are inverses", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CAPI-004", + "title": "CError maps RcpError", + "text": "From<&RcpError> for CError maps all RcpError variants to CError codes", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-001", + "title": "Adapter trait", + "text": "Adapter defines to_command and to_message", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-002", + "title": "AdaptController wraps inner", + "text": "AdaptController delegates sends via Adapter conversion", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-003", + "title": "send_msg uses adapter", + "text": "send_msg() converts M to Command, sends, and converts Response back to M", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-004", + "title": "PassthroughAdapter identity", + "text": "PassthroughAdapter for Command implements identity conversion", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-005", + "title": "Payload preserved", + "text": "PassthroughAdapter preserves payload through round-trip", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CLI-001", + "title": "send command", + "text": "rcp send dispatches a command to a zone controller", + "standard": "iso26262", + "level": "HLR", + "asil": "QM", + "verificationMethod": "test" + }, + { + "id": "REQ-CLI-002", + "title": "send options", + "text": "rcp send accepts --zone, --type, --priority, --payload flags", + "standard": "iso26262", + "level": "HLR", + "asil": "QM", + "verificationMethod": "test" + }, + { + "id": "REQ-CLI-003", + "title": "version command", + "text": "rcp version prints crate version and RELAY spec version", + "standard": "iso26262", + "level": "HLR", + "asil": "QM", + "verificationMethod": "test" + }, + { + "id": "REQ-CLI-004", + "title": "zones command", + "text": "rcp zones lists all standard zone IDs and names", + "standard": "iso26262", + "level": "HLR", + "asil": "QM", + "verificationMethod": "test" + }, + { + "id": "REQ-CLI-005", + "title": "status command", + "text": "rcp status subscribes to a zone and prints the first Status", + "standard": "iso26262", + "level": "HLR", + "asil": "QM", + "verificationMethod": "test" + }, + { + "id": "REQ-CLI-006", + "title": "version --format json", + "text": "The CLI must emit a valid \u00a712.1 JSON document when invoked with version --format json containing tool, version, spec_version, language, runtime fields", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CLI-007", + "title": "capabilities subcommand", + "text": "The CLI must emit a valid \u00a712.2 capabilities JSON document when invoked with capabilities", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CLI-008", + "title": "status --format json", + "text": "The CLI must emit a valid \u00a712.3 status JSON document when invoked with status --format json (no --zone) containing protocol, tool, version, healthy, connected, endpoint, details fields", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-CLI-009", + "title": "convert --protocol RCP", + "text": "The CLI must accept a relay.Status JSON on stdin and emit a relay.Message JSON on stdout when invoked with convert --protocol RCP; exit 1 on invalid input, exit 2 on wrong/missing --protocol", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-001", + "title": "Protocol enum", + "text": "The relay module shall define a Protocol enum matching RELAY spec \u00a73 identifiers, serializing as its integer value", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-002", + "title": "Message envelope", + "text": "The relay module shall define a Message type matching the RELAY spec \u00a74 universal envelope, with base64-encoded payload", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-003", + "title": "BackPressurePolicy", + "text": "The relay module shall define a BackPressurePolicy enum (DropNewest/DropOldest/Block) per RELAY spec \u00a714, defaulting to DropNewest", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-004", + "title": "SubscriberOptions", + "text": "The relay module shall define a SubscriberOptions type per RELAY spec \u00a718.3 with a channel_depth default of 64", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-005", + "title": "relay::Error sentinels", + "text": "The relay module shall define the four mandatory RELAY error sentinels (Closed, NotConnected, Timeout, PayloadTooLarge) per \u00a75.1, mutually distinct", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-006", + "title": "Context", + "text": "The relay module shall define a Context type carrying an optional deadline per RELAY spec \u00a718.3", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-007", + "title": "relay::Node trait", + "text": "The relay module shall define an async Node trait (protocol/send/subscribe/close) per RELAY spec \u00a710.1", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-008", + "title": "relay::Caller trait", + "text": "The relay module shall define an async Caller trait extending Node with call() per RELAY spec \u00a710.2", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-009", + "title": "Base64 payload serde", + "text": "Message and Command/Response/Status payload fields shall serialize as base64 strings per RELAY spec \u00a715.1, matching Go's []byte JSON marshalling", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-010", + "title": "Numeric enum serde", + "text": "Zone, Priority, CommandType, and ResponseStatus shall serialize/deserialize as bare JSON integers per RELAY spec \u00a715.5, not as single-element arrays", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-RELAY-011", + "title": "Canonical type serde field names", + "text": "Command, Response, and Status shall derive Serialize/Deserialize with the exact \u00a715.5 JSON field names (including Command's \"type\" rename)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-006", + "title": "Status.ToMessage()", + "text": "to_message() shall convert a Status to a relay::Message per RELAY spec \u00a715.7.5 (zone name as ID, seq, healthy meta key, payload)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-007", + "title": "Message.FromMessage() to Command", + "text": "from_message() shall convert a relay::Message to a Command per RELAY spec \u00a715.7.5 (zone from ID, priority/cmd_type from meta keys), returning NotFound for an unknown zone", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-008", + "title": "Response.ToMessage()", + "text": "response_to_message() shall convert a Response to a relay::Message per RELAY spec \u00a715.7.5 (zone name as ID, status meta key, payload)", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-009", + "title": "Adapt()", + "text": "adapt() shall wrap a Controller as a relay::Node/Caller per RELAY spec \u00a710.3, dispatching blocking Controller calls via spawn_blocking and applying the SubscriberOptions back-pressure policy per \u00a710.5", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + }, + { + "id": "REQ-ADAPT-010", + "title": "Caller.Call()", + "text": "The Adapt() adapter's call() shall dispatch a Command derived from the request Message and return the Response as a relay::Message per RELAY spec \u00a710.2/\u00a715.7.5", + "standard": "iso26262", + "level": "HLR", + "asil": "ASIL-B", + "verificationMethod": "test" + } + ] +} diff --git a/.fusa.json b/.fusa.json index c306da8..1ba0ee5 100644 --- a/.fusa.json +++ b/.fusa.json @@ -1,16 +1,25 @@ { "version": "1", + "standard": "iso26262", "project": { "name": "rust-RCP", "module": "github.com/SoundMatt/rust-RCP", "standard": "iso26262", "asil": "ASIL-B", "language": "rust", - "spec_version": "1.10", + "spec_version": "1.11", "description": "Remote Control Protocol — Rust implementation for automotive zonal architecture", "safety_manager": "safety@example.com", "security_contact": "security@example.com" }, + "rules": { + "exclude": [], + "severity": {} + }, + "report": { + "format": "json", + "output": "check-report.json" + }, "tools": { "annotation": "fusa:req / fusa:test inline comments", "runner": "rsfusa", @@ -23,6 +32,7 @@ "problems": ".fusa-problems.json", "safety_plan": "SAFETY_PLAN.md", "hara_doc": "HARA.md", + "tara": "tara.json", "security_doc": "SECURITY.md", "incident_response": "INCIDENT-RESPONSE.md" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ffa89d..9a8f19e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,9 +126,9 @@ jobs: - name: install relay run: go install github.com/SoundMatt/RELAY/cmd/relay@latest - name: build rcp binary - run: cargo build --bin rcp --release + run: cargo build --bin rust-rcp --release - name: relay conform --strict - run: relay conform --strict target/release/rcp + run: relay conform --strict target/release/rust-rcp # ── RELAY interop ───────────────────────────────────────────────────────── relay-interop: @@ -145,10 +145,10 @@ jobs: - name: install relay run: go install github.com/SoundMatt/RELAY/cmd/relay@latest - name: build rcp binary - run: cargo build --bin rcp --release + run: cargo build --bin rust-rcp --release - name: relay interop (rcp-status EQUIVALENT) run: | - output=$(relay interop target/release/rcp 2>&1) || true + output=$(relay interop target/release/rust-rcp 2>&1) || true echo "$output" if echo "$output" | grep -q "rcp-status"; then echo "$output" | grep -A3 "^rcp-status" | grep "rcp" | grep -q "EQUIVALENT" @@ -158,7 +158,7 @@ jobs: # ── RELAY wire+e2e unit tests (spec traceability) ───────────────────────── relay-conformance: - name: RELAY spec v1.10 unit conformance + name: RELAY spec v1.11 unit conformance needs: lint runs-on: ubuntu-latest steps: @@ -169,6 +169,8 @@ jobs: run: | cargo test --lib -- 'wire::' 2>&1 cargo test --lib -- 'e2e::' 2>&1 + cargo test --lib -- 'relay::' 2>&1 + cargo test --lib -- 'adapt::' 2>&1 # ── IEC 62443 cyber gap check ───────────────────────────────────────────── cyber: @@ -180,6 +182,99 @@ jobs: - name: run cyber gap check run: bash scripts/cyber-gap-check.sh + # ── Full rust-FuSa lifecycle (§20.1.2) ──────────────────────────────────── + # RELAY spec §20.1 item 2: the language safety tool run across its full + # lifecycle — standard check (ERROR-gated), requirement traceability, + # cybersecurity analysis, dependency vulnerability scan (see the separate + # `audit` job), and tool qualification — not just this repo's own + # `fusa-gap-check.sh`/`cyber-gap-check.sh` scripts (the `fusa`/`cyber` jobs + # above), which predate rust-FuSa's own CI integration. + rust-fusa: + name: rust-FuSa full lifecycle + needs: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: install rsfusa + run: cargo install --git https://github.com/SoundMatt/rust-FuSa rsfusa --locked + - name: build release binary (needed for qualify) + # Cargo.lock is gitignored (library crate convention), so no --locked. + run: cargo build --release + + - name: coding standard lint (best-effort) + run: rsfusa lint --dir . --format json --output lint-report.json || true + - name: static analysis (best-effort) + run: rsfusa analyze --dir . --format json --output analyze-report.json || true + - name: cyclomatic complexity V(G) (best-effort) + run: rsfusa comp --dir . --format json --output comp-report.json || true + + - name: safety check — gates on ERROR findings (§20.1 item 2) + run: rsfusa check --dir . --format json --output check-report.json + - name: safety check SARIF, strict (best-effort, code scanning) + run: rsfusa check --dir . --strict --format sarif --output results.sarif || true + + - name: requirement traceability matrix (§20.1 item 2 / §20.4) + run: | + rsfusa trace --dir . --format json --output trace.json + rsfusa trace --dir . --format text + + - name: FMEA (best-effort, §20.4 dFMEA evidence) + run: rsfusa fmea --dir . || true + - name: TARA (§20.4) — see the hand-authored tara.json for the source of truth + run: rsfusa tara --dir . + - name: cybersecurity analysis — CWE-mapped (§20.1 item 2) + run: rsfusa cyber --dir . --format json --output cyber-report.json + - name: boundary diagram (best-effort) + run: rsfusa boundary --dir . || true + - name: HARA (best-effort) + run: rsfusa hara show --dir . || true + - name: safety case assembly (best-effort) + run: rsfusa safety-case --dir . || true + - name: ISO 26262 Part 6 gap report (best-effort) + run: rsfusa iso26262 --dir . --format json --output iso26262-gap-report.json || true + + - name: tool qualification (§20.1 item 2) + run: rsfusa qualify + + - name: release artifacts — SBOM, provenance, manifest (§20.5) + run: rsfusa release --dir . + - name: audit pack (best-effort) + run: rsfusa audit-pack --output audit-pack.zip || true + + - name: upload SARIF to GitHub code scanning + if: always() && github.event_name != 'pull_request' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif + continue-on-error: true + + - name: upload safety evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: fusa-safety-evidence + path: | + check-report.json + lint-report.json + analyze-report.json + comp-report.json + cyber-report.json + trace.json + fmea.json + tara.json + tara.md + safety-case.json + safety-case.md + iso26262-gap-report.json + qualify-report.json + sbom.json + provenance.json + artifact-manifest.json + audit-pack.zip + if-no-files-found: warn + # ── SBOM ───────────────────────────────────────────────────────────────── sbom: name: SBOM (CycloneDX) diff --git a/Cargo.toml b/Cargo.toml index 8c43609..2a45883 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rcp" -version = "0.1.0" +version = "0.3.0" edition = "2021" rust-version = "1.75" license = "MPL-2.0" @@ -13,7 +13,7 @@ name = "rcp" path = "src/lib.rs" [[bin]] -name = "rcp" +name = "rust-rcp" path = "src/bin/rcp.rs" [dependencies] @@ -22,9 +22,14 @@ parking_lot = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } +async-trait = "0.1" [dev-dependencies] -tempfile = "3" +tempfile = "3" +tokio-test = "0.4" [profile.release] strip = true diff --git a/README.md b/README.md index aa4c9f2..77dad6a 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,18 @@ [![ASIL-B](https://img.shields.io/badge/ISO%2026262-ASIL--B-orange)](SAFETY_PLAN.md) [![IEC 62443](https://img.shields.io/badge/IEC%2062443-SL--2-blue)](SECURITY.md) -Rust implementation of the **Remote Control Protocol (RCP)** for automotive zonal architecture, compliant with the **RELAY specification v1.6**. +Rust implementation of the **Remote Control Protocol (RCP)** for automotive zonal architecture, compliant with the **RELAY specification v1.11**. RCP is used by a central HPC to dispatch `Command`s to zone controllers (front-left, front-right, rear-left, rear-right, central) and receive `Response`s and periodic `Status` telemetry. ## Features -- Full RELAY spec v1.10 compliance -- **ASIL-B** (ISO 26262:2018) with full FuSa artifact set +- Full RELAY spec v1.11 compliance, including the `Adapt()` RELAY adapter (§10.3) — `rcp::adapt(ctrl)` wraps a `Controller` as a `relay::Caller` +- **ASIL-B** (ISO 26262:2018) with full FuSa artifact set, including a TARA (ISO/SAE 21434, see [tara.json](tara.json)) - **IEC 62443 SL-2** cybersecurity controls - `#![forbid(unsafe_code)]` — 100% safe Rust -- Blocking synchronous API (`no_std`-compatible core, no tokio required) -- 42 modules covering core protocol, bridges, safety, and observability +- Core `Controller`/`Registry` API is a plain blocking `fn` interface; the RELAY-facing `relay::Node`/`Caller` adapter is `async fn` per §18.3 and runs on `tokio` +- 44 modules covering core protocol, bridges, safety, and observability ## Quick Start @@ -93,12 +93,15 @@ assert_eq!(resp.zone, Zone::FRONT_LEFT); | `udsbr` | UDS (ISO 14229) bridge | | `doipbr` | DoIP (ISO 13400-2) bridge | | `capi` | C FFI types and error codes | -| `adapt` | External message format adapter | +| `adapt` | External message format adapter, and the RELAY `Adapt()`/`to_message()`/`from_message()` entry point (§10.3, §15.7.5) | +| `relay` | Vendored RELAY protocol types — `Message`, `Node`, `Caller`, error sentinels (§18.3) | +| `base64_serde` | Base64 serde helpers for `Message`/`Command`/`Response`/`Status` payload fields | ## Safety & Security - [SAFETY_PLAN.md](SAFETY_PLAN.md) — ISO 26262 safety plan - [HARA.md](HARA.md) — Hazard analysis +- [tara.json](tara.json) — Threat analysis and risk assessment (ISO/SAE 21434) - [SECURITY.md](SECURITY.md) — Security policy and controls - [INCIDENT-RESPONSE.md](INCIDENT-RESPONSE.md) — Incident response plan - [.fusa.json](.fusa.json) — FuSa project manifest diff --git a/ROADMAP.md b/ROADMAP.md index 4315601..0d37879 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # rust-RCP Roadmap -## v0.1 — Initial Release (current) +## v0.1 — Initial Release - All 42 protocol modules implemented and unit-tested - Wire format encoder/decoder with magic-byte and CRC validation @@ -21,11 +21,23 @@ - IEC 62443 SL-2 cybersecurity artifacts - CI: lint, cross-platform tests, coverage ≥90%, fuzz, benchmark, audit -## v0.2 — Hardening +## v0.2 / v0.3 — Hardening + RELAY §10.3 conformance (current) +- [x] RELAY `Adapt()` implemented: `rcp::adapt(ctrl)` wraps a `Controller` as + an async `relay::Caller`/`relay::Node` (§10.3), backed by + `to_message()`/`from_message()` (§15.7.5) and a vendored `relay` + module (§18.3) +- [x] `RELAY_SPEC_VERSION` exported from the crate root; canonical types + derive `serde::Serialize`/`Deserialize` (§18.3) +- [x] Full `rsfusa` (rust-FuSa) CI lifecycle gate + TARA (ISO/SAE 21434, + `tara.json`) (§20.1.2, §20.4) +- [x] CLI binary renamed `rcp` → `rust-rcp` per §13.2 +- [x] `tokio`-based async boundary: delivered as the `relay::Node`/`Caller` + adapter (`adapt()`) rather than a wrapper over the whole + `Controller`/`Registry` API — see issue #7 for why the core API + remains synchronous pending spec guidance on a `no_std`/embedded profile - [ ] Real mDNS-SD backend (using `mdns-sd` crate) as optional feature - [ ] AUTOSAR-CP transport backend (optional feature) -- [ ] `async-controller` feature: tokio-based async wrappers around blocking API - [ ] Persistent audit log module (`auditlog`) for forensic traceability - [ ] Formal property test suite using `proptest` - [ ] Codec registry for pluggable serialization (CBOR, Protobuf, Cap'n Proto) diff --git a/SAFETY_PLAN.md b/SAFETY_PLAN.md index 3692354..7c877b6 100644 --- a/SAFETY_PLAN.md +++ b/SAFETY_PLAN.md @@ -18,7 +18,7 @@ This safety plan covers the **rust-RCP** crate — a Rust implementation of the |---|---|---| | ISO 26262 | 2018 | ASIL-B | | IEC 62443 | 2019 | SL-2 | -| RELAY Spec | 1.10 | Full compliance | +| RELAY Spec | 1.11 | Full compliance | ## 4. Safety Lifecycle Activities diff --git a/scripts/fusa-gap-check.sh b/scripts/fusa-gap-check.sh index b9009c8..50edfb2 100755 --- a/scripts/fusa-gap-check.sh +++ b/scripts/fusa-gap-check.sh @@ -18,7 +18,11 @@ import sys, json, re, pathlib, textwrap reqs_file = pathlib.Path(sys.argv[1]) src_dir = pathlib.Path(sys.argv[2]) -declared = {r["id"] for r in json.loads(reqs_file.read_text())} +reqs_data = json.loads(reqs_file.read_text()) +# .fusa-reqs.json schema 1.0: {"schemaVersion": "1.0", "requirements": [...]}. +# Older files were a bare array; accept both for robustness. +reqs_list = reqs_data["requirements"] if isinstance(reqs_data, dict) else reqs_data +declared = {r["id"] for r in reqs_list} src_text = "\n".join(p.read_text() for p in src_dir.rglob("*.rs")) in_src = set(re.findall(r"//\s*fusa:req\s+(REQ-[\w-]+)", src_text)) diff --git a/src/adapt.rs b/src/adapt.rs index a657987..2e0e89c 100644 --- a/src/adapt.rs +++ b/src/adapt.rs @@ -3,16 +3,29 @@ // fusa:req REQ-ADAPT-003 // fusa:req REQ-ADAPT-004 // fusa:req REQ-ADAPT-005 +// fusa:req REQ-ADAPT-006 +// fusa:req REQ-ADAPT-007 +// fusa:req REQ-ADAPT-008 +// fusa:req REQ-ADAPT-009 +// fusa:req REQ-ADAPT-010 //! Adapter layer — converts between RCP and external protocol representations. //! //! Provides bi-directional mapping between `Command`/`Response` and -//! arbitrary external message formats via the [`Adapter`] trait. +//! arbitrary external message formats via the [`Adapter`] trait, and the +//! RELAY-spec `Adapt()` entry point (§10.3) that wraps a [`Controller`] as a +//! [`crate::relay::Caller`] using `to_message()`/`from_message()` (§15.7.5). -use std::sync::Arc; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; use std::time::Duration; -use crate::{Command, Controller, RcpError, Response}; +use async_trait::async_trait; +use chrono::Utc; +use tokio::sync::{mpsc, Notify}; + +use crate::relay::{BackPressurePolicy, Context, Message, Protocol, SubscriberOptions, Version}; +use crate::{zone_from_str, Command, CommandType, Controller, Priority, RcpError, Response}; // ── Adapter trait ───────────────────────────────────────────────────────────── @@ -68,6 +81,325 @@ impl Adapter for PassthroughAdapter { } } +// --------------------------------------------------------------------------- +// to_message / from_message — RELAY spec §15.7.5 +// --------------------------------------------------------------------------- + +/// Convert a [`Status`](crate::Status) to a `relay::Message` (Subscribe +/// direction) per RELAY spec §15.7.5. +// fusa:req REQ-ADAPT-006 +pub fn to_message(status: &crate::Status) -> Message { + let mut meta = std::collections::BTreeMap::new(); + meta.insert("rcp.healthy".to_string(), status.healthy.to_string()); + Message { + protocol: Protocol::Rcp, + version: Version::default(), + id: status.zone.as_str().to_string(), + payload: status.payload.clone().unwrap_or_default(), + timestamp: Utc::now(), + seq: status.seq as u64, + meta, + } +} + +/// Convert a `relay::Message` to a [`Command`] (Caller.Call direction, +/// request half) per RELAY spec §15.7.5. +/// +/// Returns `Err(RcpError::NotFound)` if `msg.id` is not a known zone name. +// fusa:req REQ-ADAPT-007 +pub fn from_message(msg: &Message) -> Result { + let zone = zone_from_str(&msg.id)?; + let priority = msg + .meta + .get("rcp.priority") + .map(|v| parse_priority(v)) + .unwrap_or(Priority::NORMAL); + let cmd_type = msg + .meta + .get("rcp.cmd_type") + .map(|v| parse_cmd_type(v)) + .unwrap_or(CommandType::NOOP); + let payload = if msg.payload.is_empty() { + None + } else { + Some(msg.payload.clone()) + }; + Ok(Command { + id: 0, + zone, + cmd_type, + priority, + payload, + }) +} + +/// Convert a [`Response`] to a `relay::Message` (Caller.Call direction, +/// reply half) per RELAY spec §15.7.5. +// fusa:req REQ-ADAPT-008 +pub fn response_to_message(resp: &Response) -> Message { + let mut meta = std::collections::BTreeMap::new(); + meta.insert("rcp.status".to_string(), resp.status.0.to_string()); + Message { + protocol: Protocol::Rcp, + version: Version::default(), + id: resp.zone.as_str().to_string(), + payload: resp.payload.clone().unwrap_or_default(), + timestamp: Utc::now(), + seq: 0, + meta, + } +} + +fn parse_priority(s: &str) -> Priority { + match s { + "high" => Priority::HIGH, + "critical" => Priority::CRITICAL, + _ => Priority::NORMAL, + } +} + +fn parse_cmd_type(s: &str) -> CommandType { + match s { + "set" => CommandType::SET, + "get" => CommandType::GET, + "reset" => CommandType::RESET, + "watchdog" => CommandType::WATCHDOG, + "sleep" => CommandType::SLEEP, + "wake" => CommandType::WAKE, + _ => CommandType::NOOP, + } +} + +// --------------------------------------------------------------------------- +// AdaptQueue — policy-aware buffer for the Adapt()-level relay.Message +// channel, per RELAY spec §10.5 rule 3. +// --------------------------------------------------------------------------- + +/// A bounded `Message` queue implementing `DropNewest`/`DropOldest`/`Block` +/// back-pressure, sitting between the blocking [`Controller::subscribe`] +/// forwarding task and the async `relay::Node::subscribe` channel returned +/// to the caller. +struct AdaptQueue { + queue: Mutex>, + capacity: usize, + policy: BackPressurePolicy, + notify_push: Notify, + notify_pop: Notify, + closed: std::sync::atomic::AtomicBool, +} + +impl AdaptQueue { + fn new(capacity: usize, policy: BackPressurePolicy) -> Self { + Self { + queue: Mutex::new(VecDeque::with_capacity(capacity.min(256))), + capacity: capacity.max(1), + policy, + notify_push: Notify::new(), + notify_pop: Notify::new(), + closed: std::sync::atomic::AtomicBool::new(false), + } + } + + async fn push(&self, msg: Message) { + match self.policy { + BackPressurePolicy::DropNewest => { + let mut q = self.queue.lock().unwrap(); + if q.len() < self.capacity { + q.push_back(msg); + drop(q); + self.notify_push.notify_one(); + } + } + BackPressurePolicy::DropOldest => { + let mut q = self.queue.lock().unwrap(); + if q.len() >= self.capacity { + q.pop_front(); + } + q.push_back(msg); + drop(q); + self.notify_push.notify_one(); + } + BackPressurePolicy::Block => loop { + { + let mut q = self.queue.lock().unwrap(); + if q.len() < self.capacity { + q.push_back(msg); + drop(q); + self.notify_push.notify_one(); + return; + } + } + self.notify_pop.notified().await; + }, + } + } + + async fn pop(&self) -> Option { + loop { + { + let mut q = self.queue.lock().unwrap(); + if let Some(m) = q.pop_front() { + drop(q); + self.notify_pop.notify_one(); + return Some(m); + } + } + if self.closed.load(std::sync::atomic::Ordering::SeqCst) { + return self.queue.lock().unwrap().pop_front(); + } + self.notify_push.notified().await; + } + } + + fn close(&self) { + self.closed.store(true, std::sync::atomic::Ordering::SeqCst); + self.notify_push.notify_waiters(); + } +} + +// --------------------------------------------------------------------------- +// adapt() — RELAY spec §10.3: RCP `Adapt(c Controller) relay.Caller` +// --------------------------------------------------------------------------- + +/// Wrap a [`Controller`] as a `relay::Caller` (which also satisfies +/// `relay::Node`) per RELAY spec §10.3. +/// +/// `Controller`'s methods are blocking; each call here dispatches through +/// [`tokio::task::spawn_blocking`] so the async boundary required by §18.3 +/// is genuine rather than a facade over a blocking call on the calling task. +// fusa:req REQ-ADAPT-009 +pub fn adapt(ctrl: Arc) -> Box { + Box::new(RcpAdapter { ctrl }) +} + +struct RcpAdapter { + ctrl: Arc, +} + +fn ctx_timeout(ctx: &Context) -> Option { + ctx.deadline.map(|d| { + let now = std::time::Instant::now(); + if d > now { + d - now + } else { + Duration::ZERO + } + }) +} + +fn map_err(e: RcpError) -> crate::relay::Error { + if e.is_relay_closed() { + crate::relay::Error::Closed + } else if e.is_relay_timeout() { + crate::relay::Error::Timeout + } else if e.is_relay_payload_too_large() { + crate::relay::Error::PayloadTooLarge + } else { + // NotConnected / NotFound / ZoneMismatch / wire / e2e / other — closest + // available sentinel is NotConnected (§5.3: "no usable route" family). + crate::relay::Error::NotConnected + } +} + +#[async_trait] +impl crate::relay::Node for RcpAdapter { + fn protocol(&self) -> Protocol { + Protocol::Rcp + } + + /// Send a `relay::Message` by converting it to a [`Command`] and + /// dispatching it; the [`Response`] is awaited (so delivery can be + /// confirmed) but discarded, matching `Node::send`'s fire-and-forget + /// contract (§10.1) — use [`crate::relay::Caller::call`] for the reply. + async fn send(&self, ctx: Context, msg: Message) -> Result<(), crate::relay::Error> { + let cmd = from_message(&msg).map_err(map_err)?; + let ctrl = Arc::clone(&self.ctrl); + let timeout = ctx_timeout(&ctx); + tokio::task::spawn_blocking(move || ctrl.send(&cmd, timeout)) + .await + .map_err(|_| crate::relay::Error::Closed)? + .map_err(map_err)?; + Ok(()) + } + + /// Subscribe to [`Status`](crate::Status) updates and forward them as + /// `relay::Message`s, following the goroutine/task model of §10.5: one + /// task per subscription, its own `Seq` counter starting at 0, and the + /// caller-supplied `BackPressurePolicy` applied at the `relay.Message` + /// layer via [`AdaptQueue`]. + async fn subscribe( + &self, + opts: SubscriberOptions, + ) -> Result, crate::relay::Error> { + let depth = opts.chan_depth(64); + let policy = opts.back_pressure; + let ctrl = Arc::clone(&self.ctrl); + + let sub = tokio::task::spawn_blocking(move || ctrl.subscribe()) + .await + .map_err(|_| crate::relay::Error::Closed)? + .map_err(map_err)?; + + let (tx, rx) = mpsc::channel::(1); + let queue = Arc::new(AdaptQueue::new(depth, policy)); + + // Producer: blocking thread draining the sync Subscription, applying + // the back-pressure policy against `queue` (§10.5 rule 3). The + // runtime `Handle` is captured here (on the async task, inside the + // runtime) since `Handle::current()` panics if called from a plain + // `std::thread::spawn` thread with no ambient runtime context. + let producer_queue = Arc::clone(&queue); + let rt = tokio::runtime::Handle::current(); + std::thread::spawn(move || { + let mut seq: u64 = 0; + while let Some(status) = sub.recv() { + let mut m = to_message(&status); + m.seq = seq; + seq += 1; + rt.block_on(producer_queue.push(m)); + } + producer_queue.close(); + }); + + // Forwarder: drains `queue` into the external channel one message at + // a time. §10.5 rule 2: the channel closes when this task exits. + tokio::spawn(async move { + while let Some(msg) = queue.pop().await { + if tx.send(msg).await.is_err() { + break; // receiver dropped + } + } + }); + + Ok(rx) + } + + async fn close(&self) -> Result<(), crate::relay::Error> { + let ctrl = Arc::clone(&self.ctrl); + tokio::task::spawn_blocking(move || ctrl.close()) + .await + .map_err(|_| crate::relay::Error::Closed)? + .map_err(map_err) + } +} + +#[async_trait] +impl crate::relay::Caller for RcpAdapter { + /// Dispatch `req` and return the zone controller's reply as a + /// `relay::Message`, per RELAY spec §10.2/§15.7.5. + // fusa:req REQ-ADAPT-010 + async fn call(&self, ctx: Context, req: Message) -> Result { + let cmd = from_message(&req).map_err(map_err)?; + let ctrl = Arc::clone(&self.ctrl); + let timeout = ctx_timeout(&ctx); + let resp = tokio::task::spawn_blocking(move || ctrl.send(&cmd, timeout)) + .await + .map_err(|_| crate::relay::Error::Closed)? + .map_err(map_err)?; + Ok(response_to_message(&resp)) + } +} + // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── @@ -139,4 +471,146 @@ mod tests { let out = ctrl.send_msg(cmd, None).unwrap(); assert_eq!(out.payload, Some(b"data".to_vec())); } + + // ── to_message / from_message / response_to_message (§15.7.5) ──────────── + + #[test] + // fusa:test REQ-ADAPT-006 + fn status_to_message_maps_zone_seq_healthy_payload() { + let status = crate::Status { + zone: Zone::FRONT_LEFT, + seq: 3, + healthy: true, + payload: Some(vec![0x01]), + }; + let msg = to_message(&status); + assert_eq!(msg.protocol, Protocol::Rcp); + assert_eq!(msg.id, "FrontLeft"); + assert_eq!(msg.seq, 3); + assert_eq!(msg.payload, vec![0x01]); + assert_eq!(msg.meta.get("rcp.healthy"), Some(&"true".to_string())); + } + + #[test] + // fusa:test REQ-ADAPT-007 + fn message_from_message_maps_zone_priority_cmd_type() { + let mut meta = std::collections::BTreeMap::new(); + meta.insert("rcp.priority".to_string(), "critical".to_string()); + meta.insert("rcp.cmd_type".to_string(), "reset".to_string()); + let msg = Message { + protocol: Protocol::Rcp, + version: Version::default(), + id: "RearRight".to_string(), + payload: vec![0xAA], + timestamp: Utc::now(), + seq: 0, + meta, + }; + let cmd = from_message(&msg).unwrap(); + assert_eq!(cmd.zone, Zone::REAR_RIGHT); + assert_eq!(cmd.priority, Priority::CRITICAL); + assert_eq!(cmd.cmd_type, CommandType::RESET); + assert_eq!(cmd.payload, Some(vec![0xAA])); + } + + #[test] + // fusa:test REQ-ADAPT-007 + fn message_from_message_unknown_zone_is_not_found() { + let msg = Message::new(Protocol::Rcp, "NotAZone", vec![]); + let err = from_message(&msg).unwrap_err(); + assert_eq!(err, RcpError::NotFound); + } + + #[test] + // fusa:test REQ-ADAPT-008 + fn response_to_message_maps_zone_status_payload() { + let resp = Response { + command_id: 9, + zone: Zone::CENTRAL, + status: ResponseStatus::ERROR, + payload: Some(vec![0xFF]), + }; + let msg = response_to_message(&resp); + assert_eq!(msg.id, "Central"); + assert_eq!(msg.payload, vec![0xFF]); + assert_eq!(msg.meta.get("rcp.status"), Some(&"1".to_string())); + } + + // ── adapt() (§10.3) ──────────────────────────────────────────────────── + + #[tokio::test] + // fusa:test REQ-ADAPT-009 + // fusa:test REQ-ADAPT-010 + // fusa:test REQ-RELAY-008 + async fn adapt_call_dispatches_and_returns_message() { + let node = adapt(ok_ctrl()); + let req = Message::new(Protocol::Rcp, "FrontLeft", vec![1, 2]); + let reply = node.call(Context::background(), req).await.unwrap(); + assert_eq!(reply.id, "FrontLeft"); + assert_eq!(reply.payload, vec![1, 2]); + assert_eq!(reply.meta.get("rcp.status"), Some(&"0".to_string())); + } + + #[tokio::test] + // fusa:test REQ-ADAPT-009 + async fn adapt_send_discards_response() { + let node = adapt(ok_ctrl()); + let msg = Message::new(Protocol::Rcp, "FrontLeft", vec![]); + crate::relay::Node::send(&*node, Context::background(), msg) + .await + .unwrap(); + } + + #[tokio::test] + // fusa:test REQ-ADAPT-009 + async fn adapt_send_invalid_zone_is_not_connected() { + let node = adapt(ok_ctrl()); + let msg = Message::new(Protocol::Rcp, "NoSuchZone", vec![]); + let err = crate::relay::Node::send(&*node, Context::background(), msg) + .await + .unwrap_err(); + assert_eq!(err, crate::relay::Error::NotConnected); + } + + #[tokio::test] + // fusa:test REQ-ADAPT-009 + async fn adapt_subscribe_delivers_published_status() { + let ctrl = MockController::new(Zone::FRONT_LEFT, None); + let publishable = Arc::clone(&ctrl); + let node = adapt(ctrl as Arc); + + let mut rx = crate::relay::Node::subscribe(&*node, SubscriberOptions::default()) + .await + .unwrap(); + + publishable.publish(Some(vec![0xAB])); + let msg = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("no message received in time") + .expect("channel closed"); + assert_eq!(msg.id, "FrontLeft"); + assert_eq!(msg.payload, vec![0xAB]); + } + + #[tokio::test] + // fusa:test REQ-ADAPT-009 + async fn adapt_close_closes_inner_controller() { + let ctrl = MockController::new(Zone::FRONT_LEFT, None); + let inner = Arc::clone(&ctrl); + let node = adapt(ctrl as Arc); + crate::relay::Node::close(&*node).await.unwrap(); + let cmd = Command { + zone: Zone::FRONT_LEFT, + ..Default::default() + }; + assert_eq!(inner.send(&cmd, None).unwrap_err(), RcpError::Closed); + } + + #[test] + // fusa:test REQ-ADAPT-009 + // fusa:test REQ-RELAY-007 + fn adapt_protocol_is_rcp() { + let node = adapt(ok_ctrl()); + assert_eq!(crate::relay::Node::protocol(&*node), Protocol::Rcp); + } } diff --git a/src/base64_serde.rs b/src/base64_serde.rs new file mode 100644 index 0000000..d9135fc --- /dev/null +++ b/src/base64_serde.rs @@ -0,0 +1,122 @@ +// fusa:req REQ-RELAY-009 + +//! Serde helper: serialize `Vec` as a base64 string per RELAY spec §15.1. +//! +//! `relay::Message.payload` and `rcp::{Command,Response,Status}.payload` are +//! base64-encoded strings in the canonical JSON representation (mirroring +//! Go's `encoding/json` marshalling of `[]byte`); raw byte arrays are +//! rejected by `relay interop` and `relay conform --strict`. + +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::{Deserialize, Deserializer, Serializer}; + +/// Base64 serde for a required `Vec` field (used by [`crate::relay::Message`]). +pub fn serialize(bytes: &[u8], serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&STANDARD.encode(bytes)) +} + +/// Base64 serde-deserialize for a required `Vec` field. +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + STANDARD + .decode(s.as_bytes()) + .map_err(serde::de::Error::custom) +} + +/// Base64 serde for an `Option>` payload field (used by +/// `rcp::{Command,Response,Status}.payload`, whose Go source is +/// `[]byte \`json:"payload,omitempty"\`` — `None` is omitted from the +/// serialized object entirely). +pub mod opt { + use super::*; + + // fusa:req REQ-RELAY-009 + pub fn serialize(bytes: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match bytes { + Some(b) => serializer.serialize_str(&STANDARD.encode(b)), + None => serializer.serialize_none(), + } + } + + // fusa:req REQ-RELAY-009 + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => STANDARD + .decode(s.as_bytes()) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } + + /// `skip_serializing_if` predicate for `Option>` payload fields. + pub fn is_none(v: &Option>) -> bool { + v.is_none() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── +#[cfg(test)] +mod tests { + use super::*; + + #[derive(serde::Serialize, serde::Deserialize)] + struct Wrapper { + #[serde(with = "super")] + data: Vec, + } + + #[derive(serde::Serialize, serde::Deserialize, Default)] + struct OptWrapper { + #[serde(default, skip_serializing_if = "opt::is_none", with = "opt")] + data: Option>, + } + + #[test] + // fusa:test REQ-RELAY-009 + fn base64_round_trip() { + let w = Wrapper { + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + }; + let json = serde_json::to_string(&w).unwrap(); + assert_eq!(json, r#"{"data":"3q2+7w=="}"#); + let back: Wrapper = serde_json::from_str(&json).unwrap(); + assert_eq!(back.data, w.data); + } + + #[test] + // fusa:test REQ-RELAY-009 + fn base64_opt_none_omitted() { + let w = OptWrapper::default(); + let json = serde_json::to_string(&w).unwrap(); + assert_eq!(json, "{}"); + let back: OptWrapper = serde_json::from_str("{}").unwrap(); + assert_eq!(back.data, None); + } + + #[test] + // fusa:test REQ-RELAY-009 + fn base64_opt_some_round_trip() { + let w = OptWrapper { + data: Some(vec![1, 2, 3]), + }; + let json = serde_json::to_string(&w).unwrap(); + let back: OptWrapper = serde_json::from_str(&json).unwrap(); + assert_eq!(back.data, Some(vec![1, 2, 3])); + } +} diff --git a/src/bin/rcp.rs b/src/bin/rcp.rs index ee386fd..d1f57f3 100644 --- a/src/bin/rcp.rs +++ b/src/bin/rcp.rs @@ -11,12 +11,12 @@ //! RCP command-line interface — RELAY spec §12 conformant. //! //! Usage: -//! rcp version [--format json] -//! rcp capabilities -//! rcp status [--format json] -//! rcp convert --protocol RCP [--format json] -//! rcp send --zone --type [--priority

] [--payload ] -//! rcp zones +//! rust-rcp version [--format json] +//! rust-rcp capabilities +//! rust-rcp status [--format json] +//! rust-rcp convert --protocol RCP [--format json] +//! rust-rcp send --zone --type [--priority

] [--payload ] +//! rust-rcp zones use std::io::Read; use std::process; @@ -24,7 +24,7 @@ use std::time::Duration; use rcp::Registry; -const TOOL: &str = "rcp"; +const TOOL: &str = "rust-rcp"; const PROTOCOL: &str = "RCP"; const PROTOCOL_INT: u8 = 5; @@ -32,7 +32,7 @@ fn main() { let args: Vec = std::env::args().collect(); if args.len() < 2 { - eprintln!("Usage: rcp [options]"); + eprintln!("Usage: rust-rcp [options]"); eprintln!("Commands: version, capabilities, status, convert, send, zones"); process::exit(1); } @@ -91,7 +91,7 @@ fn main() { " \"transports\": [],\n", " \"features\": [\"loaning\"],\n", " \"interfaces\": [\"Controller\",\"Registry\"],\n", - " \"optional_interfaces\": [],\n", + " \"optional_interfaces\": [\"LoaningController\"],\n", " \"adapt\": true\n", "}}" ), @@ -387,8 +387,14 @@ mod tests { #[test] // fusa:test REQ-CLI-006 - fn spec_version_is_relay_1_10() { - assert_eq!(rcp::SPEC_VERSION, "1.10", "must track RELAY spec v1.10"); + fn spec_version_is_relay_1_11() { + assert_eq!(rcp::SPEC_VERSION, "1.11", "must track RELAY spec v1.11"); + } + + #[test] + // fusa:test REQ-SPEC-001 + fn relay_spec_version_alias_matches_spec_version() { + assert_eq!(rcp::RELAY_SPEC_VERSION, rcp::SPEC_VERSION); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 246bb4c..36abc50 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,6 +69,7 @@ pub mod adapt; pub mod admin; pub mod authz; +pub(crate) mod base64_serde; pub mod canbr; pub mod capi; pub mod certgap; @@ -97,6 +98,7 @@ pub mod proxy; pub mod ratelimit; pub mod record; pub mod redundancy; +pub mod relay; pub mod restbridge; pub mod shmem; pub mod sim; @@ -109,15 +111,24 @@ pub mod watchdog; pub mod wire; pub mod zonegroup; +pub use adapt::{adapt, from_message, to_message}; + use std::fmt; use std::sync::{mpsc, Arc}; use std::time::Duration; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + // ── Spec version ──────────────────────────────────────────────────────────── /// RELAY specification version this crate implements. // fusa:req REQ-SPEC-001 -pub const SPEC_VERSION: &str = "1.10"; +pub const SPEC_VERSION: &str = "1.11"; + +/// Alias for [`SPEC_VERSION`], exported from the crate root per RELAY spec +/// §18.3 ("`RELAY_SPEC_VERSION` MUST be exported from the crate root"). +// fusa:req REQ-SPEC-001 +pub const RELAY_SPEC_VERSION: &str = SPEC_VERSION; // ── Zone ──────────────────────────────────────────────────────────────────── @@ -143,6 +154,22 @@ impl fmt::Display for Zone { } } +// Numeric serde per RELAY spec §15.5 (`Zone uint8` — a bare integer in JSON, +// not `#[derive(Serialize)]`'s default newtype-as-array encoding). +// fusa:req REQ-RELAY-010 +impl Serialize for Zone { + fn serialize(&self, s: S) -> Result { + s.serialize_u8(self.0) + } +} + +// fusa:req REQ-RELAY-010 +impl<'de> Deserialize<'de> for Zone { + fn deserialize>(d: D) -> Result { + Ok(Zone(u8::deserialize(d)?)) + } +} + impl Zone { /// Canonical PascalCase name used as the RELAY message ID (spec §15.7.5). pub fn as_str(self) -> &'static str { @@ -198,6 +225,20 @@ impl fmt::Display for Priority { } } +// fusa:req REQ-RELAY-010 +impl Serialize for Priority { + fn serialize(&self, s: S) -> Result { + s.serialize_u8(self.0) + } +} + +// fusa:req REQ-RELAY-010 +impl<'de> Deserialize<'de> for Priority { + fn deserialize>(d: D) -> Result { + Ok(Priority(u8::deserialize(d)?)) + } +} + // ── CommandType ────────────────────────────────────────────────────────────── /// Intent of a command dispatched to a zone controller. @@ -231,6 +272,20 @@ impl fmt::Display for CommandType { } } +// fusa:req REQ-RELAY-010 +impl Serialize for CommandType { + fn serialize(&self, s: S) -> Result { + s.serialize_u16(self.0) + } +} + +// fusa:req REQ-RELAY-010 +impl<'de> Deserialize<'de> for CommandType { + fn deserialize>(d: D) -> Result { + Ok(CommandType(u16::deserialize(d)?)) + } +} + // ── ResponseStatus ──────────────────────────────────────────────────────────── /// Outcome of a command execution reported by a zone controller. @@ -259,6 +314,20 @@ impl fmt::Display for ResponseStatus { } } +// fusa:req REQ-RELAY-010 +impl Serialize for ResponseStatus { + fn serialize(&self, s: S) -> Result { + s.serialize_u8(self.0) + } +} + +// fusa:req REQ-RELAY-010 +impl<'de> Deserialize<'de> for ResponseStatus { + fn deserialize>(d: D) -> Result { + Ok(ResponseStatus(u8::deserialize(d)?)) + } +} + // ── Structs ─────────────────────────────────────────────────────────────────── /// Control message dispatched to a zone controller. @@ -267,12 +336,19 @@ impl fmt::Display for ResponseStatus { /// `Zone::UNKNOWN`, `CommandType::NOOP`, `Priority::NORMAL`, payload `None`. // fusa:req REQ-CMDSTRUCT-001 // fusa:req REQ-CMDSTRUCT-002 -#[derive(Debug, Clone, Default, PartialEq)] +// fusa:req REQ-RELAY-011 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Command { pub id: u32, pub zone: Zone, + #[serde(rename = "type")] pub cmd_type: CommandType, pub priority: Priority, + #[serde( + default, + skip_serializing_if = "base64_serde::opt::is_none", + with = "base64_serde::opt" + )] pub payload: Option>, } @@ -280,21 +356,33 @@ pub struct Command { /// /// A zero-value `Response` has `status == ResponseStatus::OK`. // fusa:req REQ-RESP-003 -#[derive(Debug, Clone, Default, PartialEq)] +// fusa:req REQ-RELAY-011 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Response { pub command_id: u32, pub zone: Zone, pub status: ResponseStatus, + #[serde( + default, + skip_serializing_if = "base64_serde::opt::is_none", + with = "base64_serde::opt" + )] pub payload: Option>, } /// Periodic telemetry update published by a zone controller. // fusa:req REQ-STAT-001 -#[derive(Debug, Clone, Default, PartialEq)] +// fusa:req REQ-RELAY-011 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Status { pub zone: Zone, pub seq: u32, pub healthy: bool, + #[serde( + default, + skip_serializing_if = "base64_serde::opt::is_none", + with = "base64_serde::opt" + )] pub payload: Option>, } @@ -1013,4 +1101,83 @@ mod tests { fn zone_default_is_unknown() { assert_eq!(Zone::default(), Zone::UNKNOWN); } + + // ── RELAY serde (§18.3 / §15.5) ─────────────────────────────────────────── + + #[test] + // fusa:test REQ-RELAY-010 + fn zone_serializes_as_bare_integer() { + let json = serde_json::to_string(&Zone::FRONT_LEFT).unwrap(); + assert_eq!(json, "1"); + let back: Zone = serde_json::from_str("1").unwrap(); + assert_eq!(back, Zone::FRONT_LEFT); + } + + #[test] + // fusa:test REQ-RELAY-010 + fn priority_cmdtype_responsestatus_serialize_as_bare_integers() { + assert_eq!(serde_json::to_string(&Priority::HIGH).unwrap(), "1"); + assert_eq!(serde_json::to_string(&CommandType::RESET).unwrap(), "3"); + assert_eq!(serde_json::to_string(&ResponseStatus::ERROR).unwrap(), "1"); + } + + #[test] + // fusa:test REQ-RELAY-011 + fn command_serializes_with_spec_field_names() { + let cmd = Command { + id: 7, + zone: Zone::CENTRAL, + cmd_type: CommandType::SET, + priority: Priority::HIGH, + payload: Some(vec![0xAA]), + }; + let v: serde_json::Value = serde_json::to_value(&cmd).unwrap(); + assert_eq!(v["id"], 7); + assert_eq!(v["zone"], 5); + assert_eq!(v["type"], 1); + assert_eq!(v["priority"], 1); + assert_eq!(v["payload"], "qg=="); + } + + #[test] + // fusa:test REQ-RELAY-011 + fn command_payload_omitted_when_none() { + let cmd = Command { + zone: Zone::FRONT_LEFT, + ..Default::default() + }; + let v: serde_json::Value = serde_json::to_value(&cmd).unwrap(); + assert!(v.get("payload").is_none()); + } + + #[test] + // fusa:test REQ-RELAY-011 + fn response_and_status_round_trip_serde() { + let resp = Response { + command_id: 1, + zone: Zone::REAR_LEFT, + status: ResponseStatus::OK, + payload: None, + }; + let json = serde_json::to_string(&resp).unwrap(); + let back: Response = serde_json::from_str(&json).unwrap(); + assert_eq!(back, resp); + + let st = Status { + zone: Zone::REAR_RIGHT, + seq: 4, + healthy: true, + payload: Some(vec![1]), + }; + let json = serde_json::to_string(&st).unwrap(); + let back: Status = serde_json::from_str(&json).unwrap(); + assert_eq!(back, st); + } + + #[test] + // fusa:test REQ-SPEC-001 + fn relay_spec_version_is_exported_and_matches_spec_version() { + assert!(!RELAY_SPEC_VERSION.is_empty()); + assert_eq!(RELAY_SPEC_VERSION, SPEC_VERSION); + } } diff --git a/src/relay.rs b/src/relay.rs new file mode 100644 index 0000000..f332394 --- /dev/null +++ b/src/relay.rs @@ -0,0 +1,355 @@ +// fusa:req REQ-RELAY-001 +// fusa:req REQ-RELAY-002 +// fusa:req REQ-RELAY-003 +// fusa:req REQ-RELAY-004 +// fusa:req REQ-RELAY-005 +// fusa:req REQ-RELAY-006 +// fusa:req REQ-RELAY-007 +// fusa:req REQ-RELAY-008 + +//! RELAY protocol types, bundled locally until a published `relay-rs` crate +//! exists to depend on directly. +//! +//! These types mirror the RELAY spec v1.11 definitions for Rust (§18.3): +//! the universal message envelope, the four mandatory error sentinels, and +//! the [`Node`]/[`Caller`] application interfaces (§10.1/§10.2). + +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; +use thiserror::Error; +use tokio::sync::mpsc; + +// ── Protocol ────────────────────────────────────────────────────────────────── + +/// Protocol identifier per RELAY spec §3. Serialises as its integer value. +// fusa:req REQ-RELAY-001 +#[repr(i32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Protocol { + Can = 1, + Dds = 2, + Lin = 3, + Mqtt = 4, + Rcp = 5, + Someip = 6, +} + +impl From for i32 { + fn from(p: Protocol) -> i32 { + p as i32 + } +} + +impl TryFrom for Protocol { + type Error = String; + fn try_from(v: i32) -> Result { + match v { + 1 => Ok(Protocol::Can), + 2 => Ok(Protocol::Dds), + 3 => Ok(Protocol::Lin), + 4 => Ok(Protocol::Mqtt), + 5 => Ok(Protocol::Rcp), + 6 => Ok(Protocol::Someip), + _ => Err(format!("unknown protocol: {v}")), + } + } +} + +impl Serialize for Protocol { + fn serialize(&self, s: S) -> Result { + s.serialize_i32((*self).into()) + } +} + +impl<'de> Deserialize<'de> for Protocol { + fn deserialize>(d: D) -> Result { + let v = i32::deserialize(d)?; + Protocol::try_from(v).map_err(serde::de::Error::custom) + } +} + +impl std::fmt::Display for Protocol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Protocol::Can => "CAN", + Protocol::Dds => "DDS", + Protocol::Lin => "LIN", + Protocol::Mqtt => "MQTT", + Protocol::Rcp => "RCP", + Protocol::Someip => "SOMEIP", + }; + f.write_str(s) + } +} + +// ── Version ─────────────────────────────────────────────────────────────────── + +/// Semantic version triplet per RELAY spec §4.1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Version { + pub major: i32, + pub minor: i32, + pub patch: i32, +} + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +// ── Message ─────────────────────────────────────────────────────────────────── + +/// Universal message envelope per RELAY spec §4. +// fusa:req REQ-RELAY-002 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Message { + pub protocol: Protocol, + pub version: Version, + pub id: String, + #[serde(with = "crate::base64_serde")] + pub payload: Vec, + /// RFC 3339 nanosecond timestamp. + pub timestamp: DateTime, + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub seq: u64, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub meta: BTreeMap, +} + +fn is_zero_u64(v: &u64) -> bool { + *v == 0 +} + +impl Message { + /// Create a new message with the given protocol, id, and payload. + pub fn new(protocol: Protocol, id: impl Into, payload: Vec) -> Self { + Self { + protocol, + version: Version::default(), + id: id.into(), + payload, + timestamp: Utc::now(), + seq: 0, + meta: BTreeMap::new(), + } + } +} + +// ── Back-pressure policy ────────────────────────────────────────────────────── + +/// Back-pressure policy for subscriber channels per RELAY spec §14. +// fusa:req REQ-RELAY-003 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BackPressurePolicy { + /// Drop the arriving message when the channel is full (default). + #[default] + DropNewest, + /// Drop the oldest buffered message to make room. + DropOldest, + /// Block until space is available (use only with fast consumers). + Block, +} + +// ── SubscriberOptions ───────────────────────────────────────────────────────── + +/// Subscriber channel configuration per RELAY spec §18.3. +// fusa:req REQ-RELAY-004 +#[derive(Clone, Debug, Default)] +pub struct SubscriberOptions { + /// Buffer depth; 0 means use the default (64). + pub channel_depth: usize, + /// Back-pressure policy applied when the channel is full. + pub back_pressure: BackPressurePolicy, +} + +impl SubscriberOptions { + /// Return the effective channel depth, falling back to `default_depth` + /// when `channel_depth` is zero. + pub fn chan_depth(&self, default_depth: usize) -> usize { + if self.channel_depth > 0 { + self.channel_depth + } else { + default_depth + } + } +} + +// ── Error ───────────────────────────────────────────────────────────────────── + +/// The four mandatory RELAY error sentinels per §5.1. +// fusa:req REQ-RELAY-005 +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum Error { + #[error("relay: closed")] + Closed, + #[error("relay: not connected")] + NotConnected, + #[error("relay: timeout")] + Timeout, + #[error("relay: payload too large")] + PayloadTooLarge, +} + +// ── Context ─────────────────────────────────────────────────────────────────── + +/// Lightweight context carrying an optional deadline per RELAY spec §18.3. +// fusa:req REQ-RELAY-006 +#[derive(Clone, Debug)] +pub struct Context { + pub deadline: Option, +} + +impl Context { + /// A background context with no deadline. + pub fn background() -> Self { + Self { deadline: None } + } + + /// A context that expires after `d`. + pub fn with_timeout(d: Duration) -> Self { + Self { + deadline: Some(Instant::now() + d), + } + } + + /// Returns true if the deadline has passed. + pub fn done(&self) -> bool { + self.deadline.is_some_and(|d| Instant::now() >= d) + } +} + +impl Default for Context { + fn default() -> Self { + Self::background() + } +} + +// ── Node and Caller traits ──────────────────────────────────────────────────── + +/// Protocol-agnostic pub/sub interface per RELAY spec §10.1. +// fusa:req REQ-RELAY-007 +#[async_trait] +pub trait Node: Send + Sync { + fn protocol(&self) -> Protocol; + + async fn send(&self, ctx: Context, msg: Message) -> Result<(), Error>; + + async fn subscribe(&self, opts: SubscriberOptions) -> Result, Error>; + + async fn close(&self) -> Result<(), Error>; +} + +/// Extends [`Node`] with request/response semantics per RELAY spec §10.2. +// fusa:req REQ-RELAY-008 +#[async_trait] +pub trait Caller: Node { + async fn call(&self, ctx: Context, req: Message) -> Result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── +#[cfg(test)] +mod tests { + use super::*; + + #[test] + // fusa:test REQ-RELAY-001 + fn protocol_display() { + assert_eq!(Protocol::Rcp.to_string(), "RCP"); + assert_eq!(Protocol::Can.to_string(), "CAN"); + } + + #[test] + // fusa:test REQ-RELAY-001 + fn protocol_serde_roundtrip() { + let p = Protocol::Rcp; + let json = serde_json::to_string(&p).unwrap(); + assert_eq!(json, "5"); + let p2: Protocol = serde_json::from_str(&json).unwrap(); + assert_eq!(p, p2); + } + + #[test] + // fusa:test REQ-RELAY-001 + fn protocol_deserialize_unknown_rejected() { + let err = serde_json::from_str::("99"); + assert!(err.is_err()); + } + + #[test] + // fusa:test REQ-RELAY-002 + fn message_new_defaults() { + let m = Message::new(Protocol::Rcp, "FrontLeft", vec![1, 2, 3]); + assert_eq!(m.id, "FrontLeft"); + assert_eq!(m.payload, vec![1, 2, 3]); + assert_eq!(m.seq, 0); + assert!(m.meta.is_empty()); + } + + #[test] + // fusa:test REQ-RELAY-002 + fn message_serde_base64_payload() { + let m = Message::new(Protocol::Rcp, "Central", vec![0xDE, 0xAD]); + let json = serde_json::to_value(&m).unwrap(); + assert_eq!(json["payload"], "3q0="); + assert_eq!(json["protocol"], 5); + } + + #[test] + // fusa:test REQ-RELAY-006 + fn context_background_not_done() { + let ctx = Context::background(); + assert!(!ctx.done()); + } + + #[test] + // fusa:test REQ-RELAY-006 + fn context_expired() { + let ctx = Context::with_timeout(Duration::from_nanos(1)); + std::thread::sleep(Duration::from_millis(1)); + assert!(ctx.done()); + } + + #[test] + // fusa:test REQ-RELAY-004 + fn subscriber_options_chan_depth() { + let opts = SubscriberOptions::default(); + assert_eq!(opts.chan_depth(64), 64); + let opts2 = SubscriberOptions { + channel_depth: 128, + ..Default::default() + }; + assert_eq!(opts2.chan_depth(64), 128); + } + + #[test] + // fusa:test REQ-RELAY-003 + fn back_pressure_default_is_drop_newest() { + assert_eq!( + BackPressurePolicy::default(), + BackPressurePolicy::DropNewest + ); + } + + #[test] + // fusa:test REQ-RELAY-005 + fn error_sentinels_are_distinct() { + let sentinels = [ + Error::Closed, + Error::NotConnected, + Error::Timeout, + Error::PayloadTooLarge, + ]; + for i in 0..sentinels.len() { + for j in (i + 1)..sentinels.len() { + assert_ne!(sentinels[i], sentinels[j]); + } + } + } +} diff --git a/tara.json b/tara.json new file mode 100644 index 0000000..959f462 --- /dev/null +++ b/tara.json @@ -0,0 +1,310 @@ +{ + "project": "rust-RCP", + "standard": "ISO/SAE 21434:2021", + "createdAt": "2026-07-27T00:00:00Z", + "scope": "rust-RCP is a software library component used in road-vehicle zonal-architecture ECUs. This TARA covers cybersecurity threats arising from rust-RCP's Command/Response/Status control-plane surface (wire framing, E2E CRC/replay protection, authorization policy, rate limiting, priority scheduling, and the UDP/TLS transport bridges) when integrated into a central HPC or zone controller that exchanges RCP frames over an in-vehicle network. System-level CAL may differ after architectural decomposition per ISO/SAE 21434 clause 15.", + + "assets": [ + { + "id": "A-001", + "name": "Wire frame structural integrity", + "description": "Correctness of the 16-byte wire header (magic, protocol version, message type, zone, ID/seq, payload length) as decoded by wire::validate_header() and the UDP/TLS bridges", + "cybersecurityProperty": "Integrity" + }, + { + "id": "A-002", + "name": "E2E-protected command/response/status authenticity", + "description": "Guarantee that a Command/Response/Status accepted via e2e::unwrap() originates from a genuine, non-replayed sender with correct sequence and payload", + "cybersecurityProperty": "Integrity" + }, + { + "id": "A-003", + "name": "Zone controller command-execution availability", + "description": "Timely dispatch of Watchdog/Reset/Sleep/Wake commands to the correct zone controller; a delayed or suppressed safety-relevant command can mask a real failure condition", + "cybersecurityProperty": "Availability" + }, + { + "id": "A-004", + "name": "Authorization policy enforcement", + "description": "authz::Policy allowlist correctly restricts which CommandTypes and priorities a caller may dispatch to a given zone controller", + "cybersecurityProperty": "Integrity" + }, + { + "id": "A-005", + "name": "Rate-limiter and priority-queue availability", + "description": "ratelimit::TokenBucket and prioqueue::PrioController remain responsive to Critical-priority commands under load from lower-priority traffic", + "cybersecurityProperty": "Availability" + }, + { + "id": "A-006", + "name": "Transport confidentiality/authenticity (TLS bridge)", + "description": "tlstransport::TlsBridge enforces TLS 1.2+ and mutual authentication so that wire frames are not exposed to, or accepted from, an unauthenticated peer", + "cybersecurityProperty": "Confidentiality" + } + ], + + "threatScenarios": [ + { + "id": "T-RCP-01", + "title": "Frame injection — bad magic, version, or truncated header", + "asset": "A-001", + "property": "Integrity", + "stride": "Tampering", + "description": "An attacker on the RCP transport (UDP or an unauthenticated TLS peer) sends malformed wire frames — truncated below the 16-byte header, wrong magic bytes, or an unsupported protocol version — attempting to desynchronize the receiver or trigger undefined decode behaviour.", + "attackPath": "Attacker gains network access to the UDP/TLS transport; crafts a raw byte sequence shorter than HEADER_LEN, or with magic bytes other than 'RC', or ProtoVer != 0x01; sends it before any application-level filtering.", + "impact": { + "safety": "Moderate", + "financial": "Negligible", + "operational": "Major", + "privacy": "Negligible" + }, + "impactRating": 2, + "attackFeasibility": { + "elapsedTime": "< 1 day", + "expertise": "Proficient", + "knowledgeOfItem": "Public", + "windowOfOpportunity": "Easy", + "equipment": "Standard" + }, + "feasibilityRating": "High", + "cal": "CAL-2", + "cybersecurityGoal": "CSG-RCP-01", + "mitigations": ["REQ-WIRE-001", "REQ-WIRE-002", "REQ-WIRE-008", "REQ-WIRE-009"], + "residualRisk": "Low — validate_header() is the mandatory gate before any frame is decoded; ShortFrame/BadMagic/BadVersion are returned instead of panicking" + }, + { + "id": "T-RCP-02", + "title": "Payload tampering over unauthenticated UDP transport", + "asset": "A-002", + "property": "Integrity", + "stride": "Tampering", + "description": "UDP provides no authentication or encryption. An attacker on the same network segment intercepts a wire frame in transit and modifies the payload bytes (e.g. a Set command's actuation value) before it reaches the zone controller.", + "attackPath": "Attacker performs ARP spoofing or otherwise gains a man-in-the-middle position on the CAN-to-Ethernet gateway segment carrying RCP-over-UDP; captures a frame; edits payload bytes; re-computes/forges the E2E CRC (see T-RCP-07); re-injects.", + "impact": { + "safety": "Severe", + "financial": "Moderate", + "operational": "Major", + "privacy": "Negligible" + }, + "impactRating": 3, + "attackFeasibility": { + "elapsedTime": "< 1 week", + "expertise": "Proficient", + "knowledgeOfItem": "Restricted", + "windowOfOpportunity": "Moderate", + "equipment": "Specialized" + }, + "feasibilityRating": "Medium", + "cal": "CAL-3", + "cybersecurityGoal": "CSG-RCP-02", + "mitigations": ["REQ-TLS-001", "REQ-TLS-002", "REQ-E2E-003"], + "residualRisk": "Medium on plain UDP — deployments carrying safety-relevant commands MUST use the TLS bridge (mutual auth, TLS >= 1.2); UDP-only deployments rely solely on the non-cryptographic E2E CRC" + }, + { + "id": "T-RCP-03", + "title": "Replayed command re-injection", + "asset": "A-002", + "property": "Integrity", + "stride": "Spoofing", + "description": "An attacker records a previously valid, E2E-wrapped Command frame (e.g. Wake or Reset) and re-transmits it later to re-trigger the action outside its intended context.", + "attackPath": "Attacker passively captures a genuine wire frame from the transport; re-sends the identical bytes at a time of their choosing.", + "impact": { + "safety": "Severe", + "financial": "Low", + "operational": "Major", + "privacy": "Negligible" + }, + "impactRating": 3, + "attackFeasibility": { + "elapsedTime": "< 1 day", + "expertise": "Layman", + "knowledgeOfItem": "Public", + "windowOfOpportunity": "Easy", + "equipment": "Standard" + }, + "feasibilityRating": "High", + "cal": "CAL-3", + "cybersecurityGoal": "CSG-RCP-02", + "mitigations": ["REQ-E2E-004", "REQ-E2E-005"], + "residualRisk": "Low — ReplayGuard rejects any seqNum already observed within the replay window (REQ-E2E-005); window eviction is bounded (REPLAY_WINDOW = 32)" + }, + { + "id": "T-RCP-04", + "title": "Command flooding — priority-inversion denial of service", + "asset": "A-005", + "property": "Availability", + "stride": "Denial of Service", + "description": "An attacker (or a malfunctioning peer) floods a zone controller with a high volume of Normal/High-priority commands, attempting to starve or delay Critical-priority commands such as Watchdog or Reset.", + "attackPath": "Attacker gains send access to a zone controller's transport; issues Set/Get commands as fast as the transport allows, with no rate limiting in front of the controller.", + "impact": { + "safety": "Severe", + "financial": "Negligible", + "operational": "Major", + "privacy": "Negligible" + }, + "impactRating": 3, + "attackFeasibility": { + "elapsedTime": "< 1 day", + "expertise": "Layman", + "knowledgeOfItem": "Public", + "windowOfOpportunity": "Easy", + "equipment": "Standard" + }, + "feasibilityRating": "High", + "cal": "CAL-3", + "cybersecurityGoal": "CSG-RCP-03", + "mitigations": ["REQ-RL-006", "REQ-RL-007", "REQ-PQ-004"], + "residualRisk": "Low when both controllers are composed — ratelimit::TokenBucket exempts Critical priority from the bucket (REQ-RL-007) and prioqueue::PrioController always dispatches Critical before High/Normal (REQ-PQ-004); a deployment that omits either wrapper remains exposed" + }, + { + "id": "T-RCP-05", + "title": "Unauthorized safety-relevant command execution", + "asset": "A-004", + "property": "Integrity", + "stride": "Elevation of Privilege", + "description": "A caller without the intended authority sends a Reset, Sleep, or Wake command to a zone controller that should only accept Get/Set from that caller, attempting to force an unintended power-state transition.", + "attackPath": "Attacker (or a compromised, lower-trust peer) obtains network access to a zone controller that is composed without an authz::AuthzController wrapper, or whose Policy allowlist was misconfigured to allow_all(); sends a Reset/Sleep/Wake Command directly.", + "impact": { + "safety": "Severe", + "financial": "Moderate", + "operational": "Major", + "privacy": "Negligible" + }, + "impactRating": 3, + "attackFeasibility": { + "elapsedTime": "< 1 week", + "expertise": "Proficient", + "knowledgeOfItem": "Restricted", + "windowOfOpportunity": "Moderate", + "equipment": "Standard" + }, + "feasibilityRating": "Medium", + "cal": "CAL-3", + "cybersecurityGoal": "CSG-RCP-04", + "mitigations": ["REQ-AUTHZ-001", "REQ-AUTHZ-005", "REQ-AUTHZ-006"], + "residualRisk": "Residual risk depends on deployment: the ACL is enforced only when the integrator wraps every externally reachable Controller in an AuthzController with a least-privilege Policy; rust-RCP cannot force composition at the library level" + }, + { + "id": "T-RCP-06", + "title": "TLS downgrade or missing peer authentication", + "asset": "A-006", + "property": "Confidentiality", + "stride": "Spoofing", + "description": "An attacker forces (or a misconfigured deployment permits) a TLS session below TLSv1.2, or a peer without a valid client certificate, allowing eavesdropping or impersonation of a zone controller/HPC endpoint.", + "attackPath": "Attacker performs a protocol-downgrade attack against the TLS handshake, or connects to a TlsBridge whose injected TlsStream implementation does not actually enforce mutual authentication despite REQUIRE_MUTUAL_AUTH being declared true.", + "impact": { + "safety": "Moderate", + "financial": "Moderate", + "operational": "Major", + "privacy": "Moderate" + }, + "impactRating": 2, + "attackFeasibility": { + "elapsedTime": "< 1 month", + "expertise": "Expert", + "knowledgeOfItem": "Confidential", + "windowOfOpportunity": "Difficult", + "equipment": "Specialized" + }, + "feasibilityRating": "Low", + "cal": "CAL-2", + "cybersecurityGoal": "CSG-RCP-05", + "mitigations": ["REQ-TLS-001", "REQ-TLS-002"], + "residualRisk": "Low in-library — MIN_TLS_VERSION/REQUIRE_MUTUAL_AUTH are the declared policy constants; actual enforcement depends on the concrete TlsStream implementation the integrator injects, which rust-RCP does not itself provide or audit" + }, + { + "id": "T-RCP-07", + "title": "E2E CRC forgery — active tampering by a capable attacker", + "asset": "A-002", + "property": "Integrity", + "stride": "Tampering", + "description": "The E2E header uses CRC-16/CCITT-FALSE, which is a safety control against random bit errors, not a keyed cryptographic MAC. An attacker who can modify a frame in transit can recompute a valid CRC-16 over the tampered seqNum+payload, defeating the check.", + "attackPath": "Attacker intercepts a wire frame (see T-RCP-02); modifies the payload; recomputes CRC-16/CCITT-FALSE over the new seqNum+payload bytes (no key material required — the algorithm and polynomial are public); re-injects the forged frame.", + "impact": { + "safety": "Severe", + "financial": "Moderate", + "operational": "Major", + "privacy": "Negligible" + }, + "impactRating": 3, + "attackFeasibility": { + "elapsedTime": "< 1 day", + "expertise": "Proficient", + "knowledgeOfItem": "Public", + "windowOfOpportunity": "Easy", + "equipment": "Standard" + }, + "feasibilityRating": "High", + "cal": "CAL-3", + "cybersecurityGoal": "CSG-RCP-02", + "mitigations": ["REQ-TLS-001", "REQ-TLS-002"], + "residualRisk": "Medium-High without transport-layer authentication — e2e::wrap/unwrap MUST be documented as a safety control (random error detection) only, not a security control; deployments exposed to an adversarial network MUST run RCP over the TLS bridge (mutually authenticated) rather than relying on CRC-16 alone" + }, + { + "id": "T-RCP-08", + "title": "Watchdog suppression masking loss of supervision", + "asset": "A-003", + "property": "Availability", + "stride": "Denial of Service", + "description": "An attacker selectively drops or delays Watchdog commands/responses on the transport so that watchdog::WatchdogController never observes enough misses to flag a zone as unhealthy, masking a real failure or an ongoing attack on that zone.", + "attackPath": "Attacker with transport-level interception capability (see T-RCP-02) selectively filters Watchdog-type frames while allowing other traffic through, keeping miss_count below WatchdogConfig's unhealthy threshold.", + "impact": { + "safety": "Severe", + "financial": "Low", + "operational": "Major", + "privacy": "Negligible" + }, + "impactRating": 3, + "attackFeasibility": { + "elapsedTime": "< 1 week", + "expertise": "Expert", + "knowledgeOfItem": "Restricted", + "windowOfOpportunity": "Moderate", + "equipment": "Specialized" + }, + "feasibilityRating": "Medium", + "cal": "CAL-3", + "cybersecurityGoal": "CSG-RCP-04", + "mitigations": ["REQ-WDG-002", "REQ-WDG-008", "REQ-TLS-001"], + "residualRisk": "Medium — selective frame dropping is a transport-layer attack outside rust-RCP's own visibility; running the watchdog path over an authenticated, integrity-protected transport (TLS bridge) is the primary mitigation, since the library cannot itself detect absence of traffic caused by a third party" + } + ], + + "cybersecurityGoals": [ + { + "id": "CSG-RCP-01", + "description": "rust-RCP shall reject all wire frames with a structurally invalid header (short, bad magic, or unsupported protocol version) before any field is interpreted.", + "cal": "CAL-2", + "threats": ["T-RCP-01"], + "requirements": ["REQ-WIRE-001", "REQ-WIRE-002", "REQ-WIRE-008", "REQ-WIRE-009"] + }, + { + "id": "CSG-RCP-02", + "description": "rust-RCP shall provide mechanisms to detect replayed or corrupted Command/Response/Status frames, and shall document that cryptographic integrity/authenticity against an active attacker requires running RCP over the mutually authenticated TLS bridge rather than the CRC-16 E2E header alone.", + "cal": "CAL-3", + "threats": ["T-RCP-02", "T-RCP-03", "T-RCP-07"], + "requirements": ["REQ-E2E-003", "REQ-E2E-004", "REQ-E2E-005", "REQ-TLS-001", "REQ-TLS-002"] + }, + { + "id": "CSG-RCP-03", + "description": "rust-RCP shall resist priority-inversion denial-of-service by exempting Critical-priority commands from rate limiting and always dispatching them ahead of lower-priority traffic when both wrappers are composed.", + "cal": "CAL-3", + "threats": ["T-RCP-04"], + "requirements": ["REQ-RL-006", "REQ-RL-007", "REQ-PQ-004"] + }, + { + "id": "CSG-RCP-04", + "description": "rust-RCP shall provide an authorization allowlist that integrators can compose in front of any Controller to restrict which command types a caller may dispatch, and a watchdog mechanism whose miss-count threshold is independently verifiable.", + "cal": "CAL-3", + "threats": ["T-RCP-05", "T-RCP-08"], + "requirements": ["REQ-AUTHZ-001", "REQ-AUTHZ-005", "REQ-AUTHZ-006", "REQ-WDG-002", "REQ-WDG-008"] + }, + { + "id": "CSG-RCP-05", + "description": "rust-RCP's TLS bridge shall declare and enforce a minimum TLS version of 1.2 and mandatory mutual authentication for any transport carrying safety-relevant commands.", + "cal": "CAL-2", + "threats": ["T-RCP-06"], + "requirements": ["REQ-TLS-001", "REQ-TLS-002"] + } + ] +}