diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 8584da1..232610d 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -35,7 +35,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - name: Format and Clippy - Nightly toolchain pinned - uses: eclipse-opensovd/cicd-workflows/rust-lint-and-format-action@41c531349e9d7e7852455c35682cd200519fb81b + uses: eclipse-opensovd/cicd-workflows/rust-lint-and-format-action@fef5cc7b2d4c4c6593b105402fbf4edd54db7d5a with: toolchain: nightly-2025-07-14 github-token: ${{ secrets.GITHUB_TOKEN }} @@ -46,7 +46,7 @@ jobs: - name: Format and Clippy - Nightly toolchain latest id: nightly-clippy continue-on-error: true - uses: eclipse-opensovd/cicd-workflows/rust-lint-and-format-action@41c531349e9d7e7852455c35682cd200519fb81b + uses: eclipse-opensovd/cicd-workflows/rust-lint-and-format-action@fef5cc7b2d4c4c6593b105402fbf4edd54db7d5a with: toolchain: nightly github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 05725da..9c4cd8b 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -33,4 +33,4 @@ jobs: runs-on: ubuntu-latest steps: - name: Run checks - uses: eclipse-opensovd/cicd-workflows/pre-commit-action@41c531349e9d7e7852455c35682cd200519fb81b + uses: eclipse-opensovd/cicd-workflows/pre-commit-action@fef5cc7b2d4c4c6593b105402fbf4edd54db7d5a diff --git a/.gitignore b/.gitignore index b31a6b4..3c5442b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + .env .cache .gradle diff --git a/Cargo.lock b/Cargo.lock index 55e3925..f382651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,5 +3,302 @@ 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", + "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 = [ + "bytes", + "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..495c93c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,39 +8,29 @@ # 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" -edition = "2024" -license = "Apache-2.0" -homepage = "https://github.com/eclipse-opensovd/uds2sovd-proxy" +[workspace] +members = ["doip-server"] +resolver = "2" -[lints] -workspace = true - -# Lints are aligned with shared-lints.toml from: -# https://github.com/eclipse-opensovd/cicd-workflows/tree/main/shared-lints [workspace.lints.clippy] # enable pedantic pedantic = { level = "warn", priority = -1 } ## exclude some too pedantic lints for now similar_names = "allow" +module_name_repetitions = "allow" ## lints related to runtime panic behavior -# enforce only checked access to slices to avoid runtime panics -indexing_slicing = "deny" -# disallow any unwraps in the production code -# (unwrap in test code is explicitly allowed) unwrap_used = "deny" -# enforce that arithmetic operations that can produce side effects always use -# either checked or explicit versions of the operations. eg. `.checked_add(...)` -# or `.saturating_sub(...)` to avoid unexpected runtime behavior or panics. +indexing_slicing = "deny" arithmetic_side_effects = "deny" ## lints related to readability of code -# enforce that references are cloned via eg. `Arc::clone` instead of `.clone()` -# making it explicit that a reference is cloned here and not the underlying data. 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" + +[profile.release] +opt-level = 3 + +[profile.release-with-debug] +inherits = "release" +debug = true diff --git a/deny.toml b/deny.toml index 10e3516..992f9f0 100644 --- a/deny.toml +++ b/deny.toml @@ -32,7 +32,9 @@ ignore = [] # List of explicitly allowed licenses # See https://spdx.org/licenses/ for list of possible licenses # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. -allow = ["Apache-2.0", "MIT"] +# Unicode-3.0 is required by `unicode-ident` (transitive dep of serde/thiserror) +# and is an OSI-approved license covering Unicode data tables. +allow = ["Apache-2.0", "MIT", "Unicode-3.0"] confidence-threshold = 0.9 exceptions = [] diff --git a/doip-server/Cargo.toml b/doip-server/Cargo.toml new file mode 100644 index 0000000..4c91f5f --- /dev/null +++ b/doip-server/Cargo.toml @@ -0,0 +1,46 @@ +# 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 = "2024" +license = "Apache-2.0" + +# ---- async runtime ---- +[dependencies] +tokio = { version = "1", default-features = false, features = ["io-util"] } +tokio-util = { version = "0.7", default-features = false, features = ["codec"] } + +# ---- serialization ---- +serde = { version = "1.0", default-features = false, features = [ + "std", + "derive", +] } +toml = { version = "0.9", default-features = false, features = [ + "parse", + "serde", +] } + +# ---- tracing & logging ---- +tracing = { version = "0.1", default-features = false, features = ["std"] } + +# ---- error handling ---- +thiserror = { version = "2.0", default-features = false, features = ["std"] } + +# ---- data structures ---- +bytes = { version = "1.0", default-features = false, features = ["std"] } +parking_lot = { version = "0.12", default-features = false } + +# ---- utility crates ---- +hex = { version = "0.4", default-features = false, features = ["std"] } + +[lints] +workspace = true diff --git a/doip-server/src/doip/alive_check.rs b/doip-server/src/doip/alive_check.rs new file mode 100644 index 0000000..5fc9162 --- /dev/null +++ b/doip-server/src/doip/alive_check.rs @@ -0,0 +1,136 @@ +/* + * 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}; + +/// 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). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Request; + +impl DoipParseable for Request { + fn parse(_payload: &[u8]) -> crate::Result { + Ok(Self) + } +} + +impl DoipSerializable for Request { + fn serialized_len(&self) -> Option { + Some(0) + } + + /// Alive Check Request carries no payload bytes per ISO 13400-2; + /// nothing is written to the buffer by design. + fn write_to(&self, _buf: &mut BytesMut) { + // Intentionally empty: Alive Check Request has a zero-length payload. + } +} + +/// 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, Clone, PartialEq, Eq)] +pub struct Response { + source_address: u16, +} + +impl DoipParseable for Response { + fn parse(payload: &[u8]) -> crate::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 Response { + fn serialized_len(&self) -> Option { + Some(Self::LEN) + } + + fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.source_address); + } +} + +impl Response { + /// Fixed wire-format length of the Alive Check Response payload (2-byte source address). + pub 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 } + } + + /// The logical source address of the tester + #[must_use] + pub fn source_address(&self) -> u16 { + self.source_address + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doip::{DoipParseable, DoipSerializable}; + + #[test] + fn parse_request() { + let req = Request::parse(&[]).unwrap(); + assert_eq!(req, Request); + } + + #[test] + fn request_empty_payload() { + let req = Request; + let bytes = req.to_bytes(); + assert!(bytes.is_empty()); + } + + #[test] + fn parse_response() { + let payload = [0x0E, 0x80]; + let resp = Response::parse(&payload).unwrap(); + assert_eq!(resp.source_address, 0x0E80); + } + + #[test] + fn reject_short_response() { + let short = [0x0E]; + assert!(Response::parse(&short).is_err()); + } + + #[test] + fn build_response() { + let resp = Response::new(0x0E80); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), 2); + assert_eq!(&bytes[..], &[0x0E, 0x80]); + } + + #[test] + fn roundtrip_response() { + let original = Response::new(0x0F00); + let bytes = original.to_bytes(); + let parsed = Response::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..afef9c7 --- /dev/null +++ b/doip-server/src/doip/codec.rs @@ -0,0 +1,178 @@ +/* + * 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(()) + } +} diff --git a/doip-server/src/doip/diagnostic_message.rs b/doip-server/src/doip/diagnostic_message.rs new file mode 100644 index 0000000..84b2e64 --- /dev/null +++ b/doip-server/src/doip/diagnostic_message.rs @@ -0,0 +1,432 @@ +/* + * 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; + +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; + +/// 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(NackCode), +} + +/// Diagnostic message negative acknowledgment codes per ISO 13400-2:2019 Table 28. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum NackCode { + InvalidSourceAddress = 0x02, + UnknownTargetAddress = 0x03, + DiagnosticMessageTooLarge = 0x04, + OutOfMemory = 0x05, + TargetUnreachable = 0x06, + UnknownNetwork = 0x07, + TransportProtocolError = 0x08, +} + +impl TryFrom for NackCode { + 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: NackCode) -> u8 { + code as u8 + } +} + +/// 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`+) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + source_address: u16, + target_address: u16, + user_data: Bytes, +} + +impl Message { + /// Minimum message length in bytes (SA + TA + at least 1 byte UDS data) + pub 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 + pub fn new(source: u16, target: u16, data: Bytes) -> Self { + Self { + source_address: source, + target_address: target, + user_data: data, + } + } + + /// Get the source address + pub fn source_address(&self) -> u16 { + self.source_address + } + + /// Get the target address + pub fn target_address(&self) -> u16 { + self.target_address + } + + /// Get the UDS user data + 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. + 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, Clone, 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 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: NackCode) -> 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 fn parse_positive(payload: &[u8]) -> crate::Result { + let (source_address, target_address, previous_data) = + Self::parse_ack_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 fn parse_negative(payload: &[u8]) -> crate::Result { + let (source_address, target_address, previous_data) = + Self::parse_ack_header(payload, "DiagnosticNegativeAck")?; + let nack_code = payload + .get(HEADER_BYTES) + .copied() + .ok_or_else(|| too_short(payload, Self::MIN_LEN)) + .and_then(NackCode::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_ack_header(payload: &[u8], context: &str) -> crate::Result<(u16, u16, Option)> { + let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, context)?; + let source_address = u16::from_be_bytes([header[0], header[1]]); + let target_address = u16::from_be_bytes([header[2], header[3]]); + 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 Message { + fn parse(payload: &[u8]) -> crate::Result { + let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, "DiagnosticMessage")?; + + let source_address = u16::from_be_bytes([header[0], header[1]]); + let target_address = u16::from_be_bytes([header[2], header[3]]); + + 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 Message { + 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 => 0x00, + 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!(NackCode::InvalidSourceAddress as u8, 0x02); + assert_eq!(NackCode::UnknownTargetAddress as u8, 0x03); + assert_eq!(NackCode::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 = Message::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 = Message::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!(Message::parse(&short).is_err()); + } + + #[test] + fn build_diagnostic_message() { + let uds = Bytes::from_static(&[0x22, 0xF1, 0x90]); + let msg = Message::new(0x0E80, 0x1000, uds); + 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, NackCode::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(NackCode::UnknownTargetAddress) + ); + } + + #[test] + fn build_negative_ack_target_unreachable() { + let nack = DiagnosticAck::negative(0x1000, 0x0E80, NackCode::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(NackCode::UnknownTargetAddress) + ); + } + + #[test] + fn roundtrip_message() { + let original = Message::new(0x0E80, 0x1000, Bytes::from_static(&[0x10, 0x01])); + let bytes = original.to_bytes(); + let parsed = Message::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, NackCode::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..96bc9fa --- /dev/null +++ b/doip-server/src/doip/header.rs @@ -0,0 +1,830 @@ +/* + * 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::{debug, warn}; + +/// 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 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 const PROTOCOL_VERSION_V3: u8 = 0x03; +/// Wildcard/default protocol version (ISO 13400-2:2019 – accept any version) +pub 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 const DOIP_HEADER_VERSION_MASK: u8 = 0xFF; +/// Inverse of default protocol version for header validation +pub 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) -> u16 { + pt as u16 + } +} + +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]) -> crate::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 { + debug!( + 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 `true` if [`validate`](Self::validate) finds no errors in this header. + #[must_use] + pub fn is_valid(&self) -> bool { + self.validate().is_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 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 { + /// Create a new `DoIP` message with the default protocol version. + #[cfg(test)] + 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, + } + } + + /// Create a `DoIP` message with a raw (unparsed) payload type. + /// + /// This is primarily used for testing unknown/invalid payload types. + /// Production code should use `new()` or `with_version()` instead. + /// + /// # Panics + /// + /// Panics if `payload.len()` exceeds `u32::MAX`. + #[cfg(test)] + pub 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, + } + } + + /// 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)] +#[allow(clippy::indexing_slicing)] +mod tests { + use tokio_util::codec::{Decoder, Encoder}; + + use super::*; + use crate::doip::codec::DoipCodec; + + // --- 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..183854c --- /dev/null +++ b/doip-server/src/doip/mod.rs @@ -0,0 +1,124 @@ +/* + * 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, DEFAULT_PROTOCOL_VERSION_INV, DOIP_HEADER_LENGTH, + DOIP_HEADER_VERSION_MASK, DOIP_VERSION_DEFAULT, DoipHeader, DoipMessage, GenericNackCode, + MAX_DOIP_MESSAGE_SIZE, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V3, PayloadType, +}; +pub use payload::DoipPayload; +use tracing::error; + +use crate::DoipError; + +/// 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 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]) -> crate::Result; +} + +/// Trait for `DoIP` message types that can be serialized to a [`Bytes`] buffer. +/// +/// Implement [`write_to`] with the wire-format logic. The default [`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`] 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`] 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`] returns `Some`. + fn to_bytes(&self) -> Bytes { + let mut buf = match self.serialized_len() { + Some(n) => BytesMut::with_capacity(n), + None => BytesMut::new(), + }; + 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) -> crate::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, +) -> crate::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 + }) +} diff --git a/doip-server/src/doip/payload.rs b/doip-server/src/doip/payload.rs new file mode 100644 index 0000000..ac79b3f --- /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; + +/// A fully-parsed `DoIP` message payload. +/// +/// Each variant corresponds to one [`PayloadType`] and wraps the concrete +/// struct returned by its [`DoipParseable`] impl. +#[derive(Debug, Clone)] +pub enum DoipPayload { + /// `0x0007` – Alive Check Request (zero-length payload) + AliveCheckRequest(alive_check::Request), + /// `0x0008` – Alive Check Response + AliveCheckResponse(alive_check::Response), + /// `0x0005` – Routing Activation Request + RoutingActivationRequest(routing_activation::Request), + /// `0x0006` – Routing Activation Response + RoutingActivationResponse(routing_activation::Response), + /// `0x8001` – Diagnostic Message (UDS data) + DiagnosticMessage(diagnostic_message::Message), + /// `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::Request), + /// `0x0002` – Vehicle Identification Request filtered by EID + VehicleIdentificationRequestWithEid(vehicle_id::RequestWithEid), + /// `0x0003` – Vehicle Identification Request filtered by VIN + VehicleIdentificationRequestWithVin(vehicle_id::RequestWithVin), + /// `0x0004` – Vehicle Identification Response / Announce + VehicleIdentificationResponse(vehicle_id::Response), + /// `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) -> crate::Result { + let payload = msg.payload().as_ref(); + + let payload_type = msg + .payload_type() + .ok_or(DoipError::UnknownPayloadType(msg.header().payload_type()))?; + + match payload_type { + PayloadType::AliveCheckRequest => Ok(Self::AliveCheckRequest( + alive_check::Request::parse(payload)?, + )), + PayloadType::AliveCheckResponse => Ok(Self::AliveCheckResponse( + alive_check::Response::parse(payload)?, + )), + PayloadType::RoutingActivationRequest => Ok(Self::RoutingActivationRequest( + routing_activation::Request::parse(payload)?, + )), + PayloadType::RoutingActivationResponse => Ok(Self::RoutingActivationResponse( + routing_activation::Response::parse(payload)?, + )), + PayloadType::DiagnosticMessage => Ok(Self::DiagnosticMessage( + diagnostic_message::Message::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::Request::parse(payload)?, + )), + PayloadType::VehicleIdentificationRequestWithEid => { + Ok(Self::VehicleIdentificationRequestWithEid( + vehicle_id::RequestWithEid::parse(payload)?, + )) + } + PayloadType::VehicleIdentificationRequestWithVin => { + Ok(Self::VehicleIdentificationRequestWithVin( + vehicle_id::RequestWithVin::parse(payload)?, + )) + } + PayloadType::VehicleIdentificationResponse => Ok(Self::VehicleIdentificationResponse( + vehicle_id::Response::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::*; + use crate::doip::{DoipMessage, PayloadType}; + + 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::Response::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..99d5710 --- /dev/null +++ b/doip-server/src/doip/routing_activation.rs @@ -0,0 +1,449 @@ +/* + * 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 bytes::{BufMut, BytesMut}; +use tracing::error; + +use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; +use crate::DoipError; + +/// Routing activation response codes per ISO 13400-2:2019 Table 25. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ResponseCode { + /// 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 ResponseCode { + 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: ResponseCode) -> u8 { + code as u8 + } +} + +impl ResponseCode { + /// 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) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + source_address: u16, + activation_type: ActivationType, + reserved: u32, + oem_specific: Option, +} + +impl Request { + /// Minimum wire-format length of a Routing Activation Request payload + /// (7 bytes: SA + type + reserved, without OEM data). + pub const MIN_LEN: usize = 7; + /// Maximum wire-format length of a Routing Activation Request payload + /// (11 bytes: includes optional 4-byte OEM data). + pub 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) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + tester_address: u16, + entity_address: u16, + code: ResponseCode, + reserved: u32, + oem_specific: Option, +} + +impl Response { + /// Minimum wire-format length of a Routing Activation Response payload + /// (9 bytes: tester addr + entity addr + code + reserved, without OEM data). + pub const MIN_LEN: usize = 9; + /// Maximum wire-format length of a Routing Activation Response payload + /// (13 bytes: includes optional 4-byte OEM data). + pub 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: ResponseCode::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: ResponseCode) -> 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) -> ResponseCode { + 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 + } +} + +impl DoipParseable for Request { + fn parse(payload: &[u8]) -> crate::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 = payload + .get(Self::MIN_LEN..Self::MAX_LEN) + .and_then(|s| <[u8; 4]>::try_from(s).ok()) + .map(u32::from_be_bytes); + + Ok(Self { + source_address, + activation_type, + reserved, + oem_specific, + }) + } +} + +impl DoipParseable for Response { + fn parse(payload: &[u8]) -> crate::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 = ResponseCode::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 = payload + .get(Self::MIN_LEN..Self::MAX_LEN) + .and_then(|s| <[u8; 4]>::try_from(s).ok()) + .map(u32::from_be_bytes); + + Ok(Self { + tester_address, + entity_address, + code: response_code, + reserved, + oem_specific, + }) + } +} + +impl DoipSerializable for Response { + fn serialized_len(&self) -> Option { + Some(Self::MIN_LEN.saturating_add(if self.oem_specific.is_some() { 4 } 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) + ResponseCode(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 = Response::MIN_LEN; // 9 + const OEM_DATA_END: usize = Response::MAX_LEN; // 13 + + #[test] + fn response_code_success_check() { + assert!(ResponseCode::SuccessfullyActivated.is_success()); + assert!(ResponseCode::ConfirmationRequired.is_success()); + assert!(!ResponseCode::UnknownSourceAddress.is_success()); + assert!(!ResponseCode::TlsRequired.is_success()); + } + + #[test] + fn response_code_values() { + assert_eq!(ResponseCode::UnknownSourceAddress as u8, 0x00); + assert_eq!(ResponseCode::SuccessfullyActivated as u8, 0x10); + assert_eq!(ResponseCode::ConfirmationRequired as u8, 0x11); + } + + #[test] + fn parse_minimal_request() { + let payload = [0x0E, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00]; + let req = Request::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 = Request::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 = Request::parse(&payload).unwrap(); + assert_eq!(req.activation_type, ActivationType::WwhObd); + } + + #[test] + fn reject_short_request() { + let short = [0x0E, 0x80, 0x00, 0x00]; + assert!(Request::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!(Request::parse(&payload).is_err()); + } + + #[test] + fn build_success_response() { + let resp = Response::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 = Response::denial(0x0E80, 0x1000, ResponseCode::AllSocketsRegistered); + assert!(!resp.is_success()); + } + + #[test] + fn serialize_response() { + let resp = Response::success(0x0E80, 0x1000); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), Response::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], + ResponseCode::SuccessfullyActivated as u8 + ); + } + + #[test] + fn serialize_response_with_oem() { + let mut resp = Response::success(0x0E80, 0x1000); + resp.oem_specific = Some(0x1234_5678); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), Response::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 = Response::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 = Response::parse(&payload).unwrap(); + assert!(!resp.is_success()); + assert_eq!(resp.response_code(), ResponseCode::AllSocketsRegistered); + } + + #[test] + fn roundtrip_response() { + let original = Response::success(0x0E80, 0x1000); + let bytes = original.to_bytes(); + let parsed = Response::parse(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn roundtrip_response_with_oem() { + let mut original = Response::denial(0x0F00, 0x2000, ResponseCode::MissingAuthentication); + original.oem_specific = Some(0xCAFE_BABE); + let bytes = original.to_bytes(); + let parsed = Response::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..2b4756a --- /dev/null +++ b/doip-server/src/doip/vehicle_id.rs @@ -0,0 +1,430 @@ +/* + * 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; + +// 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. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Request; + +/// Vehicle Identification Request filtered by EID (payload type `0x0002`). +/// +/// Only the `DoIP` entity with a matching 6-byte EID should respond. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestWithEid { + eid: [u8; 6], +} + +impl RequestWithEid { + /// Fixed wire-format length of a Vehicle Identification Request with EID + /// payload (6-byte EID filter). + pub const LEN: usize = 6; + + /// Create a new Vehicle Identification Request filtered by the given 6-byte EID. + #[must_use] + pub fn new(eid: [u8; 6]) -> Self { + Self { eid } + } + + /// The EID filter value + #[must_use] + pub fn eid(&self) -> &[u8; 6] { + &self.eid + } +} + +/// Vehicle Identification Request filtered by VIN (payload type `0x0003`). +/// +/// Only the `DoIP` entity with a matching 17-byte VIN should respond. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestWithVin { + vin: [u8; 17], +} + +impl RequestWithVin { + /// Fixed wire-format length of a Vehicle Identification Request with VIN payload (17-byte VIN). + pub const LEN: usize = 17; + + /// Create a new Vehicle Identification Request filtered by the given 17-byte VIN. + #[must_use] + pub fn new(vin: [u8; 17]) -> Self { + Self { vin } + } + + /// The VIN filter value as bytes + #[must_use] + pub fn vin(&self) -> &[u8; 17] { + &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) -> u8 { + action as u8 + } +} + +/// 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 SyncStatus { + Synchronized = 0x00, + NotSynchronized = 0x10, +} + +impl TryFrom for SyncStatus { + 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: SyncStatus) -> u8 { + status as u8 + } +} + +/// 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 SyncStatus(1) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + vin: [u8; 17], + logical_address: u16, + eid: [u8; 6], + gid: [u8; 6], + further_action: FurtherAction, + sync_status: Option, +} + +impl Response { + /// Minimum wire-format length of a Vehicle Identification Response payload + /// (32 bytes: VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1)). + pub const MIN_LEN: usize = SYNC_STATUS_IDX; + /// Maximum wire-format length of a Vehicle Identification Response payload + /// (33 bytes: adds optional SyncStatus(1)). + pub 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; 17], logical_address: u16, eid: [u8; 6], gid: [u8; 6]) -> 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: SyncStatus) -> 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() + } +} + +impl DoipParseable for Request { + fn parse(_payload: &[u8]) -> crate::Result { + Ok(Self) + } +} + +impl DoipParseable for RequestWithEid { + fn parse(payload: &[u8]) -> crate::Result { + let eid: [u8; 6] = parse_fixed_slice(payload, "VehicleId RequestWithEid")?; + Ok(Self { eid }) + } +} + +impl DoipParseable for RequestWithVin { + fn parse(payload: &[u8]) -> crate::Result { + let vin: [u8; 17] = parse_fixed_slice(payload, "VehicleId RequestWithVin")?; + Ok(Self { vin }) + } +} + +impl DoipParseable for Response { + fn parse(payload: &[u8]) -> crate::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| SyncStatus::try_from(b).map_err(DoipError::UnknownSyncStatus)) + .transpose()?; + + Ok(Self { + vin, + logical_address, + eid, + gid, + further_action, + sync_status, + }) + } +} + +impl DoipSerializable for Response { + fn serialized_len(&self) -> Option { + Some(Self::MIN_LEN.saturating_add(usize::from(self.sync_status.is_some()))) + } + + 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 = Request::parse(&[]).unwrap(); + assert_eq!(req, Request); + } + + #[test] + fn parse_request_with_eid() { + let payload = [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]; + let req = RequestWithEid::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!(RequestWithEid::parse(&short).is_err()); + } + + #[test] + fn parse_request_with_vin() { + let vin = b"WVWZZZ3CZWE123456"; + let req = RequestWithVin::parse(vin).unwrap(); + assert_eq!(req.vin_string(), "WVWZZZ3CZWE123456"); + } + + #[test] + fn reject_short_vin_request() { + let short = b"WVWZZZ"; + assert!(RequestWithVin::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 = Response::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 = Response::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 = Response::new(vin, 0x1000, eid, gid); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), Response::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 = Response::new(vin, 0x1000, eid, gid).with_sync_status(SyncStatus::Synchronized); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), Response::MAX_LEN); + assert_eq!(bytes[SYNC_STATUS_IDX], SyncStatus::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 = Response::new(vin, 0x1000, eid, gid); + let bytes = original.to_bytes(); + let parsed = Response::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 = Response::new(vin, 0x1000, eid, gid) + .with_routing_required() + .with_sync_status(SyncStatus::NotSynchronized); + + let bytes = original.to_bytes(); + let parsed = Response::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..986d921 --- /dev/null +++ b/doip-server/src/error.rs @@ -0,0 +1,216 @@ +/* + * 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; + +// Re-export from the canonical definitions in the protocol modules +pub use crate::doip::diagnostic_message::NackCode as DiagnosticNackCode; +pub use crate::doip::{ + header::GenericNackCode, routing_activation::ResponseCode as RoutingActivationCode, +}; + +/// Result type alias for `DoIP` operations +pub type Result = std::result::Result; + +/// Main `DoIP` Error type +#[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 protocol version: expected 0x{expected:02X}, got 0x{actual:02X}")] + InvalidProtocolVersion { expected: u8, actual: u8 }, + + #[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, +} + +#[cfg(test)] +/// UDS Negative Response Codes (ISO 14229-1:2020) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +enum UdsNrc { + GeneralReject = 0x10, + ServiceNotSupported = 0x11, + SubFunctionNotSupported = 0x12, + IncorrectMessageLength = 0x13, + BusyRepeatRequest = 0x21, + ConditionsNotCorrect = 0x22, + RequestSequenceError = 0x24, + RequestOutOfRange = 0x31, + SecurityAccessDenied = 0x33, + InvalidKey = 0x35, + ExceededNumberOfAttempts = 0x36, + RequiredTimeDelayNotExpired = 0x37, + ResponsePending = 0x78, + ServiceNotSupportedInActiveSession = 0x7F, +} + +#[cfg(test)] +impl UdsNrc { + #[must_use] + const fn as_u8(self) -> u8 { + self as u8 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_routing_activation_success() { + assert!(RoutingActivationCode::SuccessfullyActivated.is_success()); + assert!(!RoutingActivationCode::UnknownSourceAddress.is_success()); + } + + #[test] + fn test_nrc_values() { + assert_eq!(UdsNrc::ServiceNotSupported.as_u8(), 0x11); + assert_eq!(DiagnosticNackCode::UnknownTargetAddress as u8, 0x03); + } + + #[test] + fn test_generic_nack_code_values() { + let cases = [ + (GenericNackCode::IncorrectPatternFormat, 0x00), + (GenericNackCode::UnknownPayloadType, 0x01), + (GenericNackCode::MessageTooLarge, 0x02), + (GenericNackCode::OutOfMemory, 0x03), + (GenericNackCode::InvalidPayloadLength, 0x04), + ]; + for (code, expected) in cases { + assert_eq!(code as u8, expected); + } + } + + #[test] + fn test_routing_activation_code_values() { + let cases = [ + (RoutingActivationCode::UnknownSourceAddress, 0x00), + (RoutingActivationCode::AllSocketsRegistered, 0x01), + (RoutingActivationCode::DifferentSourceAddress, 0x02), + (RoutingActivationCode::SourceAddressAlreadyActive, 0x03), + (RoutingActivationCode::MissingAuthentication, 0x04), + (RoutingActivationCode::RejectedConfirmation, 0x05), + (RoutingActivationCode::UnsupportedActivationType, 0x06), + (RoutingActivationCode::SuccessfullyActivated, 0x10), + (RoutingActivationCode::ConfirmationRequired, 0x11), + ]; + for (code, expected) in cases { + assert_eq!(code as u8, expected); + } + } + + #[test] + fn test_diagnostic_nack_code_values() { + let cases = [ + (DiagnosticNackCode::InvalidSourceAddress, 0x02), + (DiagnosticNackCode::UnknownTargetAddress, 0x03), + (DiagnosticNackCode::DiagnosticMessageTooLarge, 0x04), + (DiagnosticNackCode::OutOfMemory, 0x05), + (DiagnosticNackCode::TargetUnreachable, 0x06), + (DiagnosticNackCode::UnknownNetwork, 0x07), + (DiagnosticNackCode::TransportProtocolError, 0x08), + ]; + for (code, expected) in cases { + assert_eq!(code as u8, expected); + } + } + + #[test] + fn test_uds_nrc_values() { + let cases = [ + (UdsNrc::GeneralReject, 0x10), + (UdsNrc::ServiceNotSupported, 0x11), + (UdsNrc::SubFunctionNotSupported, 0x12), + (UdsNrc::IncorrectMessageLength, 0x13), + (UdsNrc::BusyRepeatRequest, 0x21), + (UdsNrc::ConditionsNotCorrect, 0x22), + (UdsNrc::RequestSequenceError, 0x24), + (UdsNrc::RequestOutOfRange, 0x31), + (UdsNrc::SecurityAccessDenied, 0x33), + (UdsNrc::InvalidKey, 0x35), + (UdsNrc::ExceededNumberOfAttempts, 0x36), + (UdsNrc::RequiredTimeDelayNotExpired, 0x37), + (UdsNrc::ResponsePending, 0x78), + (UdsNrc::ServiceNotSupportedInActiveSession, 0x7F), + ]; + for (code, expected) in cases { + assert_eq!(code.as_u8(), expected); + } + } + + #[test] + fn test_doip_error_display_variants() { + let errors = [ + DoipError::Io(io::Error::other("io")), + DoipError::InvalidConfig("bad config".to_string()), + DoipError::InvalidProtocolVersion { + expected: 0x02, + actual: 0x00, + }, + DoipError::InvalidHeader("bad header".to_string()), + DoipError::UnknownPayloadType(0x1234), + DoipError::PayloadTooShort { + expected: 8, + actual: 4, + }, + DoipError::UnknownRoutingActivationResponseCode(0x99), + DoipError::EmptyUserData, + ]; + + for err in errors { + let _ = err.to_string(); + } + } +} diff --git a/doip-server/src/lib.rs b/doip-server/src/lib.rs new file mode 100644 index 0000000..0c8b098 --- /dev/null +++ b/doip-server/src/lib.rs @@ -0,0 +1,21 @@ +/* + * 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 + */ +/// 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}; diff --git a/doip-server/src/server/config.rs b/doip-server/src/server/config.rs new file mode 100644 index 0000000..39fa96c --- /dev/null +++ b/doip-server/src/server/config.rs @@ -0,0 +1,418 @@ +/* + * 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; + +// ============================================================================ +// 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; + +/// Default Vehicle Identification Number (17 ASCII characters per ISO 3779) +const DEFAULT_VIN: &[u8; 17] = 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; 17], + /// 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) -> crate::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) -> crate::Result<[u8; 17]> { + let bytes = s.as_bytes(); + if bytes.len() != 17 { + return Err(DoipError::InvalidConfig(format!( + "VIN must be exactly 17 characters, got {}", + bytes.len() + ))); + } + let mut vin = [0u8; 17]; + vin.copy_from_slice(bytes); + Ok(vin) + } + + fn parse_hex_array(s: &str) -> crate::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 test_default_config() { + 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 test_new_with_logical_address() { + let config = ServerConfig::new(0x1234); + + assert_eq!(config.logical_address(), 0x1234); + assert_eq!(config.tcp_addr().port(), DEFAULT_DOIP_PORT); + } + + #[test] + fn test_parse_vin_valid() { + let result = ServerConfig::parse_vin("WVWZZZ3CZWE123456"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 17); + } + + #[test] + fn test_parse_vin_invalid_length() { + let result = ServerConfig::parse_vin("SHORTVIN"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_hex_array_valid() { + let result: crate::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 test_parse_hex_array_with_0x_prefix() { + let result: crate::Result<[u8; 6]> = ServerConfig::parse_hex_array("0x001A2B3C4D5E"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_hex_array_invalid_length() { + let result: crate::Result<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B"); + assert!(result.is_err()); + } + + #[test] + fn test_with_vin_builder() { + let new_vin = *b"NEWVIN12345678901"; + let config = ServerConfig::default().with_vin(new_vin); + + assert_eq!(config.vin(), new_vin); + } + + #[test] + fn test_with_addresses_builder() { + 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..fdfffd2 --- /dev/null +++ b/doip-server/src/server/mod.rs @@ -0,0 +1,23 @@ +/* + * 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::{Session, SessionManager}; diff --git a/doip-server/src/server/session.rs b/doip-server/src/server/session.rs new file mode 100644 index 0000000..cfd2dee --- /dev/null +++ b/doip-server/src/server/session.rs @@ -0,0 +1,251 @@ +/* + * 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; + +/// Session states per ISO 13400-2:2019 connection lifecycle +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + Connected, + RoutingActive, + Closed, +} + +/// A single `DoIP` tester connection and its lifecycle state. +#[derive(Debug, Clone)] +pub struct Session { + /// Unique monotonic session identifier assigned at connection time + id: u64, + /// 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. + #[must_use] + pub fn new(id: u64, peer_addr: SocketAddr) -> Self { + Self { + id, + peer_addr, + tester_address: 0, + state: SessionState::Connected, + } + } + + /// Transition this session to [`SessionState::RoutingActive`] and record + /// the tester's logical address. + pub fn activate_routing(&mut self, tester_address: u16) { + debug!(session_id = self.id, tester_address, "routing activated"); + self.tester_address = tester_address; + self.state = SessionState::RoutingActive; + } + + /// Returns `true` if routing has been activated for this session. + #[must_use] + pub fn is_routing_active(&self) -> bool { + self.state == SessionState::RoutingActive + } + + /// Returns the unique session ID. + #[must_use] + pub fn id(&self) -> u64 { + self.id + } + + /// Returns the remote socket address of the connected tester. + #[must_use] + pub fn peer_addr(&self) -> SocketAddr { + self.peer_addr + } + + /// Returns the tester logical address (`0` until routing is activated). + #[must_use] + pub fn tester_address(&self) -> u16 { + self.tester_address + } + + /// Returns the current lifecycle state. + #[must_use] + pub fn state(&self) -> SessionState { + self.state + } +} + +/// Thread-safe registry of active `DoIP` sessions. +/// +/// The mutable session state is held in a 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 { + sessions: HashMap, + addr_to_session: HashMap, +} + +/// Thread-safe registry of active `DoIP` tester sessions. +/// +/// Uses a single [`RwLock`] over [`SessionManagerInner`] 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 { + inner: RwLock, + 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. + pub fn create_session(&self, peer_addr: SocketAddr) -> Session { + let id = 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, peer = %peer_addr, "Session created"); + session + } + + /// Look up a session by its numeric ID. Returns `None` if not found. + pub fn get_session(&self, id: u64) -> Option { + self.inner.read().sessions.get(&id).cloned() + } + + /// Look up a session by the tester's remote address. Returns `None` if not found. + pub 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. + pub fn update_session(&self, id: u64, f: F) -> bool + where + F: FnOnce(&mut Session), + { + if let Some(session) = self.inner.write().sessions.get_mut(&id) { + f(session); + true + } else { + false + } + } + + /// Remove and return the session with the given `id`, or `None` if not found. + pub fn remove_session(&self, id: u64) -> Option { + let mut inner = self.inner.write(); + let session = inner.sessions.remove(&id)?; + inner.addr_to_session.remove(&session.peer_addr); + debug!(session_id = id, peer = %session.peer_addr, "Session removed"); + Some(session) + } + + /// Remove and return the session associated with `addr`, or `None` if not found. + pub fn remove_session_by_addr(&self, addr: &SocketAddr) -> Option { + let mut inner = self.inner.write(); + let id = inner.addr_to_session.remove(addr)?; + let session = inner.sessions.remove(&id)?; + debug!(session_id = id, 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 create_and_get_session() { + let mgr = SessionManager::new(); + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); + + let session = mgr.create_session(addr); + assert_eq!(session.state(), SessionState::Connected); + + let retrieved = mgr.get_session(session.id()).unwrap(); + assert_eq!(retrieved.peer_addr(), addr); + } + + #[test] + fn activate_routing() { + 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(); + assert!(updated.is_routing_active()); + assert_eq!(updated.tester_address(), 0x0E80); + } + + #[test] + fn remove_session() { + 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()); + assert_eq!(mgr.session_count(), 0); + assert!(mgr.get_session(session.id()).is_none()); + } + + #[test] + fn check_tester_registered() { + let mgr = SessionManager::new(); + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); + + let session = mgr.create_session(addr); + assert!(!mgr.is_tester_registered(0x0E80)); + + mgr.update_session(session.id(), |s| s.activate_routing(0x0E80)); + 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..6a29043 --- /dev/null +++ b/doip-server/src/uds/handler.rs @@ -0,0 +1,181 @@ +/* + * 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) +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). None is returned for empty payloads. + 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..3718925 --- /dev/null +++ b/doip-server/src/uds/mod.rs @@ -0,0 +1,21 @@ +/* + * 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};