diff --git a/Cargo.lock b/Cargo.lock index 55e3925..e40b81a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,5 +3,300 @@ version = 4 [[package]] -name = "uds2sovd-proxy" +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "doip-server" version = "0.1.0" +dependencies = [ + "bytes", + "hex", + "parking_lot", + "serde", + "thiserror", + "tokio-util", + "toml", + "tracing", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" diff --git a/Cargo.toml b/Cargo.toml index 529569d..907054e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,15 +8,31 @@ # terms of the Apache License Version 2.0 which is available at # https://www.apache.org/licenses/LICENSE-2.0 -[package] -name = "uds2sovd-proxy" -version = "0.1.0" +[workspace.package] edition = "2024" license = "Apache-2.0" homepage = "https://github.com/eclipse-opensovd/uds2sovd-proxy" -[lints] -workspace = true +[workspace] +resolver = "3" +members = ["doip-server"] + +[workspace.dependencies] +# ---- async runtime ---- +tokio = { version = "1", default-features = false } +tokio-util = { version = "0.7", default-features = false } +# ---- serialization ---- +serde = { version = "1.0", default-features = false } +toml = { version = "0.9", default-features = false } +# ---- tracing & logging ---- +tracing = { version = "0.1", default-features = false } +# ---- error handling ---- +thiserror = { version = "2.0", default-features = false } +# ---- data structures ---- +bytes = { version = "1.0", default-features = false } +parking_lot = { version = "0.12", default-features = false } +# ---- utility crates ---- +hex = { version = "0.4", default-features = false } # Lints are aligned with shared-lints.toml from: # https://github.com/eclipse-opensovd/cicd-workflows/tree/main/shared-lints @@ -44,3 +60,13 @@ clone_on_ref_ptr = "warn" # enforce that the type suffix of a literal is always appended directly # eg. 12u8 instead of 12_u8 separated_literal_suffix = "deny" + +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(nightly)'] } + +[profile.release.package."*"] +opt-level = 3 + +[profile.release-with-debug] +inherits = "release" +debug = true diff --git a/deny.toml b/deny.toml index 10e3516..229b337 100644 --- a/deny.toml +++ b/deny.toml @@ -35,7 +35,14 @@ ignore = [] allow = ["Apache-2.0", "MIT"] confidence-threshold = 0.9 -exceptions = [] +exceptions = [ + # unicode-ident is a transitive dependency pulled in by serde (via proc-macro2). + # It is licensed under Unicode-3.0, an OSI-approved license covering only + # Unicode data tables. There is no Apache-2.0 or MIT alternative for this crate. + { allow = [ + "Unicode-3.0", + ], name = "unicode-ident" }, +] [licenses.private] ignore = true diff --git a/doip-server/Cargo.toml b/doip-server/Cargo.toml new file mode 100644 index 0000000..307854c --- /dev/null +++ b/doip-server/Cargo.toml @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 + +[package] +name = "doip-server" +version = "0.1.0" +edition.workspace = true +publish = false +description = "DoIP server implementation for uds2sovd-proxy" +homepage.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +# ---- async runtime ---- +tokio-util = { workspace = true, features = ["codec"] } +# ---- serialization ---- +serde = { workspace = true, features = ["std", "derive"] } +toml = { workspace = true, features = ["parse", "serde"] } + +# ---- tracing & logging ---- +tracing = { workspace = true, features = ["std"] } + +# ---- error handling ---- +thiserror = { workspace = true, features = ["std"] } + +# ---- data structures ---- +bytes = { workspace = true, features = ["std"] } +parking_lot = { workspace = true } + +# ---- utility crates ---- +hex = { workspace = true, features = ["std"] } diff --git a/doip-server/doc/ClassDiagram.puml b/doip-server/doc/ClassDiagram.puml new file mode 100644 index 0000000..0390dd2 --- /dev/null +++ b/doip-server/doc/ClassDiagram.puml @@ -0,0 +1,464 @@ +@startuml ClassDiagram +' ========================================================== +' Crisp layered overview of the DoIP server (ISO 13400-2). +' +' Colour key: +' Blue (#EBF5FB) — Wire / TCP framing layer +' Teal (#E8F8F5) — Payload dispatch & shared traits +' Light (#EEF4FF) — DoIP message types (one pkg per §) +' Green (#EAFAF1) — Server & session management +' Amber (#FEF9E7) — UDS integration (user extension point) +' ========================================================== + +skinparam linetype ortho +skinparam classAttributeIconSize 0 +skinparam packageStyle rectangle +skinparam defaultFontSize 12 +skinparam arrowColor #555555 +skinparam backgroundColor white + +skinparam class { + BackgroundColor #EEF4FF + BorderColor #4A7CC7 + HeaderBackgroundColor #4A7CC7 + HeaderFontColor white +} +skinparam interface { + BackgroundColor #FFF8E1 + BorderColor #F9A825 + HeaderBackgroundColor #F9A825 + HeaderFontColor white +} +skinparam package { + BackgroundColor #FAFAFA + BorderColor #BDBDBD +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #E0AC00 +} + +' ══════════════════════════════════════════════════════════ +' 1. TCP FRAMING +' ══════════════════════════════════════════════════════════ +package "TCP Framing" #EBF5FB { + + class DoipCodec { + - state: AwaitingHeader | AwaitingPayload + - max_payload_size: u32 + .. + + new() : Self + + with_max_payload_size(n: u32) : Self + } + + class DoipMessage { + + header: DoipHeader + + payload: Bytes + } + + class DoipHeader <<8-byte generic header §6>> { + version: u8 ' 0x02 = ISO 13400-2:2019 + inverse_version: u8 ' version XOR 0xFF + payload_type: u16 + payload_length: u32 ' big-endian + .. + + parse(bytes) : Result + + validate() : Option + } + + enum PayloadType <> { + GenericNack = 0x0000 + VehicleIdentificationRequest = 0x0001 + VehicleIdentificationReqEid = 0x0002 + VehicleIdentificationReqVin = 0x0003 + VehicleIdentificationResponse = 0x0004 + RoutingActivationRequest = 0x0005 + RoutingActivationResponse = 0x0006 + AliveCheckRequest = 0x0007 + AliveCheckResponse = 0x0008 + DiagnosticMessage = 0x8001 + DiagnosticMessagePositiveAck = 0x8002 + DiagnosticMessageNegativeAck = 0x8003 + } + + enum GenericNackCode <
> { + IncorrectPatternFormat = 0x00 + UnknownPayloadType = 0x01 + MessageTooLarge = 0x02 + OutOfMemory = 0x03 + InvalidPayloadLength = 0x04 + } +} + +note right of DoipCodec + Two-state TCP reassembler: + **AwaitingHeader** (8 bytes) → + **AwaitingPayload** (declared length) + Drops connection on bad header. + DoS guard: rejects payload > max_payload_size. +end note + +' ══════════════════════════════════════════════════════════ +' 2. PAYLOAD DISPATCH & SHARED TRAITS +' ══════════════════════════════════════════════════════════ +package "Payload Dispatch" #E8F8F5 { + + interface DoipParseable <> { + + parse(payload: &[u8]) : Result + } + + interface DoipSerializable <> { + + write_to(buf: &mut BytesMut) + + to_bytes() : Bytes + + serialized_len() : Option + } + + enum DoipPayload <> { + AliveCheckRequest / AliveCheckResponse + RoutingActivationRequest / RoutingActivationResponse + DiagnosticMessage + DiagnosticMessagePositiveAck / NegativeAck + VehicleIdentificationRequest / WithEid / WithVin + VehicleIdentificationResponse + GenericNack(GenericNackCode) + .. + + parse(msg: &DoipMessage) : Result + + payload_type() : PayloadType + } +} + +note right of DoipPayload + Single entry-point for decoding any DoIP message. + Matches on PayloadType and delegates to the + correct module's parse() automatically. + Every variant wraps a fully-typed struct. +end note + +' ══════════════════════════════════════════════════════════ +' 3. DoIP MESSAGE TYPES +' ══════════════════════════════════════════════════════════ + +package "Alive Check (ISO §7.6)" #EEF4FF { + class AliveCheckRequest <> {} + class AliveCheckResponse { + tester_address: u16 + } +} + +package "Routing Activation (ISO §7.7)" #EEF4FF { + class RoutingActivationRequest { + source_address: u16 + activation_type: ActivationType + reserved: u32 + oem_specific: Option + } + class RoutingActivationResponse { + tester_address: u16 + entity_address: u16 + response_code: ActivationResponseCode + reserved: u32 + oem_specific: Option + .. + + success(tester, entity) : Self + + denial(tester, entity, code) : Self + + is_success() : bool + } + enum ActivationType <
> { + Default = 0x00 + WwhObd = 0x01 + CentralSecurity = 0xE0 + } + enum ActivationResponseCode <
> { + SuccessfullyActivated = 0x10 + ConfirmationRequired = 0x11 + -- denial codes -- + UnknownSourceAddress = 0x00 + AllSocketsRegistered = 0x01 + DifferentSourceAddress = 0x02 + SourceAddressAlreadyActive = 0x03 + MissingAuthentication = 0x04 + RejectedConfirmation = 0x05 + UnsupportedActivationType = 0x06 + TlsRequired = 0x07 + } +} + +package "Diagnostic Message (ISO §7.9)" #EEF4FF { + class DiagnosticMessage { + source_address: u16 + target_address: u16 + user_data: Bytes ' UDS payload (min 1 byte) + .. + + service_id() : Option + } + class DiagnosticAck { + source_address: u16 + target_address: u16 + result: AckResult + previous_data: Option + .. + + positive(src, tgt) : Self + + negative(src, tgt, code) : Self + } + enum AckResult { + Positive ' 0x00 + Negative(DiagnosticNackCode) + } + enum DiagnosticNackCode <
> { + InvalidSourceAddress = 0x02 + UnknownTargetAddress = 0x03 + DiagnosticMessageTooLarge = 0x04 + OutOfMemory = 0x05 + TargetUnreachable = 0x06 + UnknownNetwork = 0x07 + TransportProtocolError = 0x08 + } +} + +package "Vehicle Identification (ISO §7.5)" #EEF4FF { + class VehicleIdRequest <> {} + class VehicleIdRequestWithEid { + eid: [u8; 6] + } + class VehicleIdRequestWithVin { + vin: [u8; 17] + } + class VehicleIdResponse { + vin: [u8; 17] + logical_address: u16 + eid: [u8; 6] + gid: [u8; 6] + further_action: FurtherAction + sync_status: Option + } +} + +' ══════════════════════════════════════════════════════════ +' 4. INTERNAL IMPLEMENTATION (crate-only) +' ══════════════════════════════════════════════════════════ +package "Internal Implementation (- = crate-only)" #EBF5FB { + + class TcpListener { + - config: Arc + - sessions: Arc + - handler: Arc + .. + + new(config, sessions, handler) : Self + + start(token: CancellationToken) : Result<()> + - accept_connection() : Result + } + + class UdpListener { + - config: Arc + .. + + new(config: Arc) : Self + + start(token: CancellationToken) : Result<()> + - receive_message() : Result + - send_response(peer, response) + } + + class TcpConnection { + - session_id: u64 + - codec: DoipCodec + - dispatcher: MessageDispatcher + - sessions: Arc + .. + + run() : Result<()> + - handle_message() : Result<()> + - send_response(msg: DoipMessage) + - send_alive_check() + - close() + } + + note right of TcpConnection + SRP: transport only. + send_alive_check() = I/O action. + Protocol logic lives in + MessageDispatcher handles. + end note + + class MessageDispatcher { + - routing: RoutingActivationHandler + - alive: AliveCheckHandler + - diag: Arc + - sessions: Arc + .. + + new(sessions, handler) : Self + + dispatch(payload: DoipPayload,\n session_id: u64) : Result + } + + note right of MessageDispatcher + Strategy pattern: + each handler is a focused unit. + Only dispatcher mutates + session protocol state. + end note + + class RoutingActivationHandler { + + handle(req, session_id,\n sessions) : Result + } + + class AliveCheckHandler { + + handle_response(resp, session_id,\n sessions) : Result<()> + } + + class SessionManager { + - sessions: Mutex> + - next_id: AtomicU64 + .. + + new() : Arc + + create_session(peer_addr: SocketAddr) : Session + + get_session(id: u64) : Option + + remove_session(id: u64) + + update_session_state(id: u64, tester_addr: u16) + + reset_inactivity_timer(id: u64) + + check_timeouts(config) : Vec + } + + note right of SessionManager + Thread-safe session store. + One Session per active TCP connection. + Enforces max_connections limit. + end note + + enum SessionState <> { + Connected + RoutingActive + Closed + } + + class Session { + - id: u64 + - peer_addr: SocketAddr + - tester_address: u16 + - state: SessionState + - last_activity: Instant + .. + + is_routing_active() : bool + + tester_address() : u16 + } +} + +' ══════════════════════════════════════════════════════════ +' 5. PUBLIC INTERFACE (user API) +' ══════════════════════════════════════════════════════════ +package "Public Interface (+ = user API)" #EAFAF1 { + + class DoipServer { + - udp: UdpListener + - tcp: TcpListener + - token: CancellationToken + .. + + new(config: Arc,\n handler: Arc) : Self + + start() : Result<()> + + stop() + - handle_udp() + - handle_tcp() + } + + class ServerConfig { + + tcp_addr: SocketAddr + + udp_addr: SocketAddr + + logical_address: u16 + + vin: [u8; 17] + + eid: [u8; 6] + + gid: [u8; 6] + + max_connections: usize + + initial_inactivity_ms: u64 + + general_inactivity_ms: u64 + .. + + from_file(path) : Result + + default() : Self + } + + interface UdsHandler <> { + + handle(request: UdsRequest) : Result + } + + note right of UdsHandler + Returns Result — handler + can signal ECU errors. + Implement this trait to + connect your ECU logic. + end note + + class UdsRequest { + source_address: u16 + target_address: u16 + payload: Bytes + .. + + service_id() : Option + } + + class UdsResponse { + source_address: u16 + target_address: u16 + payload: Bytes + } +} + +' ══════════════════════════════════════════════════════════ +' RELATIONSHIPS +' ══════════════════════════════════════════════════════════ + +' — TCP framing — +DoipCodec ..> DoipMessage : frames +DoipMessage *-- DoipHeader +DoipHeader --> PayloadType + +' — Dispatch — +DoipPayload ..> DoipMessage : parse() +DoipPayload --> AliveCheckRequest +DoipPayload --> AliveCheckResponse +DoipPayload --> RoutingActivationRequest +DoipPayload --> RoutingActivationResponse +DoipPayload --> DiagnosticMessage +DoipPayload --> DiagnosticAck +DoipPayload --> VehicleIdRequest +DoipPayload --> VehicleIdRequestWithEid +DoipPayload --> VehicleIdRequestWithVin +DoipPayload --> VehicleIdResponse + +' — All message types implement DoipParseable + DoipSerializable — +note "All message types above\nimplement DoipParseable\nand DoipSerializable." as TraitNote +DoipParseable .. TraitNote +DoipSerializable .. TraitNote + +' — Enum ownership — +RoutingActivationRequest --> ActivationType +RoutingActivationResponse --> ActivationResponseCode +DiagnosticAck --> AckResult +AckResult --> DiagnosticNackCode + +' — Public API owns listeners — +DoipServer o-- UdpListener : owns +DoipServer o-- TcpListener : owns +DoipServer ..> ServerConfig : Arc<> +DoipServer ..> UdsHandler : Arc + +' — Internal wiring — +TcpListener ..> ServerConfig : Arc<> +TcpListener ..> SessionManager : Arc<> +TcpListener ..> UdsHandler : Arc +TcpListener ..> TcpConnection : spawns per accept + +UdpListener ..> ServerConfig : Arc<> + +TcpConnection o-- DoipCodec : Arc<>\nlifecycle only +TcpConnection o-- MessageDispatcher +TcpConnection ..> SessionManager : Arc<>\nprotocol state only + +MessageDispatcher o-- RoutingActivationHandler : owns +MessageDispatcher o-- AliveCheckHandler : owns +MessageDispatcher ..> UdsHandler : Arc +MessageDispatcher ..> SessionManager : Arc<> + +SessionManager "1" o-- "0..*" Session : manages 1..* +Session *-- SessionState + +' — UDS bridge — +DiagnosticMessage ..> UdsRequest : user_data → +UdsHandler ..> UdsRequest : consumes +UdsHandler ..> UdsResponse : produces + +@enduml \ No newline at end of file diff --git a/doip-server/doc/ClassDiagram.svg b/doip-server/doc/ClassDiagram.svg new file mode 100644 index 0000000..fb2c962 --- /dev/null +++ b/doip-server/doc/ClassDiagram.svg @@ -0,0 +1,527 @@ +TCP FramingPayload DispatchAlive Check (ISO §7.6)Routing Activation (ISO §7.7)Diagnostic Message (ISO §7.9)Vehicle Identification (ISO §7.5)Internal Implementation (- = crate-only)Public Interface (+ = user API)DoipCodec-state: AwaitingHeader | AwaitingPayload-max_payload_size: u32+new() : Self+with_max_payload_size(n: u32) : SelfDoipMessage+header: DoipHeader+payload: Bytes«8-byte generic header §6»DoipHeaderversion: u8 ' 0x02 = ISO 13400-2:2019inverse_version: u8 ' version XOR 0xFFpayload_type: u16payload_length: u32 ' big-endian+parse(bytes) : Result<Self>+validate() : Option<GenericNackCode>«Table 16»PayloadTypeGenericNack = 0x0000VehicleIdentificationRequest = 0x0001VehicleIdentificationReqEid = 0x0002VehicleIdentificationReqVin = 0x0003VehicleIdentificationResponse = 0x0004RoutingActivationRequest = 0x0005RoutingActivationResponse = 0x0006AliveCheckRequest = 0x0007AliveCheckResponse = 0x0008DiagnosticMessage = 0x8001DiagnosticMessagePositiveAck = 0x8002DiagnosticMessageNegativeAck = 0x8003«Table 17»GenericNackCodeIncorrectPatternFormat = 0x00UnknownPayloadType = 0x01MessageTooLarge = 0x02OutOfMemory = 0x03InvalidPayloadLength = 0x04«trait»DoipParseable+parse(payload: &[u8]) : Result<Self>«trait»DoipSerializable+write_to(buf: &mut BytesMut)+to_bytes() : Bytes+serialized_len() : Option<usize>«typed dispatch envelope»DoipPayloadAliveCheckRequest / AliveCheckResponseRoutingActivationRequest / RoutingActivationResponseDiagnosticMessageDiagnosticMessagePositiveAck / NegativeAckVehicleIdentificationRequest / WithEid / WithVinVehicleIdentificationResponseGenericNack(GenericNackCode)+parse(msg: &DoipMessage) : Result<Self>+payload_type() : PayloadType«zero-length payload»AliveCheckRequestAliveCheckResponsetester_address: u16RoutingActivationRequestsource_address: u16activation_type: ActivationTypereserved: u32oem_specific: Option<u32>RoutingActivationResponsetester_address: u16entity_address: u16response_code: ActivationResponseCodereserved: u32oem_specific: Option<u32>+success(tester, entity) : Self+denial(tester, entity, code) : Self+is_success() : bool«Table 24»ActivationTypeDefault = 0x00WwhObd = 0x01CentralSecurity = 0xE0«Table 25»ActivationResponseCodeSuccessfullyActivated = 0x10ConfirmationRequired = 0x11UnknownSourceAddress = 0x00AllSocketsRegistered = 0x01DifferentSourceAddress = 0x02SourceAddressAlreadyActive = 0x03MissingAuthentication = 0x04RejectedConfirmation = 0x05UnsupportedActivationType = 0x06TlsRequired = 0x07denial codesDiagnosticMessagesource_address: u16target_address: u16user_data: Bytes ' UDS payload (min 1 byte)+service_id() : Option<u8>DiagnosticAcksource_address: u16target_address: u16result: AckResultprevious_data: Option<Bytes>+positive(src, tgt) : Self+negative(src, tgt, code) : SelfAckResultPositive ' 0x00Negative(DiagnosticNackCode)«Table 28»DiagnosticNackCodeInvalidSourceAddress = 0x02UnknownTargetAddress = 0x03DiagnosticMessageTooLarge = 0x04OutOfMemory = 0x05TargetUnreachable = 0x06UnknownNetwork = 0x07TransportProtocolError = 0x08«broadcast — no filter»VehicleIdRequestVehicleIdRequestWithEideid: [u8; 6]VehicleIdRequestWithVinvin: [u8; 17]VehicleIdResponsevin: [u8; 17]logical_address: u16eid: [u8; 6]gid: [u8; 6]further_action: FurtherActionsync_status: Option<GidSyncStatus>TcpListener-config: Arc<ServerConfig>-sessions: Arc<SessionManager>-handler: Arc<dyn UdsHandler>+new(config, sessions, handler) : Self+start(token: CancellationToken) : Result<()>-accept_connection() : Result<TcpStream>UdpListener-config: Arc<ServerConfig>+new(config: Arc<ServerConfig>) : Self+start(token: CancellationToken) : Result<()>-receive_message() : Result<DoipMessage>-send_response(peer, response)TcpConnection-session_id: u64-codec: DoipCodec-dispatcher: MessageDispatcher-sessions: Arc<SessionManager>+run() : Result<()>-handle_message() : Result<()>-send_response(msg: DoipMessage)-send_alive_check()-close()SRP: transport only.send_alive_check() = I/O action.Protocol logic lives inMessageDispatcher handles.MessageDispatcher-routing: RoutingActivationHandler-alive: AliveCheckHandler-diag: Arc<dyn UdsHandler>-sessions: Arc<SessionManager>+new(sessions, handler) : Self+dispatch(payload: DoipPayload,session_id: u64) : Result<DoipMessage>Strategy pattern:each handler is a focused unit.Only dispatcher mutatessession protocol state.RoutingActivationHandler+handle(req, session_id,sessions) : Result<DoipMessage>AliveCheckHandler+handle_response(resp, session_id,sessions) : Result<()>SessionManager-sessions: Mutex<HashMap<u64, Session>>-next_id: AtomicU64+new() : Arc<Self>+create_session(peer_addr: SocketAddr) : Session+get_session(id: u64) : Option<Session>+remove_session(id: u64)+update_session_state(id: u64, tester_addr: u16)+reset_inactivity_timer(id: u64)+check_timeouts(config) : Vec<u64>Thread-safe session store.One Session per active TCP connection.Enforces max_connections limit.«ISO §7.4 lifecycle»SessionStateConnectedRoutingActiveClosedSession-id: u64-peer_addr: SocketAddr-tester_address: u16-state: SessionState-last_activity: Instant+is_routing_active() : bool+tester_address() : u16DoipServer-udp: UdpListener-tcp: TcpListener-token: CancellationToken+new(config: Arc<ServerConfig>,handler: Arc<dyn UdsHandler>) : Self+start() : Result<()>+stop()-handle_udp()-handle_tcp()ServerConfig+tcp_addr: SocketAddr+udp_addr: SocketAddr+logical_address: u16+vin: [u8; 17]+eid: [u8; 6]+gid: [u8; 6]+max_connections: usize+initial_inactivity_ms: u64+general_inactivity_ms: u64+from_file(path) : Result<Self>+default() : Self«implement this trait to\nconnect your ECU logic»UdsHandler+handle(request: UdsRequest) : Result<UdsResponse>Returns Result — handlercan signal ECU errors.Implement this trait toconnect your ECU logic.UdsRequestsource_address: u16target_address: u16payload: Bytes+service_id() : Option<u8>UdsResponsesource_address: u16target_address: u16payload: BytesTwo-state TCP reassembler:AwaitingHeader(8 bytes) →AwaitingPayload(declared length)Drops connection on bad header.DoS guard: rejects payload > max_payload_size.Single entry-point for decoding any DoIP message.Matches on PayloadType and delegates to thecorrect module's parse() automatically.Every variant wraps a fully-typed struct.All message types aboveimplement DoipParseableand DoipSerializable.framesparse()ownsownsArc<>Arc<dyn>Arc<>Arc<>Arc<dyn>spawns per acceptArc<>Arc<>lifecycle onlyArc<>protocol state onlyownsownsArc<dyn>Arc<>manages 1..*10..*user_data →consumesproduces \ No newline at end of file diff --git a/doip-server/doc/flowchart.puml b/doip-server/doc/flowchart.puml new file mode 100644 index 0000000..aad81fc --- /dev/null +++ b/doip-server/doc/flowchart.puml @@ -0,0 +1,205 @@ +@startuml flowchart + +skinparam backgroundColor #FAFAFA +skinparam activity { + BackgroundColor #EEF4FF + BorderColor #4A7CC7 + FontSize 12 + ArrowColor #4A7CC7 + StartColor #4A7CC7 + EndColor #C0392B + DiamondBackgroundColor #FFF9E3 + DiamondBorderColor #E67E22 +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 +} + +start + +:DoipServer::new(\n Arc, Arc); +note right: ServerConfig::from_file()\nor ServerConfig::default() + +:CancellationToken::new(); + +fork + :tokio::spawn(\n UdpListener::start(token.clone())); + note right: Stateless — handles Vehicle\nIdentification broadcasts (UDP) +fork again + :tokio::spawn(\n TcpListener::start(token.clone())); +end fork + +' ── TcpListener accept loop ── +repeat + + if (token.is_cancelled()?) then (yes) + :Graceful shutdown; + stop + else (no) + endif + + :TcpListener::accept() → TcpStream\n(peer_addr); + + :SessionManager::create_session(peer_addr); + note right: Session { state=Connected\n tester_address=0\n last_activity=Instant::now() } + + fork + :tokio::spawn(TcpConnection::run()); + fork again + :Continue accepting\nnew connections; + end fork + + ' ── Per-connection task (TcpConnection::run) ── + + repeat + + if (token.is_cancelled()?) then (yes) + :tcp_stream.shutdown(); + :SessionManager::remove_session(id); + stop + else (no) + endif + + :SessionManager::check_timeouts(config); + if (session timed out?) then (yes) + if (state == Connected\n&& elapsed > initial_ms?) then (yes) + :Initial inactivity — no routing\nactivation received in time; + :tcp_stream.shutdown(); + :SessionManager::remove_session(id); + stop + else (RoutingActive) + :Send AliveCheckRequest [0x0007]; + if (AliveCheckResponse within\nT_TCP_Alive_Check?) then (yes) + :SessionManager::reset_inactivity_timer(id); + else (no) + :Err(DoipError::AliveCheckTimeout); + :tcp_stream.shutdown(); + :SessionManager::remove_session(id); + stop + endif + endif + else (no) + endif + + :Wait for bytes on TCP stream; + + :DoipCodec::decode(src: BytesMut); + + if (src.len < 8 bytes?) then (yes) + :return Ok(None)\nwait for more data; + else (no) + endif + + :DoipHeader::parse(header_slice); + + if (Header valid?\nversion + inverse XOR = 0xFF) then (no) + :return Err(DoipError::InvalidHeader); + :Close TCP connection; + stop + else (yes) + endif + + if (payload_length > max_payload_size?) then (yes) + :return Err(DoipError::MessageTooLarge); + :Close TCP connection; + stop + else (no) + endif + + if (src.len < 8 + payload_length?) then (yes) + :return Ok(None)\nwait for more data; + else (no) + endif + + :split_to → DoipMessage { header, payload: Bytes }; + + :MessageDispatcher::dispatch(\n DoipPayload::parse(msg), session_id); + + if (payload_type known?) then (no) + :DoipError::UnknownPayloadType; + :Send GenericNack(UnknownPayloadType); + else (yes) + + switch (payload_type?) + + case (0x0005\nRoutingActivationRequest) + :RoutingActivationHandler::handle(\n req, session_id); + if (Session already RoutingActive\nwith same address?) then (yes) + :RoutingActivationResponse::denial(\n SourceAddressAlreadyActive); + elseif (Max connections reached?) then (yes) + :RoutingActivationResponse::denial(\n AllSocketsRegistered); + else (no) + :SessionManager::update_session_state(\n session_id, SA); + note right: Session.state = RoutingActive\nSession.last_activity = Instant::now() + :RoutingActivationResponse::success(\n tester_addr, entity_addr); + endif + :DoipCodec::encode(RoutingActivationResponse); + :Send bytes on TCP; + + case (0x0007\nAliveCheckRequest) + :AliveCheckHandler::handle(req, session_id); + :AliveCheckRequest::parse(payload)\n[zero-length, always Ok]; + :AliveCheckResponse::new(\n session.tester_address()); + :to_bytes() → [addr_hi, addr_lo]; + :DoipCodec::encode(AliveCheckResponse\ntype=0x0008); + :Send 10 bytes on TCP; + + case (0x0008\nAliveCheckResponse) + :AliveCheckHandler::handle_response(\n resp, session_id); + :AliveCheckResponse::parse(payload)\n→ tester_address: u16; + if (tester_address matches\nsession.tester_address()?) then (yes) + :SessionManager::reset_inactivity_timer(id); + else (no) + :Log mismatch\nClose TCP connection; + stop + endif + + case (0x8001\nDiagnosticMessage) + if (session.is_routing_active()?) then (no) + :DiagnosticAck::negative(\n InvalidSourceAddress); + :Send DiagnosticMessageNegativeAck\n(type=0x8003); + else (yes) + :DiagnosticMessage::parse(payload)\n→ SA, TA, user_data; + :DiagnosticAck::positive(TA, SA); + :Send DiagnosticMessagePositiveAck\n(type=0x8002); + :UdsRequest::new(SA, TA, user_data); + :UdsHandler::handle(UdsRequest)\n→ Result\n[USER IMPLEMENTATION]; + if (Ok(UdsResponse)?) then (yes) + :DiagnosticMessage::new(\n SA=entity, TA=tester, data); + :DoipCodec::encode(DiagnosticMessage\ntype=0x8001); + :Send UDS response bytes on TCP; + else (Err(ApplicationError)) + :Send GenericNack or log error; + endif + endif + + case (0x0001/0x0002/0x0003\nVehicleIdRequest) + note right: Handled by UdpListener (stateless).\nTCP connections send GenericNack. + :Send GenericNack\n(IncorrectPatternFormat); + + case (other) + :Send GenericNack\n(IncorrectPatternFormat); + + endswitch + + endif + + repeat while (TCP connection open?) is (yes) + -> no; + + :SessionManager::remove_session(id); + note right: Session dropped\nstate=Closed + +repeat while (token.is_cancelled() == false) is (yes) +-> no; + +:CancellationToken::cancel() +(DoipServer::stop() called); +:All TcpConnection tasks receive +cancellation signal → shutdown; +:DoipServer::start() returns Ok(()); + +stop + +@enduml \ No newline at end of file diff --git a/doip-server/doc/flowchart.svg b/doip-server/doc/flowchart.svg new file mode 100644 index 0000000..558f26b --- /dev/null +++ b/doip-server/doc/flowchart.svg @@ -0,0 +1,215 @@ +ServerConfig::from_file()or ServerConfig::default()DoipServer::new(Arc<ServerConfig>, Arc<dyn UdsHandler>)CancellationToken::new()Stateless — handles VehicleIdentification broadcasts (UDP)tokio::spawn(UdpListener::start(token.clone()))tokio::spawn(TcpListener::start(token.clone()))Graceful shutdownyestoken.is_cancelled()?noTcpListener::accept() → TcpStream(peer_addr)Session { state=Connectedtester_address=0last_activity=Instant::now() }SessionManager::create_session(peer_addr)tokio::spawn(TcpConnection::run())Continue acceptingnew connectionstcp_stream.shutdown()SessionManager::remove_session(id)yestoken.is_cancelled()?noSessionManager::check_timeouts(config)state == Connected&& elapsed > initial_ms?yesRoutingActiveInitial inactivity — no routingactivation received in timetcp_stream.shutdown()SessionManager::remove_session(id)Send AliveCheckRequest [0x0007]AliveCheckResponse withinT_TCP_Alive_Check?yesnoSessionManager::reset_inactivity_timer(id)Err(DoipError::AliveCheckTimeout)tcp_stream.shutdown()SessionManager::remove_session(id)yessession timed out?noWait for bytes on TCP streamDoipCodec::decode(src: BytesMut)return Ok(None)wait for more datayessrc.len < 8 bytes?noDoipHeader::parse(header_slice)return Err(DoipError::InvalidHeader)Close TCP connectionnoHeader valid?version + inverse XOR = 0xFFyesreturn Err(DoipError::MessageTooLarge)Close TCP connectionyespayload_length > max_payload_size?noreturn Ok(None)wait for more datayessrc.len < 8 + payload_length?nosplit_to → DoipMessage { header, payload: Bytes }MessageDispatcher::dispatch(DoipPayload::parse(msg), session_id)payload_type known?noyesDoipError::UnknownPayloadTypeSend GenericNack(UnknownPayloadType)payload_type?RoutingActivationHandler::handle(req, session_id)yesSession already RoutingActivewith same address?RoutingActivationResponse::denial(SourceAddressAlreadyActive)yesMax connections reached?noRoutingActivationResponse::denial(AllSocketsRegistered)Session.state = RoutingActiveSession.last_activity = Instant::now()SessionManager::update_session_state(session_id, SA)RoutingActivationResponse::success(tester_addr, entity_addr)DoipCodec::encode(RoutingActivationResponse)Send bytes on TCPAliveCheckHandler::handle(req, session_id)AliveCheckRequest::parse(payload)[zero-length, always Ok]AliveCheckResponse::new(session.tester_address())to_bytes() → [addr_hi, addr_lo]DoipCodec::encode(AliveCheckResponsetype=0x0008)Send 10 bytes on TCPAliveCheckHandler::handle_response(resp, session_id)AliveCheckResponse::parse(payload)→ tester_address: u16tester_address matchessession.tester_address()?yesnoSessionManager::reset_inactivity_timer(id)Log mismatchClose TCP connectionsession.is_routing_active()?noyesDiagnosticAck::negative(InvalidSourceAddress)Send DiagnosticMessageNegativeAck(type=0x8003)DiagnosticMessage::parse(payload)→ SA, TA, user_dataDiagnosticAck::positive(TA, SA)Send DiagnosticMessagePositiveAck(type=0x8002)UdsRequest::new(SA, TA, user_data)UdsHandler::handle(UdsRequest)→ Result<UdsResponse>[USER IMPLEMENTATION]Ok(UdsResponse)?yesErr(ApplicationError)DiagnosticMessage::new(SA=entity, TA=tester, data)DoipCodec::encode(DiagnosticMessagetype=0x8001)Send UDS response bytes on TCPSend GenericNack or log errorSend GenericNack(IncorrectPatternFormat)Send GenericNack(IncorrectPatternFormat)TCP connection open?yesSession droppedstate=ClosedSessionManager::remove_session(id)token.is_cancelled() == falseyesCancellationToken::cancel()(DoipServer::stop() called)All TcpConnection tasks receivecancellation signal → shutdownDoipServer::start() returns Ok(())0x0005RoutingActivationRequestother0x0007AliveCheckRequest0x0008AliveCheckResponse0x8001DiagnosticMessage0x0001/0x0002/0x0003VehicleIdRequestnono \ No newline at end of file diff --git a/doip-server/doc/sequence.puml b/doip-server/doc/sequence.puml new file mode 100644 index 0000000..8f0ff0f --- /dev/null +++ b/doip-server/doc/sequence.puml @@ -0,0 +1,296 @@ +@startuml sequence + +' ========================================================== +' Abstraction shown via 3 techniques: +' 1. box grouping — Public Interface vs Internal Impl +' 2. self-calls — hidden internal logic (X -> X) +' 3. boundary note — <> marks abstraction edge +' ========================================================== + +skinparam backgroundColor white +skinparam defaultFontSize 12 +skinparam sequence { + ArrowColor #4A7CC7 + LifeLineBorderColor #4A7CC7 + LifeLineBackgroundColor #EBF5FB + ParticipantBackgroundColor #EEF4FF + ParticipantBorderColor #4A7CC7 + ParticipantFontStyle bold + ActorBorderColor #4A7CC7 + ActorBackgroundColor #EBF5FB + NoteBackgroundColor #FFFDE7 + NoteBorderColor #E0AC00 + GroupBackgroundColor #F5F5F5 + GroupBorderColor #BDBDBD + BoxBackgroundColor #FAFAFA + BoxBorderColor #4A7CC7 + ParticipantPadding 20 + MessageSpacing 15 +} + +' ── Abstraction Technique 1: box grouping ───────────────── +box "Public Interface — visible to user" #D5F5E3 + actor Tester as T #EBF5FB + participant "DoipServer\n(pub)" as DS #D5F5E3 +end box + +box "Internal Implementation — pub(crate) only" #EEF4FF + participant "UdpListener" as UDP #EEF4FF + participant "TcpListener" as TCP #EEF4FF + participant "TcpConnection" as Conn #EEF4FF + participant "DoipCodec" as Codec #FEF9E7 + participant "MessageDispatcher" as Disp #EEF4FF + participant "UdsHandler\n(user impl)" as UDS #F9F3FF +end box + +' ── Abstraction Technique 3: boundary note ──────────────── +note over DS, UDS + <> + Tester interacts only with DoipServer (pub). + UdpListener, TcpListener, TcpConnection, DoipCodec, + MessageDispatcher are all pub(crate) — invisible externally. + UdsHandler is pub — user implements it, library calls it. +end note + +' ────────────────────────────────────────────────────────── +== 0. Server Initialization == +' ────────────────────────────────────────────────────────── + +note over DS, TCP #FFFDE7 + Arc shared by all components at construction. + Holds: VIN · EID · GID · logical_address + tcp_addr · udp_addr · max_connections + initial_inactivity_ms · general_inactivity_ms +end note + +DS -> DS : DoipServer::new(Arc,\n Arc) +DS -> DS : CancellationToken::new() +DS -> UDP : UdpListener::new(Arc) +DS -> UDP : UdpSocket::bind(config.udp_addr()) +DS -> TCP : TcpListener::new(Arc,\n Arc, Arc) +DS -> TCP : TcpSocket::bind(config.tcp_addr()) +DS -> DS : tokio::spawn(UdpListener::start(token.clone())) +DS -> DS : tokio::spawn(TcpListener::start(token.clone())) + +' ────────────────────────────────────────────────────────── +== 1. Vehicle Identification (UDP, ISO §7.5) == +' ────────────────────────────────────────────────────────── +T -> UDP : VehicleIdentificationRequest [0x0001]\n(UDP broadcast) + +note over UDP #FFFDE7 + <> enters internal impl +end note + +' Abstraction Technique 2: self-calls = hidden logic +UDP -> UDP : DoipCodec::decode(bytes) → DoipMessage +UDP -> UDP : DoipPayload::parse(&msg) + +alt VehicleIdRequest [0x0001] — broadcast (no filter) + UDP -> UDP : respond unconditionally +else VehicleIdRequestWithEid [0x0002] + UDP -> UDP : req.eid() == config.eid() → match +else VehicleIdRequestWithVin [0x0003] + UDP -> UDP : req.vin() == config.vin() → match +end + +UDP -> UDP : VehicleIdResponse::new(\n config.vin(), config.logical_address(),\n config.eid(), config.gid()) +UDP -> UDP : DoipCodec::encode(response) → bytes + +note over UDP #FFFDE7 + <> returns to public interface +end note + +UDP --> T : VehicleIdentificationResponse [0x0004] + +' ────────────────────────────────────────────────────────── +== 2. TCP Connection Establishment == +' ────────────────────────────────────────────────────────── +T -> TCP : TCP connect (port 13400) +TCP -> TCP : TcpListener::accept() → TcpStream +TCP -> TCP : check_max_connections() +TCP -> Conn : tokio::spawn(TcpConnection::run(TcpStream)) +Conn -> Conn : SessionManager::create_session(peer_addr)\nstate = Connected +TCP --> T : connection accepted + +' ────────────────────────────────────────────────────────── +== 3. Alive Check (ISO §7.6) == +' ────────────────────────────────────────────────────────── +Conn -> T : AliveCheckRequest [0x0007] +T -> Conn : AliveCheckResponse [0x0008]\ntester_address (2 bytes) + +note over Conn, Disp #FFFDE7 + <> enters internal impl +end note + +Conn -> Codec : decode(bytes) → DoipMessage +Codec --> Conn : Ok(AliveCheckResponse) +Conn -> Disp : dispatch(AliveCheckResponse, session_id) +Disp -> Disp : AliveCheckHandler::handle_response(resp, session_id) +Disp -> Disp : SessionManager::reset_inactivity_timer(session_id) + +' ────────────────────────────────────────────────────────── +== 4. Routing Activation (ISO §7.7) == +' ────────────────────────────────────────────────────────── +T -> Conn : RoutingActivationRequest [0x0005]\nSA=0x0E00 + +note over Conn, Codec #FFFDE7 + <> enters internal impl +end note + +Conn -> Codec : decode(bytes) +Codec -> Codec : parse_header() +Codec -> Codec : validate_version() +Codec -> Codec : check_payload_size() +Codec --> Conn : Ok(RoutingActivationRequest) + +Conn -> Disp : dispatch(RoutingActivationRequest, session_id) +Disp -> Disp : RoutingActivationHandler::handle(req, session_id) +Disp -> Disp : SessionManager::is_tester_registered(SA) → false +Disp -> Disp : SessionManager::update_session_state(\n session_id, SA=0x0E00) → RoutingActive + +Conn -> Codec : encode(RoutingActivationResponse) +Codec --> Conn : bytes + +note over Conn, Codec #FFFDE7 + <> returns to public interface +end note + +alt success + Conn --> T : RoutingActivationResponse [0x0006]\ncode=SuccessfullyActivated(0x10) +else max connections reached + Conn --> T : RoutingActivationResponse [0x0006]\ncode=AllSocketsRegistered(0x01) +else address already active + Conn --> T : RoutingActivationResponse [0x0006]\ncode=SourceAddressAlreadyActive(0x03) +end + +' ────────────────────────────────────────────────────────── +== 5. Diagnostic Message (ISO §7.9) == +' ────────────────────────────────────────────────────────── +T -> Conn : DiagnosticMessage [0x8001]\nSA=0x0E00 TA=0xE000 data=[0x10,0x03] + +note over Conn, Codec #FFFDE7 + <> enters internal impl +end note + +Conn -> Codec : decode(bytes) +Codec --> Conn : Ok(DiagnosticMessage) +Conn -> Disp : dispatch(DiagnosticMessage, session_id) +Disp -> Disp : SessionManager::is_routing_active(session_id) → true +Conn -> Codec : encode(DiagnosticMessagePositiveAck) +Codec --> Conn : bytes + +note over Conn, Codec #FFFDE7 + <> returns to public interface +end note + +Conn --> T : DiagnosticMessagePositiveAck [0x8002] + +note over Disp, UDS #FFFDE7 + <> enters UDS layer +end note + +Disp -> UDS : handle(UdsRequest\nSA=0x0E00, TA=0xE000, data=[0x10,0x03]) +UDS -> UDS : process_service() +UDS -> UDS : build_response(data=[0x50,0x03]) +UDS --> Disp : Ok(UdsResponse { data=[0x50,0x03] }) + +note over Disp, UDS #FFFDE7 + <> returns from UDS layer +end note + +Disp --> Conn : Ok(DiagnosticMessage response) +Conn -> Codec : encode(DiagnosticMessage response) +Codec --> Conn : bytes +Conn --> T : DiagnosticMessage [0x8001]\ndata=[0x50,0x03] (positive response) + +group Routing NOT Active + T -> Conn : DiagnosticMessage [0x8001]\nSA=0x0E99 + Conn -> Codec : decode(bytes) + Codec --> Conn : Ok(DiagnosticMessage) + Conn -> Disp : dispatch(DiagnosticMessage, session_id) + Disp -> Disp : SessionManager::is_routing_active(session_id) → false + Conn -> Codec : encode(DiagnosticMessageNegativeAck) + Codec --> Conn : bytes + Conn --> T : DiagnosticMessageNegativeAck [0x8003]\ncode=InvalidSourceAddress(0x02) +end + +' ────────────────────────────────────────────────────────── +== 6. Generic NACK == +' ────────────────────────────────────────────────────────── +T -> Conn : [unknown payload 0x00FF] +Conn -> Codec : decode(bytes) +Codec --> Conn : Ok(DoipMessage { type=0x00FF }) +Conn -> Disp : dispatch(msg, session_id) +Disp -> Disp : DoipPayload::parse() → Err(UnknownPayloadType) +Disp --> Conn : Err(DoipError::UnknownPayloadType) +Conn -> Codec : encode(GenericNack) +Codec --> Conn : bytes +Conn --> T : GenericNack [0x0000]\ncode=UnknownPayloadType(0x01) + +' ────────────────────────────────────────────────────────── +== 7. Disconnection == +' ────────────────────────────────────────────────────────── + +alt Tester-initiated disconnect + T ->x Conn : TCP FIN / RST (tester closes connection) + Conn -> Conn : read() returns EOF + Conn -> Conn : SessionManager::remove_session(id) + Conn -> Conn : tcp_stream.shutdown() +note over Conn #FFFDE7 + Server detects close via read() EOF. + No explicit disconnect API — tester owns initiation. +end note +else Server-initiated disconnect (inactivity timeout) + Conn -> Conn : check_timeouts(config) → session expired + Conn -> Conn : SessionManager::remove_session(id) + Conn -> Conn : tcp_stream.shutdown() + Conn --> T : TCP close +note over Conn #FFFDE7 + Server closes after T_TCP_Initial or T_TCP_General + timer fires with no response from tester. +end note +end + +' ────────────────────────────────────────────────────────── +== 8. Graceful Shutdown == +' ────────────────────────────────────────────────────────── +note over DS #FFFDE7 + DoipServer::stop() called\n(e.g. SIGINT / OS signal) +end note + +DS -> DS : token.cancel() +DS -> UDP : CancellationToken::cancelled() → triggered +DS -> TCP : CancellationToken::cancelled() → triggered +UDP -> UDP : UdpSocket::close() +TCP -> TCP : TcpListener::close() +Conn -> Conn : token.cancelled() → exit run() loop +Conn -> Conn : SessionManager::remove_session(id) +DS -> DS : DoipServer::start() returns Ok(()) + +' ────────────────────────────────────────────────────────── +== 9. Session Timeout == +' ────────────────────────────────────────────────────────── +note over Conn #FFFDE7 + T_TCP_Initial (2 s) or T_TCP_General (5 min)\ninactivity timer fires inside TcpConnection::run() +end note + +Conn -> Conn : SessionManager::check_timeouts(config)\n→ Vec + +alt initial inactivity — no routing activation before T_TCP_Initial + Conn -> Conn : session.state == Connected &&\n elapsed > initial_inactivity_ms + Conn -> Conn : SessionManager::remove_session(id) + Conn -> Conn : tcp_stream.shutdown() + Conn --> T : TCP close\n(routing not activated in time) +else general inactivity — RoutingActive but no UDS traffic + Conn -> Conn : session.state == RoutingActive &&\n elapsed > general_inactivity_ms + Conn -> T : AliveCheckRequest [0x0007] + T -> Conn : AliveCheckResponse [0x0008] + Conn -> Conn : SessionManager::reset_inactivity_timer(id) +else alive check timeout — no response received + Conn -> Conn : Err(DoipError::AliveCheckTimeout) + Conn -> Conn : SessionManager::remove_session(id) + Conn -> Conn : tcp_stream.shutdown() + Conn --> T : TCP close\n(alive check timed out) +end + +@enduml diff --git a/doip-server/doc/sequence.svg b/doip-server/doc/sequence.svg new file mode 100644 index 0000000..f12fcba --- /dev/null +++ b/doip-server/doc/sequence.svg @@ -0,0 +1,279 @@ +Public Interface — visible to userInternal Implementation — pub(crate) onlyTesterTesterDoipServer(pub)DoipServer(pub)UdpListenerUdpListenerTcpListenerTcpListenerTcpConnectionTcpConnectionDoipCodecDoipCodecMessageDispatcherMessageDispatcherUdsHandler(user impl)UdsHandler(user impl)<<abstraction boundary>>Tester interacts only with DoipServer (pub).UdpListener, TcpListener, TcpConnection, DoipCodec,MessageDispatcher are all pub(crate) — invisible externally.UdsHandler is pub — user implements it, library calls it.0. Server InitializationArc<ServerConfig> shared by all components at construction.Holds: VIN · EID · GID · logical_addresstcp_addr · udp_addr · max_connectionsinitial_inactivity_ms · general_inactivity_msDoipServer::new(Arc<ServerConfig>,Arc<dyn UdsHandler>)CancellationToken::new()UdpListener::new(Arc<ServerConfig>)UdpSocket::bind(config.udp_addr())TcpListener::new(Arc<ServerConfig>,Arc<SessionManager>, Arc<dyn UdsHandler>)TcpSocket::bind(config.tcp_addr())tokio::spawn(UdpListener::start(token.clone()))tokio::spawn(TcpListener::start(token.clone()))1. Vehicle Identification (UDP, ISO §7.5)VehicleIdentificationRequest [0x0001](UDP broadcast)<<boundary>> enters internal implDoipCodec::decode(bytes) → DoipMessageDoipPayload::parse(&msg)alt[VehicleIdRequest [0x0001] — broadcast (no filter)]respond unconditionally[VehicleIdRequestWithEid [0x0002]]req.eid() == config.eid() → match[VehicleIdRequestWithVin [0x0003]]req.vin() == config.vin() → matchVehicleIdResponse::new(config.vin(), config.logical_address(),config.eid(), config.gid())DoipCodec::encode(response) → bytes<<boundary>> returns to public interfaceVehicleIdentificationResponse [0x0004]2. TCP Connection EstablishmentTCP connect (port 13400)TcpListener::accept() → TcpStreamcheck_max_connections()tokio::spawn(TcpConnection::run(TcpStream))SessionManager::create_session(peer_addr)state = Connectedconnection accepted3. Alive Check (ISO §7.6)AliveCheckRequest [0x0007]AliveCheckResponse [0x0008]tester_address (2 bytes)<<boundary>> enters internal impldecode(bytes) → DoipMessageOk(AliveCheckResponse)dispatch(AliveCheckResponse, session_id)AliveCheckHandler::handle_response(resp, session_id)SessionManager::reset_inactivity_timer(session_id)4. Routing Activation (ISO §7.7)RoutingActivationRequest [0x0005]SA=0x0E00<<boundary>> enters internal impldecode(bytes)parse_header()validate_version()check_payload_size()Ok(RoutingActivationRequest)dispatch(RoutingActivationRequest, session_id)RoutingActivationHandler::handle(req, session_id)SessionManager::is_tester_registered(SA) → falseSessionManager::update_session_state(session_id, SA=0x0E00) → RoutingActiveencode(RoutingActivationResponse)bytes<<boundary>> returns to public interfacealt[success]RoutingActivationResponse [0x0006]code=SuccessfullyActivated(0x10)[max connections reached]RoutingActivationResponse [0x0006]code=AllSocketsRegistered(0x01)[address already active]RoutingActivationResponse [0x0006]code=SourceAddressAlreadyActive(0x03)5. Diagnostic Message (ISO §7.9)DiagnosticMessage [0x8001]SA=0x0E00 TA=0xE000 data=[0x10,0x03]<<boundary>> enters internal impldecode(bytes)Ok(DiagnosticMessage)dispatch(DiagnosticMessage, session_id)SessionManager::is_routing_active(session_id) → trueencode(DiagnosticMessagePositiveAck)bytes<<boundary>> returns to public interfaceDiagnosticMessagePositiveAck [0x8002]<<boundary>> enters UDS layerhandle(UdsRequestSA=0x0E00, TA=0xE000, data=[0x10,0x03])process_service()build_response(data=[0x50,0x03])Ok(UdsResponse { data=[0x50,0x03] })<<boundary>> returns from UDS layerOk(DiagnosticMessage response)encode(DiagnosticMessage response)bytesDiagnosticMessage [0x8001]data=[0x50,0x03] (positive response)Routing NOT ActiveDiagnosticMessage [0x8001]SA=0x0E99decode(bytes)Ok(DiagnosticMessage)dispatch(DiagnosticMessage, session_id)SessionManager::is_routing_active(session_id) → falseencode(DiagnosticMessageNegativeAck)bytesDiagnosticMessageNegativeAck [0x8003]code=InvalidSourceAddress(0x02)6. Generic NACK[unknown payload 0x00FF]decode(bytes)Ok(DoipMessage { type=0x00FF })dispatch(msg, session_id)DoipPayload::parse() → Err(UnknownPayloadType)Err(DoipError::UnknownPayloadType)encode(GenericNack)bytesGenericNack [0x0000]code=UnknownPayloadType(0x01)7. Disconnectionalt[Tester-initiated disconnect]TCP FIN / RST (tester closes connection)read() returns EOFSessionManager::remove_session(id)tcp_stream.shutdown()Server detects close via read() EOF.No explicit disconnect API — tester owns initiation.[Server-initiated disconnect (inactivity timeout)]check_timeouts(config) → session expiredSessionManager::remove_session(id)tcp_stream.shutdown()TCP closeServer closes after T_TCP_Initial or T_TCP_Generaltimer fires with no response from tester.8. Graceful ShutdownDoipServer::stop() called\n(e.g. SIGINT / OS signal)token.cancel()CancellationToken::cancelled() → triggeredCancellationToken::cancelled() → triggeredUdpSocket::close()TcpListener::close()token.cancelled() → exit run() loopSessionManager::remove_session(id)DoipServer::start() returns Ok(())9. Session TimeoutT_TCP_Initial (2 s) or T_TCP_General (5 min)\ninactivity timer fires inside TcpConnection::run()SessionManager::check_timeouts(config)→ Vec<expired_session_id>alt[initial inactivity — no routing activation before T_TCP_Initial]session.state == Connected &&elapsed > initial_inactivity_msSessionManager::remove_session(id)tcp_stream.shutdown()TCP close(routing not activated in time)[general inactivity — RoutingActive but no UDS traffic]session.state == RoutingActive &&elapsed > general_inactivity_msAliveCheckRequest [0x0007]AliveCheckResponse [0x0008]SessionManager::reset_inactivity_timer(id)[alive check timeout — no response received]Err(DoipError::AliveCheckTimeout)SessionManager::remove_session(id)tcp_stream.shutdown()TCP close(alive check timed out) \ No newline at end of file diff --git a/doip-server/src/doip/alive_check.rs b/doip-server/src/doip/alive_check.rs new file mode 100644 index 0000000..5047b85 --- /dev/null +++ b/doip-server/src/doip/alive_check.rs @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +//! Alive Check handlers (ISO 13400-2) + +use bytes::{BufMut, BytesMut}; + +use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; +use crate::Result; + +/// Alive Check Request (payload type `0x0007`) – sent by the `DoIP` entity to verify +/// a tester is still connected. +/// +/// This message carries no payload (zero-length body per ISO 13400-2:2019 §7.6). +/// It is only parsed inbound; the entity sends it as a bare `DoIP` header with zero payload length. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AliveCheckRequest; + +impl DoipParseable for AliveCheckRequest { + fn parse(_payload: &[u8]) -> Result { + Ok(Self) + } +} + +/// Alive Check Response (payload type `0x0008`) – sent by the tester in reply, +/// carrying its logical address. +/// +/// # Wire Format +/// Payload: `source_address` (2 bytes, big-endian) +#[derive(Debug, PartialEq, Eq)] +pub struct AliveCheckResponse { + source_address: u16, +} + +impl DoipParseable for AliveCheckResponse { + fn parse(payload: &[u8]) -> Result { + let bytes: [u8; 2] = parse_fixed_slice(payload, "AliveCheck Response")?; + let source_address = u16::from_be_bytes(bytes); + Ok(Self { source_address }) + } +} + +impl DoipSerializable for AliveCheckResponse { + fn serialized_len(&self) -> Option { + Some(Self::LEN) + } + + fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.source_address); + } +} + +impl AliveCheckResponse { + /// Fixed wire-format length of the Alive Check Response payload (2-byte source address). + const LEN: usize = 2; + + /// Create a new Alive Check Response with the given tester source address. + #[must_use] + pub fn new(source_address: u16) -> Self { + Self { source_address } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doip::{DoipParseable, DoipSerializable}; + + impl AliveCheckResponse { + /// Returns the tester's logical address. Test-only accessor. + pub(crate) fn get_address(&self) -> u16 { + self.source_address + } + } + + #[test] + fn parse_request() { + let req = AliveCheckRequest::parse(&[]).unwrap(); + assert_eq!(req, AliveCheckRequest); + } + + #[test] + fn parse_response() { + let payload = [0x0E, 0x80]; + let resp = AliveCheckResponse::parse(&payload).unwrap(); + assert_eq!(resp.get_address(), 0x0E80); + } + + #[test] + fn reject_short_response() { + let short = [0x0E]; + assert!(AliveCheckResponse::parse(&short).is_err()); + } + + #[test] + fn build_response() { + let resp = AliveCheckResponse::new(0x0E80); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), 2); + assert_eq!(&bytes[..], &[0x0E, 0x80]); + } + + #[test] + fn roundtrip_response() { + let original = AliveCheckResponse::new(0x0F00); + let bytes = original.to_bytes(); + let parsed = AliveCheckResponse::parse(&bytes).unwrap(); + assert_eq!(original, parsed); + } +} diff --git a/doip-server/src/doip/codec.rs b/doip-server/src/doip/codec.rs new file mode 100644 index 0000000..19a97fd --- /dev/null +++ b/doip-server/src/doip/codec.rs @@ -0,0 +1,370 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ + +//! `DoIP` TCP Codec +//! +//! Provides a Tokio [`Decoder`]/[`Encoder`] pair for framing `DoIP` messages over TCP +//! streams according to ISO 13400-2:2019. A simple two-state machine handles reassembly +//! across packet boundaries: +//! +//! 1. **Header** – wait for 8 bytes, validate, then transition to Payload. +//! 2. **Payload** – wait for the declared payload length, then emit a [`DoipMessage`]. +//! +//! See [`header`](super::header) for the underlying type definitions. + +use std::io; + +use bytes::BytesMut; +use tokio_util::codec::{Decoder, Encoder}; +use tracing::{debug, warn}; + +use super::header::{DOIP_HEADER_LENGTH, DoipHeader, DoipMessage, MAX_DOIP_MESSAGE_SIZE}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DecodeState { + Header, + Payload(DoipHeader), +} + +const DEFAULT_MAX_PAYLOAD_SIZE: u32 = MAX_DOIP_MESSAGE_SIZE; + +/// `DoIP` TCP Codec +/// +/// Implements the Tokio [`Decoder`] / [`Encoder`] trait pair to frame raw TCP bytes +/// into [`DoipMessage`] values and vice-versa. +/// +/// The codec enforces a configurable maximum payload size (default 4 MB) to provide +/// `DoS` protection against oversized message attacks. +#[derive(Debug)] +pub struct DoipCodec { + state: DecodeState, + max_payload_size: u32, +} + +impl DoipCodec { + /// Create a new `DoipCodec` with the default maximum payload size. + #[must_use] + pub fn new() -> Self { + Self { + state: DecodeState::Header, + max_payload_size: DEFAULT_MAX_PAYLOAD_SIZE, + } + } + + /// Create codec with custom max payload size limit + /// + /// The size is u32 to match the `DoIP` header `payload_length` field (4 bytes). + /// This provides `DoS` protection by rejecting oversized messages early. + #[must_use] + pub fn with_max_payload_size(max_size: u32) -> Self { + Self { + state: DecodeState::Header, + max_payload_size: max_size, + } + } +} + +impl Default for DoipCodec { + fn default() -> Self { + Self::new() + } +} + +impl Decoder for DoipCodec { + type Item = DoipMessage; + type Error = io::Error; + + fn decode( + &mut self, + src: &mut BytesMut, + ) -> std::result::Result, Self::Error> { + loop { + match self.state { + DecodeState::Header => { + if src.len() < DOIP_HEADER_LENGTH { + // Reserve space to reduce reallocations when more data arrives + src.reserve(DOIP_HEADER_LENGTH); + return Ok(None); + } + + // Log raw bytes for debugging + let header_slice = src.get(..DOIP_HEADER_LENGTH).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "buffer too short") + })?; + debug!(header_bytes = ?header_slice, "Received raw header bytes"); + + let header = DoipHeader::parse(header_slice) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + if let Some(nack_code) = header.validate() { + warn!( + nack_code = ?nack_code, + header_bytes = ?header_slice, + "Header validation failed" + ); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("validation failed: {nack_code:?}"), + )); + } + + if header.payload_length() > self.max_payload_size { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "payload too large: {} > {}", + header.payload_length(), + self.max_payload_size + ), + )); + } + + // Pre-allocate buffer for the complete message (best-effort hint) + if let Some(reserve_len) = header.message_length() { + src.reserve(reserve_len); + } + self.state = DecodeState::Payload(header); + } + + DecodeState::Payload(header) => { + let Some(total_len) = header.message_length() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "payload length overflows usize", + )); + }; + if src.len() < total_len { + // Still waiting for complete payload - this is normal for large messages + // or when data arrives in multiple TCP packets + return Ok(None); + } + + let _ = src.split_to(DOIP_HEADER_LENGTH); + let payload = src + .split_to(total_len.saturating_sub(DOIP_HEADER_LENGTH)) + .freeze(); + + self.state = DecodeState::Header; + return Ok(Some(DoipMessage { header, payload })); + } + } + } + } +} + +impl Encoder for DoipCodec { + type Error = io::Error; + + fn encode( + &mut self, + item: DoipMessage, + dst: &mut BytesMut, + ) -> std::result::Result<(), Self::Error> { + dst.reserve(item.message_length()); + item.header.write_to(dst); + dst.extend_from_slice(&item.payload); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use tokio_util::codec::{Decoder, Encoder}; + + use super::*; + use crate::doip::header::{DoipMessage, PayloadType}; + + // Build a minimal valid DoIP frame: + // version=0x02, inverse=0xFD, payload_type (u16 BE), payload_length (u32 BE), payload + fn make_frame(payload_type: u16, payload: &[u8]) -> BytesMut { + let len = u32::try_from(payload.len()).expect("test payload must fit in u32"); + let [t0, t1] = payload_type.to_be_bytes(); + let [l0, l1, l2, l3] = len.to_be_bytes(); + let mut buf = BytesMut::new(); + buf.extend_from_slice(&[0x02, 0xFD, t0, t1, l0, l1, l2, l3]); + buf.extend_from_slice(payload); + buf + } + + #[test] + fn new_and_default_behave_the_same() { + // Both should decode the same frame identically + let mut a = DoipCodec::new(); + let mut b = DoipCodec::default(); + let frame = make_frame(0x0007, &[]); + let ra = a.decode(&mut frame.clone()).unwrap(); + let rb = b.decode(&mut frame.clone()).unwrap(); + assert_eq!(ra.is_some(), rb.is_some()); + } + + #[test] + fn new_accepts_large_payload_within_default_limit() { + // Default codec must not reject reasonable-sized payloads + let data = vec![0u8; 1024]; + let mut codec = DoipCodec::new(); + let mut buf = make_frame(0x8001, &data); + assert!(codec.decode(&mut buf).unwrap().is_some()); + } + + #[test] + fn with_max_payload_size_limits_accepted_size() { + // A payload 1 byte over the custom limit must be rejected + let mut codec = DoipCodec::with_max_payload_size(1024); + let too_large = vec![0u8; 1025]; + let mut buf = make_frame(0x8001, &too_large); + assert!(codec.decode(&mut buf).is_err()); + + // Exactly at the limit must be accepted + let mut codec2 = DoipCodec::with_max_payload_size(1024); + let exact = vec![0u8; 1024]; + let mut buf2 = make_frame(0x8001, &exact); + assert!(codec2.decode(&mut buf2).unwrap().is_some()); + } + + #[test] + fn decode_alive_check_request() { + // Alive check request (type 0x0007) has no payload + let mut codec = DoipCodec::new(); + let mut buf = make_frame(0x0007, &[]); + let msg = codec.decode(&mut buf).unwrap().unwrap(); + assert_eq!(msg.header.payload_type(), 0x0007); + assert!(msg.payload.is_empty()); + assert!(buf.is_empty()); + } + + #[test] + fn decode_message_with_payload() { + let mut codec = DoipCodec::new(); + let data = [0x0E, 0x80, 0x10, 0x01, 0x3E]; + let mut buf = make_frame(0x8001, &data); + let msg = codec.decode(&mut buf).unwrap().unwrap(); + assert_eq!(msg.header.payload_type(), 0x8001); + assert_eq!(&msg.payload[..], &data); + } + + #[test] + fn decode_resets_state_for_next_message() { + // After a successful decode, the codec must accept another frame + let mut codec = DoipCodec::new(); + let mut buf = make_frame(0x0007, &[]); + codec.decode(&mut buf).unwrap().unwrap(); + // Buffer is empty — a healthy reset returns None, not an error + assert!(codec.decode(&mut buf).unwrap().is_none()); + } + + #[test] + fn decode_back_to_back_frames() { + let mut codec = DoipCodec::new(); + let mut buf = make_frame(0x0007, &[]); + buf.extend_from_slice(&make_frame(0x0008, &[0xAA, 0xBB])); + + let m1 = codec.decode(&mut buf).unwrap().unwrap(); + let m2 = codec.decode(&mut buf).unwrap().unwrap(); + + assert_eq!(m1.header.payload_type(), 0x0007); + assert_eq!(m2.header.payload_type(), 0x0008); + assert!(buf.is_empty()); + } + + #[test] + fn decode_returns_none_when_header_incomplete() { + let mut codec = DoipCodec::new(); + // Only 4 of the 8 header bytes present + let mut buf = BytesMut::from(&[0x02u8, 0xFD, 0x00, 0x07][..]); + assert!(codec.decode(&mut buf).unwrap().is_none()); + } + + #[test] + fn decode_returns_none_when_payload_incomplete() { + let mut codec = DoipCodec::new(); + // AliveCheckResponse (0x0008) minimum is 2 bytes. + // Declare 4 bytes in the header but only provide 2 → codec must wait. + let mut buf = make_frame(0x0008, &[0x0E, 0x80, 0x00, 0x00]); + buf.truncate(buf.len() - 2); // strip last 2 payload bytes + assert!(codec.decode(&mut buf).unwrap().is_none()); + } + + #[test] + fn decode_returns_none_on_empty_buffer() { + let mut codec = DoipCodec::new(); + let mut buf = BytesMut::new(); + assert!(codec.decode(&mut buf).unwrap().is_none()); + } + + #[test] + fn decode_rejects_invalid_version() { + let mut codec = DoipCodec::new(); + // version 0x04 is not a valid DoIP protocol version + let mut buf = BytesMut::from(&[0x04u8, 0xFB, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00][..]); + assert!(codec.decode(&mut buf).is_err()); + } + + #[test] + fn decode_rejects_payload_exceeding_max_size() { + let mut codec = DoipCodec::with_max_payload_size(10); + // Declare payload_length = 11, over the limit of 10 + let mut buf = BytesMut::from(&[0x02u8, 0xFD, 0x80, 0x01, 0x00, 0x00, 0x00, 0x0B][..]); + assert!(codec.decode(&mut buf).is_err()); + } + + #[test] + fn decode_accepts_payload_at_exact_max_size() { + let data = vec![0u8; 10]; + let mut codec = DoipCodec::with_max_payload_size(10); + let mut buf = make_frame(0x8001, &data); + assert!(codec.decode(&mut buf).unwrap().is_some()); + } + + #[test] + fn encode_produces_correct_wire_bytes() { + let mut codec = DoipCodec::new(); + let payload = Bytes::from_static(&[0x0E, 0x80, 0x10, 0x01]); + let msg = DoipMessage::new(PayloadType::DiagnosticMessage, payload); + + let mut buf = BytesMut::new(); + codec.encode(msg, &mut buf).unwrap(); + + // Header: version, inverse, type (0x8001), length (0x00000004) + assert_eq!(buf.get(..2).unwrap(), &[0x02, 0xFD]); + assert_eq!(buf.get(2..4).unwrap(), &[0x80, 0x01]); + assert_eq!(buf.get(4..8).unwrap(), &[0x00, 0x00, 0x00, 0x04]); + assert_eq!(buf.get(8..).unwrap(), &[0x0E, 0x80, 0x10, 0x01]); + } + + #[test] + fn encode_empty_payload() { + let mut codec = DoipCodec::new(); + let msg = DoipMessage::new(PayloadType::AliveCheckRequest, Bytes::new()); + + let mut buf = BytesMut::new(); + codec.encode(msg, &mut buf).unwrap(); + + assert_eq!(buf.len(), 8); // header only + assert_eq!(buf.get(4..8).unwrap(), &[0x00, 0x00, 0x00, 0x00]); // zero payload length + } + + #[test] + fn roundtrip_encode_then_decode() { + let mut codec = DoipCodec::new(); + let payload = Bytes::from_static(&[0x0E, 0x80, 0x22, 0xF1, 0x90]); + let original = DoipMessage::new(PayloadType::DiagnosticMessage, payload); + + let mut buf = BytesMut::new(); + codec.encode(original.clone(), &mut buf).unwrap(); + let decoded = codec.decode(&mut buf).unwrap().unwrap(); + + assert_eq!(original.header, decoded.header); + assert_eq!(original.payload, decoded.payload); + } +} diff --git a/doip-server/src/doip/diagnostic_message.rs b/doip-server/src/doip/diagnostic_message.rs new file mode 100644 index 0000000..8ee8f8c --- /dev/null +++ b/doip-server/src/doip/diagnostic_message.rs @@ -0,0 +1,460 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +//! Diagnostic Message handlers (ISO 13400-2:2019) + +use bytes::{BufMut, Bytes, BytesMut}; +use tracing::error; + +use super::{DoipParseable, DoipSerializable, parse_fixed_slice, too_short}; +use crate::{DoipError, Result}; + +const ADDRESS_BYTES: usize = 2; +const HEADER_BYTES: usize = ADDRESS_BYTES * 2; +const ACK_CODE_BYTES: usize = 1; +const MIN_USER_DATA_BYTES: usize = 1; +/// Wire code for a positive diagnostic acknowledgment (ISO 13400-2:2019 Table 27). +const POSITIVE_ACK_CODE: u8 = 0x00; + +/// Parse source and target addresses from a 4-byte address-pair header. +/// Layout: SA(2 bytes, big-endian) + TA(2 bytes, big-endian). +fn parse_address_pair(header: [u8; HEADER_BYTES]) -> (u16, u16) { + let source = u16::from_be_bytes([header[0], header[1]]); + let target = u16::from_be_bytes([header[2], header[3]]); + (source, target) +} + +/// Outcome of a diagnostic message acknowledgment (ISO 13400-2:2019). +/// +/// Used by [`DiagnosticAck`] to represent either a positive or negative result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AckResult { + /// Positive acknowledgment — message was accepted (wire code 0x00). + Positive, + /// Negative acknowledgment — message was rejected with the given code. + Negative(DiagnosticNackCode), +} + +/// Diagnostic message negative acknowledgment codes per ISO 13400-2:2019 Table 28. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum DiagnosticNackCode { + InvalidSourceAddress = 0x02, + UnknownTargetAddress = 0x03, + DiagnosticMessageTooLarge = 0x04, + OutOfMemory = 0x05, + TargetUnreachable = 0x06, + UnknownNetwork = 0x07, + TransportProtocolError = 0x08, +} + +impl TryFrom for DiagnosticNackCode { + type Error = DoipError; + + fn try_from(value: u8) -> std::result::Result { + match value { + 0x02 => Ok(Self::InvalidSourceAddress), + 0x03 => Ok(Self::UnknownTargetAddress), + 0x04 => Ok(Self::DiagnosticMessageTooLarge), + 0x05 => Ok(Self::OutOfMemory), + 0x06 => Ok(Self::TargetUnreachable), + 0x07 => Ok(Self::UnknownNetwork), + 0x08 => Ok(Self::TransportProtocolError), + other => Err(DoipError::UnknownNackCode(other)), + } + } +} + +impl From for u8 { + fn from(code: DiagnosticNackCode) -> Self { + code as Self + } +} + +/// Diagnostic Message - carries UDS data between tester and ECU +/// +/// Represents a `DoIP` diagnostic message as defined in ISO 13400-2:2019. +/// The message contains source/target addresses and UDS payload data. +/// +/// # Wire Format +/// Payload: SA(2) + TA(2) + `user_data(1`+) +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagnosticMessage { + source_address: u16, + target_address: u16, + user_data: Bytes, +} + +impl DiagnosticMessage { + /// Minimum message length in bytes (SA + TA + at least 1 byte UDS data) + pub(crate) const MIN_LEN: usize = HEADER_BYTES + MIN_USER_DATA_BYTES; + + /// Create a new diagnostic message. + /// + /// # Arguments + /// * `source` - Source address (tester or ECU) + /// * `target` - Target address (tester or ECU) + /// * `data` - UDS payload data (must not be empty) + /// + /// # Errors + /// Returns [`DoipError::EmptyUserData`] if `data` is empty. + pub fn new(source: u16, target: u16, data: Bytes) -> Result { + if data.is_empty() { + return Err(DoipError::EmptyUserData); + } + Ok(Self { + source_address: source, + target_address: target, + user_data: data, + }) + } + + /// Get the source address + #[must_use] + pub fn source_address(&self) -> u16 { + self.source_address + } + + /// Get the target address + #[must_use] + pub fn target_address(&self) -> u16 { + self.target_address + } + + /// Get the UDS user data + #[must_use] + pub fn user_data(&self) -> &Bytes { + &self.user_data + } + + /// Returns the UDS service ID (first byte of user data), or `None` if the payload is empty. + #[must_use] + pub fn service_id(&self) -> Option { + self.user_data.first().copied() + } +} + +/// Diagnostic Message Acknowledgment (payload types 0x8002 and 0x8003) +/// +/// Represents both positive and negative acknowledgments as defined in +/// ISO 13400-2:2019. Use [`AckResult`] to distinguish the outcome. +/// +/// # Wire Format +/// Payload: SA(2) + TA(2) + code(1) + optional `previous_diag_data` +#[derive(Debug, PartialEq, Eq)] +pub struct DiagnosticAck { + source_address: u16, + target_address: u16, + result: AckResult, + previous_data: Option, +} + +impl DiagnosticAck { + /// Minimum ack length in bytes (SA + TA + code byte). + pub(crate) const MIN_LEN: usize = HEADER_BYTES + ACK_CODE_BYTES; + + /// Create a positive acknowledgment. + #[must_use] + pub fn positive(source: u16, target: u16) -> Self { + Self { + source_address: source, + target_address: target, + result: AckResult::Positive, + previous_data: None, + } + } + + /// Create a negative acknowledgment. + #[must_use] + pub fn negative(source: u16, target: u16, code: DiagnosticNackCode) -> Self { + Self { + source_address: source, + target_address: target, + result: AckResult::Negative(code), + previous_data: None, + } + } + + /// Returns the source address. + #[must_use] + pub fn source_address(&self) -> u16 { + self.source_address + } + + /// Returns the target address. + #[must_use] + pub fn target_address(&self) -> u16 { + self.target_address + } + + /// Returns the acknowledgment result. + #[must_use] + pub fn result(&self) -> AckResult { + self.result + } + + /// Returns the previous diagnostic data, if any. + #[must_use] + pub fn previous_data(&self) -> Option<&Bytes> { + self.previous_data.as_ref() + } + + /// Parse a positive acknowledgment payload (payload type 0x8002). + /// + /// # Errors + /// Returns [`DoipError::PayloadTooShort`] if payload is less than 4 bytes. + pub(crate) fn parse_positive(payload: &[u8]) -> Result { + let (source_address, target_address, previous_data) = + Self::parse_address_header(payload, "DiagnosticPositiveAck")?; + Ok(Self { + source_address, + target_address, + result: AckResult::Positive, + previous_data, + }) + } + + /// Parse a negative acknowledgment payload (payload type 0x8003). + /// + /// # Errors + /// Returns [`DoipError::PayloadTooShort`] if payload is less than 5 bytes. + /// Returns [`DoipError::UnknownNackCode`] for unrecognized NACK codes. + pub(crate) fn parse_negative(payload: &[u8]) -> Result { + let (source_address, target_address, previous_data) = + Self::parse_address_header(payload, "DiagnosticNegativeAck")?; + let nack_code = payload + .get(HEADER_BYTES) + .copied() + .ok_or_else(|| too_short(payload, Self::MIN_LEN)) + .and_then(DiagnosticNackCode::try_from)?; + Ok(Self { + source_address, + target_address, + result: AckResult::Negative(nack_code), + previous_data, + }) + } + + /// Parse SA, TA and optional trailing `previous_data` from an ack payload. + fn parse_address_header(payload: &[u8], context: &str) -> Result<(u16, u16, Option)> { + let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, context)?; + let (source_address, target_address) = parse_address_pair(header); + let previous_data = payload + .get(Self::MIN_LEN..) + .filter(|d| !d.is_empty()) + .map(Bytes::copy_from_slice); + Ok((source_address, target_address, previous_data)) + } +} + +impl DoipParseable for DiagnosticMessage { + fn parse(payload: &[u8]) -> Result { + let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, "DiagnosticMessage")?; + + let (source_address, target_address) = parse_address_pair(header); + + let user_data = payload + .get(HEADER_BYTES..) + .map(Bytes::copy_from_slice) + .ok_or_else(|| { + let e = too_short(payload, Self::MIN_LEN); + error!(error = %e, "DiagnosticMessage parse failed"); + e + })?; + + if user_data.is_empty() { + error!( + message_type = "DiagnosticMessage", + reason = "empty_user_data", + "parse failed" + ); + return Err(DoipError::EmptyUserData); + } + + Ok(Self { + source_address, + target_address, + user_data, + }) + } +} + +impl DoipSerializable for DiagnosticMessage { + fn serialized_len(&self) -> Option { + Some(HEADER_BYTES.saturating_add(self.user_data.len())) + } + + fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.source_address); + buf.put_u16(self.target_address); + buf.extend_from_slice(&self.user_data); + } +} + +impl DoipSerializable for DiagnosticAck { + fn serialized_len(&self) -> Option { + Some(Self::MIN_LEN.saturating_add(self.previous_data.as_ref().map_or(0, bytes::Bytes::len))) + } + + fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.source_address); + buf.put_u16(self.target_address); + buf.put_u8(match self.result { + AckResult::Positive => POSITIVE_ACK_CODE, + AckResult::Negative(code) => u8::from(code), + }); + if let Some(ref data) = self.previous_data { + buf.extend_from_slice(data); + } + } +} + +#[cfg(test)] +#[allow(clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::doip::{DoipParseable, DoipSerializable}; + + #[test] + fn nack_code_values() { + assert_eq!(DiagnosticNackCode::InvalidSourceAddress as u8, 0x02); + assert_eq!(DiagnosticNackCode::UnknownTargetAddress as u8, 0x03); + assert_eq!(DiagnosticNackCode::TargetUnreachable as u8, 0x06); + } + + #[test] + fn parse_diagnostic_message() { + // SA=0x0E80, TA=0x1000, UDS=0x22 0xF1 0x90 (ReadDataByID) + let payload = [0x0E, 0x80, 0x10, 0x00, 0x22, 0xF1, 0x90]; + let msg = DiagnosticMessage::parse(&payload).unwrap(); + + assert_eq!(msg.source_address(), 0x0E80); + assert_eq!(msg.target_address(), 0x1000); + assert_eq!(msg.user_data().as_ref(), &[0x22, 0xF1, 0x90]); + assert_eq!(msg.service_id(), Some(0x22)); + } + + #[test] + fn parse_tester_present() { + // TesterPresent service + let payload = [0x0E, 0x80, 0x10, 0x00, 0x3E, 0x00]; + let msg = DiagnosticMessage::parse(&payload).unwrap(); + + assert_eq!(msg.service_id(), Some(0x3E)); + assert_eq!(msg.user_data().len(), 2); + } + + #[test] + fn reject_short_message() { + let short = [0x0E, 0x80, 0x10, 0x00]; // no user data + assert!(DiagnosticMessage::parse(&short).is_err()); + } + + #[test] + fn build_diagnostic_message() { + let uds = Bytes::from_static(&[0x22, 0xF1, 0x90]); + let msg = DiagnosticMessage::new(0x0E80, 0x1000, uds).unwrap(); + let bytes = msg.to_bytes(); + + assert_eq!(&bytes[..ADDRESS_BYTES], &[0x0E, 0x80]); + assert_eq!(&bytes[ADDRESS_BYTES..HEADER_BYTES], &[0x10, 0x00]); + assert_eq!(&bytes[HEADER_BYTES..], &[0x22, 0xF1, 0x90]); + } + + #[test] + fn build_positive_ack() { + let ack = DiagnosticAck::positive(0x1000, 0x0E80); + let bytes = ack.to_bytes(); + + assert_eq!(bytes.len(), DiagnosticAck::MIN_LEN); + assert_eq!(&bytes[..ADDRESS_BYTES], &[0x10, 0x00]); + assert_eq!(&bytes[ADDRESS_BYTES..HEADER_BYTES], &[0x0E, 0x80]); + assert_eq!(bytes[HEADER_BYTES], 0x00); // positive ack wire code + assert_eq!(ack.result(), AckResult::Positive); + } + + #[test] + fn build_negative_ack() { + let nack = + DiagnosticAck::negative(0x1000, 0x0E80, DiagnosticNackCode::UnknownTargetAddress); + let bytes = nack.to_bytes(); + + assert_eq!(bytes.len(), DiagnosticAck::MIN_LEN); + assert_eq!(bytes[HEADER_BYTES], 0x03); + assert_eq!( + nack.result(), + AckResult::Negative(DiagnosticNackCode::UnknownTargetAddress) + ); + } + + #[test] + fn build_negative_ack_target_unreachable() { + let nack = DiagnosticAck::negative(0x1000, 0x0E80, DiagnosticNackCode::TargetUnreachable); + let bytes = nack.to_bytes(); + assert_eq!(bytes[HEADER_BYTES], 0x06); + } + + #[test] + fn parse_positive_ack() { + let payload = [0x10, 0x00, 0x0E, 0x80, 0x00]; + let ack = DiagnosticAck::parse_positive(&payload).unwrap(); + + assert_eq!(ack.source_address(), 0x1000); + assert_eq!(ack.target_address(), 0x0E80); + assert_eq!(ack.result(), AckResult::Positive); + assert!(ack.previous_data().is_none()); + } + + #[test] + fn parse_negative_ack() { + let payload = [0x10, 0x00, 0x0E, 0x80, 0x03]; + let nack = DiagnosticAck::parse_negative(&payload).unwrap(); + + assert_eq!( + nack.result(), + AckResult::Negative(DiagnosticNackCode::UnknownTargetAddress) + ); + } + + #[test] + fn reject_empty_user_data() { + let err = DiagnosticMessage::new(0x0E80, 0x1000, Bytes::new()); + assert!(matches!(err, Err(DoipError::EmptyUserData))); + } + + #[test] + fn roundtrip_message() { + let original = + DiagnosticMessage::new(0x0E80, 0x1000, Bytes::from_static(&[0x10, 0x01])).unwrap(); + let bytes = original.to_bytes(); + let parsed = DiagnosticMessage::parse(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn roundtrip_positive_ack() { + let original = DiagnosticAck::positive(0x1000, 0x0E80); + let bytes = original.to_bytes(); + let parsed = DiagnosticAck::parse_positive(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn roundtrip_negative_ack() { + let original = DiagnosticAck::negative(0x1000, 0x0E80, DiagnosticNackCode::OutOfMemory); + let bytes = original.to_bytes(); + let parsed = DiagnosticAck::parse_negative(&bytes).unwrap(); + assert_eq!(original, parsed); + } +} diff --git a/doip-server/src/doip/header.rs b/doip-server/src/doip/header.rs new file mode 100644 index 0000000..c6a10d1 --- /dev/null +++ b/doip-server/src/doip/header.rs @@ -0,0 +1,829 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ + +//! `DoIP` Header Types +//! +//! Defines the core `DoIP` protocol types for header parsing and message structures +//! according to ISO 13400-2:2019. +//! +//! The `DoIP` header consists of 8 bytes: +//! - Protocol version (1 byte) + inverse version (1 byte) +//! - Payload type (2 bytes, big-endian) +//! - Payload length (4 bytes, big-endian) +//! +//! This module accepts protocol versions 0x01, 0x02, 0x03, and 0xFF to support +//! both ISO 13400-2:2012 and ISO 13400-2:2019 specifications. +//! +//! See [`codec`](super::codec) for the Tokio TCP framing codec. + +use bytes::{BufMut, Bytes, BytesMut}; +use tracing::{trace, warn}; + +use crate::Result; + +/// Generic Header NACK codes (ISO 13400-2:2019 Table 17) +/// +/// Negative acknowledgment codes sent in Generic `DoIP` Header NACK (payload type 0x0000) +/// when the `DoIP` entity cannot process a received message due to header-level issues. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum GenericNackCode { + IncorrectPatternFormat = 0x00, + UnknownPayloadType = 0x01, + MessageTooLarge = 0x02, + OutOfMemory = 0x03, + InvalidPayloadLength = 0x04, +} + +impl TryFrom for GenericNackCode { + type Error = u8; + + fn try_from(value: u8) -> std::result::Result { + match value { + 0x00 => Ok(Self::IncorrectPatternFormat), + 0x01 => Ok(Self::UnknownPayloadType), + 0x02 => Ok(Self::MessageTooLarge), + 0x03 => Ok(Self::OutOfMemory), + 0x04 => Ok(Self::InvalidPayloadLength), + other => Err(other), + } + } +} + +/// `DoIP` protocol version 0x01 (legacy, pre-ISO 13400-2:2012) +pub(crate) const PROTOCOL_VERSION_V1: u8 = 0x01; +/// Default `DoIP` protocol version 0x02 (ISO 13400-2:2012 / 2019) +pub const DEFAULT_PROTOCOL_VERSION: u8 = 0x02; +/// `DoIP` protocol version 0x03 (ISO 13400-2:2019 update) +pub(crate) const PROTOCOL_VERSION_V3: u8 = 0x03; +/// Wildcard/default protocol version (ISO 13400-2:2019 – accept any version) +pub(crate) const DOIP_VERSION_DEFAULT: u8 = 0xFF; +/// XOR mask used to compute and verify the inverse version byte in the `DoIP` header. +/// The header requires `version XOR inverse_version == 0xFF`. +pub(crate) const DOIP_HEADER_VERSION_MASK: u8 = 0xFF; +/// Inverse of default protocol version for header validation +pub(crate) const DEFAULT_PROTOCOL_VERSION_INV: u8 = + DEFAULT_PROTOCOL_VERSION ^ DOIP_HEADER_VERSION_MASK; +/// Size of `DoIP` header in bytes (ISO 13400-2:2019 Section 6) +pub const DOIP_HEADER_LENGTH: usize = 8; +/// Maximum `DoIP` message size (4MB) - provides `DoS` protection while allowing +/// large diagnostic data transfers. Can be customized via `DoipCodec::with_max_payload_size()`. +pub const MAX_DOIP_MESSAGE_SIZE: u32 = 0x0040_0000; // 4MB + +/// `DoIP` Payload Types (ISO 13400-2:2019 Table 16) +/// +/// Identifies the type of message carried in the `DoIP` payload. +/// Supports diagnostic messages (0x8001), routing activation (0x0005-0x0006), +/// vehicle identification (0x0001-0x0004), alive check (0x0007-0x0008), +/// and diagnostic power mode requests (0x4003-0x4004). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum PayloadType { + GenericNack = 0x0000, + VehicleIdentificationRequest = 0x0001, + VehicleIdentificationRequestWithEid = 0x0002, + VehicleIdentificationRequestWithVin = 0x0003, + VehicleIdentificationResponse = 0x0004, + RoutingActivationRequest = 0x0005, + RoutingActivationResponse = 0x0006, + AliveCheckRequest = 0x0007, + AliveCheckResponse = 0x0008, + DoipEntityStatusRequest = 0x4001, + DoipEntityStatusResponse = 0x4002, + DiagnosticPowerModeRequest = 0x4003, + DiagnosticPowerModeResponse = 0x4004, + DiagnosticMessage = 0x8001, + DiagnosticMessagePositiveAck = 0x8002, + DiagnosticMessageNegativeAck = 0x8003, +} + +impl TryFrom for PayloadType { + type Error = u16; + + fn try_from(value: u16) -> std::result::Result { + match value { + 0x0000 => Ok(Self::GenericNack), + 0x0001 => Ok(Self::VehicleIdentificationRequest), + 0x0002 => Ok(Self::VehicleIdentificationRequestWithEid), + 0x0003 => Ok(Self::VehicleIdentificationRequestWithVin), + 0x0004 => Ok(Self::VehicleIdentificationResponse), + 0x0005 => Ok(Self::RoutingActivationRequest), + 0x0006 => Ok(Self::RoutingActivationResponse), + 0x0007 => Ok(Self::AliveCheckRequest), + 0x0008 => Ok(Self::AliveCheckResponse), + 0x4001 => Ok(Self::DoipEntityStatusRequest), + 0x4002 => Ok(Self::DoipEntityStatusResponse), + 0x4003 => Ok(Self::DiagnosticPowerModeRequest), + 0x4004 => Ok(Self::DiagnosticPowerModeResponse), + 0x8001 => Ok(Self::DiagnosticMessage), + 0x8002 => Ok(Self::DiagnosticMessagePositiveAck), + 0x8003 => Ok(Self::DiagnosticMessageNegativeAck), + other => Err(other), + } + } +} + +impl From for u16 { + fn from(pt: PayloadType) -> Self { + pt as Self + } +} + +impl PayloadType { + /// Returns minimum payload length for this payload type (ISO 13400-2:2019 Section 7) + /// + /// Each payload type has a defined minimum length to be considered valid. + /// For example, `DiagnosticMessage` requires at least 5 bytes (source address, + /// target address, and at least 1 UDS data byte). + #[must_use] + pub const fn min_payload_length(self) -> usize { + match self { + Self::VehicleIdentificationRequest + | Self::AliveCheckRequest + | Self::DoipEntityStatusRequest + | Self::DiagnosticPowerModeRequest => 0, + Self::GenericNack | Self::DiagnosticPowerModeResponse => 1, + Self::AliveCheckResponse => 2, + Self::DoipEntityStatusResponse => 3, + Self::DiagnosticMessage + | Self::DiagnosticMessagePositiveAck + | Self::DiagnosticMessageNegativeAck => 5, + Self::VehicleIdentificationRequestWithEid => 6, + Self::RoutingActivationRequest => 7, + Self::RoutingActivationResponse => 9, + Self::VehicleIdentificationRequestWithVin => 17, + Self::VehicleIdentificationResponse => 32, + } + } +} + +/// `DoIP` Header Structure (ISO 13400-2:2019 Section 6) +/// +/// Represents the 8-byte generic `DoIP` header that precedes every `DoIP` message. +/// The header uses big-endian byte order for multi-byte fields. +/// +/// The `payload_type` field is stored as `u16` rather than `PayloadType` enum to allow +/// receiving and processing unknown/future payload types. This follows the robustness +/// principle: be liberal in what you accept. Unknown types trigger `GenericNackCode` +/// during validation but don't cause parsing failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DoipHeader { + version: u8, + inverse_version: u8, + payload_type: u16, + payload_length: u32, +} + +impl DoipHeader { + /// Parse a `DoIP` header from a byte slice + /// + /// # Errors + /// Returns [`crate::DoipError::InvalidHeader`] if data is less than 8 bytes. + pub(crate) fn parse(data: &[u8]) -> Result { + let header: [u8; DOIP_HEADER_LENGTH] = data + .get(..DOIP_HEADER_LENGTH) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + crate::DoipError::InvalidHeader(format!( + "DoIP header too short: expected {}, got {}", + DOIP_HEADER_LENGTH, + data.len() + )) + })?; + + Ok(Self { + version: header[0], + inverse_version: header[1], + payload_type: u16::from_be_bytes([header[2], header[3]]), + payload_length: u32::from_be_bytes([header[4], header[5], header[6], header[7]]), + }) + } + + /// Validate the `DoIP` header against ISO 13400-2:2019 rules. + /// + /// Returns `None` if valid, or the `GenericNackCode` describing the first violation. + pub fn validate(&self) -> Option { + trace!( + version = format!("0x{:02X}", self.version), + inverse_version = format!("0x{:02X}", self.inverse_version), + payload_type = format!("0x{:04X}", self.payload_type), + payload_length = self.payload_length, + "validating DoIP header" + ); + + // Accept DoIP protocol versions V1, V2, V3 (and wildcard 0xFF for default/any) + // ISO 13400-2:2012 uses V2, ISO 13400-2:2019 uses V3 + let valid_version = matches!( + self.version, + PROTOCOL_VERSION_V1 + | DEFAULT_PROTOCOL_VERSION + | PROTOCOL_VERSION_V3 + | DOIP_VERSION_DEFAULT + ); + if !valid_version { + warn!(version = self.version, "Invalid DoIP version"); + return Some(GenericNackCode::IncorrectPatternFormat); + } + + // Check version XOR inverse_version == DOIP_HEADER_VERSION_MASK (0xFF) + if self.version ^ self.inverse_version != DOIP_HEADER_VERSION_MASK { + warn!( + version = self.version, + inverse_version = self.inverse_version, + expected_mask = DOIP_HEADER_VERSION_MASK, + "Version/inverse mismatch" + ); + return Some(GenericNackCode::IncorrectPatternFormat); + } + + let Some(payload_type) = PayloadType::try_from(self.payload_type).ok() else { + warn!(payload_type = self.payload_type, "Unknown payload type"); + return Some(GenericNackCode::UnknownPayloadType); + }; + + if self.payload_length > MAX_DOIP_MESSAGE_SIZE { + warn!( + payload_length = self.payload_length, + max = MAX_DOIP_MESSAGE_SIZE, + "Message too large" + ); + return Some(GenericNackCode::MessageTooLarge); + } + let Ok(payload_len_usize) = usize::try_from(self.payload_length) else { + return Some(GenericNackCode::MessageTooLarge); + }; + if payload_len_usize < payload_type.min_payload_length() { + warn!( + payload_type = ?payload_type, + payload_length = self.payload_length, + min_length = payload_type.min_payload_length(), + "Payload too short for payload type" + ); + return Some(GenericNackCode::InvalidPayloadLength); + } + + None + } + + /// Returns the total message length (header + payload), or `None` if + /// `payload_length` overflows `usize` (impossible on 32/64-bit platforms, + /// but handled explicitly to avoid any panic path). + #[must_use] + pub fn message_length(&self) -> Option { + usize::try_from(self.payload_length) + .ok() + .map(|pl| DOIP_HEADER_LENGTH.saturating_add(pl)) + } + + /// Serialize header to bytes + #[must_use] + pub fn to_bytes(&self) -> Bytes { + let mut buf = BytesMut::with_capacity(DOIP_HEADER_LENGTH); + self.write_to(&mut buf); + buf.freeze() + } + + /// Write the 8-byte `DoIP` header into `buf`. + pub(crate) fn write_to(self, buf: &mut BytesMut) { + buf.put_u8(self.version); + buf.put_u8(self.inverse_version); + buf.put_u16(self.payload_type); + buf.put_u32(self.payload_length); + } + + /// Protocol version byte + #[must_use] + pub fn version(&self) -> u8 { + self.version + } + + /// Inverted protocol version byte + #[must_use] + pub fn inverse_version(&self) -> u8 { + self.inverse_version + } + + /// Raw payload type as `u16` + #[must_use] + pub fn payload_type(&self) -> u16 { + self.payload_type + } + + /// Payload length in bytes + #[must_use] + pub fn payload_length(&self) -> u32 { + self.payload_length + } +} + +impl Default for DoipHeader { + fn default() -> Self { + Self { + version: DEFAULT_PROTOCOL_VERSION, + inverse_version: DEFAULT_PROTOCOL_VERSION_INV, + payload_type: 0, + payload_length: 0, + } + } +} + +impl std::fmt::Display for DoipHeader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let payload_name = PayloadType::try_from(self.payload_type).ok().map_or_else( + || format!("Unknown(0x{:04X})", self.payload_type), + |pt| format!("{pt:?}"), + ); + write!( + f, + "DoipHeader {{ version: 0x{:02X}, type: {}, length: {} }}", + self.version, payload_name, self.payload_length + ) + } +} + +/// `DoIP` Message (ISO 13400-2 Section 6) +/// +/// Complete `DoIP` message consisting of an 8-byte header and variable-length payload. +/// ISO 13400-2 uses the term "message" throughout the specification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DoipMessage { + pub(crate) header: DoipHeader, + pub(crate) payload: Bytes, +} + +impl DoipMessage { + /// Returns the decoded `PayloadType`, or `None` if the raw value is not a known type. + pub fn payload_type(&self) -> Option { + PayloadType::try_from(self.header.payload_type).ok() + } + + /// The `DoIP` header + pub fn header(&self) -> &DoipHeader { + &self.header + } + + /// The message payload bytes + pub fn payload(&self) -> &Bytes { + &self.payload + } + + /// Returns the total message length (header + payload) + #[must_use] + pub fn message_length(&self) -> usize { + DOIP_HEADER_LENGTH.saturating_add(self.payload.len()) + } + + /// Serialize the full `DoIP` message (header + payload) into a `Bytes` buffer. + pub fn to_bytes(&self) -> Bytes { + let mut buf = BytesMut::with_capacity(self.message_length()); + self.header.write_to(&mut buf); + buf.extend_from_slice(&self.payload); + buf.freeze() + } +} + +// ============================================================================ +// Unit Tests +// ============================================================================ + +#[cfg(test)] +// Test helpers index into fixed-size byte slices whose lengths are asserted +// immediately before the index — safe, but requires the allow. +#[allow(clippy::indexing_slicing)] +mod tests { + use tokio_util::codec::{Decoder, Encoder}; + + use super::*; + use crate::doip::codec::DoipCodec; + + impl DoipHeader { + /// Returns `true` if [`validate`](Self::validate) finds no errors in this header. Test-only. + pub(crate) fn is_valid(self) -> bool { + self.validate().is_none() + } + } + + impl DoipMessage { + /// Build a typed `DoIP` message. Test-only. + pub(crate) fn new(payload_type: PayloadType, payload: Bytes) -> Self { + Self { + header: DoipHeader { + version: DEFAULT_PROTOCOL_VERSION, + inverse_version: DEFAULT_PROTOCOL_VERSION_INV, + payload_type: u16::from(payload_type), + payload_length: u32::try_from(payload.len()).expect("test payload fits in u32"), + }, + payload, + } + } + + /// Build a `DoIP` message with a raw (possibly unknown) payload type. Test-only. + pub(crate) fn with_raw_payload_type(payload_type: u16, payload: Bytes) -> Self { + Self { + header: DoipHeader { + version: DEFAULT_PROTOCOL_VERSION, + inverse_version: DEFAULT_PROTOCOL_VERSION_INV, + payload_type, + payload_length: u32::try_from(payload.len()).expect("test payload fits in u32"), + }, + payload, + } + } + } + + // --- Helper to build a valid DoIP header quickly --- + fn make_header(payload_type: u16, payload_len: u32) -> DoipHeader { + DoipHeader { + version: 0x02, + inverse_version: 0xFD, + payload_type, + payload_length: payload_len, + } + } + + // ------------------------------------------------------------------------- + // Basic header parsing - the bread and butter + // ------------------------------------------------------------------------- + + #[test] + fn parse_vehicle_id_request_from_tester() { + // Real-world: tester broadcasts "who's there?" on UDP + let raw = [0x02, 0xFD, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]; + let hdr = DoipHeader::parse(&raw).unwrap(); + + assert_eq!(hdr.payload_type, 0x0001); + assert_eq!(hdr.payload_length, 0); + assert!(hdr.is_valid()); + } + + #[test] + fn parse_diagnostic_message_with_uds_payload() { + // Tester sends UDS request: SA=0x0E80, TA=0x1001, SID=0x22 (ReadDataByID) + let raw = [0x02, 0xFD, 0x80, 0x01, 0x00, 0x00, 0x00, 0x07]; + let hdr = DoipHeader::parse(&raw).unwrap(); + + assert_eq!(hdr.payload_type, 0x8001); // DiagnosticMessage + assert_eq!(hdr.payload_length, 7); // 2+2+3 = SA+TA+UDS + } + + #[test] + fn parse_routing_activation_request() { + // Tester wants to start a diagnostic session + let raw = [0x02, 0xFD, 0x00, 0x05, 0x00, 0x00, 0x00, 0x07]; + let hdr = DoipHeader::parse(&raw).unwrap(); + + assert_eq!(hdr.payload_type, 0x0005); + assert!(hdr.is_valid()); + } + + #[test] + fn reject_truncated_header() { + // Only 4 bytes arrived - not enough + let partial = [0x02, 0xFD, 0x00, 0x01]; + assert!(DoipHeader::parse(&partial).is_err()); + } + + #[test] + fn extra_bytes_after_header_are_ignored() { + // Header + some payload bytes mixed in + let raw = [0x02, 0xFD, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xDE, 0xAD]; + let hdr = DoipHeader::parse(&raw).unwrap(); + assert_eq!(hdr.payload_length, 0); // Parses only header + } + + // ------------------------------------------------------------------------- + // Validation - reject bad packets before processing + // ------------------------------------------------------------------------- + + #[test] + fn reject_wrong_protocol_version() { + // Someone sends version 0x04 - we only support 0x01, 0x02, 0x03, 0xFF + let hdr = DoipHeader { + version: 0x04, + inverse_version: 0xFB, + payload_type: 0x0001, + payload_length: 0, + }; + assert_eq!( + hdr.validate(), + Some(GenericNackCode::IncorrectPatternFormat) + ); + } + + #[test] + fn reject_corrupted_inverse_version() { + // Inverse should be 0xFD for version 0x02, but we got 0xFC + let hdr = DoipHeader { + version: 0x02, + inverse_version: 0xFC, // Wrong! + payload_type: 0x0001, + payload_length: 0, + }; + assert_eq!( + hdr.validate(), + Some(GenericNackCode::IncorrectPatternFormat) + ); + } + + #[test] + fn reject_unknown_payload_type() { + // 0x1234 is not a valid DoIP payload type + let hdr = make_header(0x1234, 0); + assert_eq!(hdr.validate(), Some(GenericNackCode::UnknownPayloadType)); + } + + #[test] + fn reject_oversized_message() { + // Payload claims to be 256MB - way too big + let hdr = make_header(0x8001, MAX_DOIP_MESSAGE_SIZE + 1); + assert_eq!(hdr.validate(), Some(GenericNackCode::MessageTooLarge)); + } + + #[test] + fn reject_diagnostic_msg_with_too_small_payload() { + // DiagnosticMessage needs at least 5 bytes (SA + TA + 1 UDS byte) + let hdr = make_header(0x8001, 3); + assert_eq!(hdr.validate(), Some(GenericNackCode::InvalidPayloadLength)); + } + + #[test] + fn accept_max_allowed_payload_size() { + let hdr = make_header(0x8001, MAX_DOIP_MESSAGE_SIZE); + assert!(hdr.is_valid()); + } + + // ------------------------------------------------------------------------- + // PayloadType enum - mapping values correctly + // ------------------------------------------------------------------------- + + #[test] + fn payload_type_lookup_works() { + assert_eq!( + PayloadType::try_from(0x0001).ok(), + Some(PayloadType::VehicleIdentificationRequest) + ); + assert_eq!( + PayloadType::try_from(0x0005).ok(), + Some(PayloadType::RoutingActivationRequest) + ); + assert_eq!( + PayloadType::try_from(0x8001).ok(), + Some(PayloadType::DiagnosticMessage) + ); + assert_eq!( + PayloadType::try_from(0x8002).ok(), + Some(PayloadType::DiagnosticMessagePositiveAck) + ); + } + + #[test] + fn payload_type_gaps_return_none() { + // These are in gaps between valid ranges + assert!(PayloadType::try_from(0x0009u16).is_err()); + assert!(PayloadType::try_from(0x4000u16).is_err()); + assert!(PayloadType::try_from(0x8000u16).is_err()); + assert!(PayloadType::try_from(0xFFFFu16).is_err()); + } + + #[test] + fn minimum_payload_lengths_per_spec() { + // ISO 13400-2 requirements + assert_eq!( + PayloadType::VehicleIdentificationRequest.min_payload_length(), + 0 + ); + assert_eq!( + PayloadType::RoutingActivationRequest.min_payload_length(), + 7 + ); + assert_eq!(PayloadType::DiagnosticMessage.min_payload_length(), 5); + assert_eq!(PayloadType::AliveCheckResponse.min_payload_length(), 2); + } + + // ------------------------------------------------------------------------- + // DoipMessage - wrapping header + payload together + // ------------------------------------------------------------------------- + + #[test] + fn create_tester_present_message() { + // UDS TesterPresent: 0x3E 0x00 + let uds = Bytes::from_static(&[0x0E, 0x80, 0x10, 0x01, 0x3E, 0x00]); + let msg = DoipMessage::new(PayloadType::DiagnosticMessage, uds); + + assert_eq!(msg.header.payload_type, 0x8001); + assert_eq!(msg.header.payload_length, 6); + assert_eq!(msg.payload_type(), Some(PayloadType::DiagnosticMessage)); + } + + #[test] + fn create_vehicle_id_broadcast() { + // Empty payload for discovery + let msg = DoipMessage::new(PayloadType::VehicleIdentificationRequest, Bytes::new()); + + assert_eq!(msg.header.payload_length, 0); + assert_eq!(msg.message_length(), 8); // Just the header + } + + #[test] + fn message_with_unknown_type() { + // For testing/fuzzing - create msg with invalid type + let msg = DoipMessage::with_raw_payload_type(0xBEEF, Bytes::new()); + assert_eq!(msg.payload_type(), None); + } + + #[test] + fn serialize_message_to_wire_format() { + let msg = DoipMessage::new(PayloadType::AliveCheckRequest, Bytes::new()); + let wire = msg.to_bytes(); + + assert_eq!(wire.len(), 8); + assert_eq!(&wire[..4], &[0x02, 0xFD, 0x00, 0x07]); // Version + type + assert_eq!(&wire[4..8], &[0x00, 0x00, 0x00, 0x00]); // Length = 0 + } + + // ------------------------------------------------------------------------- + // Codec - TCP stream framing + // ------------------------------------------------------------------------- + + #[test] + fn decode_complete_alive_check_response() { + let mut codec = DoipCodec::new(); + // AliveCheckResponse with source address 0x0E80 + let mut buf = + BytesMut::from(&[0x02, 0xFD, 0x00, 0x08, 0x00, 0x00, 0x00, 0x02, 0x0E, 0x80][..]); + + let msg = codec.decode(&mut buf).unwrap().unwrap(); + assert_eq!(msg.header.payload_type, 0x0008); + assert_eq!(msg.payload.as_ref(), &[0x0E, 0x80]); + assert!(buf.is_empty()); // Consumed everything + } + + #[test] + fn wait_for_more_data_when_header_incomplete() { + let mut codec = DoipCodec::new(); + let mut buf = BytesMut::from(&[0x02, 0xFD, 0x00][..]); // Only 3 bytes + + assert!(codec.decode(&mut buf).unwrap().is_none()); + assert_eq!(buf.len(), 3); // Nothing consumed + } + + #[test] + fn wait_for_more_data_when_payload_incomplete() { + let mut codec = DoipCodec::new(); + // Header says 5 bytes payload, but only 2 arrived + let mut buf = + BytesMut::from(&[0x02, 0xFD, 0x80, 0x01, 0x00, 0x00, 0x00, 0x05, 0x0E, 0x80][..]); + + assert!(codec.decode(&mut buf).unwrap().is_none()); + } + + #[test] + fn decode_back_to_back_messages() { + let mut codec = DoipCodec::new(); + let mut buf = BytesMut::from( + &[ + // Msg 1: AliveCheckRequest + 0x02, 0xFD, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, + // Msg 2: AliveCheckResponse + 0x02, 0xFD, 0x00, 0x08, 0x00, 0x00, 0x00, 0x02, 0x0E, 0x80, + ][..], + ); + + let m1 = codec.decode(&mut buf).unwrap().unwrap(); + let m2 = codec.decode(&mut buf).unwrap().unwrap(); + + assert_eq!(m1.header.payload_type, 0x0007); + assert_eq!(m2.header.payload_type, 0x0008); + assert!(buf.is_empty()); + } + + #[test] + fn reject_invalid_header_in_stream() { + let mut codec = DoipCodec::new(); + // Bad version (0x04 is not valid - only 0x01, 0x02, 0x03, 0xFF are accepted) + let mut buf = BytesMut::from(&[0x04, 0xFB, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00][..]); + + assert!(codec.decode(&mut buf).is_err()); + } + + #[test] + fn respect_custom_max_payload_size() { + let mut codec = DoipCodec::with_max_payload_size(100); + // Payload length = 101, over our limit + let mut buf = BytesMut::from(&[0x02, 0xFD, 0x80, 0x01, 0x00, 0x00, 0x00, 0x65][..]); + + assert!(codec.decode(&mut buf).is_err()); + } + + #[test] + fn encode_diagnostic_message() { + let mut codec = DoipCodec::new(); + let payload = Bytes::from_static(&[0x0E, 0x80, 0x10, 0x01, 0x3E]); + let msg = DoipMessage::new(PayloadType::DiagnosticMessage, payload); + + let mut buf = BytesMut::new(); + codec.encode(msg, &mut buf).unwrap(); + + assert_eq!(&buf[..4], &[0x02, 0xFD, 0x80, 0x01]); + assert_eq!(&buf[4..8], &[0x00, 0x00, 0x00, 0x05]); + assert_eq!(&buf[8..], &[0x0E, 0x80, 0x10, 0x01, 0x3E]); + } + + // ------------------------------------------------------------------------- + // Round-trip: encode then decode should give same data + // ------------------------------------------------------------------------- + + #[test] + fn roundtrip_diagnostic_message() { + let mut codec = DoipCodec::new(); + let original = DoipMessage::new( + PayloadType::DiagnosticMessage, + Bytes::from_static(&[0x0E, 0x80, 0x10, 0x01, 0x22, 0xF1, 0x90]), + ); + + let mut buf = BytesMut::new(); + codec.encode(original.clone(), &mut buf).unwrap(); + let decoded = codec.decode(&mut buf).unwrap().unwrap(); + + assert_eq!(original.header, decoded.header); + assert_eq!(original.payload, decoded.payload); + } + + #[test] + fn roundtrip_header_only() { + let original = make_header(0x8001, 42); + let bytes = original.to_bytes(); + let parsed = DoipHeader::parse(&bytes).unwrap(); + + assert_eq!(original, parsed); + } + + // ------------------------------------------------------------------------- + // Edge cases and error handling + // ------------------------------------------------------------------------- + + #[test] + fn handle_all_zeros_gracefully() { + let garbage = [0x00; 8]; + let hdr = DoipHeader::parse(&garbage).unwrap(); + assert!(!hdr.is_valid()); // Wrong version, but doesn't panic + } + + #[test] + fn handle_all_ones_gracefully() { + let garbage = [0xFF; 8]; + let hdr = DoipHeader::parse(&garbage).unwrap(); + assert!(!hdr.is_valid()); + } + + #[test] + fn parse_error_shows_useful_message() { + let result = DoipHeader::parse(&[]); + let msg = format!("{}", result.unwrap_err()); + assert!(msg.contains("DoIP header")); + } + + #[test] + fn nack_codes_have_correct_values() { + // Per ISO 13400-2 Table 17 + assert_eq!(GenericNackCode::IncorrectPatternFormat as u8, 0x00); + assert_eq!(GenericNackCode::UnknownPayloadType as u8, 0x01); + assert_eq!(GenericNackCode::MessageTooLarge as u8, 0x02); + assert_eq!(GenericNackCode::OutOfMemory as u8, 0x03); + assert_eq!(GenericNackCode::InvalidPayloadLength as u8, 0x04); + } + + #[test] + fn protocol_version_inverse_relationship() { + // Version XOR inverse must equal DOIP_HEADER_VERSION_MASK (0xFF, per ISO 13400-2:2019 spec) + assert_eq!( + DEFAULT_PROTOCOL_VERSION ^ DEFAULT_PROTOCOL_VERSION_INV, + DOIP_HEADER_VERSION_MASK + ); + } + + #[test] + fn header_display_shows_readable_info() { + let hdr = make_header(0x8001, 10); + let s = format!("{hdr}"); + assert!(s.contains("DiagnosticMessage")); + assert!(s.contains("10")); // payload length + } + + #[test] + fn default_header_has_correct_version() { + let hdr = DoipHeader::default(); + assert_eq!(hdr.version, 0x02); + assert_eq!(hdr.inverse_version, 0xFD); + } +} diff --git a/doip-server/src/doip/mod.rs b/doip-server/src/doip/mod.rs new file mode 100644 index 0000000..e4162d3 --- /dev/null +++ b/doip-server/src/doip/mod.rs @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +//! +//! This module provides the core `DoIP` protocol types and codec for TCP/UDP communication. + +/// Alive Check request/response handlers (ISO 13400-2:2019 §7.6). +pub mod alive_check; +/// Tokio codec framing for DoIP TCP streams. +pub mod codec; +/// Diagnostic Message request and acknowledgment handlers (ISO 13400-2:2019 §7.9). +pub mod diagnostic_message; +/// DoIP header parsing, validation, and serialization. +pub mod header; +/// DoIP payload type enumeration and dispatch. +pub mod payload; +/// Routing Activation request/response handlers (ISO 13400-2:2019 §7.7). +pub mod routing_activation; +/// Vehicle Identification request/response handlers (ISO 13400-2:2019 §7.5). +pub mod vehicle_id; + +// Re-export core types and constants for convenient access. +// Constants are exported to allow external testing and custom DoIP message construction. +use bytes::{Bytes, BytesMut}; +pub use codec::DoipCodec; +pub use header::{ + DEFAULT_PROTOCOL_VERSION, DOIP_HEADER_LENGTH, DoipHeader, DoipMessage, GenericNackCode, + MAX_DOIP_MESSAGE_SIZE, PayloadType, +}; +pub use payload::DoipPayload; +use tracing::error; + +use crate::{DoipError, Result}; + +/// Trait for `DoIP` message types that can be parsed from a raw payload slice. +/// +/// Implement this for every message struct so callers can decode incoming +/// `DoIP` frames through a uniform interface. +pub(crate) trait DoipParseable: Sized { + /// Parse a `DoIP` message from a raw payload byte slice. + /// + /// # Errors + /// Returns [`DoipError`] if the payload is malformed or too short. + fn parse(payload: &[u8]) -> Result; +} + +/// Trait for `DoIP` message types that can be serialized to a [`Bytes`] buffer. +/// +/// Implement [`write_to`](DoipSerializable::write_to) with the wire-format logic. +/// The default [`to_bytes`](DoipSerializable::to_bytes) wraps it in a `BytesMut` +/// and calls `freeze()`, so you never write that boilerplate again. +pub trait DoipSerializable { + /// Write the serialized wire-format bytes into `buf`. + fn write_to(&self, buf: &mut BytesMut); + + /// Returns `Some(n)` when the size is known ahead of serialization, + /// enabling [`to_bytes`](DoipSerializable::to_bytes) to pre-allocate the buffer + /// and avoid incremental `BytesMut` reallocations for large messages. + /// + /// Returns `None` (the default) to indicate the size is not known in + /// advance; [`to_bytes`](DoipSerializable::to_bytes) will then use a + /// dynamically-growing buffer. Override this in your implementation whenever + /// the encoded length is computable upfront. + fn serialized_len(&self) -> Option { + None + } + + /// Serialize this message into a [`Bytes`] buffer. + /// + /// Pre-allocates the buffer when [`serialized_len`](DoipSerializable::serialized_len) + /// returns `Some`. + fn to_bytes(&self) -> Bytes { + let mut buf = self + .serialized_len() + .map_or_else(BytesMut::new, BytesMut::with_capacity); + self.write_to(&mut buf); + buf.freeze() + } +} + +/// Build a [`DoipError::PayloadTooShort`] from the given slice and expected length. +pub(crate) fn too_short(payload: &[u8], expected: usize) -> DoipError { + DoipError::PayloadTooShort { + expected, + actual: payload.len(), + } +} + +/// Return `Err` if `payload` is shorter than `expected` bytes. +pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> Result<()> { + if payload.len() < expected { + Err(too_short(payload, expected)) + } else { + Ok(()) + } +} + +/// Extract the first `N` bytes of `payload` as a fixed-size array. +/// +/// Logs an error and returns [`DoipError::PayloadTooShort`] when the slice +/// is shorter than `N` bytes, using `context` to identify the call site in the log. +pub(crate) fn parse_fixed_slice(payload: &[u8], context: &str) -> Result<[u8; N]> { + payload + .get(..N) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + let e = too_short(payload, N); + error!(context, error = %e, "parse failed"); + e + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::DoipError; + + #[test] + fn too_short_produces_correct_error() { + let payload = [0x01u8, 0x02]; + let err = too_short(&payload, 5); + assert!(matches!( + err, + DoipError::PayloadTooShort { + expected: 5, + actual: 2 + } + )); + } + + #[test] + fn too_short_on_empty_slice() { + let err = too_short(&[], 4); + assert!(matches!( + err, + DoipError::PayloadTooShort { + expected: 4, + actual: 0 + } + )); + } + + #[test] + fn check_min_len_passes_when_exact() { + assert!(check_min_len(&[0x01, 0x02], 2).is_ok()); + } + + #[test] + fn check_min_len_passes_when_longer() { + assert!(check_min_len(&[0x01, 0x02, 0x03], 2).is_ok()); + } + + #[test] + fn check_min_len_errors_when_too_short() { + let result = check_min_len(&[0x01], 2); + assert!(matches!( + result, + Err(DoipError::PayloadTooShort { + expected: 2, + actual: 1 + }) + )); + } + + #[test] + fn check_min_len_errors_on_empty_slice() { + let result = check_min_len(&[], 1); + assert!(matches!( + result, + Err(DoipError::PayloadTooShort { + expected: 1, + actual: 0 + }) + )); + } + + #[test] + fn check_min_len_zero_always_passes() { + assert!(check_min_len(&[], 0).is_ok()); + } + + #[test] + fn parse_fixed_slice_extracts_exact_bytes() { + let payload = [0x0E, 0x80, 0x10, 0x01]; + let result: [u8; 2] = parse_fixed_slice(&payload, "test").unwrap(); + assert_eq!(result, [0x0E, 0x80]); + } + + #[test] + fn parse_fixed_slice_succeeds_with_extra_bytes() { + let payload = [0x01, 0x02, 0x03, 0x04, 0x05]; + let result: [u8; 3] = parse_fixed_slice(&payload, "test").unwrap(); + assert_eq!(result, [0x01, 0x02, 0x03]); + } + + #[test] + fn parse_fixed_slice_errors_when_too_short() { + let payload = [0x01u8]; + let result: crate::Result<[u8; 4]> = parse_fixed_slice(&payload, "test"); + assert!(matches!( + result, + Err(DoipError::PayloadTooShort { + expected: 4, + actual: 1 + }) + )); + } + + #[test] + fn parse_fixed_slice_errors_on_empty() { + let result: crate::Result<[u8; 2]> = parse_fixed_slice(&[], "context"); + assert!(matches!( + result, + Err(DoipError::PayloadTooShort { + expected: 2, + actual: 0 + }) + )); + } + + #[test] + fn parse_fixed_slice_zero_size_always_succeeds() { + let result: [u8; 0] = parse_fixed_slice(&[], "empty").unwrap(); + assert_eq!(result, []); + } +} diff --git a/doip-server/src/doip/payload.rs b/doip-server/src/doip/payload.rs new file mode 100644 index 0000000..4331cb2 --- /dev/null +++ b/doip-server/src/doip/payload.rs @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +//! Typed dispatch envelope for `DoIP` message payloads (ISO 13400-2:2019). +//! +//! [`DoipPayload`] is a strongly-typed enum that wraps every concrete payload +//! struct. Use [`DoipPayload::parse`] to decode a raw [`DoipMessage`] into +//! the correct variant in one step, instead of manually matching on +//! [`PayloadType`] throughout the codebase. +//! +//! # Example +//! ```no_run +//! # use doip_server::doip::{DoipMessage, DoipPayload}; +//! # use doip_server::Result; +//! fn dispatch(msg: &DoipMessage) -> Result<()> { +//! match DoipPayload::parse(msg)? { +//! DoipPayload::DiagnosticMessage(m) => println!("UDS payload: {:?}", m), +//! DoipPayload::AliveCheckRequest(_) => println!("Alive check received"), +//! _ => {} +//! } +//! Ok(()) +//! } +//! ``` + +use super::{ + DoipMessage, DoipParseable, GenericNackCode, PayloadType, alive_check, diagnostic_message, + routing_activation, vehicle_id, +}; +use crate::DoipError; +use crate::Result; + +/// A fully-parsed `DoIP` message payload. +/// +/// Each variant corresponds to one [`PayloadType`] and wraps the concrete +/// struct returned by its [`DoipParseable`] impl. +#[derive(Debug)] +pub enum DoipPayload { + /// `0x0007` – Alive Check Request (zero-length payload) + AliveCheckRequest(alive_check::AliveCheckRequest), + /// `0x0008` – Alive Check Response + AliveCheckResponse(alive_check::AliveCheckResponse), + /// `0x0005` – Routing Activation Request + RoutingActivationRequest(routing_activation::RoutingActivationRequest), + /// `0x0006` – Routing Activation Response + RoutingActivationResponse(routing_activation::RoutingActivationResponse), + /// `0x8001` – Diagnostic Message (UDS data) + DiagnosticMessage(diagnostic_message::DiagnosticMessage), + /// `0x8002` – Diagnostic Message Positive Acknowledgement + DiagnosticMessagePositiveAck(diagnostic_message::DiagnosticAck), + /// `0x8003` – Diagnostic Message Negative Acknowledgement + DiagnosticMessageNegativeAck(diagnostic_message::DiagnosticAck), + /// `0x0001` – Vehicle Identification Request (no filter) + VehicleIdentificationRequest(vehicle_id::VehicleIdRequest), + /// `0x0002` – Vehicle Identification Request filtered by EID + VehicleIdentificationRequestWithEid(vehicle_id::VehicleIdRequestWithEid), + /// `0x0003` – Vehicle Identification Request filtered by VIN + VehicleIdentificationRequestWithVin(vehicle_id::VehicleIdRequestWithVin), + /// `0x0004` – Vehicle Identification Response / Announce + VehicleIdentificationResponse(vehicle_id::VehicleIdResponse), + /// `0x0000` – Generic `DoIP` Header Negative Acknowledgement + GenericNack(GenericNackCode), +} + +impl DoipPayload { + /// Decode the payload of a [`DoipMessage`] into a typed [`DoipPayload`] variant. + /// + /// # Errors + /// Returns [`DoipError::UnknownPayloadType`] when the `payload_type` field + /// in the header does not map to a known [`PayloadType`] variant, or when + /// the `DoIP` payload byte is not a recognized `GenericNackCode`. + /// + /// Returns a more specific [`DoipError`] (e.g. [`DoipError::PayloadTooShort`]) + /// when the payload bytes are present but malformed. + pub fn parse(msg: &DoipMessage) -> Result { + let payload = msg.payload().as_ref(); + + let payload_type = msg + .payload_type() + .ok_or_else(|| DoipError::UnknownPayloadType(msg.header().payload_type()))?; + + match payload_type { + PayloadType::AliveCheckRequest => Ok(Self::AliveCheckRequest( + alive_check::AliveCheckRequest::parse(payload)?, + )), + PayloadType::AliveCheckResponse => Ok(Self::AliveCheckResponse( + alive_check::AliveCheckResponse::parse(payload)?, + )), + PayloadType::RoutingActivationRequest => Ok(Self::RoutingActivationRequest( + routing_activation::RoutingActivationRequest::parse(payload)?, + )), + PayloadType::RoutingActivationResponse => Ok(Self::RoutingActivationResponse( + routing_activation::RoutingActivationResponse::parse(payload)?, + )), + PayloadType::DiagnosticMessage => Ok(Self::DiagnosticMessage( + diagnostic_message::DiagnosticMessage::parse(payload)?, + )), + PayloadType::DiagnosticMessagePositiveAck => Ok(Self::DiagnosticMessagePositiveAck( + diagnostic_message::DiagnosticAck::parse_positive(payload)?, + )), + PayloadType::DiagnosticMessageNegativeAck => Ok(Self::DiagnosticMessageNegativeAck( + diagnostic_message::DiagnosticAck::parse_negative(payload)?, + )), + PayloadType::VehicleIdentificationRequest => Ok(Self::VehicleIdentificationRequest( + vehicle_id::VehicleIdRequest::parse(payload)?, + )), + PayloadType::VehicleIdentificationRequestWithEid => { + Ok(Self::VehicleIdentificationRequestWithEid( + vehicle_id::VehicleIdRequestWithEid::parse(payload)?, + )) + } + PayloadType::VehicleIdentificationRequestWithVin => { + Ok(Self::VehicleIdentificationRequestWithVin( + vehicle_id::VehicleIdRequestWithVin::parse(payload)?, + )) + } + PayloadType::VehicleIdentificationResponse => Ok(Self::VehicleIdentificationResponse( + vehicle_id::VehicleIdResponse::parse(payload)?, + )), + PayloadType::GenericNack => { + let byte = payload.first().copied().ok_or(DoipError::PayloadTooShort { + expected: 1, + actual: 0, + })?; + let code = GenericNackCode::try_from(byte) + .map_err(|b| DoipError::UnknownPayloadType(u16::from(b)))?; + Ok(Self::GenericNack(code)) + } + PayloadType::DoipEntityStatusRequest + | PayloadType::DoipEntityStatusResponse + | PayloadType::DiagnosticPowerModeRequest + | PayloadType::DiagnosticPowerModeResponse => { + Err(DoipError::UnknownPayloadType(u16::from(payload_type))) + } + } + } + + /// Return the [`PayloadType`] that corresponds to this payload variant. + #[must_use] + pub fn payload_type(&self) -> PayloadType { + match self { + Self::AliveCheckRequest(_) => PayloadType::AliveCheckRequest, + Self::AliveCheckResponse(_) => PayloadType::AliveCheckResponse, + Self::RoutingActivationRequest(_) => PayloadType::RoutingActivationRequest, + Self::RoutingActivationResponse(_) => PayloadType::RoutingActivationResponse, + Self::DiagnosticMessage(_) => PayloadType::DiagnosticMessage, + Self::DiagnosticMessagePositiveAck(_) => PayloadType::DiagnosticMessagePositiveAck, + Self::DiagnosticMessageNegativeAck(_) => PayloadType::DiagnosticMessageNegativeAck, + Self::VehicleIdentificationRequest(_) => PayloadType::VehicleIdentificationRequest, + Self::VehicleIdentificationRequestWithEid(_) => { + PayloadType::VehicleIdentificationRequestWithEid + } + Self::VehicleIdentificationRequestWithVin(_) => { + PayloadType::VehicleIdentificationRequestWithVin + } + Self::VehicleIdentificationResponse(_) => PayloadType::VehicleIdentificationResponse, + Self::GenericNack(_) => PayloadType::GenericNack, + } + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use super::*; + + fn make_msg(payload_type: PayloadType, payload: impl Into) -> DoipMessage { + DoipMessage::new(payload_type, payload.into()) + } + + #[test] + fn alive_check_request_roundtrip() { + let msg = make_msg(PayloadType::AliveCheckRequest, vec![]); + let parsed = DoipPayload::parse(&msg).unwrap(); + assert!(matches!(parsed, DoipPayload::AliveCheckRequest(_))); + assert_eq!(parsed.payload_type(), PayloadType::AliveCheckRequest); + } + + #[test] + fn alive_check_response_roundtrip() { + let msg = make_msg(PayloadType::AliveCheckResponse, vec![0x0E, 0x80]); + let parsed = DoipPayload::parse(&msg).unwrap(); + assert!(matches!(parsed, DoipPayload::AliveCheckResponse(_))); + } + + #[test] + fn routing_activation_request_roundtrip() { + let payload = vec![0x0E, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00]; + let msg = make_msg(PayloadType::RoutingActivationRequest, payload); + let parsed = DoipPayload::parse(&msg).unwrap(); + assert!(matches!(parsed, DoipPayload::RoutingActivationRequest(_))); + } + + #[test] + fn routing_activation_response_roundtrip() { + use crate::doip::DoipSerializable; + let resp = routing_activation::RoutingActivationResponse::success(0x0E80, 0x1000); + let msg = make_msg(PayloadType::RoutingActivationResponse, resp.to_bytes()); + let parsed = DoipPayload::parse(&msg).unwrap(); + assert!(matches!(parsed, DoipPayload::RoutingActivationResponse(_))); + } + + #[test] + fn generic_nack_roundtrip() { + let msg = make_msg(PayloadType::GenericNack, vec![0x02]); + let parsed = DoipPayload::parse(&msg).unwrap(); + assert!(matches!( + parsed, + DoipPayload::GenericNack(GenericNackCode::MessageTooLarge) + )); + } + + #[test] + fn unknown_payload_type_error() { + let msg = DoipMessage::with_raw_payload_type(0xFFFF, Bytes::new()); + let err = DoipPayload::parse(&msg).unwrap_err(); + assert!(matches!(err, crate::DoipError::UnknownPayloadType(0xFFFF))); + } + + #[test] + fn missing_alive_check_response_data_errors() { + let msg = make_msg(PayloadType::AliveCheckResponse, vec![]); + assert!(DoipPayload::parse(&msg).is_err()); + } + + #[test] + fn payload_type_round_trips() { + assert_eq!( + DoipPayload::GenericNack(GenericNackCode::MessageTooLarge).payload_type(), + PayloadType::GenericNack, + ); + } +} diff --git a/doip-server/src/doip/routing_activation.rs b/doip-server/src/doip/routing_activation.rs new file mode 100644 index 0000000..9ecbf6c --- /dev/null +++ b/doip-server/src/doip/routing_activation.rs @@ -0,0 +1,487 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +//! Routing Activation handlers (ISO 13400-2:2019) + +use std::mem::size_of; + +use bytes::{BufMut, BytesMut}; +use tracing::error; + +use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; +use crate::DoipError; +use crate::Result; + +/// Byte length of the optional OEM-specific field (a single u32, big-endian). +const OEM_DATA_LEN: usize = size_of::(); + +/// Parse the optional 4-byte OEM-specific field from the end of a routing activation payload. +/// Returns `Some(u32)` when exactly `OEM_DATA_LEN` bytes are present beyond `min_len`, +/// and `None` when the payload ends at `min_len` (i.e. no OEM data). +fn parse_oem_specific(payload: &[u8], min_len: usize, max_len: usize) -> Option { + payload + .get(min_len..max_len) + .and_then(|s| <[u8; OEM_DATA_LEN]>::try_from(s).ok()) + .map(u32::from_be_bytes) +} + +/// Routing activation response codes per ISO 13400-2:2019 Table 25. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ActivationResponseCode { + /// Source address unknown to the `DoIP` entity (`0x00`) + UnknownSourceAddress = 0x00, + /// All TCP sockets on the `DoIP` entity are registered and active (`0x01`) + AllSocketsRegistered = 0x01, + /// Source address differs from the one registered to the socket (`0x02`) + DifferentSourceAddress = 0x02, + /// Source address is already registered on a different socket (`0x03`) + SourceAddressAlreadyActive = 0x03, + /// Routing activation denied; authentication required (`0x04`) + MissingAuthentication = 0x04, + /// Routing activation denied; confirmation rejected (`0x05`) + RejectedConfirmation = 0x05, + /// Unsupported routing activation type requested (`0x06`) + UnsupportedActivationType = 0x06, + /// TLS connection required before routing can be activated (`0x07`) + TlsRequired = 0x07, + /// Routing successfully activated (`0x10`) + SuccessfullyActivated = 0x10, + /// Routing activation pending; confirmation required (`0x11`) + ConfirmationRequired = 0x11, +} + +impl TryFrom for ActivationResponseCode { + type Error = DoipError; + + fn try_from(value: u8) -> std::result::Result { + match value { + 0x00 => Ok(Self::UnknownSourceAddress), + 0x01 => Ok(Self::AllSocketsRegistered), + 0x02 => Ok(Self::DifferentSourceAddress), + 0x03 => Ok(Self::SourceAddressAlreadyActive), + 0x04 => Ok(Self::MissingAuthentication), + 0x05 => Ok(Self::RejectedConfirmation), + 0x06 => Ok(Self::UnsupportedActivationType), + 0x07 => Ok(Self::TlsRequired), + 0x10 => Ok(Self::SuccessfullyActivated), + 0x11 => Ok(Self::ConfirmationRequired), + other => Err(DoipError::UnknownRoutingActivationResponseCode(other)), + } + } +} + +impl From for u8 { + fn from(code: ActivationResponseCode) -> Self { + code as Self + } +} + +impl ActivationResponseCode { + /// Returns `true` if this code represents a successful or pending-confirmation activation. + #[must_use] + pub fn is_success(self) -> bool { + matches!( + self, + Self::SuccessfullyActivated | Self::ConfirmationRequired + ) + } +} + +/// Routing activation types per ISO 13400-2:2019 Table 24. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ActivationType { + /// Default routing activation (`0x00`) + Default = 0x00, + /// WWH-OBD routing activation (`0x01`) + WwhObd = 0x01, + /// Central security routing activation (`0xE0`) + CentralSecurity = 0xE0, +} + +impl TryFrom for ActivationType { + type Error = DoipError; + + fn try_from(value: u8) -> std::result::Result { + match value { + 0x00 => Ok(Self::Default), + 0x01 => Ok(Self::WwhObd), + 0xE0 => Ok(Self::CentralSecurity), + other => Err(DoipError::UnknownActivationType(other)), + } + } +} + +/// Routing Activation Request (payload type `0x0005`) – sent by the tester +/// to activate a routing path. +/// +/// # Wire Format +/// Payload: SA(2) + type(1) + reserved(4) + optional OEM(4) +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoutingActivationRequest { + source_address: u16, + activation_type: ActivationType, + reserved: u32, + oem_specific: Option, +} + +impl RoutingActivationRequest { + /// Minimum wire-format length of a Routing Activation Request payload + /// (7 bytes: SA + type + reserved, without OEM data). + pub(crate) const MIN_LEN: usize = 7; + /// Maximum wire-format length of a Routing Activation Request payload + /// (11 bytes: includes optional 4-byte OEM data). + pub(crate) const MAX_LEN: usize = 11; + + /// Tester logical source address + #[must_use] + pub fn source_address(&self) -> u16 { + self.source_address + } + + /// Activation type requested + #[must_use] + pub fn activation_type(&self) -> ActivationType { + self.activation_type + } + + /// Reserved field (must be 0x00000000) + #[must_use] + pub fn reserved(&self) -> u32 { + self.reserved + } + + /// Optional OEM-specific data + #[must_use] + pub fn oem_specific(&self) -> Option { + self.oem_specific + } +} + +/// Routing Activation Response (payload type `0x0006`) – sent by the `DoIP` +/// entity to confirm or deny routing. +/// +/// # Wire Format +/// Payload: testerAddr(2) + entityAddr(2) + code(1) + reserved(4) + optional OEM(4) +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoutingActivationResponse { + tester_address: u16, + entity_address: u16, + code: ActivationResponseCode, + reserved: u32, + oem_specific: Option, +} + +impl RoutingActivationResponse { + /// Minimum wire-format length of a Routing Activation Response payload + /// (9 bytes: tester addr + entity addr + code + reserved, without OEM data). + pub(crate) const MIN_LEN: usize = 9; + /// Maximum wire-format length of a Routing Activation Response payload + /// (13 bytes: includes optional 4-byte OEM data). + pub(crate) const MAX_LEN: usize = 13; + + /// Build a successful routing activation response. + #[must_use] + pub fn success(tester_address: u16, entity_address: u16) -> Self { + Self { + tester_address, + entity_address, + code: ActivationResponseCode::SuccessfullyActivated, + reserved: 0, + oem_specific: None, + } + } + + /// Build a denied routing activation response with the given `code`. + #[must_use] + pub fn denial(tester_address: u16, entity_address: u16, code: ActivationResponseCode) -> Self { + Self { + tester_address, + entity_address, + code, + reserved: 0, + oem_specific: None, + } + } + + /// Returns `true` if the response code indicates successful or pending-confirmation activation. + #[must_use] + pub fn is_success(&self) -> bool { + self.code.is_success() + } + + /// Tester logical address + #[must_use] + pub fn tester_address(&self) -> u16 { + self.tester_address + } + + /// `DoIP` entity logical address + #[must_use] + pub fn entity_address(&self) -> u16 { + self.entity_address + } + + /// Routing activation response code + #[must_use] + pub fn response_code(&self) -> ActivationResponseCode { + self.code + } + + /// Reserved field + #[must_use] + pub fn reserved(&self) -> u32 { + self.reserved + } + + /// Optional OEM-specific data + #[must_use] + pub fn oem_specific(&self) -> Option { + self.oem_specific + } + + /// Attach OEM-specific data to this response. + #[must_use] + pub fn with_oem_specific(mut self, oem: u32) -> Self { + self.oem_specific = Some(oem); + self + } +} + +impl DoipParseable for RoutingActivationRequest { + fn parse(payload: &[u8]) -> Result { + let header: [u8; Self::MIN_LEN] = parse_fixed_slice(payload, "RoutingActivation Request")?; + + let source_address = u16::from_be_bytes([header[0], header[1]]); + let activation_type = ActivationType::try_from(header[2]).map_err(|e| { + error!(error = %e, "RoutingActivation Request parse failed"); + e + })?; + let reserved = u32::from_be_bytes([header[3], header[4], header[5], header[6]]); + + let oem_specific = parse_oem_specific(payload, Self::MIN_LEN, Self::MAX_LEN); + + Ok(Self { + source_address, + activation_type, + reserved, + oem_specific, + }) + } +} + +impl DoipParseable for RoutingActivationResponse { + fn parse(payload: &[u8]) -> Result { + let header: [u8; Self::MIN_LEN] = parse_fixed_slice(payload, "RoutingActivation Response")?; + + let tester_address = u16::from_be_bytes([header[0], header[1]]); + let entity_address = u16::from_be_bytes([header[2], header[3]]); + let response_code = ActivationResponseCode::try_from(header[4]).map_err(|e| { + error!(error = %e, "RoutingActivation Response parse failed"); + e + })?; + let reserved = u32::from_be_bytes([header[5], header[6], header[7], header[8]]); + + let oem_specific = parse_oem_specific(payload, Self::MIN_LEN, Self::MAX_LEN); + + Ok(Self { + tester_address, + entity_address, + code: response_code, + reserved, + oem_specific, + }) + } +} + +impl DoipSerializable for RoutingActivationResponse { + fn serialized_len(&self) -> Option { + Some( + Self::MIN_LEN.saturating_add(if self.oem_specific.is_some() { + OEM_DATA_LEN + } else { + 0 + }), + ) + } + + fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.tester_address); + buf.put_u16(self.entity_address); + buf.put_u8(u8::from(self.code)); + buf.put_u32(self.reserved); + if let Some(oem) = self.oem_specific { + buf.put_u32(oem); + } + } +} + +#[cfg(test)] +#[allow(clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::doip::{DoipParseable, DoipSerializable}; + + // Wire-format byte offsets for RoutingActivation Response + // Layout: TesterAddr(2) + EntityAddr(2) + ActivationResponseCode(1) + Reserved(4) + OEM(4 optional) + const TESTER_ADDR_END: usize = 2; + const ENTITY_ADDR_END: usize = 4; + const RESP_CODE_IDX: usize = 4; + const OEM_DATA_START: usize = RoutingActivationResponse::MIN_LEN; // 9 + const OEM_DATA_END: usize = RoutingActivationResponse::MAX_LEN; // 13 + + #[test] + fn response_code_success_check() { + assert!(ActivationResponseCode::SuccessfullyActivated.is_success()); + assert!(ActivationResponseCode::ConfirmationRequired.is_success()); + assert!(!ActivationResponseCode::UnknownSourceAddress.is_success()); + assert!(!ActivationResponseCode::TlsRequired.is_success()); + } + + #[test] + fn response_code_values() { + assert_eq!(ActivationResponseCode::UnknownSourceAddress as u8, 0x00); + assert_eq!(ActivationResponseCode::SuccessfullyActivated as u8, 0x10); + assert_eq!(ActivationResponseCode::ConfirmationRequired as u8, 0x11); + } + + #[test] + fn parse_minimal_request() { + let payload = [0x0E, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00]; + let req = RoutingActivationRequest::parse(&payload).unwrap(); + + assert_eq!(req.source_address(), 0x0E80); + assert_eq!(req.activation_type(), ActivationType::Default); + assert_eq!(req.reserved(), 0); + assert!(req.oem_specific().is_none()); + } + + #[test] + fn parse_request_with_oem() { + let payload = [ + 0x0E, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF, + ]; + let req = RoutingActivationRequest::parse(&payload).unwrap(); + assert_eq!(req.oem_specific(), Some(0xDEAD_BEEF)); + } + + #[test] + fn parse_wwh_obd_request() { + let payload = [0x0F, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]; + let req = RoutingActivationRequest::parse(&payload).unwrap(); + assert_eq!(req.activation_type(), ActivationType::WwhObd); + } + + #[test] + fn reject_short_request() { + let short = [0x0E, 0x80, 0x00, 0x00]; + assert!(RoutingActivationRequest::parse(&short).is_err()); + } + + #[test] + fn reject_unknown_activation_type() { + // 0x99 is not a valid ActivationType — parse must fail, not silently accept + let payload = [0x0E, 0x80, 0x99, 0x00, 0x00, 0x00, 0x00]; + assert!(RoutingActivationRequest::parse(&payload).is_err()); + } + + #[test] + fn build_success_response() { + let resp = RoutingActivationResponse::success(0x0E80, 0x1000); + assert_eq!(resp.tester_address(), 0x0E80); + assert_eq!(resp.entity_address(), 0x1000); + assert!(resp.is_success()); + } + + #[test] + fn build_denial_response() { + let resp = RoutingActivationResponse::denial( + 0x0E80, + 0x1000, + ActivationResponseCode::AllSocketsRegistered, + ); + assert!(!resp.is_success()); + } + + #[test] + fn serialize_response() { + let resp = RoutingActivationResponse::success(0x0E80, 0x1000); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), RoutingActivationResponse::MIN_LEN); + assert_eq!(&bytes[..TESTER_ADDR_END], &[0x0E, 0x80]); + assert_eq!(&bytes[TESTER_ADDR_END..ENTITY_ADDR_END], &[0x10, 0x00]); + assert_eq!( + bytes[RESP_CODE_IDX], + ActivationResponseCode::SuccessfullyActivated as u8 + ); + } + + #[test] + fn serialize_response_with_oem() { + let resp = + RoutingActivationResponse::success(0x0E80, 0x1000).with_oem_specific(0x1234_5678); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), RoutingActivationResponse::MAX_LEN); + assert_eq!( + &bytes[OEM_DATA_START..OEM_DATA_END], + &[0x12, 0x34, 0x56, 0x78] + ); + } + + #[test] + fn parse_success_response() { + let payload = [0x0E, 0x80, 0x10, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00]; + let resp = RoutingActivationResponse::parse(&payload).unwrap(); + assert!(resp.is_success()); + assert_eq!(resp.tester_address(), 0x0E80); + assert_eq!(resp.entity_address(), 0x1000); + } + + #[test] + fn parse_denial_response() { + let payload = [0x0E, 0x80, 0x10, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]; + let resp = RoutingActivationResponse::parse(&payload).unwrap(); + assert!(!resp.is_success()); + assert_eq!( + resp.response_code(), + ActivationResponseCode::AllSocketsRegistered + ); + } + + #[test] + fn roundtrip_response() { + let original = RoutingActivationResponse::success(0x0E80, 0x1000); + let bytes = original.to_bytes(); + let parsed = RoutingActivationResponse::parse(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn roundtrip_response_with_oem() { + let mut original = RoutingActivationResponse::denial( + 0x0F00, + 0x2000, + ActivationResponseCode::MissingAuthentication, + ); + original.oem_specific = Some(0xCAFE_BABE); + let bytes = original.to_bytes(); + let parsed = RoutingActivationResponse::parse(&bytes).unwrap(); + assert_eq!(original, parsed); + } +} diff --git a/doip-server/src/doip/vehicle_id.rs b/doip-server/src/doip/vehicle_id.rs new file mode 100644 index 0000000..77775a3 --- /dev/null +++ b/doip-server/src/doip/vehicle_id.rs @@ -0,0 +1,490 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Vehicle Identification handlers (ISO 13400-2:2019) + +use bytes::{BufMut, BytesMut}; +use tracing::error; + +use super::{DoipParseable, DoipSerializable, check_min_len, parse_fixed_slice, too_short}; +use crate::DoipError; +use crate::Result; + +// Wire-format field lengths for VehicleIdentificationResponse (ISO 13400-2:2019) +const VIN_LEN: usize = 17; +const LOGICAL_ADDR_LEN: usize = 2; +const EID_LEN: usize = 6; +const GID_LEN: usize = 6; +const FURTHER_ACTION_LEN: usize = 1; + +// Pre-computed byte offsets derived from field layout +const VIN_END: usize = VIN_LEN; // 17 +const ADDR_START: usize = VIN_END; // 17 +const ADDR_END: usize = ADDR_START + LOGICAL_ADDR_LEN; // 19 +const EID_START: usize = ADDR_END; // 19 +const EID_END: usize = EID_START + EID_LEN; // 25 +const GID_START: usize = EID_END; // 25 +const GID_END: usize = GID_START + GID_LEN; // 31 +const FURTHER_ACTION_IDX: usize = GID_END; // 31 +const SYNC_STATUS_IDX: usize = FURTHER_ACTION_IDX + FURTHER_ACTION_LEN; // 32 + +/// Vehicle Identification Request (payload type `0x0001`) – broadcast with no filter criteria. +/// +/// The `DoIP` entity responds with a Vehicle Identification Response containing VIN, EID, and GID. +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VehicleIdRequest; + +/// Vehicle Identification Request filtered by EID (payload type `0x0002`). +/// +/// Only the `DoIP` entity with a matching 6-byte EID should respond. +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VehicleIdRequestWithEid { + eid: [u8; EID_LEN], +} + +impl VehicleIdRequestWithEid { + /// Fixed wire-format length of a Vehicle Identification Request with EID + /// payload (6-byte EID filter). + pub(crate) const PAYLOAD_LEN: usize = EID_LEN; + + /// Create a new Vehicle Identification Request filtered by the given 6-byte EID. + #[must_use] + pub fn new(eid: [u8; EID_LEN]) -> Self { + Self { eid } + } + + /// The EID filter value + #[must_use] + pub fn eid(&self) -> &[u8; EID_LEN] { + &self.eid + } +} + +/// Vehicle Identification Request filtered by VIN (payload type `0x0003`). +/// +/// Only the `DoIP` entity with a matching 17-byte VIN should respond. +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VehicleIdRequestWithVin { + vin: [u8; VIN_LEN], +} + +impl VehicleIdRequestWithVin { + /// Fixed wire-format length of a Vehicle Identification Request with VIN payload (17-byte VIN). + pub(crate) const PAYLOAD_LEN: usize = VIN_LEN; + + /// Create a new Vehicle Identification Request filtered by the given 17-byte VIN. + #[must_use] + pub fn new(vin: [u8; VIN_LEN]) -> Self { + Self { vin } + } + + /// The VIN filter value as bytes + #[must_use] + pub fn vin(&self) -> &[u8; VIN_LEN] { + &self.vin + } + + /// The VIN filter value as a UTF-8 string (lossy – non-UTF-8 bytes replaced with `�`). + #[must_use] + pub fn vin_string(&self) -> String { + String::from_utf8_lossy(&self.vin).to_string() + } +} + +/// Further action codes per ISO 13400-2:2019 Table 23. +/// +/// Indicates whether the tester must take additional steps (e.g., routing +/// activation) after identification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum FurtherAction { + NoFurtherAction = 0x00, + RoutingActivationRequired = 0x10, +} + +impl TryFrom for FurtherAction { + type Error = u8; + fn try_from(value: u8) -> std::result::Result { + match value { + 0x00 => Ok(Self::NoFurtherAction), + 0x10 => Ok(Self::RoutingActivationRequired), + other => Err(other), + } + } +} + +impl From for u8 { + fn from(action: FurtherAction) -> Self { + action as Self + } +} + +/// GID synchronization status per ISO 13400-2:2019 Table 22. +/// +/// Indicates whether the `DoIP` entity's Group ID is synchronized across all ECUs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum GidSyncStatus { + Synchronized = 0x00, + NotSynchronized = 0x10, +} + +impl TryFrom for GidSyncStatus { + type Error = u8; + fn try_from(value: u8) -> std::result::Result { + match value { + 0x00 => Ok(Self::Synchronized), + 0x10 => Ok(Self::NotSynchronized), + other => Err(other), + } + } +} + +impl From for u8 { + fn from(status: GidSyncStatus) -> Self { + status as Self + } +} + +/// Vehicle Identification Response (payload type `0x0004`) – sent by the `DoIP` entity. +/// +/// Contains VIN, logical address, EID, GID, further action code, and optional sync status. +/// +/// # Wire Format +/// VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1) + optional GidSyncStatus(1) +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VehicleIdResponse { + vin: [u8; VIN_LEN], + logical_address: u16, + eid: [u8; EID_LEN], + gid: [u8; GID_LEN], + further_action: FurtherAction, + sync_status: Option, +} + +impl VehicleIdResponse { + /// Minimum wire-format length of a Vehicle Identification Response payload + /// (32 bytes: VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1)). + pub(crate) const MIN_LEN: usize = SYNC_STATUS_IDX; + /// Maximum wire-format length of a Vehicle Identification Response payload + /// (33 bytes: adds optional GidSyncStatus(1)). + pub(crate) const MAX_LEN: usize = SYNC_STATUS_IDX + 1; + + /// Create a new Vehicle Identification Response with the required fields. + /// + /// `further_action` defaults to [`FurtherAction::NoFurtherAction`]; use + /// [`with_routing_required`](Self::with_routing_required) to override. + #[must_use] + pub fn new( + vin: [u8; VIN_LEN], + logical_address: u16, + eid: [u8; EID_LEN], + gid: [u8; GID_LEN], + ) -> Self { + Self { + vin, + logical_address, + eid, + gid, + further_action: FurtherAction::NoFurtherAction, + sync_status: None, + } + } + + /// Set `FurtherAction` to `RoutingActivationRequired` (ISO 13400-2:2019 Table 23 – 0x10). + #[must_use] + pub fn with_routing_required(mut self) -> Self { + self.further_action = FurtherAction::RoutingActivationRequired; + self + } + + /// Attach an optional GID synchronization status byte to the response + /// (ISO 13400-2:2019 Table 22). + #[must_use] + pub fn with_sync_status(mut self, status: GidSyncStatus) -> Self { + self.sync_status = Some(status); + self + } + + /// Returns the VIN as a UTF-8 string (lossy – non-UTF-8 bytes replaced with `�`). + #[must_use] + pub fn vin_string(&self) -> String { + String::from_utf8_lossy(&self.vin).to_string() + } + + /// The raw VIN bytes. + #[must_use] + pub fn vin(&self) -> &[u8; VIN_LEN] { + &self.vin + } + + /// The logical address of the `DoIP` entity. + #[must_use] + pub fn logical_address(&self) -> u16 { + self.logical_address + } + + /// The 6-byte Entity Identifier. + #[must_use] + pub fn eid(&self) -> &[u8; EID_LEN] { + &self.eid + } + + /// The 6-byte Group Identifier. + #[must_use] + pub fn gid(&self) -> &[u8; GID_LEN] { + &self.gid + } + + /// The further action code. + #[must_use] + pub fn further_action(&self) -> FurtherAction { + self.further_action + } + + /// The optional GID synchronization status. + #[must_use] + pub fn sync_status(&self) -> Option { + self.sync_status + } +} + +impl DoipParseable for VehicleIdRequest { + fn parse(payload: &[u8]) -> Result { + if !payload.is_empty() { + return Err(DoipError::UnexpectedPayload { + actual: payload.len(), + }); + } + Ok(Self) + } +} + +impl DoipParseable for VehicleIdRequestWithEid { + fn parse(payload: &[u8]) -> Result { + let eid: [u8; Self::PAYLOAD_LEN] = parse_fixed_slice(payload, "VehicleIdRequestWithEid")?; + Ok(Self { eid }) + } +} + +impl DoipParseable for VehicleIdRequestWithVin { + fn parse(payload: &[u8]) -> Result { + let vin: [u8; Self::PAYLOAD_LEN] = parse_fixed_slice(payload, "VehicleIdRequestWithVin")?; + Ok(Self { vin }) + } +} + +impl DoipParseable for VehicleIdResponse { + fn parse(payload: &[u8]) -> Result { + if let Err(e) = check_min_len(payload, Self::MIN_LEN) { + error!(error = %e, "VehicleId Response parse failed"); + return Err(e); + } + + let vin: [u8; VIN_LEN] = payload + .get(..VIN_END) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; + + let addr_bytes: [u8; LOGICAL_ADDR_LEN] = payload + .get(ADDR_START..ADDR_END) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; + let logical_address = u16::from_be_bytes(addr_bytes); + + let eid: [u8; EID_LEN] = payload + .get(EID_START..EID_END) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; + + let gid: [u8; GID_LEN] = payload + .get(GID_START..GID_END) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; + + let further_action_byte = payload + .get(FURTHER_ACTION_IDX) + .copied() + .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; + let further_action = FurtherAction::try_from(further_action_byte) + .map_err(DoipError::UnknownFurtherAction)?; + + let sync_status = payload + .get(SYNC_STATUS_IDX) + .map(|&b| GidSyncStatus::try_from(b).map_err(DoipError::UnknownSyncStatus)) + .transpose()?; + + Ok(Self { + vin, + logical_address, + eid, + gid, + further_action, + sync_status, + }) + } +} + +impl DoipSerializable for VehicleIdResponse { + fn serialized_len(&self) -> Option { + Some(if self.sync_status.is_some() { + Self::MAX_LEN + } else { + Self::MIN_LEN + }) + } + + fn write_to(&self, buf: &mut BytesMut) { + buf.extend_from_slice(&self.vin); + buf.put_u16(self.logical_address); + buf.extend_from_slice(&self.eid); + buf.extend_from_slice(&self.gid); + buf.put_u8(u8::from(self.further_action)); + if let Some(status) = self.sync_status { + buf.put_u8(u8::from(status)); + } + } +} + +#[cfg(test)] +#[allow(clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::doip::{DoipParseable, DoipSerializable}; + + #[test] + fn parse_basic_request() { + let req = VehicleIdRequest::parse(&[]).unwrap(); + assert_eq!(req, VehicleIdRequest); + } + + #[test] + fn parse_request_with_eid() { + let payload = [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]; + let req = VehicleIdRequestWithEid::parse(&payload).unwrap(); + assert_eq!(req.eid(), &[0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]); + } + + #[test] + fn reject_short_eid_request() { + let short = [0x00, 0x1A, 0x2B]; + assert!(VehicleIdRequestWithEid::parse(&short).is_err()); + } + + #[test] + fn parse_request_with_vin() { + let vin = b"WVWZZZ3CZWE123456"; + let req = VehicleIdRequestWithVin::parse(vin).unwrap(); + assert_eq!(req.vin_string(), "WVWZZZ3CZWE123456"); + } + + #[test] + fn reject_short_vin_request() { + let short = b"WVWZZZ"; + assert!(VehicleIdRequestWithVin::parse(short).is_err()); + } + + #[test] + fn build_basic_response() { + let vin = *b"WVWZZZ3CZWE123456"; + let eid = [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]; + let gid = [0x00, 0x00, 0x00, 0x00, 0x00, 0x01]; + + let resp = VehicleIdResponse::new(vin, 0x1000, eid, gid); + + assert_eq!(resp.logical_address(), 0x1000); + assert_eq!(resp.further_action(), FurtherAction::NoFurtherAction); + assert!(resp.sync_status().is_none()); + } + + #[test] + fn build_response_with_routing_required() { + let vin = *b"WVWZZZ3CZWE123456"; + let eid = [0; 6]; + let gid = [0; 6]; + + let resp = VehicleIdResponse::new(vin, 0x1000, eid, gid).with_routing_required(); + assert_eq!( + resp.further_action(), + FurtherAction::RoutingActivationRequired + ); + } + + #[test] + fn serialize_response_minimal() { + let vin = *b"WVWZZZ3CZWE123456"; + let eid = [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]; + let gid = [0x00, 0x00, 0x00, 0x00, 0x00, 0x01]; + + let resp = VehicleIdResponse::new(vin, 0x1000, eid, gid); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), VehicleIdResponse::MIN_LEN); + assert_eq!(&bytes[..VIN_LEN], b"WVWZZZ3CZWE123456"); + assert_eq!(&bytes[ADDR_START..ADDR_END], &[0x10, 0x00]); // logical address + } + + #[test] + fn serialize_response_with_sync() { + let vin = *b"WVWZZZ3CZWE123456"; + let eid = [0; 6]; + let gid = [0; 6]; + + let resp = VehicleIdResponse::new(vin, 0x1000, eid, gid) + .with_sync_status(GidSyncStatus::Synchronized); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), VehicleIdResponse::MAX_LEN); + assert_eq!(bytes[SYNC_STATUS_IDX], GidSyncStatus::Synchronized as u8); // sync status + } + + #[test] + fn parse_response() { + let vin = *b"WVWZZZ3CZWE123456"; + let eid = [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]; + let gid = [0x00, 0x00, 0x00, 0x00, 0x00, 0x01]; + + let original = VehicleIdResponse::new(vin, 0x1000, eid, gid); + let bytes = original.to_bytes(); + let parsed = VehicleIdResponse::parse(&bytes).unwrap(); + + assert_eq!(parsed.vin(), &vin); + assert_eq!(parsed.logical_address(), 0x1000); + assert_eq!(parsed.eid(), &eid); + assert_eq!(parsed.gid(), &gid); + } + + #[test] + fn roundtrip_response() { + let vin = *b"WVWZZZ3CZWE123456"; + let eid = [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]; + let gid = [0x00, 0x00, 0x00, 0x00, 0x00, 0x01]; + + let original = VehicleIdResponse::new(vin, 0x1000, eid, gid) + .with_routing_required() + .with_sync_status(GidSyncStatus::NotSynchronized); + + let bytes = original.to_bytes(); + let parsed = VehicleIdResponse::parse(&bytes).unwrap(); + + assert_eq!(original, parsed); + } +} diff --git a/doip-server/src/error.rs b/doip-server/src/error.rs new file mode 100644 index 0000000..d90b6f7 --- /dev/null +++ b/doip-server/src/error.rs @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +//! Error Types for `DoIP` Server (ISO 13400-2:2019 & ISO 14229-1:2020) + +use std::{io, net::AddrParseError}; + +use thiserror::Error; + +/// Result type alias for `DoIP` operations +pub type Result = std::result::Result; + +/// Main `DoIP` Error type +#[non_exhaustive] +#[derive(Error, Debug)] +pub enum DoipError { + #[error("I/O error: {0}")] + Io(#[from] io::Error), + + #[error("configuration error: {0}")] + InvalidConfig(String), + + #[error("configuration file error: {0}")] + ConfigFileError(String), + + #[error("hex decode error: {0}")] + HexDecodeError(String), + + #[error("invalid address: {0}")] + InvalidAddress(#[from] AddrParseError), + + #[error("invalid DoIP header: {0}")] + InvalidHeader(String), + + #[error("unknown payload type: 0x{0:04X}")] + UnknownPayloadType(u16), + + #[error("payload too short: need {expected} bytes, got {actual}")] + PayloadTooShort { expected: usize, actual: usize }, + + #[error("unknown routing activation response code: {0:#04x}")] + UnknownRoutingActivationResponseCode(u8), + + #[error("unknown activation type: {0:#04x}")] + UnknownActivationType(u8), + + #[error("unknown diagnostic nack code: {0:#04x}")] + UnknownNackCode(u8), + + #[error("unknown further action byte: {0:#04x}")] + UnknownFurtherAction(u8), + + #[error("unknown sync status byte: {0:#04x}")] + UnknownSyncStatus(u8), + + #[error("diagnostic message has no user data")] + EmptyUserData, + + #[error("unexpected payload data: expected empty payload, got {actual} bytes")] + UnexpectedPayload { actual: usize }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_display_variants() { + // Smoke-test every DoipError variant produces a non-empty Display string. + let errors = [ + DoipError::Io(io::Error::other("io")), + DoipError::InvalidConfig("bad config".to_string()), + DoipError::InvalidHeader("bad header".to_string()), + DoipError::UnknownPayloadType(0x1234), + DoipError::PayloadTooShort { + expected: 8, + actual: 4, + }, + DoipError::UnknownRoutingActivationResponseCode(0x99), + DoipError::EmptyUserData, + ]; + for err in errors { + assert!(!err.to_string().is_empty()); + } + } +} diff --git a/doip-server/src/lib.rs b/doip-server/src/lib.rs new file mode 100644 index 0000000..a0d3f3d --- /dev/null +++ b/doip-server/src/lib.rs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +//! `DoIP` server library (ISO 13400-2:2019 / ISO 14229-1:2020). +//! +//! Provides TCP framing ([`doip::DoipCodec`]), protocol parsing, session +//! management, and a [`uds::UdsHandler`] extension point for UDS processing. +/// Core DoIP protocol types, codec, and wire-format handlers (ISO 13400-2:2019). +pub mod doip; +/// Error types and the crate-level [`Result`] alias. +pub mod error; +/// DoIP server configuration and session management. +pub mod server; +/// UDS service layer – bridges DoIP transport to ISO 14229-1 request/response handling. +pub mod uds; +pub use error::{DoipError, Result}; + +// No unit tests: this file contains only module declarations and re-exports. +// All logic lives in the sub-modules, which carry their own test suites. diff --git a/src/main.rs b/doip-server/src/main.rs similarity index 100% rename from src/main.rs rename to doip-server/src/main.rs diff --git a/doip-server/src/server/config.rs b/doip-server/src/server/config.rs new file mode 100644 index 0000000..db2e94a --- /dev/null +++ b/doip-server/src/server/config.rs @@ -0,0 +1,422 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ + +//! `DoIP` Server Configuration + +use std::{net::SocketAddr, path::Path}; + +use serde::Deserialize; + +use crate::DoipError; +use crate::Result; + +// ============================================================================ +// Default Configuration Constants (per ISO 13400-2 DoIP specification) +// ============================================================================ + +/// Default `DoIP` port for both TCP and UDP as defined in ISO 13400-2 +const DEFAULT_DOIP_PORT: u16 = 13400; + +/// Default bind address - listen on all network interfaces +const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0"; + +/// Default ECU logical address (`DoIP` entity address) +const DEFAULT_LOGICAL_ADDRESS: u16 = 0x0091; + +/// VIN length per ISO 3779:2009 §5.3 — exactly 17 alphanumeric characters. +pub(crate) const VIN_LEN: usize = 17; + +/// Default Vehicle Identification Number (17 ASCII characters per ISO 3779) +const DEFAULT_VIN: &[u8; VIN_LEN] = b"TESTVIN1234567890"; + +/// Default Entity Identification (6 bytes, typically MAC address) +const DEFAULT_EID: [u8; 6] = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC]; + +/// Default Group Identification (6 bytes) +const DEFAULT_GID: [u8; 6] = [0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54]; + +/// Maximum concurrent TCP connections allowed +const DEFAULT_MAX_CONNECTIONS: usize = 10; + +/// Initial inactivity timeout in milliseconds (`T_TCP_Initial` per ISO 13400-2: 2 seconds) +const DEFAULT_INITIAL_INACTIVITY_TIMEOUT_MS: u64 = 2_000; + +/// General inactivity timeout in milliseconds (`T_TCP_General` per ISO 13400-2: 5 minutes) +const DEFAULT_GENERAL_INACTIVITY_TIMEOUT_MS: u64 = 300_000; + +/// Runtime configuration for the `DoIP` server. +/// +/// Constructed either from a TOML file via [`ServerConfig::from_file`] or +/// from sensible ISO-compliant defaults via [`ServerConfig::default`]. +#[derive(Debug, Clone)] +pub struct ServerConfig { + /// TCP bind address for diagnostic connections (ISO 13400-2 default port: 13400) + tcp_addr: SocketAddr, + /// UDP bind address for vehicle identification broadcasts (ISO 13400-2 default port: 13400) + udp_addr: SocketAddr, + /// Logical address of this `DoIP` entity (ISO 13400-2 Section 7.3) + logical_address: u16, + /// Vehicle Identification Number — 17 ASCII bytes per ISO 3779 + vin: [u8; VIN_LEN], + /// Entity Identification — 6 bytes, typically the MAC address + eid: [u8; 6], + /// Group Identification — 6 bytes identifying a functional group + gid: [u8; 6], + /// Maximum number of concurrent TCP diagnostic connections + max_connections: usize, + /// Initial inactivity timeout in milliseconds (`T_TCP_Initial`, ISO 13400-2: 2 s) + initial_inactivity_timeout_ms: u64, + /// General inactivity timeout in milliseconds (`T_TCP_General`, ISO 13400-2: 5 min) + general_inactivity_timeout_ms: u64, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + tcp_addr: SocketAddr::from(([0, 0, 0, 0], DEFAULT_DOIP_PORT)), + udp_addr: SocketAddr::from(([0, 0, 0, 0], DEFAULT_DOIP_PORT)), + logical_address: DEFAULT_LOGICAL_ADDRESS, + vin: *DEFAULT_VIN, + eid: DEFAULT_EID, + gid: DEFAULT_GID, + max_connections: DEFAULT_MAX_CONNECTIONS, + initial_inactivity_timeout_ms: DEFAULT_INITIAL_INACTIVITY_TIMEOUT_MS, + general_inactivity_timeout_ms: DEFAULT_GENERAL_INACTIVITY_TIMEOUT_MS, + } + } +} + +fn default_doip_port() -> u16 { + DEFAULT_DOIP_PORT +} +fn default_bind_address() -> String { + DEFAULT_BIND_ADDRESS.to_string() +} +fn default_max_connections() -> usize { + DEFAULT_MAX_CONNECTIONS +} +fn default_logical_address() -> u16 { + DEFAULT_LOGICAL_ADDRESS +} +fn default_initial_inactivity_ms() -> u64 { + DEFAULT_INITIAL_INACTIVITY_TIMEOUT_MS +} +fn default_general_inactivity_ms() -> u64 { + DEFAULT_GENERAL_INACTIVITY_TIMEOUT_MS +} + +#[derive(Debug, Deserialize, Default)] +struct ConfigFile { + #[serde(default)] + server: ServerSection, + #[serde(default)] + vehicle: VehicleSection, + #[serde(default)] + timeouts: TimeoutSection, +} + +#[derive(Debug, Deserialize)] +struct ServerSection { + #[serde(default = "default_doip_port")] + tcp_port: u16, + #[serde(default = "default_doip_port")] + udp_port: u16, + #[serde(default = "default_bind_address")] + bind_address: String, + #[serde(default = "default_max_connections")] + max_connections: usize, +} + +impl Default for ServerSection { + fn default() -> Self { + Self { + tcp_port: DEFAULT_DOIP_PORT, + udp_port: DEFAULT_DOIP_PORT, + bind_address: DEFAULT_BIND_ADDRESS.to_string(), + max_connections: DEFAULT_MAX_CONNECTIONS, + } + } +} + +#[derive(Debug, Deserialize)] +struct VehicleSection { + #[serde(default = "default_logical_address")] + logical_address: u16, + vin: Option, + eid: Option, + gid: Option, +} + +impl Default for VehicleSection { + fn default() -> Self { + Self { + logical_address: DEFAULT_LOGICAL_ADDRESS, + vin: None, + eid: None, + gid: None, + } + } +} + +#[derive(Debug, Deserialize)] +struct TimeoutSection { + #[serde(default = "default_initial_inactivity_ms")] + initial_inactivity_ms: u64, + #[serde(default = "default_general_inactivity_ms")] + general_inactivity_ms: u64, +} + +impl Default for TimeoutSection { + fn default() -> Self { + Self { + initial_inactivity_ms: DEFAULT_INITIAL_INACTIVITY_TIMEOUT_MS, + general_inactivity_ms: DEFAULT_GENERAL_INACTIVITY_TIMEOUT_MS, + } + } +} + +impl ServerConfig { + /// Create a config with all defaults except for the given `logical_address`. + #[must_use] + pub fn new(logical_address: u16) -> Self { + Self { + logical_address, + ..Default::default() + } + } + + /// Load configuration from TOML file + /// + /// # Errors + /// Returns [`DoipError::ConfigFileError`] if file cannot be read or parsed. + /// Returns [`DoipError::InvalidConfig`] if values are invalid. + /// Returns [`DoipError::InvalidAddress`] if bind address is malformed. + pub fn from_file>(path: P) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| DoipError::ConfigFileError(e.to_string()))?; + let file: ConfigFile = + toml::from_str(&content).map_err(|e| DoipError::ConfigFileError(e.to_string()))?; + + let bind = &file.server.bind_address; + Ok(Self { + tcp_addr: format!("{bind}:{}", file.server.tcp_port).parse()?, + udp_addr: format!("{bind}:{}", file.server.udp_port).parse()?, + max_connections: file.server.max_connections, + logical_address: file.vehicle.logical_address, + vin: file + .vehicle + .vin + .as_deref() + .map(Self::parse_vin) + .transpose()? + .unwrap_or(*DEFAULT_VIN), + eid: file + .vehicle + .eid + .as_deref() + .map(Self::parse_hex_array) + .transpose()? + .unwrap_or(DEFAULT_EID), + gid: file + .vehicle + .gid + .as_deref() + .map(Self::parse_hex_array) + .transpose()? + .unwrap_or(DEFAULT_GID), + initial_inactivity_timeout_ms: file.timeouts.initial_inactivity_ms, + general_inactivity_timeout_ms: file.timeouts.general_inactivity_ms, + }) + } + + fn parse_vin(s: &str) -> Result<[u8; VIN_LEN]> { + let bytes = s.as_bytes(); + if bytes.len() != VIN_LEN { + return Err(DoipError::InvalidConfig(format!( + "VIN must be exactly {VIN_LEN} characters (ISO 3779), got {}", + bytes.len() + ))); + } + let mut vin = [0u8; VIN_LEN]; + vin.copy_from_slice(bytes); + Ok(vin) + } + + fn parse_hex_array(s: &str) -> Result<[u8; N]> { + let s = s.trim_start_matches("0x").replace([':', '-', ' '], ""); + let bytes = hex::decode(&s).map_err(|e| DoipError::HexDecodeError(e.to_string()))?; + if bytes.len() != N { + return Err(DoipError::InvalidConfig(format!( + "Expected {} bytes, got {}", + N, + bytes.len() + ))); + } + let mut arr = [0u8; N]; + arr.copy_from_slice(&bytes); + Ok(arr) + } + + /// Override the VIN advertised in Vehicle Identification Responses. + #[must_use] + pub fn with_vin(mut self, vin: [u8; 17]) -> Self { + self.vin = vin; + self + } + + /// Override the TCP and UDP bind addresses (default: `0.0.0.0:13400`). + #[must_use] + pub fn with_addresses(mut self, tcp: SocketAddr, udp: SocketAddr) -> Self { + self.tcp_addr = tcp; + self.udp_addr = udp; + self + } + + /// Returns the TCP bind address. + #[must_use] + pub fn tcp_addr(&self) -> SocketAddr { + self.tcp_addr + } + + /// Returns the UDP bind address. + #[must_use] + pub fn udp_addr(&self) -> SocketAddr { + self.udp_addr + } + + /// Returns the logical address of this `DoIP` entity. + #[must_use] + pub fn logical_address(&self) -> u16 { + self.logical_address + } + + /// Returns the Vehicle Identification Number. + #[must_use] + pub fn vin(&self) -> [u8; 17] { + self.vin + } + + /// Returns the Entity Identification bytes. + #[must_use] + pub fn eid(&self) -> [u8; 6] { + self.eid + } + + /// Returns the Group Identification bytes. + #[must_use] + pub fn gid(&self) -> [u8; 6] { + self.gid + } + + /// Returns the maximum number of concurrent TCP connections. + #[must_use] + pub fn max_connections(&self) -> usize { + self.max_connections + } + + /// Returns the initial inactivity timeout in milliseconds. + #[must_use] + pub fn initial_inactivity_timeout_ms(&self) -> u64 { + self.initial_inactivity_timeout_ms + } + + /// Returns the general inactivity timeout in milliseconds. + #[must_use] + pub fn general_inactivity_timeout_ms(&self) -> u64 { + self.general_inactivity_timeout_ms + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_has_iso_port_and_standard_timeouts() { + let config = ServerConfig::default(); + + assert_eq!(config.tcp_addr().port(), DEFAULT_DOIP_PORT); + assert_eq!(config.udp_addr().port(), DEFAULT_DOIP_PORT); + assert_eq!(config.logical_address(), DEFAULT_LOGICAL_ADDRESS); + assert_eq!(config.vin(), *DEFAULT_VIN); + assert_eq!(config.eid(), DEFAULT_EID); + assert_eq!(config.gid(), DEFAULT_GID); + assert_eq!(config.max_connections(), DEFAULT_MAX_CONNECTIONS); + assert_eq!( + config.initial_inactivity_timeout_ms(), + DEFAULT_INITIAL_INACTIVITY_TIMEOUT_MS + ); + assert_eq!( + config.general_inactivity_timeout_ms(), + DEFAULT_GENERAL_INACTIVITY_TIMEOUT_MS + ); + } + + #[test] + fn new_sets_logical_address_with_iso_defaults() { + let config = ServerConfig::new(0x1234); + + assert_eq!(config.logical_address(), 0x1234); + assert_eq!(config.tcp_addr().port(), DEFAULT_DOIP_PORT); + } + + #[test] + fn parse_vin_accepts_valid_17_char_iso_3779_string() { + let result = ServerConfig::parse_vin("WVWZZZ3CZWE123456"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 17); + } + + #[test] + fn parse_vin_rejects_string_not_17_chars() { + let result = ServerConfig::parse_vin("SHORTVIN"); + assert!(result.is_err()); + } + + #[test] + fn parse_hex_array_parses_colon_separated_hex_bytes() { + let result: Result<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B:3C:4D:5E"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]); + } + + #[test] + fn parse_hex_array_strips_0x_prefix_before_decoding() { + let result: Result<[u8; 6]> = ServerConfig::parse_hex_array("0x001A2B3C4D5E"); + assert!(result.is_ok()); + } + + #[test] + fn parse_hex_array_rejects_length_mismatch() { + let result: Result<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B"); + assert!(result.is_err()); + } + + #[test] + fn with_vin_replaces_default_vin() { + let new_vin = *b"NEWVIN12345678901"; + let config = ServerConfig::default().with_vin(new_vin); + + assert_eq!(config.vin(), new_vin); + } + + #[test] + fn with_addresses_replaces_default_bind_sockets() { + let tcp: SocketAddr = "192.168.1.1:13400".parse().unwrap(); + let udp: SocketAddr = "192.168.1.1:13401".parse().unwrap(); + let config = ServerConfig::default().with_addresses(tcp, udp); + + assert_eq!(config.tcp_addr(), tcp); + assert_eq!(config.udp_addr(), udp); + } +} diff --git a/doip-server/src/server/mod.rs b/doip-server/src/server/mod.rs new file mode 100644 index 0000000..59dbff7 --- /dev/null +++ b/doip-server/src/server/mod.rs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +//! Server Module +//! +//! `DoIP` server configuration and session management. + +/// DoIP server configuration (addresses, timeouts, logical address, VIN, EID, GID). +pub mod config; +/// Thread-safe session registry for active DoIP tester connections. +pub mod session; + +pub use config::ServerConfig; +pub use session::SessionManager; + +// No unit tests: this file contains only module declarations and re-exports. +// config.rs and session.rs carry their own test suites. diff --git a/doip-server/src/server/session.rs b/doip-server/src/server/session.rs new file mode 100644 index 0000000..555166a --- /dev/null +++ b/doip-server/src/server/session.rs @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +//! Session management for `DoIP` connections + +use std::{ + collections::HashMap, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use parking_lot::RwLock; +use tracing::debug; + +/// Strongly-typed session identifier — wraps a monotonic `u64` counter. +/// +/// Using a newtype prevents accidentally passing a raw integer where a session +/// ID is expected and makes call sites self-documenting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SessionId(u64); + +/// Session states per ISO 13400-2:2019 connection lifecycle +/// +/// This is an internal type. External callers should use [`Session::is_routing_active`] instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SessionState { + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + Connected, + RoutingActive, + /// Reserved per ISO 13400-2:2019 §7.4 lifecycle — not yet transitioned to in-process. + #[allow(dead_code)] + Closed, +} + +/// A single `DoIP` tester connection and its lifecycle state. +/// +/// Tracks the connection lifecycle from initial TCP connect through routing +/// activation to eventual disconnect per ISO 13400-2:2019 §7.4. +#[derive(Debug, Clone)] +pub(crate) struct Session { + /// Unique monotonic session identifier assigned at connection time + id: SessionId, + /// Remote socket address of the connected tester + peer_addr: SocketAddr, + /// Tester logical address registered during routing activation (`0` until activated) + tester_address: u16, + /// Current state in the ISO 13400-2 connection lifecycle + state: SessionState, +} + +impl Session { + /// Create a new session in the [`SessionState::Connected`] state. + /// + /// This is `pub(crate)` — sessions are only ever constructed by [`SessionManager`]. + // Used by `SessionManager::create_session` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + #[must_use] + pub(crate) fn new(id: SessionId, peer_addr: SocketAddr) -> Self { + Self { + id, + peer_addr, + tester_address: 0, + state: SessionState::Connected, + } + } + + /// Transition this session to the `RoutingActive` state and record + /// the tester's logical address. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn activate_routing(&mut self, tester_address: u16) { + debug!(session_id = self.id.0, tester_address, "routing activated"); + self.tester_address = tester_address; + self.state = SessionState::RoutingActive; + } + + /// Returns `true` if routing has been activated for this session. + // Used by `RoutingActivationHandler` in `feat/doip-handler-deps` — not yet wired up. + #[allow(dead_code)] + #[must_use] + pub(crate) fn is_routing_active(&self) -> bool { + self.state == SessionState::RoutingActive + } + + /// Returns the unique session ID. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + #[must_use] + pub(crate) fn id(&self) -> SessionId { + self.id + } + + /// Returns the remote socket address of the connected tester. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + #[must_use] + pub(crate) fn peer_addr(&self) -> SocketAddr { + self.peer_addr + } + + /// Returns the tester logical address (`0` until routing is activated). + // Used by `AliveCheckHandler` in `feat/doip-handler-deps` — not yet wired up. + #[allow(dead_code)] + #[must_use] + pub(crate) fn tester_address(&self) -> u16 { + self.tester_address + } + + /// Returns the current ISO 13400-2 connection lifecycle state. + /// + /// This is `pub(crate)` — external callers should use [`is_routing_active`] instead, + /// which provides a boolean answer without exposing the internal [`SessionState`] type. + #[must_use] + #[cfg(test)] + pub(crate) fn connection_state(&self) -> SessionState { + self.state + } +} + +/// Thread-safe registry of active `DoIP` sessions. +/// +/// The mutable session state is held in this single [`RwLock`]-protected `Inner` +/// struct for atomic multi-map updates, while the monotonic ID counter uses +/// an [`AtomicU64`] to avoid taking the write-lock just to mint a new ID. +/// Access this via the [`Arc`] returned by [`SessionManager::new`]. +#[derive(Debug, Default)] +struct SessionManagerInner { + // Used by `SessionManager` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + sessions: HashMap, + #[allow(dead_code)] + addr_to_session: HashMap, +} + +/// Thread-safe registry of active `DoIP` tester sessions. +/// +/// Uses a single [`RwLock`] over an internal map to ensure atomic consistency +/// between the session map and the address-to-ID index, plus a lock-free +/// [`AtomicU64`] counter for session ID allocation. +#[derive(Debug, Default)] +pub struct SessionManager { + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + inner: RwLock, + #[allow(dead_code)] + next_id: AtomicU64, +} + +impl SessionManager { + /// Create a new `SessionManager` wrapped in an [`Arc`] for shared ownership across tasks. + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Register a new session for `peer_addr` and return it. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn create_session(&self, peer_addr: SocketAddr) -> Session { + let id = SessionId(self.next_id.fetch_add(1, Ordering::Relaxed)); + let session = Session::new(id, peer_addr); + { + let mut inner = self.inner.write(); + inner.sessions.insert(id, session.clone()); + inner.addr_to_session.insert(peer_addr, id); + } + debug!(session_id = id.0, peer = %peer_addr, "Session created"); + session + } + + /// Look up a session by its numeric ID. Returns `None` if not found. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn get_session(&self, id: SessionId) -> Option { + self.inner.read().sessions.get(&id).cloned() + } + + /// Look up a session by the tester's remote address. Returns `None` if not found. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn get_session_by_addr(&self, addr: &SocketAddr) -> Option { + let inner = self.inner.read(); + let id = inner.addr_to_session.get(addr).copied()?; + inner.sessions.get(&id).cloned() + } + + /// Apply a mutation `f` to the session with the given `id`. Returns `true` if found. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn update_session(&self, id: SessionId, f: F) -> bool + where + F: FnOnce(&mut Session), + { + self.inner + .write() + .sessions + .get_mut(&id) + .is_some_and(|session| { + f(session); + true + }) + } + + /// Remove and return the session with the given `id`, or `None` if not found. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn remove_session(&self, id: SessionId) -> Option { + let session = { + let mut inner = self.inner.write(); + let session = inner.sessions.remove(&id)?; + inner.addr_to_session.remove(&session.peer_addr); + session + }; + debug!(session_id = id.0, peer = %session.peer_addr, "Session removed"); + Some(session) + } + + /// Remove and return the session associated with `addr`, or `None` if not found. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn remove_session_by_addr(&self, addr: &SocketAddr) -> Option { + let session = { + let mut inner = self.inner.write(); + let id = inner.addr_to_session.remove(addr)?; + inner.sessions.remove(&id)? + }; + debug!(session_id = session.id.0, peer = %addr, "Session removed by addr"); + Some(session) + } + + /// Returns the number of currently registered sessions. + pub fn session_count(&self) -> usize { + self.inner.read().sessions.len() + } + + /// Returns `true` if any active session has `tester_address` registered with routing active. + pub fn is_tester_registered(&self, tester_address: u16) -> bool { + self.inner + .read() + .sessions + .values() + .any(|s| s.tester_address == tester_address && s.state == SessionState::RoutingActive) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_is_created_in_connected_state() { + let mgr = SessionManager::new(); + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); + + let session = mgr.create_session(addr); + // New sessions must start in Connected state per ISO 13400-2 lifecycle + assert_eq!(session.connection_state(), SessionState::Connected); + + let retrieved = mgr.get_session(session.id()).unwrap(); + assert_eq!(retrieved.peer_addr(), addr); + } + + #[test] + fn session_transitions_to_routing_active() { + let mgr = SessionManager::new(); + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); + + let session = mgr.create_session(addr); + mgr.update_session(session.id(), |s| s.activate_routing(0x0E80)); + + let updated = mgr.get_session(session.id()).unwrap(); + // After routing activation the session must be RoutingActive + // and the tester logical address must be recorded + assert!(updated.is_routing_active()); + assert_eq!(updated.tester_address(), 0x0E80); + } + + #[test] + fn session_is_removed_from_registry() { + let mgr = SessionManager::new(); + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); + + let session = mgr.create_session(addr); + assert_eq!(mgr.session_count(), 1); + + mgr.remove_session(session.id()); + // Both the session map and addr index must be cleaned up + assert_eq!(mgr.session_count(), 0); + assert!(mgr.get_session(session.id()).is_none()); + } + + #[test] + fn tester_is_registered_only_after_routing_activation() { + let mgr = SessionManager::new(); + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); + + let session = mgr.create_session(addr); + // Before activation: tester address must not be registered + assert!(!mgr.is_tester_registered(0x0E80)); + + mgr.update_session(session.id(), |s| s.activate_routing(0x0E80)); + // After activation: tester address must be found in the registry + assert!(mgr.is_tester_registered(0x0E80)); + } +} diff --git a/doip-server/src/uds/handler.rs b/doip-server/src/uds/handler.rs new file mode 100644 index 0000000..6923e43 --- /dev/null +++ b/doip-server/src/uds/handler.rs @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ + +//! UDS Handler Trait +//! +//! Defines the interface between `DoIP` transport and UDS processing. +//! The `DoIP` server extracts UDS bytes from `DoIP` frames and delegates +//! to the handler. The handler returns UDS response bytes. + +use bytes::Bytes; + +/// UDS Service IDs (ISO 14229-1:2020, Section 10 – Service definition) +pub mod service_id { + /// Diagnostic Session Control (0x10) – switch the ECU into a specific diagnostic session. + pub const DIAGNOSTIC_SESSION_CONTROL: u8 = 0x10; + /// ECU Reset (0x11) – request the ECU to perform a hard, soft, or key-off-on reset. + pub const ECU_RESET: u8 = 0x11; + /// Security Access (0x27) – seed/key challenge-response to unlock protected services. + pub const SECURITY_ACCESS: u8 = 0x27; + /// Communication Control (0x28) – enable or disable specific ECU communication paths. + pub const COMMUNICATION_CONTROL: u8 = 0x28; + /// Tester Present (0x3E) – keep the active diagnostic session alive. + pub const TESTER_PRESENT: u8 = 0x3E; + /// Control DTC Setting (0x85) – enable or disable DTC storage in the ECU. + pub const CONTROL_DTC_SETTING: u8 = 0x85; + /// Read Data By Identifier (0x22) – read one or more data records by their 2-byte identifier. + pub const READ_DATA_BY_IDENTIFIER: u8 = 0x22; + /// Write Data By Identifier (0x2E) – write a data record by its 2-byte identifier. + pub const WRITE_DATA_BY_IDENTIFIER: u8 = 0x2E; + /// Routine Control (0x31) – start, stop, or query the result of an ECU routine. + pub const ROUTINE_CONTROL: u8 = 0x31; + /// Request Download (0x34) – initiate a data download transfer to the ECU. + pub const REQUEST_DOWNLOAD: u8 = 0x34; + /// Request Upload (0x35) – initiate a data upload transfer from the ECU. + pub const REQUEST_UPLOAD: u8 = 0x35; + /// Transfer Data (0x36) – transfer a block of data during an active download or upload. + pub const TRANSFER_DATA: u8 = 0x36; + /// Request Transfer Exit (0x37) – terminate an active download or upload transfer. + pub const REQUEST_TRANSFER_EXIT: u8 = 0x37; + /// Read DTC Information (0x19) – read Diagnostic Trouble Code data from the ECU. + pub const READ_DTC_INFORMATION: u8 = 0x19; + /// Clear DTC Information (0x14) – erase stored DTCs and associated snapshot data. + pub const CLEAR_DTC_INFORMATION: u8 = 0x14; +} + +/// UDS request extracted from a `DoIP` diagnostic message (ISO 14229-1:2020). +#[derive(Debug, Clone)] +pub struct UdsRequest { + /// Logical address of the tester sending the request + source_address: u16, + /// Logical address of the target ECU + target_address: u16, + /// Raw UDS payload bytes (service ID + data) + payload: Bytes, +} + +impl UdsRequest { + /// Construct a new `UdsRequest` from address pair and raw UDS payload bytes. + #[must_use] + pub fn new(source: u16, target: u16, payload: Bytes) -> Self { + Self { + source_address: source, + target_address: target, + payload, + } + } + + /// Returns the UDS Service ID, which is the first byte of the UDS payload + /// per ISO 14229-1:2020 (UDS). Returns `None` for empty payloads. + #[must_use] + pub fn service_id(&self) -> Option { + self.payload.first().copied() + } + + /// Returns the tester's logical source address. + #[must_use] + pub fn source_address(&self) -> u16 { + self.source_address + } + + /// Returns the target ECU's logical address. + #[must_use] + pub fn target_address(&self) -> u16 { + self.target_address + } + + /// Returns the raw UDS payload bytes. + #[must_use] + pub fn payload(&self) -> &Bytes { + &self.payload + } +} + +/// UDS response to be wrapped in a `DoIP` diagnostic message. +#[derive(Debug, Clone)] +pub struct UdsResponse { + /// Logical address of the ECU sending the response + source_address: u16, + /// Logical address of the tester the response is directed to + target_address: u16, + /// Raw UDS response payload bytes (positive or negative response) + payload: Bytes, +} + +impl UdsResponse { + /// Construct a new `UdsResponse` from address pair and raw UDS payload bytes. + #[must_use] + pub fn new(source: u16, target: u16, payload: Bytes) -> Self { + Self { + source_address: source, + target_address: target, + payload, + } + } + + /// Returns the ECU's logical source address. + #[must_use] + pub fn source_address(&self) -> u16 { + self.source_address + } + + /// Returns the tester's logical target address. + #[must_use] + pub fn target_address(&self) -> u16 { + self.target_address + } + + /// Returns the raw UDS response payload bytes. + #[must_use] + pub fn payload(&self) -> &Bytes { + &self.payload + } +} + +/// Trait for handling UDS requests +/// +/// Implement this trait to connect the `DoIP` server to a UDS backend +/// (e.g., UDS2SOVD converter, ODX/MDD handler, or ECU simulator) +pub trait UdsHandler: Send + Sync { + /// Process a UDS `request` and return the corresponding response. + /// + /// Called by the `DoIP` server for every inbound diagnostic message after + /// routing activation. The implementation should decode the UDS service ID, + /// execute the requested service, and return a positive or negative response. + fn handle(&self, request: UdsRequest) -> UdsResponse; +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use super::*; + + #[test] + fn uds_request_service_id() { + let request = UdsRequest::new(0x0E00, 0x1000, Bytes::from(vec![0x10, 0x01])); + assert_eq!(request.service_id(), Some(0x10)); + + let empty = UdsRequest::new(0x0E00, 0x1000, Bytes::new()); + assert_eq!(empty.service_id(), None); + } + + #[test] + fn uds_response_new_sets_payload() { + let payload = Bytes::from(vec![0x62, 0x01]); + let response = UdsResponse::new(0x1000, 0x0E00, payload.clone()); + + assert_eq!(response.source_address(), 0x1000); + assert_eq!(response.target_address(), 0x0E00); + assert_eq!(response.payload(), &payload); + } +} diff --git a/doip-server/src/uds/mod.rs b/doip-server/src/uds/mod.rs new file mode 100644 index 0000000..5712c60 --- /dev/null +++ b/doip-server/src/uds/mod.rs @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ + +//! UDS Module +//! +//! Provides the interface between `DoIP` transport and UDS processing. + +/// UDS handler trait and request/response types bridging DoIP and ISO 14229-1. +pub mod handler; + +pub use handler::{UdsHandler, UdsRequest, UdsResponse, service_id}; + +// No unit tests: this file contains only module declarations and re-exports. +// handler.rs carries its own test suite.