From 04327e27083cf13b4e8ba70b2c384c5843590abe Mon Sep 17 00:00:00 2001 From: vinayrs Date: Fri, 30 Jan 2026 11:19:02 +0530 Subject: [PATCH 01/17] feat(doip): implement header parsing with tokio codec --- Cargo.toml | 34 ++-- src/doip/hearder_parser.rs | 378 +++++++++++++++++++++++++++++++++++++ src/doip/mod.rs | 10 + src/lib.rs | 1 + 4 files changed, 401 insertions(+), 22 deletions(-) create mode 100644 src/doip/hearder_parser.rs create mode 100644 src/doip/mod.rs create mode 100644 src/lib.rs 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/src/doip/hearder_parser.rs b/src/doip/hearder_parser.rs new file mode 100644 index 0000000..4697534 --- /dev/null +++ b/src/doip/hearder_parser.rs @@ -0,0 +1,378 @@ +//! DoIP Header Parser + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use std::io; +use tokio_util::codec::{Decoder, Encoder}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum GenericNackCode { + IncorrectPatternFormat = 0x00, + UnknownPayloadType = 0x01, + MessageTooLarge = 0x02, + OutOfMemory = 0x03, + InvalidPayloadLength = 0x04, +} + +#[derive(Debug)] +pub enum ParseError { + InvalidHeader(String), + Io(io::Error), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidHeader(msg) => write!(f, "Invalid header: {}", msg), + Self::Io(e) => write!(f, "IO error: {}", e), + } + } +} + +impl std::error::Error for ParseError {} + +impl From for ParseError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +pub type Result = std::result::Result; + +pub const DEFAULT_PROTOCOL_VERSION: u8 = 0x02; +pub const DEFAULT_PROTOCOL_VERSION_INV: u8 = 0xFD; +pub const DOIP_HEADER_LENGTH: usize = 8; +pub const MAX_DOIP_MESSAGE_SIZE: u32 = 0x0FFF_FFFF; + +#[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 PayloadType { + pub fn from_u16(value: u16) -> Option { + match value { + 0x0000 => Some(Self::GenericNack), + 0x0001 => Some(Self::VehicleIdentificationRequest), + 0x0002 => Some(Self::VehicleIdentificationRequestWithEid), + 0x0003 => Some(Self::VehicleIdentificationRequestWithVin), + 0x0004 => Some(Self::VehicleIdentificationResponse), + 0x0005 => Some(Self::RoutingActivationRequest), + 0x0006 => Some(Self::RoutingActivationResponse), + 0x0007 => Some(Self::AliveCheckRequest), + 0x0008 => Some(Self::AliveCheckResponse), + 0x4001 => Some(Self::DoipEntityStatusRequest), + 0x4002 => Some(Self::DoipEntityStatusResponse), + 0x4003 => Some(Self::DiagnosticPowerModeRequest), + 0x4004 => Some(Self::DiagnosticPowerModeResponse), + 0x8001 => Some(Self::DiagnosticMessage), + 0x8002 => Some(Self::DiagnosticMessagePositiveAck), + 0x8003 => Some(Self::DiagnosticMessageNegativeAck), + _ => None, + } + } + + pub const fn min_payload_length(self) -> usize { + match self { + Self::GenericNack => 1, + Self::VehicleIdentificationRequest => 0, + Self::VehicleIdentificationRequestWithEid => 6, + Self::VehicleIdentificationRequestWithVin => 17, + Self::VehicleIdentificationResponse => 32, + Self::RoutingActivationRequest => 7, + Self::RoutingActivationResponse => 9, + Self::AliveCheckRequest => 0, + Self::AliveCheckResponse => 2, + Self::DoipEntityStatusRequest => 0, + Self::DoipEntityStatusResponse => 3, + Self::DiagnosticPowerModeRequest => 0, + Self::DiagnosticPowerModeResponse => 1, + Self::DiagnosticMessage => 5, + Self::DiagnosticMessagePositiveAck => 5, + Self::DiagnosticMessageNegativeAck => 5, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DoipHeader { + pub version: u8, + pub inverse_version: u8, + pub payload_type: u16, + pub payload_length: u32, +} + +impl DoipHeader { + pub fn parse(data: &[u8]) -> Result { + if data.len() < DOIP_HEADER_LENGTH { + return Err(ParseError::InvalidHeader(format!( + "header too short: expected {}, got {}", + DOIP_HEADER_LENGTH, + data.len() + ))); + } + Ok(Self { + version: data[0], + inverse_version: data[1], + payload_type: u16::from_be_bytes([data[2], data[3]]), + payload_length: u32::from_be_bytes([data[4], data[5], data[6], data[7]]), + }) + } + + pub fn parse_from_buf(buf: &mut Bytes) -> Result { + if buf.len() < DOIP_HEADER_LENGTH { + return Err(ParseError::InvalidHeader(format!( + "header too short: expected {}, got {}", + DOIP_HEADER_LENGTH, + buf.len() + ))); + } + Ok(Self { + version: buf.get_u8(), + inverse_version: buf.get_u8(), + payload_type: buf.get_u16(), + payload_length: buf.get_u32(), + }) + } + + pub fn validate(&self) -> Option { + if self.version != DEFAULT_PROTOCOL_VERSION { + return Some(GenericNackCode::IncorrectPatternFormat); + } + if self.inverse_version != DEFAULT_PROTOCOL_VERSION_INV { + return Some(GenericNackCode::IncorrectPatternFormat); + } + if self.version ^ self.inverse_version != 0xFF { + return Some(GenericNackCode::IncorrectPatternFormat); + } + + let payload_type = match PayloadType::from_u16(self.payload_type) { + Some(pt) => pt, + None => return Some(GenericNackCode::UnknownPayloadType), + }; + + if self.payload_length > MAX_DOIP_MESSAGE_SIZE { + return Some(GenericNackCode::MessageTooLarge); + } + if (self.payload_length as usize) < payload_type.min_payload_length() { + return Some(GenericNackCode::InvalidPayloadLength); + } + None + } + + pub fn is_valid(&self) -> bool { + self.validate().is_none() + } + + pub const fn total_length(&self) -> usize { + DOIP_HEADER_LENGTH + self.payload_length as usize + } + + pub fn to_bytes(&self) -> Bytes { + let mut buf = BytesMut::with_capacity(DOIP_HEADER_LENGTH); + buf.put_u8(self.version); + buf.put_u8(self.inverse_version); + buf.put_u16(self.payload_type); + buf.put_u32(self.payload_length); + buf.freeze() + } + + 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); + } +} + +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::from_u16(self.payload_type) + .map(|pt| format!("{:?}", pt)) + .unwrap_or_else(|| format!("Unknown(0x{:04X})", self.payload_type)); + write!( + f, + "DoipHeader {{ version: 0x{:02X}, type: {}, length: {} }}", + self.version, payload_name, self.payload_length + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DoipMessage { + pub header: DoipHeader, + pub payload: Bytes, +} + +impl DoipMessage { + pub fn new(payload_type: PayloadType, payload: Bytes) -> Self { + Self { + header: DoipHeader { + version: DEFAULT_PROTOCOL_VERSION, + inverse_version: DEFAULT_PROTOCOL_VERSION_INV, + payload_type: payload_type as u16, + payload_length: payload.len() as u32, + }, + payload, + } + } + + pub fn with_raw_type(payload_type: u16, payload: Bytes) -> Self { + Self { + header: DoipHeader { + version: DEFAULT_PROTOCOL_VERSION, + inverse_version: DEFAULT_PROTOCOL_VERSION_INV, + payload_type, + payload_length: payload.len() as u32, + }, + payload, + } + } + + pub fn payload_type(&self) -> Option { + PayloadType::from_u16(self.header.payload_type) + } + + pub fn total_length(&self) -> usize { + DOIP_HEADER_LENGTH + self.payload.len() + } + + pub fn to_bytes(&self) -> Bytes { + let mut buf = BytesMut::with_capacity(self.total_length()); + self.header.write_to(&mut buf); + buf.extend_from_slice(&self.payload); + buf.freeze() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DecodeState { + Header, + Payload(DoipHeader), +} + +#[derive(Debug)] +pub struct DoipCodec { + state: DecodeState, + max_payload_size: u32, +} + +impl DoipCodec { + pub fn new() -> Self { + Self { + state: DecodeState::Header, + max_payload_size: MAX_DOIP_MESSAGE_SIZE, + } + } + + 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 { + src.reserve(DOIP_HEADER_LENGTH); + return Ok(None); + } + + let header = DoipHeader::parse(&src[..DOIP_HEADER_LENGTH]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + if let Some(nack_code) = header.validate() { + 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 + ), + )); + } + + src.reserve(header.total_length()); + self.state = DecodeState::Payload(header); + } + + DecodeState::Payload(header) => { + if src.len() < header.total_length() { + return Ok(None); + } + + let _ = src.split_to(DOIP_HEADER_LENGTH); + let payload = src.split_to(header.payload_length as usize).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.total_length()); + item.header.write_to(dst); + dst.extend_from_slice(&item.payload); + Ok(()) + } +} diff --git a/src/doip/mod.rs b/src/doip/mod.rs new file mode 100644 index 0000000..943705e --- /dev/null +++ b/src/doip/mod.rs @@ -0,0 +1,10 @@ +//! DoIP Protocol Implementation (ISO 13400-2) + +pub mod hearder_parser; + +// Re-export commonly used types +pub use hearder_parser::{ + DoipCodec, DoipHeader, DoipMessage, GenericNackCode, ParseError, PayloadType, Result, + DEFAULT_PROTOCOL_VERSION, DEFAULT_PROTOCOL_VERSION_INV, DOIP_HEADER_LENGTH, + MAX_DOIP_MESSAGE_SIZE, +}; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..16e1767 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod doip; From a9bd56409a60d32c9419beaa844bf88392408fb5 Mon Sep 17 00:00:00 2001 From: vinayrs Date: Mon, 2 Feb 2026 18:44:19 +0530 Subject: [PATCH 02/17] test(doip): add unit tests for header_parser - Renamed hearder_parser.rs to header_parser.rs (typo fix) - Added unit tests for header parsing - Updated MAX_DOIP_MESSAGE_SIZE to 4MB --- src/doip/alive_check.rs | 120 ++++++ src/doip/diagnostic_message.rs | 420 ++++++++++++++++++ src/doip/header_parser.rs | 768 +++++++++++++++++++++++++++++++++ src/doip/mod.rs | 13 +- src/doip/routing_activation.rs | 373 ++++++++++++++++ src/doip/vehicle_id.rs | 331 ++++++++++++++ 6 files changed, 2021 insertions(+), 4 deletions(-) create mode 100644 src/doip/alive_check.rs create mode 100644 src/doip/diagnostic_message.rs create mode 100644 src/doip/header_parser.rs create mode 100644 src/doip/routing_activation.rs create mode 100644 src/doip/vehicle_id.rs diff --git a/src/doip/alive_check.rs b/src/doip/alive_check.rs new file mode 100644 index 0000000..aed545a --- /dev/null +++ b/src/doip/alive_check.rs @@ -0,0 +1,120 @@ +//! Alive Check handlers (ISO 13400-2) + +use bytes::{BufMut, Bytes, BytesMut}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + PayloadTooShort { expected: usize, actual: usize }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PayloadTooShort { expected, actual } => { + write!(f, "payload too short: need {} bytes, got {}", expected, actual) + } + } + } +} + +impl std::error::Error for Error {} + +// Alive Check Request (0x0007) - no payload +// Server sends this to check if tester is still connected +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Request; + +impl Request { + pub fn parse(_payload: &[u8]) -> Result { + Ok(Self) + } + + pub fn to_bytes(&self) -> Bytes { + Bytes::new() + } +} + +// Alive Check Response (0x0008) - 2 byte source address +// Tester responds with its logical address +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub source_address: u16, +} + +impl Response { + pub const LEN: usize = 2; + + pub fn new(source_address: u16) -> Self { + Self { source_address } + } + + pub fn parse(payload: &[u8]) -> Result { + if payload.len() < Self::LEN { + return Err(Error::PayloadTooShort { + expected: Self::LEN, + actual: payload.len(), + }); + } + + let source_address = u16::from_be_bytes([payload[0], payload[1]]); + Ok(Self { source_address }) + } + + pub fn to_bytes(&self) -> Bytes { + let mut buf = BytesMut::with_capacity(Self::LEN); + self.write_to(&mut buf); + buf.freeze() + } + + pub fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.source_address); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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/src/doip/diagnostic_message.rs b/src/doip/diagnostic_message.rs new file mode 100644 index 0000000..5f31aa1 --- /dev/null +++ b/src/doip/diagnostic_message.rs @@ -0,0 +1,420 @@ +//! Diagnostic Message handlers (ISO 13400-2) + +use bytes::{Buf, BufMut, Bytes, BytesMut}; + +// Diagnostic message positive ack codes per ISO 13400-2 Table 27 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum AckCode { + Acknowledged = 0x00, +} + +// Diagnostic message negative ack codes per ISO 13400-2 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 NackCode { + pub fn from_u8(value: u8) -> Option { + match value { + 0x02 => Some(Self::InvalidSourceAddress), + 0x03 => Some(Self::UnknownTargetAddress), + 0x04 => Some(Self::DiagnosticMessageTooLarge), + 0x05 => Some(Self::OutOfMemory), + 0x06 => Some(Self::TargetUnreachable), + 0x07 => Some(Self::UnknownNetwork), + 0x08 => Some(Self::TransportProtocolError), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + PayloadTooShort { expected: usize, actual: usize }, + EmptyUserData, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PayloadTooShort { expected, actual } => { + write!(f, "payload too short: need {} bytes, got {}", expected, actual) + } + Self::EmptyUserData => write!(f, "diagnostic message has no user data"), + } + } +} + +impl std::error::Error for Error {} + +// Diagnostic Message - carries UDS data between tester and ECU +// Payload: SA(2) + TA(2) + user_data(1+) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub source_address: u16, + pub target_address: u16, + pub user_data: Bytes, +} + +impl Message { + pub const MIN_LEN: usize = 5; // SA + TA + at least 1 byte UDS + + pub fn new(source: u16, target: u16, data: Bytes) -> Self { + Self { + source_address: source, + target_address: target, + user_data: data, + } + } + + pub fn parse(payload: &[u8]) -> Result { + if payload.len() < Self::MIN_LEN { + return Err(Error::PayloadTooShort { + expected: Self::MIN_LEN, + actual: payload.len(), + }); + } + + let source_address = u16::from_be_bytes([payload[0], payload[1]]); + let target_address = u16::from_be_bytes([payload[2], payload[3]]); + let user_data = Bytes::copy_from_slice(&payload[4..]); + + if user_data.is_empty() { + return Err(Error::EmptyUserData); + } + + Ok(Self { + source_address, + target_address, + user_data, + }) + } + + pub fn parse_buf(buf: &mut Bytes) -> Result { + if buf.len() < Self::MIN_LEN { + return Err(Error::PayloadTooShort { + expected: Self::MIN_LEN, + actual: buf.len(), + }); + } + + let source_address = buf.get_u16(); + let target_address = buf.get_u16(); + let user_data = buf.split_off(0); + + if user_data.is_empty() { + return Err(Error::EmptyUserData); + } + + Ok(Self { + source_address, + target_address, + user_data, + }) + } + + pub fn to_bytes(&self) -> Bytes { + let mut buf = BytesMut::with_capacity(4 + self.user_data.len()); + self.write_to(&mut buf); + buf.freeze() + } + + pub 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); + } + + // UDS service ID is first byte of user_data + pub fn service_id(&self) -> Option { + self.user_data.first().copied() + } +} + +// Diagnostic Message Positive Ack (0x8002) +// Payload: SA(2) + TA(2) + ack_code(1) + optional previous_diag_data +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PositiveAck { + pub source_address: u16, + pub target_address: u16, + pub ack_code: AckCode, + pub previous_data: Option, +} + +impl PositiveAck { + pub const MIN_LEN: usize = 5; + + pub fn new(source: u16, target: u16) -> Self { + Self { + source_address: source, + target_address: target, + ack_code: AckCode::Acknowledged, + previous_data: None, + } + } + + pub fn with_previous_data(source: u16, target: u16, data: Bytes) -> Self { + Self { + source_address: source, + target_address: target, + ack_code: AckCode::Acknowledged, + previous_data: Some(data), + } + } + + pub fn to_bytes(&self) -> Bytes { + let extra = self.previous_data.as_ref().map(|d| d.len()).unwrap_or(0); + let mut buf = BytesMut::with_capacity(Self::MIN_LEN + extra); + self.write_to(&mut buf); + buf.freeze() + } + + pub fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.source_address); + buf.put_u16(self.target_address); + buf.put_u8(self.ack_code as u8); + if let Some(ref data) = self.previous_data { + buf.extend_from_slice(data); + } + } + + pub fn parse(payload: &[u8]) -> Result { + if payload.len() < Self::MIN_LEN { + return Err(Error::PayloadTooShort { + expected: Self::MIN_LEN, + actual: payload.len(), + }); + } + + let source_address = u16::from_be_bytes([payload[0], payload[1]]); + let target_address = u16::from_be_bytes([payload[2], payload[3]]); + let ack_code = AckCode::Acknowledged; // only one value + + let previous_data = if payload.len() > Self::MIN_LEN { + Some(Bytes::copy_from_slice(&payload[5..])) + } else { + None + }; + + Ok(Self { + source_address, + target_address, + ack_code, + previous_data, + }) + } +} + +// Diagnostic Message Negative Ack (0x8003) +// Payload: SA(2) + TA(2) + nack_code(1) + optional previous_diag_data +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegativeAck { + pub source_address: u16, + pub target_address: u16, + pub nack_code: NackCode, + pub previous_data: Option, +} + +impl NegativeAck { + pub const MIN_LEN: usize = 5; + + pub fn new(source: u16, target: u16, code: NackCode) -> Self { + Self { + source_address: source, + target_address: target, + nack_code: code, + previous_data: None, + } + } + + pub fn with_previous_data(source: u16, target: u16, code: NackCode, data: Bytes) -> Self { + Self { + source_address: source, + target_address: target, + nack_code: code, + previous_data: Some(data), + } + } + + pub fn to_bytes(&self) -> Bytes { + let extra = self.previous_data.as_ref().map(|d| d.len()).unwrap_or(0); + let mut buf = BytesMut::with_capacity(Self::MIN_LEN + extra); + self.write_to(&mut buf); + buf.freeze() + } + + pub fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.source_address); + buf.put_u16(self.target_address); + buf.put_u8(self.nack_code as u8); + if let Some(ref data) = self.previous_data { + buf.extend_from_slice(data); + } + } + + pub fn parse(payload: &[u8]) -> Result { + if payload.len() < Self::MIN_LEN { + return Err(Error::PayloadTooShort { + expected: Self::MIN_LEN, + actual: payload.len(), + }); + } + + let source_address = u16::from_be_bytes([payload[0], payload[1]]); + let target_address = u16::from_be_bytes([payload[2], payload[3]]); + let nack_code = NackCode::from_u8(payload[4]) + .unwrap_or(NackCode::TransportProtocolError); + + let previous_data = if payload.len() > Self::MIN_LEN { + Some(Bytes::copy_from_slice(&payload[5..])) + } else { + None + }; + + Ok(Self { + source_address, + target_address, + nack_code, + previous_data, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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[0..2], &[0x0E, 0x80]); + assert_eq!(&bytes[2..4], &[0x10, 0x00]); + assert_eq!(&bytes[4..], &[0x22, 0xF1, 0x90]); + } + + #[test] + fn build_positive_ack() { + let ack = PositiveAck::new(0x1000, 0x0E80); + let bytes = ack.to_bytes(); + + assert_eq!(bytes.len(), 5); + assert_eq!(&bytes[0..2], &[0x10, 0x00]); // source (ECU) + assert_eq!(&bytes[2..4], &[0x0E, 0x80]); // target (tester) + assert_eq!(bytes[4], 0x00); // ack code + } + + #[test] + fn build_positive_ack_with_prev_data() { + let prev = Bytes::from_static(&[0x22, 0xF1, 0x90]); + let ack = PositiveAck::with_previous_data(0x1000, 0x0E80, prev); + let bytes = ack.to_bytes(); + + assert_eq!(bytes.len(), 8); + assert_eq!(&bytes[5..], &[0x22, 0xF1, 0x90]); + } + + #[test] + fn build_negative_ack() { + let nack = NegativeAck::new(0x1000, 0x0E80, NackCode::UnknownTargetAddress); + let bytes = nack.to_bytes(); + + assert_eq!(bytes.len(), 5); + assert_eq!(bytes[4], 0x03); + } + + #[test] + fn build_negative_ack_target_unreachable() { + let nack = NegativeAck::new(0x1000, 0x0E80, NackCode::TargetUnreachable); + let bytes = nack.to_bytes(); + assert_eq!(bytes[4], 0x06); + } + + #[test] + fn parse_positive_ack() { + let payload = [0x10, 0x00, 0x0E, 0x80, 0x00]; + let ack = PositiveAck::parse(&payload).unwrap(); + + assert_eq!(ack.source_address, 0x1000); + assert_eq!(ack.target_address, 0x0E80); + assert!(ack.previous_data.is_none()); + } + + #[test] + fn parse_negative_ack() { + let payload = [0x10, 0x00, 0x0E, 0x80, 0x03]; + let nack = NegativeAck::parse(&payload).unwrap(); + + assert_eq!(nack.nack_code, 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 = PositiveAck::new(0x1000, 0x0E80); + let bytes = original.to_bytes(); + let parsed = PositiveAck::parse(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn roundtrip_negative_ack() { + let original = NegativeAck::new(0x1000, 0x0E80, NackCode::OutOfMemory); + let bytes = original.to_bytes(); + let parsed = NegativeAck::parse(&bytes).unwrap(); + assert_eq!(original, parsed); + } +} diff --git a/src/doip/header_parser.rs b/src/doip/header_parser.rs new file mode 100644 index 0000000..f489da3 --- /dev/null +++ b/src/doip/header_parser.rs @@ -0,0 +1,768 @@ +//! DoIP Header Parser + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use std::io; +use tokio_util::codec::{Decoder, Encoder}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum GenericNackCode { + IncorrectPatternFormat = 0x00, + UnknownPayloadType = 0x01, + MessageTooLarge = 0x02, + OutOfMemory = 0x03, + InvalidPayloadLength = 0x04, +} + +#[derive(Debug)] +pub enum ParseError { + InvalidHeader(String), + Io(io::Error), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidHeader(msg) => write!(f, "Invalid header: {}", msg), + Self::Io(e) => write!(f, "IO error: {}", e), + } + } +} + +impl std::error::Error for ParseError {} + +impl From for ParseError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +pub type Result = std::result::Result; + +pub const DEFAULT_PROTOCOL_VERSION: u8 = 0x02; +pub const DEFAULT_PROTOCOL_VERSION_INV: u8 = 0xFD; +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 + +#[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 PayloadType { + pub fn from_u16(value: u16) -> Option { + match value { + 0x0000 => Some(Self::GenericNack), + 0x0001 => Some(Self::VehicleIdentificationRequest), + 0x0002 => Some(Self::VehicleIdentificationRequestWithEid), + 0x0003 => Some(Self::VehicleIdentificationRequestWithVin), + 0x0004 => Some(Self::VehicleIdentificationResponse), + 0x0005 => Some(Self::RoutingActivationRequest), + 0x0006 => Some(Self::RoutingActivationResponse), + 0x0007 => Some(Self::AliveCheckRequest), + 0x0008 => Some(Self::AliveCheckResponse), + 0x4001 => Some(Self::DoipEntityStatusRequest), + 0x4002 => Some(Self::DoipEntityStatusResponse), + 0x4003 => Some(Self::DiagnosticPowerModeRequest), + 0x4004 => Some(Self::DiagnosticPowerModeResponse), + 0x8001 => Some(Self::DiagnosticMessage), + 0x8002 => Some(Self::DiagnosticMessagePositiveAck), + 0x8003 => Some(Self::DiagnosticMessageNegativeAck), + _ => None, + } + } + + pub const fn min_payload_length(self) -> usize { + match self { + Self::GenericNack => 1, + Self::VehicleIdentificationRequest => 0, + Self::VehicleIdentificationRequestWithEid => 6, + Self::VehicleIdentificationRequestWithVin => 17, + Self::VehicleIdentificationResponse => 32, + Self::RoutingActivationRequest => 7, + Self::RoutingActivationResponse => 9, + Self::AliveCheckRequest => 0, + Self::AliveCheckResponse => 2, + Self::DoipEntityStatusRequest => 0, + Self::DoipEntityStatusResponse => 3, + Self::DiagnosticPowerModeRequest => 0, + Self::DiagnosticPowerModeResponse => 1, + Self::DiagnosticMessage => 5, + Self::DiagnosticMessagePositiveAck => 5, + Self::DiagnosticMessageNegativeAck => 5, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DoipHeader { + pub version: u8, + pub inverse_version: u8, + pub payload_type: u16, + pub payload_length: u32, +} + +impl DoipHeader { + pub fn parse(data: &[u8]) -> Result { + if data.len() < DOIP_HEADER_LENGTH { + return Err(ParseError::InvalidHeader(format!( + "header too short: expected {}, got {}", + DOIP_HEADER_LENGTH, + data.len() + ))); + } + Ok(Self { + version: data[0], + inverse_version: data[1], + payload_type: u16::from_be_bytes([data[2], data[3]]), + payload_length: u32::from_be_bytes([data[4], data[5], data[6], data[7]]), + }) + } + + pub fn parse_from_buf(buf: &mut Bytes) -> Result { + if buf.len() < DOIP_HEADER_LENGTH { + return Err(ParseError::InvalidHeader(format!( + "header too short: expected {}, got {}", + DOIP_HEADER_LENGTH, + buf.len() + ))); + } + Ok(Self { + version: buf.get_u8(), + inverse_version: buf.get_u8(), + payload_type: buf.get_u16(), + payload_length: buf.get_u32(), + }) + } + + pub fn validate(&self) -> Option { + if self.version != DEFAULT_PROTOCOL_VERSION { + return Some(GenericNackCode::IncorrectPatternFormat); + } + if self.inverse_version != DEFAULT_PROTOCOL_VERSION_INV { + return Some(GenericNackCode::IncorrectPatternFormat); + } + if self.version ^ self.inverse_version != 0xFF { + return Some(GenericNackCode::IncorrectPatternFormat); + } + + let payload_type = match PayloadType::from_u16(self.payload_type) { + Some(pt) => pt, + None => return Some(GenericNackCode::UnknownPayloadType), + }; + + if self.payload_length > MAX_DOIP_MESSAGE_SIZE { + return Some(GenericNackCode::MessageTooLarge); + } + if (self.payload_length as usize) < payload_type.min_payload_length() { + return Some(GenericNackCode::InvalidPayloadLength); + } + None + } + + pub fn is_valid(&self) -> bool { + self.validate().is_none() + } + + pub const fn total_length(&self) -> usize { + DOIP_HEADER_LENGTH + self.payload_length as usize + } + + pub fn to_bytes(&self) -> Bytes { + let mut buf = BytesMut::with_capacity(DOIP_HEADER_LENGTH); + buf.put_u8(self.version); + buf.put_u8(self.inverse_version); + buf.put_u16(self.payload_type); + buf.put_u32(self.payload_length); + buf.freeze() + } + + 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); + } +} + +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::from_u16(self.payload_type) + .map(|pt| format!("{:?}", pt)) + .unwrap_or_else(|| format!("Unknown(0x{:04X})", self.payload_type)); + write!( + f, + "DoipHeader {{ version: 0x{:02X}, type: {}, length: {} }}", + self.version, payload_name, self.payload_length + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DoipMessage { + pub header: DoipHeader, + pub payload: Bytes, +} + +impl DoipMessage { + pub fn new(payload_type: PayloadType, payload: Bytes) -> Self { + Self { + header: DoipHeader { + version: DEFAULT_PROTOCOL_VERSION, + inverse_version: DEFAULT_PROTOCOL_VERSION_INV, + payload_type: payload_type as u16, + payload_length: payload.len() as u32, + }, + payload, + } + } + + pub fn with_raw_type(payload_type: u16, payload: Bytes) -> Self { + Self { + header: DoipHeader { + version: DEFAULT_PROTOCOL_VERSION, + inverse_version: DEFAULT_PROTOCOL_VERSION_INV, + payload_type, + payload_length: payload.len() as u32, + }, + payload, + } + } + + pub fn payload_type(&self) -> Option { + PayloadType::from_u16(self.header.payload_type) + } + + pub fn total_length(&self) -> usize { + DOIP_HEADER_LENGTH + self.payload.len() + } + + pub fn to_bytes(&self) -> Bytes { + let mut buf = BytesMut::with_capacity(self.total_length()); + self.header.write_to(&mut buf); + buf.extend_from_slice(&self.payload); + buf.freeze() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DecodeState { + Header, + Payload(DoipHeader), +} + +#[derive(Debug)] +pub struct DoipCodec { + state: DecodeState, + max_payload_size: u32, +} + +impl DoipCodec { + pub fn new() -> Self { + Self { + state: DecodeState::Header, + max_payload_size: MAX_DOIP_MESSAGE_SIZE, + } + } + + 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 { + src.reserve(DOIP_HEADER_LENGTH); + return Ok(None); + } + + let header = DoipHeader::parse(&src[..DOIP_HEADER_LENGTH]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + if let Some(nack_code) = header.validate() { + 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 + ), + )); + } + + src.reserve(header.total_length()); + self.state = DecodeState::Payload(header); + } + + DecodeState::Payload(header) => { + if src.len() < header.total_length() { + return Ok(None); + } + + let _ = src.split_to(DOIP_HEADER_LENGTH); + let payload = src.split_to(header.payload_length as usize).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.total_length()); + item.header.write_to(dst); + dst.extend_from_slice(&item.payload); + Ok(()) + } +} + +// ============================================================================ +// Unit Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // --- 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 0x03 - we only support 0x02 + let hdr = DoipHeader { + version: 0x03, + inverse_version: 0xFC, + 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::from_u16(0x0001), Some(PayloadType::VehicleIdentificationRequest)); + assert_eq!(PayloadType::from_u16(0x0005), Some(PayloadType::RoutingActivationRequest)); + assert_eq!(PayloadType::from_u16(0x8001), Some(PayloadType::DiagnosticMessage)); + assert_eq!(PayloadType::from_u16(0x8002), Some(PayloadType::DiagnosticMessagePositiveAck)); + } + + #[test] + fn payload_type_gaps_return_none() { + // These are in gaps between valid ranges + assert!(PayloadType::from_u16(0x0009).is_none()); + assert!(PayloadType::from_u16(0x4000).is_none()); + assert!(PayloadType::from_u16(0x8000).is_none()); + assert!(PayloadType::from_u16(0xFFFF).is_none()); + } + + #[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.total_length(), 8); // Just the header + } + + #[test] + fn message_with_unknown_type() { + // For testing/fuzzing - create msg with invalid type + let msg = DoipMessage::with_raw_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 + let mut buf = BytesMut::from(&[ + 0x01, 0xFE, 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 err = ParseError::InvalidHeader("buffer too short".into()); + let msg = format!("{}", err); + assert!(msg.contains("Invalid header")); + assert!(msg.contains("buffer too short")); + } + + #[test] + fn io_errors_convert_to_parse_errors() { + let io_err = io::Error::new(io::ErrorKind::UnexpectedEof, "connection lost"); + let parse_err: ParseError = io_err.into(); + match parse_err { + ParseError::Io(e) => assert_eq!(e.kind(), io::ErrorKind::UnexpectedEof), + _ => panic!("expected Io variant"), + } + } + + #[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 0xFF (per spec) + assert_eq!(DEFAULT_PROTOCOL_VERSION ^ DEFAULT_PROTOCOL_VERSION_INV, 0xFF); + } + + #[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/src/doip/mod.rs b/src/doip/mod.rs index 943705e..dfebf91 100644 --- a/src/doip/mod.rs +++ b/src/doip/mod.rs @@ -1,9 +1,14 @@ -//! DoIP Protocol Implementation (ISO 13400-2) +//! DoIP Protocol Implementation +//! +//! This module provides the core DoIP protocol types and codec for TCP/UDP communication. -pub mod hearder_parser; +pub mod header_parser; +pub mod routing_activation; +pub mod diagnostic_message; +pub mod vehicle_id; +pub mod alive_check; -// Re-export commonly used types -pub use hearder_parser::{ +pub use header_parser::{ DoipCodec, DoipHeader, DoipMessage, GenericNackCode, ParseError, PayloadType, Result, DEFAULT_PROTOCOL_VERSION, DEFAULT_PROTOCOL_VERSION_INV, DOIP_HEADER_LENGTH, MAX_DOIP_MESSAGE_SIZE, diff --git a/src/doip/routing_activation.rs b/src/doip/routing_activation.rs new file mode 100644 index 0000000..1628b86 --- /dev/null +++ b/src/doip/routing_activation.rs @@ -0,0 +1,373 @@ +//! Routing Activation handlers (ISO 13400-2) + +use bytes::{Buf, BufMut, Bytes, BytesMut}; + +// Response codes per ISO 13400-2 Table 25 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ResponseCode { + UnknownSourceAddress = 0x00, + AllSocketsRegistered = 0x01, + DifferentSourceAddress = 0x02, + SourceAddressAlreadyActive = 0x03, + MissingAuthentication = 0x04, + RejectedConfirmation = 0x05, + UnsupportedActivationType = 0x06, + TlsRequired = 0x07, + SuccessfullyActivated = 0x10, + ConfirmationRequired = 0x11, +} + +impl ResponseCode { + pub fn from_u8(value: u8) -> Option { + match value { + 0x00 => Some(Self::UnknownSourceAddress), + 0x01 => Some(Self::AllSocketsRegistered), + 0x02 => Some(Self::DifferentSourceAddress), + 0x03 => Some(Self::SourceAddressAlreadyActive), + 0x04 => Some(Self::MissingAuthentication), + 0x05 => Some(Self::RejectedConfirmation), + 0x06 => Some(Self::UnsupportedActivationType), + 0x07 => Some(Self::TlsRequired), + 0x10 => Some(Self::SuccessfullyActivated), + 0x11 => Some(Self::ConfirmationRequired), + _ => None, + } + } + + pub fn is_success(self) -> bool { + matches!(self, Self::SuccessfullyActivated | Self::ConfirmationRequired) + } +} + +// Activation types per ISO 13400-2 Table 24 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ActivationType { + Default = 0x00, + WwhObd = 0x01, + CentralSecurity = 0xE0, +} + +impl ActivationType { + pub fn from_u8(value: u8) -> Option { + match value { + 0x00 => Some(Self::Default), + 0x01 => Some(Self::WwhObd), + 0xE0 => Some(Self::CentralSecurity), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + PayloadTooShort { expected: usize, actual: usize }, + UnknownResponseCode(u8), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PayloadTooShort { expected, actual } => { + write!(f, "payload too short: need {} bytes, got {}", expected, actual) + } + Self::UnknownResponseCode(code) => write!(f, "unknown response code: 0x{:02X}", code), + } + } +} + +impl std::error::Error for Error {} + +// Routing Activation Request - payload is 7 bytes min, 11 with OEM data +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + pub source_address: u16, + pub activation_type: u8, + pub reserved: u32, + pub oem_specific: Option, +} + +impl Request { + pub const MIN_LEN: usize = 7; + pub const MAX_LEN: usize = 11; + + pub fn parse(payload: &[u8]) -> Result { + if payload.len() < Self::MIN_LEN { + return Err(Error::PayloadTooShort { + expected: Self::MIN_LEN, + actual: payload.len(), + }); + } + + let source_address = u16::from_be_bytes([payload[0], payload[1]]); + let activation_type = payload[2]; + let reserved = u32::from_be_bytes([payload[3], payload[4], payload[5], payload[6]]); + + let oem_specific = if payload.len() >= Self::MAX_LEN { + Some(u32::from_be_bytes([payload[7], payload[8], payload[9], payload[10]])) + } else { + None + }; + + Ok(Self { + source_address, + activation_type, + reserved, + oem_specific, + }) + } + + pub fn parse_buf(buf: &mut Bytes) -> Result { + if buf.len() < Self::MIN_LEN { + return Err(Error::PayloadTooShort { + expected: Self::MIN_LEN, + actual: buf.len(), + }); + } + + let source_address = buf.get_u16(); + let activation_type = buf.get_u8(); + let reserved = buf.get_u32(); + let oem_specific = if buf.remaining() >= 4 { Some(buf.get_u32()) } else { None }; + + Ok(Self { + source_address, + activation_type, + reserved, + oem_specific, + }) + } + + pub fn activation_type_enum(&self) -> Option { + ActivationType::from_u8(self.activation_type) + } + + pub fn validate(&self) -> Option { + if ActivationType::from_u8(self.activation_type).is_none() { + return Some(ResponseCode::UnsupportedActivationType); + } + None + } +} + +// Routing Activation Response - 9 bytes min, 13 with OEM data +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub tester_address: u16, + pub entity_address: u16, + pub response_code: ResponseCode, + pub reserved: u32, + pub oem_specific: Option, +} + +impl Response { + pub const MIN_LEN: usize = 9; + pub const MAX_LEN: usize = 13; + + pub fn success(tester_address: u16, entity_address: u16) -> Self { + Self { + tester_address, + entity_address, + response_code: ResponseCode::SuccessfullyActivated, + reserved: 0, + oem_specific: None, + } + } + + pub fn denial(tester_address: u16, entity_address: u16, code: ResponseCode) -> Self { + Self { + tester_address, + entity_address, + response_code: code, + reserved: 0, + oem_specific: None, + } + } + + pub fn to_bytes(&self) -> Bytes { + let len = if self.oem_specific.is_some() { Self::MAX_LEN } else { Self::MIN_LEN }; + let mut buf = BytesMut::with_capacity(len); + self.write_to(&mut buf); + buf.freeze() + } + + pub fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.tester_address); + buf.put_u16(self.entity_address); + buf.put_u8(self.response_code as u8); + buf.put_u32(self.reserved); + if let Some(oem) = self.oem_specific { + buf.put_u32(oem); + } + } + + pub fn parse(payload: &[u8]) -> Result { + if payload.len() < Self::MIN_LEN { + return Err(Error::PayloadTooShort { + expected: Self::MIN_LEN, + actual: payload.len(), + }); + } + + let tester_address = u16::from_be_bytes([payload[0], payload[1]]); + let entity_address = u16::from_be_bytes([payload[2], payload[3]]); + let response_code = ResponseCode::from_u8(payload[4]) + .ok_or(Error::UnknownResponseCode(payload[4]))?; + let reserved = u32::from_be_bytes([payload[5], payload[6], payload[7], payload[8]]); + + let oem_specific = if payload.len() >= Self::MAX_LEN { + Some(u32::from_be_bytes([payload[9], payload[10], payload[11], payload[12]])) + } else { + None + }; + + Ok(Self { + tester_address, + entity_address, + response_code, + reserved, + oem_specific, + }) + } + + pub fn is_success(&self) -> bool { + self.response_code.is_success() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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, 0x00); + 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(0xDEADBEEF)); + } + + #[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_enum(), Some(ActivationType::WwhObd)); + } + + #[test] + fn reject_short_request() { + let short = [0x0E, 0x80, 0x00, 0x00]; + assert!(Request::parse(&short).is_err()); + } + + #[test] + fn validate_bad_activation_type() { + let payload = [0x0E, 0x80, 0x99, 0x00, 0x00, 0x00, 0x00]; + let req = Request::parse(&payload).unwrap(); + assert_eq!(req.validate(), Some(ResponseCode::UnsupportedActivationType)); + } + + #[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(), 9); + assert_eq!(&bytes[0..2], &[0x0E, 0x80]); + assert_eq!(&bytes[2..4], &[0x10, 0x00]); + assert_eq!(bytes[4], 0x10); + } + + #[test] + fn serialize_response_with_oem() { + let mut resp = Response::success(0x0E80, 0x1000); + resp.oem_specific = Some(0x12345678); + let bytes = resp.to_bytes(); + + assert_eq!(bytes.len(), 13); + assert_eq!(&bytes[9..13], &[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(0xCAFEBABE); + let bytes = original.to_bytes(); + let parsed = Response::parse(&bytes).unwrap(); + assert_eq!(original, parsed); + } +} diff --git a/src/doip/vehicle_id.rs b/src/doip/vehicle_id.rs new file mode 100644 index 0000000..101e382 --- /dev/null +++ b/src/doip/vehicle_id.rs @@ -0,0 +1,331 @@ +//! Vehicle Identification handlers (ISO 13400-2) + +use bytes::{BufMut, Bytes, BytesMut}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + PayloadTooShort { expected: usize, actual: usize }, + InvalidVinLength(usize), + InvalidEidLength(usize), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PayloadTooShort { expected, actual } => { + write!(f, "payload too short: need {} bytes, got {}", expected, actual) + } + Self::InvalidVinLength(len) => write!(f, "VIN must be 17 bytes, got {}", len), + Self::InvalidEidLength(len) => write!(f, "EID must be 6 bytes, got {}", len), + } + } +} + +impl std::error::Error for Error {} + +// Vehicle Identification Request (0x0001) - no payload +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Request; + +impl Request { + pub fn parse(_payload: &[u8]) -> Result { + Ok(Self) + } +} + +// Vehicle Identification Request with EID (0x0002) - 6 byte EID +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestWithEid { + pub eid: [u8; 6], +} + +impl RequestWithEid { + pub const LEN: usize = 6; + + pub fn parse(payload: &[u8]) -> Result { + if payload.len() < Self::LEN { + return Err(Error::PayloadTooShort { + expected: Self::LEN, + actual: payload.len(), + }); + } + + let mut eid = [0u8; 6]; + eid.copy_from_slice(&payload[0..6]); + Ok(Self { eid }) + } + + pub fn new(eid: [u8; 6]) -> Self { + Self { eid } + } +} + +// Vehicle Identification Request with VIN (0x0003) - 17 byte VIN +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestWithVin { + pub vin: [u8; 17], +} + +impl RequestWithVin { + pub const LEN: usize = 17; + + pub fn parse(payload: &[u8]) -> Result { + if payload.len() < Self::LEN { + return Err(Error::PayloadTooShort { + expected: Self::LEN, + actual: payload.len(), + }); + } + + let mut vin = [0u8; 17]; + vin.copy_from_slice(&payload[0..17]); + Ok(Self { vin }) + } + + pub fn new(vin: [u8; 17]) -> Self { + Self { vin } + } + + pub fn vin_string(&self) -> String { + String::from_utf8_lossy(&self.vin).to_string() + } +} + +// Further action codes for vehicle identification response +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum FurtherAction { + NoFurtherAction = 0x00, + RoutingActivationRequired = 0x10, +} + +// Sync status for vehicle identification response +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum SyncStatus { + Synchronized = 0x00, + NotSynchronized = 0x10, +} + +// Vehicle Identification Response (0x0004) +// VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1) = 32 bytes min +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub vin: [u8; 17], + pub logical_address: u16, + pub eid: [u8; 6], + pub gid: [u8; 6], + pub further_action: FurtherAction, + pub sync_status: Option, +} + +impl Response { + pub const MIN_LEN: usize = 32; // without sync status + pub const MAX_LEN: usize = 33; // with sync status + + 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, + } + } + + pub fn with_routing_required(mut self) -> Self { + self.further_action = FurtherAction::RoutingActivationRequired; + self + } + + pub fn with_sync_status(mut self, status: SyncStatus) -> Self { + self.sync_status = Some(status); + self + } + + pub fn to_bytes(&self) -> Bytes { + let len = if self.sync_status.is_some() { Self::MAX_LEN } else { Self::MIN_LEN }; + let mut buf = BytesMut::with_capacity(len); + self.write_to(&mut buf); + buf.freeze() + } + + pub 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(self.further_action as u8); + if let Some(status) = self.sync_status { + buf.put_u8(status as u8); + } + } + + pub fn parse(payload: &[u8]) -> Result { + if payload.len() < Self::MIN_LEN { + return Err(Error::PayloadTooShort { + expected: Self::MIN_LEN, + actual: payload.len(), + }); + } + + let mut vin = [0u8; 17]; + vin.copy_from_slice(&payload[0..17]); + + let logical_address = u16::from_be_bytes([payload[17], payload[18]]); + + let mut eid = [0u8; 6]; + eid.copy_from_slice(&payload[19..25]); + + let mut gid = [0u8; 6]; + gid.copy_from_slice(&payload[25..31]); + + let further_action = match payload[31] { + 0x10 => FurtherAction::RoutingActivationRequired, + _ => FurtherAction::NoFurtherAction, + }; + + let sync_status = if payload.len() >= Self::MAX_LEN { + Some(match payload[32] { + 0x10 => SyncStatus::NotSynchronized, + _ => SyncStatus::Synchronized, + }) + } else { + None + }; + + Ok(Self { + vin, + logical_address, + eid, + gid, + further_action, + sync_status, + }) + } + + pub fn vin_string(&self) -> String { + String::from_utf8_lossy(&self.vin).to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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(), 32); + assert_eq!(&bytes[0..17], b"WVWZZZ3CZWE123456"); + assert_eq!(&bytes[17..19], &[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(), 33); + assert_eq!(bytes[32], 0x00); // 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); + } +} From 49e0f7e2002e40d4aee818e4f1c29c8d57a24851 Mon Sep 17 00:00:00 2001 From: vinayrs Date: Mon, 2 Feb 2026 18:51:00 +0530 Subject: [PATCH 03/17] feat(error): add DoIP error types and NRC codes --- src/error.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/error.rs diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..58ec31f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,139 @@ +//! Error Types for DoIP Server (ISO 13400-2 & ISO 14229) + +use std::io; +use thiserror::Error; + +/// Result type alias +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}")] + Config(String), + + #[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("Message too large: {size} bytes (max: {max})")] + MessageTooLarge { size: usize, max: usize }, + + #[error("Routing activation failed: {message}")] + RoutingActivationFailed { code: u8, message: String }, + + #[error("Session not found")] + SessionNotFound, + + #[error("Session closed")] + SessionClosed, + + #[error("Timeout: {0}")] + Timeout(String), + + #[error("UDS error: service 0x{service:02X}, NRC 0x{nrc:02X}")] + UdsError { service: u8, nrc: u8 }, +} + +/// Generic Header NACK codes +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum GenericNackCode { + IncorrectPatternFormat = 0x00, + UnknownPayloadType = 0x01, + MessageTooLarge = 0x02, + OutOfMemory = 0x03, + InvalidPayloadLength = 0x04, +} + +impl GenericNackCode { + pub const fn as_u8(self) -> u8 { self as u8 } +} + +/// Routing Activation Response codes +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum RoutingActivationCode { + UnknownSourceAddress = 0x00, + AllSocketsRegistered = 0x01, + DifferentSourceAddress = 0x02, + SourceAddressAlreadyActive = 0x03, + MissingAuthentication = 0x04, + RejectedConfirmation = 0x05, + UnsupportedActivationType = 0x06, + SuccessfullyActivated = 0x10, + ConfirmationRequired = 0x11, +} + +impl RoutingActivationCode { + pub const fn as_u8(self) -> u8 { self as u8 } + pub const fn is_success(self) -> bool { + matches!(self, Self::SuccessfullyActivated | Self::ConfirmationRequired) + } +} + +/// Diagnostic Message NACK codes +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum DiagnosticNackCode { + InvalidSourceAddress = 0x02, + UnknownTargetAddress = 0x03, + DiagnosticMessageTooLarge = 0x04, + OutOfMemory = 0x05, + TargetUnreachable = 0x06, + UnknownNetwork = 0x07, + TransportProtocolError = 0x08, +} + +impl DiagnosticNackCode { + pub const fn as_u8(self) -> u8 { self as u8 } +} + +/// UDS Negative Response Codes +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub 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, +} + +impl UdsNrc { + pub 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); + } +} \ No newline at end of file From 2208cbb4e085f584098c0ef773d77cc8bc60864d Mon Sep 17 00:00:00 2001 From: vinayrs Date: Mon, 2 Feb 2026 18:51:13 +0530 Subject: [PATCH 04/17] feat(uds): add UdsHandler trait for pluggable backends --- src/uds/handler.rs | 128 +++++++++++++++++++++++++++++++++++++++++++++ src/uds/mod.rs | 7 +++ 2 files changed, 135 insertions(+) create mode 100644 src/uds/handler.rs create mode 100644 src/uds/mod.rs diff --git a/src/uds/handler.rs b/src/uds/handler.rs new file mode 100644 index 0000000..3d4f50f --- /dev/null +++ b/src/uds/handler.rs @@ -0,0 +1,128 @@ +//! 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 request extracted from DoIP diagnostic message +#[derive(Debug, Clone)] +pub struct UdsRequest { + pub source_address: u16, + pub target_address: u16, + pub data: Bytes, +} + +impl UdsRequest { + pub fn new(source: u16, target: u16, data: Bytes) -> Self { + Self { + source_address: source, + target_address: target, + data, + } + } + + pub fn service_id(&self) -> Option { + self.data.first().copied() + } +} + +/// UDS response to be wrapped in DoIP diagnostic message +#[derive(Debug, Clone)] +pub struct UdsResponse { + pub source_address: u16, + pub target_address: u16, + pub data: Bytes, +} + +impl UdsResponse { + pub fn new(source: u16, target: u16, data: Bytes) -> Self { + Self { + source_address: source, + target_address: target, + data, + } + } +} + +/// 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 { + fn handle(&self, request: UdsRequest) -> UdsResponse; +} + +/// Stub handler that returns NRC 0x11 (Service Not Supported) for all requests +#[derive(Debug, Default, Clone)] +pub struct StubHandler; + +impl UdsHandler for StubHandler { + fn handle(&self, request: UdsRequest) -> UdsResponse { + let sid = request.service_id().unwrap_or(0); + // Negative response: 0x7F + SID + NRC + let nrc_service_not_supported = 0x11; + let data = Bytes::from(vec![0x7F, sid, nrc_service_not_supported]); + + UdsResponse::new( + request.target_address, + request.source_address, + data, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stub_handler_returns_nrc() { + let handler = StubHandler; + let request = UdsRequest::new( + 0x0E00, + 0x1000, + Bytes::from(vec![0x22, 0xF1, 0x90]), // ReadDataByIdentifier + ); + + let response = handler.handle(request); + + assert_eq!(response.source_address, 0x1000); + assert_eq!(response.target_address, 0x0E00); + assert_eq!(response.data.as_ref(), &[0x7F, 0x22, 0x11]); + } + + #[test] + fn stub_handler_handles_empty_request() { + let handler = StubHandler; + let request = UdsRequest::new(0x0E00, 0x1000, Bytes::new()); + + let response = handler.handle(request); + + assert_eq!(response.data.as_ref(), &[0x7F, 0x00, 0x11]); + } + + #[test] + fn stub_handler_tester_present() { + let handler = StubHandler; + let request = UdsRequest::new( + 0x0E00, + 0x1000, + Bytes::from(vec![0x3E, 0x00]), // TesterPresent + ); + + let response = handler.handle(request); + + assert_eq!(response.data.as_ref(), &[0x7F, 0x3E, 0x11]); + } + + #[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); + } +} diff --git a/src/uds/mod.rs b/src/uds/mod.rs new file mode 100644 index 0000000..be783e7 --- /dev/null +++ b/src/uds/mod.rs @@ -0,0 +1,7 @@ +//! UDS Module +//! +//! Provides the interface between DoIP transport and UDS processing. + +pub mod handler; + +pub use handler::{UdsHandler, UdsRequest, UdsResponse, StubHandler}; From 1d5e99a6cf6ac6f6a0a6d1bb48d43e3d7d2c51b6 Mon Sep 17 00:00:00 2001 From: vinayrs Date: Wed, 25 Feb 2026 18:15:43 +0530 Subject: [PATCH 05/17] refactor(doip): address PR review comments and Improvement - Consolidate errors into DoipError; add TryFrom on all enums - Strongly type activation_type field (ActivationType, not u8) - Add DoipParseable / DoipSerializable traits + DoipPayload dispatch enum - Override serialized_len() on all impls; fix array size magic numbers - Make ParseError pub(crate); add serde(default), tracing --- Cargo.lock | 872 ++++++++++++++++++++++++++++++++- src/doip/alive_check.rs | 94 ++-- src/doip/diagnostic_message.rs | 462 ++++++++++------- src/doip/header_parser.rs | 517 +++++++++++++------ src/doip/hearder_parser.rs | 378 -------------- src/doip/mod.rs | 87 +++- src/doip/payload.rs | 253 ++++++++++ src/doip/routing_activation.rs | 309 ++++++------ src/doip/vehicle_id.rs | 278 ++++++----- src/error.rs | 219 ++++++--- src/lib.rs | 16 + src/server/config.rs | 317 ++++++++++++ src/server/session.rs | 186 +++++++ src/uds/handler.rs | 130 +++-- src/uds/mod.rs | 21 +- 15 files changed, 2986 insertions(+), 1153 deletions(-) delete mode 100644 src/doip/hearder_parser.rs create mode 100644 src/doip/payload.rs create mode 100644 src/server/config.rs create mode 100644 src/server/session.rs diff --git a/Cargo.lock b/Cargo.lock index 55e3925..d705ef4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,5 +3,875 @@ version = 4 [[package]] -name = "uds2sovd-proxy" +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "doip-server" version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "clap", + "dashmap", + "parking_lot", + "serde", + "thiserror", + "tokio", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +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 = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "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.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" diff --git a/src/doip/alive_check.rs b/src/doip/alive_check.rs index aed545a..358834a 100644 --- a/src/doip/alive_check.rs +++ b/src/doip/alive_check.rs @@ -1,37 +1,39 @@ +/* + * 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, Bytes, BytesMut}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Error { - PayloadTooShort { expected: usize, actual: usize }, -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::PayloadTooShort { expected, actual } => { - write!(f, "payload too short: need {} bytes, got {}", expected, actual) - } - } - } -} - -impl std::error::Error for Error {} +use bytes::{BufMut, BytesMut}; +use tracing::warn; +use crate::DoipError; +use super::{DoipParseable, DoipSerializable}; // Alive Check Request (0x0007) - no payload // Server sends this to check if tester is still connected #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Request; -impl Request { - pub fn parse(_payload: &[u8]) -> Result { +impl DoipParseable for Request { + fn parse(_payload: &[u8]) -> std::result::Result { Ok(Self) } +} - pub fn to_bytes(&self) -> Bytes { - Bytes::new() +impl DoipSerializable for Request { + fn serialized_len(&self) -> Option { + Some(0) } + + fn write_to(&self, _buf: &mut BytesMut) {} } // Alive Check Response (0x0008) - 2 byte source address @@ -41,39 +43,45 @@ pub struct Response { pub source_address: u16, } -impl Response { - pub const LEN: usize = 2; - - pub fn new(source_address: u16) -> Self { - Self { source_address } - } - - pub fn parse(payload: &[u8]) -> Result { - if payload.len() < Self::LEN { - return Err(Error::PayloadTooShort { - expected: Self::LEN, - actual: payload.len(), - }); - } - - let source_address = u16::from_be_bytes([payload[0], payload[1]]); +impl DoipParseable for Response { + fn parse(payload: &[u8]) -> std::result::Result { + let bytes: [u8; 2] = payload + .get(..Self::LEN) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + let e = DoipError::PayloadTooShort { expected: Self::LEN, actual: payload.len() }; + warn!("AliveCheck Response parse failed: {}", e); + e + })?; + + let source_address = u16::from_be_bytes(bytes); Ok(Self { source_address }) } +} - pub fn to_bytes(&self) -> Bytes { - let mut buf = BytesMut::with_capacity(Self::LEN); - self.write_to(&mut buf); - buf.freeze() +impl DoipSerializable for Response { + fn serialized_len(&self) -> Option { + Some(Self::LEN) } - pub fn write_to(&self, buf: &mut BytesMut) { + fn write_to(&self, buf: &mut BytesMut) { buf.put_u16(self.source_address); } } +impl Response { + pub const LEN: usize = 2; + + #[must_use] + pub fn new(source_address: u16) -> Self { + Self { source_address } + } +} + #[cfg(test)] mod tests { use super::*; + use crate::doip::{DoipParseable, DoipSerializable}; #[test] fn parse_request() { diff --git a/src/doip/diagnostic_message.rs b/src/doip/diagnostic_message.rs index 5f31aa1..2ed0988 100644 --- a/src/doip/diagnostic_message.rs +++ b/src/doip/diagnostic_message.rs @@ -1,15 +1,35 @@ -//! Diagnostic Message handlers (ISO 13400-2) +/* + * 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::{Buf, BufMut, Bytes, BytesMut}; +use tracing::warn; +use crate::DoipError; +use super::{DoipParseable, DoipSerializable, too_short, check_min_len}; -// Diagnostic message positive ack codes per ISO 13400-2 Table 27 +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; + +// Diagnostic message positive ack codes per ISO 13400-2:2019 Table 27 #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum AckCode { Acknowledged = 0x00, } -// Diagnostic message negative ack codes per ISO 13400-2 Table 28 +// Diagnostic message negative ack codes per ISO 13400-2:2019 Table 28 #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum NackCode { @@ -22,52 +42,47 @@ pub enum NackCode { TransportProtocolError = 0x08, } -impl NackCode { - pub fn from_u8(value: u8) -> Option { - match value { - 0x02 => Some(Self::InvalidSourceAddress), - 0x03 => Some(Self::UnknownTargetAddress), - 0x04 => Some(Self::DiagnosticMessageTooLarge), - 0x05 => Some(Self::OutOfMemory), - 0x06 => Some(Self::TargetUnreachable), - 0x07 => Some(Self::UnknownNetwork), - 0x08 => Some(Self::TransportProtocolError), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Error { - PayloadTooShort { expected: usize, actual: usize }, - EmptyUserData, -} +impl TryFrom for NackCode { + type Error = DoipError; -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::PayloadTooShort { expected, actual } => { - write!(f, "payload too short: need {} bytes, got {}", expected, actual) - } - Self::EmptyUserData => write!(f, "diagnostic message has no user data"), + 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 std::error::Error for Error {} - -// Diagnostic Message - carries UDS data between tester and ECU -// Payload: SA(2) + TA(2) + user_data(1+) +/// 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 { - pub source_address: u16, - pub target_address: u16, - pub user_data: Bytes, + source_address: u16, + target_address: u16, + user_data: Bytes, } impl Message { - pub const MIN_LEN: usize = 5; // SA + TA + at least 1 byte UDS - + /// 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, @@ -76,83 +91,56 @@ impl Message { } } - pub fn parse(payload: &[u8]) -> Result { - if payload.len() < Self::MIN_LEN { - return Err(Error::PayloadTooShort { - expected: Self::MIN_LEN, - actual: payload.len(), - }); - } - - let source_address = u16::from_be_bytes([payload[0], payload[1]]); - let target_address = u16::from_be_bytes([payload[2], payload[3]]); - let user_data = Bytes::copy_from_slice(&payload[4..]); - - if user_data.is_empty() { - return Err(Error::EmptyUserData); - } - - Ok(Self { - source_address, - target_address, - user_data, - }) + /// Get the source address + pub fn source_address(&self) -> u16 { + self.source_address } - pub fn parse_buf(buf: &mut Bytes) -> Result { - if buf.len() < Self::MIN_LEN { - return Err(Error::PayloadTooShort { - expected: Self::MIN_LEN, - actual: buf.len(), - }); - } - - let source_address = buf.get_u16(); - let target_address = buf.get_u16(); - let user_data = buf.split_off(0); - - if user_data.is_empty() { - return Err(Error::EmptyUserData); - } - - Ok(Self { - source_address, - target_address, - user_data, - }) + /// Get the target address + pub fn target_address(&self) -> u16 { + self.target_address } - pub fn to_bytes(&self) -> Bytes { - let mut buf = BytesMut::with_capacity(4 + self.user_data.len()); - self.write_to(&mut buf); - buf.freeze() + /// Get the UDS user data + pub fn user_data(&self) -> &Bytes { + &self.user_data } - pub 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); + /// Parse a Diagnostic Message from a mutable Bytes buffer + /// + /// # Errors + /// Returns [`DoipError::PayloadTooShort`] if buffer is less than 5 bytes + /// Returns [`DoipError::EmptyUserData`] if no UDS data is present + pub fn parse_buf(buf: &mut Bytes) -> std::result::Result { + let msg = ::parse(buf)?; + buf.advance(buf.len()); + Ok(msg) } - // UDS service ID is first byte of user_data pub fn service_id(&self) -> Option { self.user_data.first().copied() } } -// Diagnostic Message Positive Ack (0x8002) -// Payload: SA(2) + TA(2) + ack_code(1) + optional previous_diag_data +/// Diagnostic Message Positive Acknowledgment (message type 0x8002) +/// +/// Sent by a DoIP entity to acknowledge receipt of a diagnostic message. +/// +/// # Wire Format +/// Payload: SA(2) + TA(2) + ack_code(1) + optional previous_diag_data #[derive(Debug, Clone, PartialEq, Eq)] pub struct PositiveAck { - pub source_address: u16, - pub target_address: u16, - pub ack_code: AckCode, - pub previous_data: Option, + source_address: u16, + target_address: u16, + ack_code: AckCode, + previous_data: Option, } impl PositiveAck { - pub const MIN_LEN: usize = 5; + /// Minimum positive ack length in bytes + pub const MIN_LEN: usize = HEADER_BYTES + ACK_CODE_BYTES; + /// Create a new positive acknowledgment pub fn new(source: u16, target: u16) -> Self { Self { source_address: source, @@ -162,71 +150,58 @@ impl PositiveAck { } } + /// Create a positive acknowledgment with previous diagnostic data pub fn with_previous_data(source: u16, target: u16, data: Bytes) -> Self { Self { source_address: source, target_address: target, ack_code: AckCode::Acknowledged, - previous_data: Some(data), + // ISO 13400-2:2019: previous diagnostic data is optional; treat empty as absent + previous_data: if data.is_empty() { None } else { Some(data) }, } } - pub fn to_bytes(&self) -> Bytes { - let extra = self.previous_data.as_ref().map(|d| d.len()).unwrap_or(0); - let mut buf = BytesMut::with_capacity(Self::MIN_LEN + extra); - self.write_to(&mut buf); - buf.freeze() + /// Get the source address + pub fn source_address(&self) -> u16 { + self.source_address } - pub fn write_to(&self, buf: &mut BytesMut) { - buf.put_u16(self.source_address); - buf.put_u16(self.target_address); - buf.put_u8(self.ack_code as u8); - if let Some(ref data) = self.previous_data { - buf.extend_from_slice(data); - } + /// Get the target address + pub fn target_address(&self) -> u16 { + self.target_address } - pub fn parse(payload: &[u8]) -> Result { - if payload.len() < Self::MIN_LEN { - return Err(Error::PayloadTooShort { - expected: Self::MIN_LEN, - actual: payload.len(), - }); - } - - let source_address = u16::from_be_bytes([payload[0], payload[1]]); - let target_address = u16::from_be_bytes([payload[2], payload[3]]); - let ack_code = AckCode::Acknowledged; // only one value - - let previous_data = if payload.len() > Self::MIN_LEN { - Some(Bytes::copy_from_slice(&payload[5..])) - } else { - None - }; + /// Get the acknowledgment code + pub fn ack_code(&self) -> AckCode { + self.ack_code + } - Ok(Self { - source_address, - target_address, - ack_code, - previous_data, - }) + /// Get the previous diagnostic data + pub fn previous_data(&self) -> Option<&Bytes> { + self.previous_data.as_ref() } } -// Diagnostic Message Negative Ack (0x8003) -// Payload: SA(2) + TA(2) + nack_code(1) + optional previous_diag_data +/// Diagnostic Message Negative Acknowledgment (message type 0x8003) +/// +/// Sent by a DoIP entity to indicate rejection of a diagnostic message. +/// NACK codes are defined in ISO 13400-2:2019 Table 28. +/// +/// # Wire Format +/// Payload: SA(2) + TA(2) + nack_code(1) + optional previous_diag_data #[derive(Debug, Clone, PartialEq, Eq)] pub struct NegativeAck { - pub source_address: u16, - pub target_address: u16, - pub nack_code: NackCode, - pub previous_data: Option, + source_address: u16, + target_address: u16, + nack_code: NackCode, + previous_data: Option, } impl NegativeAck { - pub const MIN_LEN: usize = 5; + /// Minimum negative ack length in bytes + pub const MIN_LEN: usize = HEADER_BYTES + ACK_CODE_BYTES; + /// Create a new negative acknowledgment pub fn new(source: u16, target: u16, code: NackCode) -> Self { Self { source_address: source, @@ -236,49 +211,155 @@ impl NegativeAck { } } + /// 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 negative acknowledgment code + pub fn nack_code(&self) -> NackCode { + self.nack_code + } + + /// Get the previous diagnostic data + pub fn previous_data(&self) -> Option<&Bytes> { + self.previous_data.as_ref() + } + + /// Create a negative acknowledgment with previous diagnostic data pub fn with_previous_data(source: u16, target: u16, code: NackCode, data: Bytes) -> Self { Self { source_address: source, target_address: target, nack_code: code, - previous_data: Some(data), + // ISO 13400-2:2019: previous diagnostic data is optional; treat empty as absent + previous_data: if data.is_empty() { None } else { Some(data) }, } } +} + +impl DoipParseable for Message { + fn parse(payload: &[u8]) -> std::result::Result { + let header: [u8; HEADER_BYTES] = payload + .get(..HEADER_BYTES) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + let e = too_short(payload, Self::MIN_LEN); + warn!("DiagnosticMessage parse failed: {}", e); + e + })?; + + 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); + warn!("DiagnosticMessage parse failed: {}", e); + e + })?; - pub fn to_bytes(&self) -> Bytes { - let extra = self.previous_data.as_ref().map(|d| d.len()).unwrap_or(0); - let mut buf = BytesMut::with_capacity(Self::MIN_LEN + extra); - self.write_to(&mut buf); - buf.freeze() + if user_data.is_empty() { + warn!("DiagnosticMessage parse failed: empty user data"); + return Err(DoipError::EmptyUserData); + } + + Ok(Self { + source_address, + target_address, + user_data, + }) + } +} + +impl DoipSerializable for Message { + fn serialized_len(&self) -> Option { + Some(HEADER_BYTES + self.user_data.len()) } - pub fn write_to(&self, buf: &mut BytesMut) { + fn write_to(&self, buf: &mut BytesMut) { buf.put_u16(self.source_address); buf.put_u16(self.target_address); - buf.put_u8(self.nack_code as u8); + buf.extend_from_slice(&self.user_data); + } +} + +impl DoipParseable for PositiveAck { + fn parse(payload: &[u8]) -> std::result::Result { + if let Err(e) = check_min_len(payload, Self::MIN_LEN) { + warn!("DiagnosticPositiveAck parse failed: {}", e); + return Err(e); + } + + let header: [u8; HEADER_BYTES] = payload + .get(..HEADER_BYTES) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; + + let source_address = u16::from_be_bytes([header[0], header[1]]); + let target_address = u16::from_be_bytes([header[2], header[3]]); + let ack_code = AckCode::Acknowledged; + + let previous_data = payload + .get(Self::MIN_LEN..) + .filter(|d| !d.is_empty()) + .map(Bytes::copy_from_slice); + + Ok(Self { + source_address, + target_address, + ack_code, + previous_data, + }) + } +} + +impl DoipSerializable for PositiveAck { + fn serialized_len(&self) -> Option { + Some(Self::MIN_LEN + self.previous_data.as_ref().map_or(0, |d| d.len())) + } + + fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.source_address); + buf.put_u16(self.target_address); + buf.put_u8(self.ack_code as u8); if let Some(ref data) = self.previous_data { buf.extend_from_slice(data); } } +} - pub fn parse(payload: &[u8]) -> Result { - if payload.len() < Self::MIN_LEN { - return Err(Error::PayloadTooShort { - expected: Self::MIN_LEN, - actual: payload.len(), - }); +impl DoipParseable for NegativeAck { + fn parse(payload: &[u8]) -> std::result::Result { + if let Err(e) = check_min_len(payload, Self::MIN_LEN) { + warn!("DiagnosticNegativeAck parse failed: {}", e); + return Err(e); } - let source_address = u16::from_be_bytes([payload[0], payload[1]]); - let target_address = u16::from_be_bytes([payload[2], payload[3]]); - let nack_code = NackCode::from_u8(payload[4]) + let header: [u8; HEADER_BYTES] = payload + .get(..HEADER_BYTES) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; + + let source_address = u16::from_be_bytes([header[0], header[1]]); + let target_address = u16::from_be_bytes([header[2], header[3]]); + let nack_code = payload + .get(HEADER_BYTES) + .copied() + .and_then(|b| NackCode::try_from(b).ok()) .unwrap_or(NackCode::TransportProtocolError); - let previous_data = if payload.len() > Self::MIN_LEN { - Some(Bytes::copy_from_slice(&payload[5..])) - } else { - None - }; + let previous_data = payload + .get(Self::MIN_LEN..) + .filter(|d| !d.is_empty()) + .map(Bytes::copy_from_slice); Ok(Self { source_address, @@ -289,9 +370,25 @@ impl NegativeAck { } } +impl DoipSerializable for NegativeAck { + fn serialized_len(&self) -> Option { + Some(Self::MIN_LEN + self.previous_data.as_ref().map_or(0, |d| d.len())) + } + + fn write_to(&self, buf: &mut BytesMut) { + buf.put_u16(self.source_address); + buf.put_u16(self.target_address); + buf.put_u8(self.nack_code as u8); + if let Some(ref data) = self.previous_data { + buf.extend_from_slice(data); + } + } +} + #[cfg(test)] mod tests { use super::*; + use crate::doip::{DoipParseable, DoipSerializable}; #[test] fn nack_code_values() { @@ -306,9 +403,9 @@ mod tests { 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.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)); } @@ -319,7 +416,7 @@ mod tests { let msg = Message::parse(&payload).unwrap(); assert_eq!(msg.service_id(), Some(0x3E)); - assert_eq!(msg.user_data.len(), 2); + assert_eq!(msg.user_data().len(), 2); } #[test] @@ -334,9 +431,9 @@ mod tests { let msg = Message::new(0x0E80, 0x1000, uds); let bytes = msg.to_bytes(); - assert_eq!(&bytes[0..2], &[0x0E, 0x80]); - assert_eq!(&bytes[2..4], &[0x10, 0x00]); - assert_eq!(&bytes[4..], &[0x22, 0xF1, 0x90]); + 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] @@ -344,20 +441,21 @@ mod tests { let ack = PositiveAck::new(0x1000, 0x0E80); let bytes = ack.to_bytes(); - assert_eq!(bytes.len(), 5); - assert_eq!(&bytes[0..2], &[0x10, 0x00]); // source (ECU) - assert_eq!(&bytes[2..4], &[0x0E, 0x80]); // target (tester) - assert_eq!(bytes[4], 0x00); // ack code + assert_eq!(bytes.len(), PositiveAck::MIN_LEN); + assert_eq!(&bytes[..ADDRESS_BYTES], &[0x10, 0x00]); // source (ECU) + assert_eq!(&bytes[ADDRESS_BYTES..HEADER_BYTES], &[0x0E, 0x80]); // target (tester) + assert_eq!(bytes[HEADER_BYTES], 0x00); // ack code } #[test] fn build_positive_ack_with_prev_data() { let prev = Bytes::from_static(&[0x22, 0xF1, 0x90]); + let expected_len = PositiveAck::MIN_LEN + prev.len(); let ack = PositiveAck::with_previous_data(0x1000, 0x0E80, prev); let bytes = ack.to_bytes(); - assert_eq!(bytes.len(), 8); - assert_eq!(&bytes[5..], &[0x22, 0xF1, 0x90]); + assert_eq!(bytes.len(), expected_len); + assert_eq!(&bytes[PositiveAck::MIN_LEN..], &[0x22, 0xF1, 0x90]); } #[test] @@ -365,15 +463,15 @@ mod tests { let nack = NegativeAck::new(0x1000, 0x0E80, NackCode::UnknownTargetAddress); let bytes = nack.to_bytes(); - assert_eq!(bytes.len(), 5); - assert_eq!(bytes[4], 0x03); + assert_eq!(bytes.len(), NegativeAck::MIN_LEN); + assert_eq!(bytes[HEADER_BYTES], 0x03); } #[test] fn build_negative_ack_target_unreachable() { let nack = NegativeAck::new(0x1000, 0x0E80, NackCode::TargetUnreachable); let bytes = nack.to_bytes(); - assert_eq!(bytes[4], 0x06); + assert_eq!(bytes[HEADER_BYTES], 0x06); } #[test] @@ -381,9 +479,9 @@ mod tests { let payload = [0x10, 0x00, 0x0E, 0x80, 0x00]; let ack = PositiveAck::parse(&payload).unwrap(); - assert_eq!(ack.source_address, 0x1000); - assert_eq!(ack.target_address, 0x0E80); - assert!(ack.previous_data.is_none()); + assert_eq!(ack.source_address(), 0x1000); + assert_eq!(ack.target_address(), 0x0E80); + assert!(ack.previous_data().is_none()); } #[test] @@ -391,7 +489,7 @@ mod tests { let payload = [0x10, 0x00, 0x0E, 0x80, 0x03]; let nack = NegativeAck::parse(&payload).unwrap(); - assert_eq!(nack.nack_code, NackCode::UnknownTargetAddress); + assert_eq!(nack.nack_code(), NackCode::UnknownTargetAddress); } #[test] diff --git a/src/doip/header_parser.rs b/src/doip/header_parser.rs index f489da3..1da76b2 100644 --- a/src/doip/header_parser.rs +++ b/src/doip/header_parser.rs @@ -1,9 +1,43 @@ -//! DoIP Header Parser +/* + * 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 Parser and Codec +//! +//! This module implements the core `DoIP` protocol header parsing and message framing +//! according to ISO 13400-2:2019. It provides: +//! +//! - **Header Validation**: Checks protocol version, payload types, and message sizes +//! - **Message Framing**: Tokio codec for TCP stream processing with proper buffering +//! - **Type Safety**: Strongly-typed payload types and error codes per ISO specification +//! - **`DoS` Protection**: Configurable max message size limits (default 4MB) +//! +//! 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. use bytes::{Buf, BufMut, Bytes, BytesMut}; use std::io; use tokio_util::codec::{Decoder, Encoder}; +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 { @@ -14,38 +48,59 @@ pub enum GenericNackCode { InvalidPayloadLength = 0x04, } -#[derive(Debug)] -pub enum ParseError { - InvalidHeader(String), - Io(io::Error), -} +impl TryFrom for GenericNackCode { + type Error = u8; -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidHeader(msg) => write!(f, "Invalid header: {}", msg), - Self::Io(e) => write!(f, "IO error: {}", e), + 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), } } } -impl std::error::Error for ParseError {} - -impl From for ParseError { - fn from(e: io::Error) -> Self { - Self::Io(e) - } +#[derive(thiserror::Error, Debug)] +pub(crate) enum ParseError { + #[error("Invalid header: {0}")] + InvalidHeader(String), + /// Payload length exceeds `u32::MAX` (DoIP protocol limit is 4 MB) + #[error("DoIP payload exceeds u32::MAX (protocol limit is 4 MB)")] + PayloadTooLarge, + #[error("IO error: {0}")] + IoError(#[from] io::Error), } -pub type Result = std::result::Result; +pub(crate) type Result = std::result::Result; +/// `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; -pub const DEFAULT_PROTOCOL_VERSION_INV: u8 = 0xFD; +/// `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(). +/// 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 { @@ -67,51 +122,69 @@ pub enum PayloadType { DiagnosticMessageNegativeAck = 0x8003, } -impl PayloadType { - pub fn from_u16(value: u16) -> Option { +impl TryFrom for PayloadType { + type Error = u16; + + fn try_from(value: u16) -> std::result::Result { match value { - 0x0000 => Some(Self::GenericNack), - 0x0001 => Some(Self::VehicleIdentificationRequest), - 0x0002 => Some(Self::VehicleIdentificationRequestWithEid), - 0x0003 => Some(Self::VehicleIdentificationRequestWithVin), - 0x0004 => Some(Self::VehicleIdentificationResponse), - 0x0005 => Some(Self::RoutingActivationRequest), - 0x0006 => Some(Self::RoutingActivationResponse), - 0x0007 => Some(Self::AliveCheckRequest), - 0x0008 => Some(Self::AliveCheckResponse), - 0x4001 => Some(Self::DoipEntityStatusRequest), - 0x4002 => Some(Self::DoipEntityStatusResponse), - 0x4003 => Some(Self::DiagnosticPowerModeRequest), - 0x4004 => Some(Self::DiagnosticPowerModeResponse), - 0x8001 => Some(Self::DiagnosticMessage), - 0x8002 => Some(Self::DiagnosticMessagePositiveAck), - 0x8003 => Some(Self::DiagnosticMessageNegativeAck), - _ => None, + 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 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::GenericNack => 1, - Self::VehicleIdentificationRequest => 0, + 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::VehicleIdentificationRequestWithVin => 17, - Self::VehicleIdentificationResponse => 32, Self::RoutingActivationRequest => 7, Self::RoutingActivationResponse => 9, - Self::AliveCheckRequest => 0, - Self::AliveCheckResponse => 2, - Self::DoipEntityStatusRequest => 0, - Self::DoipEntityStatusResponse => 3, - Self::DiagnosticPowerModeRequest => 0, - Self::DiagnosticPowerModeResponse => 1, - Self::DiagnosticMessage => 5, - Self::DiagnosticMessagePositiveAck => 5, - Self::DiagnosticMessageNegativeAck => 5, + 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 { pub version: u8, @@ -121,26 +194,43 @@ pub struct DoipHeader { } impl DoipHeader { - pub fn parse(data: &[u8]) -> Result { - if data.len() < DOIP_HEADER_LENGTH { - return Err(ParseError::InvalidHeader(format!( - "header too short: expected {}, got {}", - DOIP_HEADER_LENGTH, - data.len() - ))); - } + /// Parse a `DoIP` header from a byte slice + /// + /// # Errors + /// Returns `ParseError::InvalidHeader` if data is less than 8 bytes + pub(crate) fn parse(data: &[u8]) -> Result { + let header: [u8; DOIP_HEADER_LENGTH] = data + .get(..DOIP_HEADER_LENGTH) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + ParseError::InvalidHeader(format!( + "DoIP header too short: expected {}, got {}", + DOIP_HEADER_LENGTH, + data.len() + )) + })?; + Ok(Self { - version: data[0], - inverse_version: data[1], - payload_type: u16::from_be_bytes([data[2], data[3]]), - payload_length: u32::from_be_bytes([data[4], data[5], data[6], data[7]]), + 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]]), }) } - pub fn parse_from_buf(buf: &mut Bytes) -> Result { + /// Parse a `DoIP` header from a mutable Bytes buffer + /// + /// # Errors + /// Returns `ParseError::InvalidHeader` if buffer is less than 8 bytes + // + // Used by the streaming decode path in the TCP/UDP server handlers + // (feature/doip-async-server). Not yet called in this branch because + // the server handler layer has not been ported here yet. + #[allow(dead_code)] + pub(crate) fn parse_from_buf(buf: &mut Bytes) -> Result { if buf.len() < DOIP_HEADER_LENGTH { return Err(ParseError::InvalidHeader(format!( - "header too short: expected {}, got {}", + "DoIP header too short: expected {}, got {}", DOIP_HEADER_LENGTH, buf.len() ))); @@ -154,44 +244,75 @@ impl DoipHeader { } pub fn validate(&self) -> Option { - if self.version != DEFAULT_PROTOCOL_VERSION { - return Some(GenericNackCode::IncorrectPatternFormat); - } - if self.inverse_version != DEFAULT_PROTOCOL_VERSION_INV { + debug!( + "Validating DoIP header: version=0x{:02X}, inverse=0x{:02X}, type=0x{:04X}, len={}", + self.version, self.inverse_version, self.payload_type, self.payload_length + ); + + // 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!("Invalid DoIP version: 0x{:02X}", self.version); return Some(GenericNackCode::IncorrectPatternFormat); } - if self.version ^ self.inverse_version != 0xFF { + + // Check version XOR inverse_version == DOIP_HEADER_VERSION_MASK (0xFF) + if self.version ^ self.inverse_version != DOIP_HEADER_VERSION_MASK { + warn!( + "Version/inverse mismatch: 0x{:02X} ^ 0x{:02X} = 0x{:02X} (expected 0x{:02X})", + self.version, + self.inverse_version, + self.version ^ self.inverse_version, + DOIP_HEADER_VERSION_MASK, + ); return Some(GenericNackCode::IncorrectPatternFormat); } - let payload_type = match PayloadType::from_u16(self.payload_type) { - Some(pt) => pt, - None => return Some(GenericNackCode::UnknownPayloadType), + let Some(payload_type) = PayloadType::try_from(self.payload_type).ok() else { + warn!("Unknown payload type: 0x{:04X}", self.payload_type); + return Some(GenericNackCode::UnknownPayloadType); }; if self.payload_length > MAX_DOIP_MESSAGE_SIZE { + warn!("Message too large: {} bytes", self.payload_length); return Some(GenericNackCode::MessageTooLarge); } if (self.payload_length as usize) < payload_type.min_payload_length() { + warn!( + "Payload too short for {:?}: {} < {}", + payload_type, + self.payload_length, + payload_type.min_payload_length() + ); return Some(GenericNackCode::InvalidPayloadLength); } + None } + #[must_use] pub fn is_valid(&self) -> bool { self.validate().is_none() } - pub const fn total_length(&self) -> usize { - DOIP_HEADER_LENGTH + self.payload_length as usize + /// Returns the total message length (header + payload) + #[must_use] + pub fn message_length(&self) -> usize { + DOIP_HEADER_LENGTH.saturating_add(self.payload_length as usize) } + /// Serialize header to bytes + #[must_use] pub fn to_bytes(&self) -> Bytes { let mut buf = BytesMut::with_capacity(DOIP_HEADER_LENGTH); - buf.put_u8(self.version); - buf.put_u8(self.inverse_version); - buf.put_u16(self.payload_type); - buf.put_u32(self.payload_length); + self.write_to(&mut buf); buf.freeze() } @@ -216,9 +337,10 @@ impl Default for DoipHeader { impl std::fmt::Display for DoipHeader { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let payload_name = PayloadType::from_u16(self.payload_type) - .map(|pt| format!("{:?}", pt)) - .unwrap_or_else(|| format!("Unknown(0x{:04X})", self.payload_type)); + 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: {} }}", @@ -227,6 +349,10 @@ impl std::fmt::Display for DoipHeader { } } +/// `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 header: DoipHeader, @@ -234,24 +360,69 @@ pub struct DoipMessage { } impl DoipMessage { - pub fn new(payload_type: PayloadType, payload: Bytes) -> Self { - Self { + /// Create a new `DoIP` message with the default protocol version. + /// + /// # Errors + /// + /// Returns [`ParseError::PayloadTooLarge`] if `payload.len()` exceeds `u32::MAX`. + /// In practice this cannot occur because the `DoIP` codec enforces a 4 MB message limit. + // + // Called by server handlers to build outgoing response messages. + // No production callers exist in this branch yet — server handlers live + // on feature/doip-async-server and have not been ported here. + #[allow(dead_code)] + pub(crate) fn new(payload_type: PayloadType, payload: Bytes) -> Result { + Ok(Self { header: DoipHeader { version: DEFAULT_PROTOCOL_VERSION, inverse_version: DEFAULT_PROTOCOL_VERSION_INV, payload_type: payload_type as u16, - payload_length: payload.len() as u32, + payload_length: u32::try_from(payload.len()) + .map_err(|_| ParseError::PayloadTooLarge)?, }, payload, - } + }) } - pub fn with_raw_type(payload_type: u16, payload: Bytes) -> Self { + /// Create a `DoIP` message with a specific protocol version. + /// + /// This mirrors the version from the incoming request. + /// + /// # Errors + /// + /// Returns [`ParseError::PayloadTooLarge`] if `payload.len()` exceeds `u32::MAX`. + /// In practice this cannot occur because the `DoIP` codec enforces a 4 MB message limit. + // + // Required by ISO 13400-2:2019: the server must mirror the client's protocol + // version in every response. No production callers exist in this branch yet — + // server handlers live on feature/doip-async-server and have not been ported here. + #[allow(dead_code)] + pub(crate) fn with_version(version: u8, payload_type: PayloadType, payload: Bytes) -> Result { + Ok(Self { + header: DoipHeader { + version, + inverse_version: version ^ DOIP_HEADER_VERSION_MASK, // Compute inverse + payload_type: payload_type as u16, + payload_length: u32::try_from(payload.len()) + .map_err(|_| ParseError::PayloadTooLarge)?, + }, + 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. + #[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, + // Safe cast: DoIP messages are limited to MAX_DOIP_MESSAGE_SIZE (4MB) + // which fits in u32. The codec enforces this limit during decode. payload_length: payload.len() as u32, }, payload, @@ -259,15 +430,17 @@ impl DoipMessage { } pub fn payload_type(&self) -> Option { - PayloadType::from_u16(self.header.payload_type) + PayloadType::try_from(self.header.payload_type).ok() } - pub fn total_length(&self) -> usize { - DOIP_HEADER_LENGTH + self.payload.len() + /// Returns the total message length (header + payload) + #[must_use] + pub fn message_length(&self) -> usize { + DOIP_HEADER_LENGTH.saturating_add(self.payload.len()) } pub fn to_bytes(&self) -> Bytes { - let mut buf = BytesMut::with_capacity(self.total_length()); + let mut buf = BytesMut::with_capacity(self.message_length()); self.header.write_to(&mut buf); buf.extend_from_slice(&self.payload); buf.freeze() @@ -287,6 +460,7 @@ pub struct DoipCodec { } impl DoipCodec { + #[must_use] pub fn new() -> Self { Self { state: DecodeState::Header, @@ -294,6 +468,11 @@ impl DoipCodec { } } + /// 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, @@ -312,6 +491,8 @@ impl Decoder for DoipCodec { type Item = DoipMessage; type Error = io::Error; + // Using fully qualified std::result::Result because the module-level Result + // type alias uses ParseError, but the Decoder trait requires io::Error. fn decode( &mut self, src: &mut BytesMut, @@ -320,17 +501,28 @@ impl Decoder for DoipCodec { 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); } - let header = DoipHeader::parse(&src[..DOIP_HEADER_LENGTH]) + // 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!("Received raw header bytes: {:02X?}", header_slice); + + let header = DoipHeader::parse(header_slice) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; if let Some(nack_code) = header.validate() { + warn!( + "Header validation failed: {:?} - raw bytes: {:02X?}", + nack_code, header_slice + ); return Err(io::Error::new( io::ErrorKind::InvalidData, - format!("validation failed: {:?}", nack_code), + format!("validation failed: {nack_code:?}"), )); } @@ -344,12 +536,16 @@ impl Decoder for DoipCodec { )); } - src.reserve(header.total_length()); + // Pre-allocate buffer for the complete message + src.reserve(header.message_length()); self.state = DecodeState::Payload(header); } DecodeState::Payload(header) => { - if src.len() < header.total_length() { + let total_len = header.message_length(); + 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); } @@ -372,7 +568,7 @@ impl Encoder for DoipCodec { item: DoipMessage, dst: &mut BytesMut, ) -> std::result::Result<(), Self::Error> { - dst.reserve(item.total_length()); + dst.reserve(item.message_length()); item.header.write_to(dst); dst.extend_from_slice(&item.payload); Ok(()) @@ -419,7 +615,7 @@ mod tests { 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 + assert_eq!(hdr.payload_length, 7); // 2+2+3 = SA+TA+UDS } #[test] @@ -453,14 +649,17 @@ mod tests { #[test] fn reject_wrong_protocol_version() { - // Someone sends version 0x03 - we only support 0x02 + // Someone sends version 0x04 - we only support 0x01, 0x02, 0x03, 0xFF let hdr = DoipHeader { - version: 0x03, - inverse_version: 0xFC, + version: 0x04, + inverse_version: 0xFB, payload_type: 0x0001, payload_length: 0, }; - assert_eq!(hdr.validate(), Some(GenericNackCode::IncorrectPatternFormat)); + assert_eq!( + hdr.validate(), + Some(GenericNackCode::IncorrectPatternFormat) + ); } #[test] @@ -472,7 +671,10 @@ mod tests { payload_type: 0x0001, payload_length: 0, }; - assert_eq!(hdr.validate(), Some(GenericNackCode::IncorrectPatternFormat)); + assert_eq!( + hdr.validate(), + Some(GenericNackCode::IncorrectPatternFormat) + ); } #[test] @@ -508,26 +710,44 @@ mod tests { #[test] fn payload_type_lookup_works() { - assert_eq!(PayloadType::from_u16(0x0001), Some(PayloadType::VehicleIdentificationRequest)); - assert_eq!(PayloadType::from_u16(0x0005), Some(PayloadType::RoutingActivationRequest)); - assert_eq!(PayloadType::from_u16(0x8001), Some(PayloadType::DiagnosticMessage)); - assert_eq!(PayloadType::from_u16(0x8002), Some(PayloadType::DiagnosticMessagePositiveAck)); + 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::from_u16(0x0009).is_none()); - assert!(PayloadType::from_u16(0x4000).is_none()); - assert!(PayloadType::from_u16(0x8000).is_none()); - assert!(PayloadType::from_u16(0xFFFF).is_none()); + assert!(PayloadType::try_from(0x0009_u16).is_err()); + assert!(PayloadType::try_from(0x4000_u16).is_err()); + assert!(PayloadType::try_from(0x8000_u16).is_err()); + assert!(PayloadType::try_from(0xFFFF_u16).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::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); } @@ -540,7 +760,7 @@ mod tests { 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); + let msg = DoipMessage::new(PayloadType::DiagnosticMessage, uds).unwrap(); assert_eq!(msg.header.payload_type, 0x8001); assert_eq!(msg.header.payload_length, 6); @@ -550,25 +770,22 @@ mod tests { #[test] fn create_vehicle_id_broadcast() { // Empty payload for discovery - let msg = DoipMessage::new(PayloadType::VehicleIdentificationRequest, Bytes::new()); + let msg = DoipMessage::new(PayloadType::VehicleIdentificationRequest, Bytes::new()).unwrap(); assert_eq!(msg.header.payload_length, 0); - assert_eq!(msg.total_length(), 8); // Just the header + 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_type(0xBEEF, Bytes::new()); + 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 msg = DoipMessage::new(PayloadType::AliveCheckRequest, Bytes::new()).unwrap(); let wire = msg.to_bytes(); assert_eq!(wire.len(), 8); @@ -584,10 +801,8 @@ mod tests { 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 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); @@ -608,10 +823,8 @@ mod tests { 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 - ][..]); + let mut buf = + BytesMut::from(&[0x02, 0xFD, 0x80, 0x01, 0x00, 0x00, 0x00, 0x05, 0x0E, 0x80][..]); assert!(codec.decode(&mut buf).unwrap().is_none()); } @@ -619,12 +832,14 @@ mod tests { #[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 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(); @@ -637,10 +852,8 @@ mod tests { #[test] fn reject_invalid_header_in_stream() { let mut codec = DoipCodec::new(); - // Bad version - let mut buf = BytesMut::from(&[ - 0x01, 0xFE, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 - ][..]); + // 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()); } @@ -649,9 +862,7 @@ mod tests { 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 - ][..]); + let mut buf = BytesMut::from(&[0x02, 0xFD, 0x80, 0x01, 0x00, 0x00, 0x00, 0x65][..]); assert!(codec.decode(&mut buf).is_err()); } @@ -660,7 +871,7 @@ mod tests { 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 msg = DoipMessage::new(PayloadType::DiagnosticMessage, payload).unwrap(); let mut buf = BytesMut::new(); codec.encode(msg, &mut buf).unwrap(); @@ -679,8 +890,9 @@ mod tests { let mut codec = DoipCodec::new(); let original = DoipMessage::new( PayloadType::DiagnosticMessage, - Bytes::from_static(&[0x0E, 0x80, 0x10, 0x01, 0x22, 0xF1, 0x90]) - ); + Bytes::from_static(&[0x0E, 0x80, 0x10, 0x01, 0x22, 0xF1, 0x90]), + ) + .unwrap(); let mut buf = BytesMut::new(); codec.encode(original.clone(), &mut buf).unwrap(); @@ -720,7 +932,7 @@ mod tests { #[test] fn parse_error_shows_useful_message() { let err = ParseError::InvalidHeader("buffer too short".into()); - let msg = format!("{}", err); + let msg = format!("{err}"); assert!(msg.contains("Invalid header")); assert!(msg.contains("buffer too short")); } @@ -730,7 +942,7 @@ mod tests { let io_err = io::Error::new(io::ErrorKind::UnexpectedEof, "connection lost"); let parse_err: ParseError = io_err.into(); match parse_err { - ParseError::Io(e) => assert_eq!(e.kind(), io::ErrorKind::UnexpectedEof), + ParseError::IoError(e) => assert_eq!(e.kind(), io::ErrorKind::UnexpectedEof), _ => panic!("expected Io variant"), } } @@ -747,14 +959,17 @@ mod tests { #[test] fn protocol_version_inverse_relationship() { - // Version XOR inverse must equal 0xFF (per spec) - assert_eq!(DEFAULT_PROTOCOL_VERSION ^ DEFAULT_PROTOCOL_VERSION_INV, 0xFF); + // 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); + let s = format!("{hdr}"); assert!(s.contains("DiagnosticMessage")); assert!(s.contains("10")); // payload length } diff --git a/src/doip/hearder_parser.rs b/src/doip/hearder_parser.rs deleted file mode 100644 index 4697534..0000000 --- a/src/doip/hearder_parser.rs +++ /dev/null @@ -1,378 +0,0 @@ -//! DoIP Header Parser - -use bytes::{Buf, BufMut, Bytes, BytesMut}; -use std::io; -use tokio_util::codec::{Decoder, Encoder}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum GenericNackCode { - IncorrectPatternFormat = 0x00, - UnknownPayloadType = 0x01, - MessageTooLarge = 0x02, - OutOfMemory = 0x03, - InvalidPayloadLength = 0x04, -} - -#[derive(Debug)] -pub enum ParseError { - InvalidHeader(String), - Io(io::Error), -} - -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidHeader(msg) => write!(f, "Invalid header: {}", msg), - Self::Io(e) => write!(f, "IO error: {}", e), - } - } -} - -impl std::error::Error for ParseError {} - -impl From for ParseError { - fn from(e: io::Error) -> Self { - Self::Io(e) - } -} - -pub type Result = std::result::Result; - -pub const DEFAULT_PROTOCOL_VERSION: u8 = 0x02; -pub const DEFAULT_PROTOCOL_VERSION_INV: u8 = 0xFD; -pub const DOIP_HEADER_LENGTH: usize = 8; -pub const MAX_DOIP_MESSAGE_SIZE: u32 = 0x0FFF_FFFF; - -#[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 PayloadType { - pub fn from_u16(value: u16) -> Option { - match value { - 0x0000 => Some(Self::GenericNack), - 0x0001 => Some(Self::VehicleIdentificationRequest), - 0x0002 => Some(Self::VehicleIdentificationRequestWithEid), - 0x0003 => Some(Self::VehicleIdentificationRequestWithVin), - 0x0004 => Some(Self::VehicleIdentificationResponse), - 0x0005 => Some(Self::RoutingActivationRequest), - 0x0006 => Some(Self::RoutingActivationResponse), - 0x0007 => Some(Self::AliveCheckRequest), - 0x0008 => Some(Self::AliveCheckResponse), - 0x4001 => Some(Self::DoipEntityStatusRequest), - 0x4002 => Some(Self::DoipEntityStatusResponse), - 0x4003 => Some(Self::DiagnosticPowerModeRequest), - 0x4004 => Some(Self::DiagnosticPowerModeResponse), - 0x8001 => Some(Self::DiagnosticMessage), - 0x8002 => Some(Self::DiagnosticMessagePositiveAck), - 0x8003 => Some(Self::DiagnosticMessageNegativeAck), - _ => None, - } - } - - pub const fn min_payload_length(self) -> usize { - match self { - Self::GenericNack => 1, - Self::VehicleIdentificationRequest => 0, - Self::VehicleIdentificationRequestWithEid => 6, - Self::VehicleIdentificationRequestWithVin => 17, - Self::VehicleIdentificationResponse => 32, - Self::RoutingActivationRequest => 7, - Self::RoutingActivationResponse => 9, - Self::AliveCheckRequest => 0, - Self::AliveCheckResponse => 2, - Self::DoipEntityStatusRequest => 0, - Self::DoipEntityStatusResponse => 3, - Self::DiagnosticPowerModeRequest => 0, - Self::DiagnosticPowerModeResponse => 1, - Self::DiagnosticMessage => 5, - Self::DiagnosticMessagePositiveAck => 5, - Self::DiagnosticMessageNegativeAck => 5, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DoipHeader { - pub version: u8, - pub inverse_version: u8, - pub payload_type: u16, - pub payload_length: u32, -} - -impl DoipHeader { - pub fn parse(data: &[u8]) -> Result { - if data.len() < DOIP_HEADER_LENGTH { - return Err(ParseError::InvalidHeader(format!( - "header too short: expected {}, got {}", - DOIP_HEADER_LENGTH, - data.len() - ))); - } - Ok(Self { - version: data[0], - inverse_version: data[1], - payload_type: u16::from_be_bytes([data[2], data[3]]), - payload_length: u32::from_be_bytes([data[4], data[5], data[6], data[7]]), - }) - } - - pub fn parse_from_buf(buf: &mut Bytes) -> Result { - if buf.len() < DOIP_HEADER_LENGTH { - return Err(ParseError::InvalidHeader(format!( - "header too short: expected {}, got {}", - DOIP_HEADER_LENGTH, - buf.len() - ))); - } - Ok(Self { - version: buf.get_u8(), - inverse_version: buf.get_u8(), - payload_type: buf.get_u16(), - payload_length: buf.get_u32(), - }) - } - - pub fn validate(&self) -> Option { - if self.version != DEFAULT_PROTOCOL_VERSION { - return Some(GenericNackCode::IncorrectPatternFormat); - } - if self.inverse_version != DEFAULT_PROTOCOL_VERSION_INV { - return Some(GenericNackCode::IncorrectPatternFormat); - } - if self.version ^ self.inverse_version != 0xFF { - return Some(GenericNackCode::IncorrectPatternFormat); - } - - let payload_type = match PayloadType::from_u16(self.payload_type) { - Some(pt) => pt, - None => return Some(GenericNackCode::UnknownPayloadType), - }; - - if self.payload_length > MAX_DOIP_MESSAGE_SIZE { - return Some(GenericNackCode::MessageTooLarge); - } - if (self.payload_length as usize) < payload_type.min_payload_length() { - return Some(GenericNackCode::InvalidPayloadLength); - } - None - } - - pub fn is_valid(&self) -> bool { - self.validate().is_none() - } - - pub const fn total_length(&self) -> usize { - DOIP_HEADER_LENGTH + self.payload_length as usize - } - - pub fn to_bytes(&self) -> Bytes { - let mut buf = BytesMut::with_capacity(DOIP_HEADER_LENGTH); - buf.put_u8(self.version); - buf.put_u8(self.inverse_version); - buf.put_u16(self.payload_type); - buf.put_u32(self.payload_length); - buf.freeze() - } - - 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); - } -} - -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::from_u16(self.payload_type) - .map(|pt| format!("{:?}", pt)) - .unwrap_or_else(|| format!("Unknown(0x{:04X})", self.payload_type)); - write!( - f, - "DoipHeader {{ version: 0x{:02X}, type: {}, length: {} }}", - self.version, payload_name, self.payload_length - ) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DoipMessage { - pub header: DoipHeader, - pub payload: Bytes, -} - -impl DoipMessage { - pub fn new(payload_type: PayloadType, payload: Bytes) -> Self { - Self { - header: DoipHeader { - version: DEFAULT_PROTOCOL_VERSION, - inverse_version: DEFAULT_PROTOCOL_VERSION_INV, - payload_type: payload_type as u16, - payload_length: payload.len() as u32, - }, - payload, - } - } - - pub fn with_raw_type(payload_type: u16, payload: Bytes) -> Self { - Self { - header: DoipHeader { - version: DEFAULT_PROTOCOL_VERSION, - inverse_version: DEFAULT_PROTOCOL_VERSION_INV, - payload_type, - payload_length: payload.len() as u32, - }, - payload, - } - } - - pub fn payload_type(&self) -> Option { - PayloadType::from_u16(self.header.payload_type) - } - - pub fn total_length(&self) -> usize { - DOIP_HEADER_LENGTH + self.payload.len() - } - - pub fn to_bytes(&self) -> Bytes { - let mut buf = BytesMut::with_capacity(self.total_length()); - self.header.write_to(&mut buf); - buf.extend_from_slice(&self.payload); - buf.freeze() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DecodeState { - Header, - Payload(DoipHeader), -} - -#[derive(Debug)] -pub struct DoipCodec { - state: DecodeState, - max_payload_size: u32, -} - -impl DoipCodec { - pub fn new() -> Self { - Self { - state: DecodeState::Header, - max_payload_size: MAX_DOIP_MESSAGE_SIZE, - } - } - - 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 { - src.reserve(DOIP_HEADER_LENGTH); - return Ok(None); - } - - let header = DoipHeader::parse(&src[..DOIP_HEADER_LENGTH]) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - if let Some(nack_code) = header.validate() { - 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 - ), - )); - } - - src.reserve(header.total_length()); - self.state = DecodeState::Payload(header); - } - - DecodeState::Payload(header) => { - if src.len() < header.total_length() { - return Ok(None); - } - - let _ = src.split_to(DOIP_HEADER_LENGTH); - let payload = src.split_to(header.payload_length as usize).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.total_length()); - item.header.write_to(dst); - dst.extend_from_slice(&item.payload); - Ok(()) - } -} diff --git a/src/doip/mod.rs b/src/doip/mod.rs index dfebf91..950fd16 100644 --- a/src/doip/mod.rs +++ b/src/doip/mod.rs @@ -1,15 +1,90 @@ -//! DoIP Protocol Implementation +/* + * 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. +//! This module provides the core `DoIP` protocol types and codec for TCP/UDP communication. +pub mod alive_check; +pub mod diagnostic_message; pub mod header_parser; +pub mod payload; pub mod routing_activation; -pub mod diagnostic_message; pub mod vehicle_id; -pub mod alive_check; +use bytes::{Bytes, BytesMut}; +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]) -> std::result::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); + + /// Return the exact serialized byte count, if known without encoding. + /// + /// Override this to enable pre-allocated buffers in [`to_bytes`], avoiding + /// incremental `BytesMut` reallocations for large messages. + 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) -> std::result::Result<(), DoipError> { + if payload.len() < expected { + Err(too_short(payload, expected)) + } else { + Ok(()) + } +} + +// Re-export core types and constants for convenient access. +// Constants are exported to allow external testing and custom DoIP message construction. pub use header_parser::{ - DoipCodec, DoipHeader, DoipMessage, GenericNackCode, ParseError, PayloadType, Result, + DoipCodec, DoipHeader, DoipMessage, GenericNackCode, PayloadType, DEFAULT_PROTOCOL_VERSION, DEFAULT_PROTOCOL_VERSION_INV, DOIP_HEADER_LENGTH, - MAX_DOIP_MESSAGE_SIZE, + MAX_DOIP_MESSAGE_SIZE, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V3, + DOIP_VERSION_DEFAULT, DOIP_HEADER_VERSION_MASK, }; +pub use payload::DoipPayload; diff --git a/src/doip/payload.rs b/src/doip/payload.rs new file mode 100644 index 0000000..cc27854 --- /dev/null +++ b/src/doip/payload.rs @@ -0,0 +1,253 @@ +/* + * 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 +//! ```ignore +//! let payload = DoipPayload::parse(&msg)?; +//! match payload { +//! DoipPayload::DiagnosticMessage(m) => handle_diag(m), +//! DoipPayload::AliveCheckRequest(_) => send_alive_response(), +//! _ => {} +//! } +//! ``` + +use crate::DoipError; +use super::{ + DoipMessage, DoipParseable, GenericNackCode, PayloadType, + alive_check, diagnostic_message, routing_activation, vehicle_id, +}; + +/// 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::PositiveAck), + /// `0x8003` – Diagnostic Message Negative Acknowledgement + DiagnosticMessageNegativeAck(diagnostic_message::NegativeAck), + /// `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) -> std::result::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::PositiveAck::parse(payload)?, + )), + PayloadType::DiagnosticMessageNegativeAck => Ok(Self::DiagnosticMessageNegativeAck( + diagnostic_message::NegativeAck::parse(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(payload_type as u16)) + } + } + } + + /// 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 super::*; + use bytes::Bytes; + use crate::doip::{DoipMessage, PayloadType}; + + fn make_msg(payload_type: PayloadType, payload: impl Into) -> DoipMessage { + DoipMessage { + header: crate::doip::DoipHeader { + version: crate::doip::DEFAULT_PROTOCOL_VERSION, + inverse_version: crate::doip::DEFAULT_PROTOCOL_VERSION_INV, + payload_type: payload_type as u16, + payload_length: 0, + }, + payload: 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 { + header: crate::doip::DoipHeader { + version: crate::doip::DEFAULT_PROTOCOL_VERSION, + inverse_version: crate::doip::DEFAULT_PROTOCOL_VERSION_INV, + payload_type: 0xFFFF, + payload_length: 0, + }, + payload: 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/src/doip/routing_activation.rs b/src/doip/routing_activation.rs index 1628b86..f8282ed 100644 --- a/src/doip/routing_activation.rs +++ b/src/doip/routing_activation.rs @@ -1,8 +1,23 @@ -//! Routing Activation handlers (ISO 13400-2) +/* + * 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::{Buf, BufMut, Bytes, BytesMut}; +use tracing::warn; +use crate::DoipError; +use super::{DoipParseable, DoipSerializable, too_short, check_min_len}; -// Response codes per ISO 13400-2 Table 25 +// Response codes per ISO 13400-2:2019 Table 25 #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum ResponseCode { @@ -18,29 +33,37 @@ pub enum ResponseCode { ConfirmationRequired = 0x11, } -impl ResponseCode { - pub fn from_u8(value: u8) -> Option { +impl TryFrom for ResponseCode { + type Error = DoipError; + + fn try_from(value: u8) -> std::result::Result { match value { - 0x00 => Some(Self::UnknownSourceAddress), - 0x01 => Some(Self::AllSocketsRegistered), - 0x02 => Some(Self::DifferentSourceAddress), - 0x03 => Some(Self::SourceAddressAlreadyActive), - 0x04 => Some(Self::MissingAuthentication), - 0x05 => Some(Self::RejectedConfirmation), - 0x06 => Some(Self::UnsupportedActivationType), - 0x07 => Some(Self::TlsRequired), - 0x10 => Some(Self::SuccessfullyActivated), - 0x11 => Some(Self::ConfirmationRequired), - _ => None, + 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 ResponseCode { + #[must_use] pub fn is_success(self) -> bool { - matches!(self, Self::SuccessfullyActivated | Self::ConfirmationRequired) + matches!( + self, + Self::SuccessfullyActivated | Self::ConfirmationRequired + ) } } -// Activation types per ISO 13400-2 Table 24 +// Activation types per ISO 13400-2:2019 Table 24 #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum ActivationType { @@ -49,41 +72,24 @@ pub enum ActivationType { CentralSecurity = 0xE0, } -impl ActivationType { - pub fn from_u8(value: u8) -> Option { - match value { - 0x00 => Some(Self::Default), - 0x01 => Some(Self::WwhObd), - 0xE0 => Some(Self::CentralSecurity), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Error { - PayloadTooShort { expected: usize, actual: usize }, - UnknownResponseCode(u8), -} +impl TryFrom for ActivationType { + type Error = DoipError; -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::PayloadTooShort { expected, actual } => { - write!(f, "payload too short: need {} bytes, got {}", expected, actual) - } - Self::UnknownResponseCode(code) => write!(f, "unknown response code: 0x{:02X}", code), + 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)), } } } -impl std::error::Error for Error {} - // Routing Activation Request - payload is 7 bytes min, 11 with OEM data #[derive(Debug, Clone, PartialEq, Eq)] pub struct Request { pub source_address: u16, - pub activation_type: u8, + pub activation_type: ActivationType, pub reserved: u32, pub oem_specific: Option, } @@ -92,20 +98,22 @@ impl Request { pub const MIN_LEN: usize = 7; pub const MAX_LEN: usize = 11; - pub fn parse(payload: &[u8]) -> Result { - if payload.len() < Self::MIN_LEN { - return Err(Error::PayloadTooShort { - expected: Self::MIN_LEN, - actual: payload.len(), - }); - } - - let source_address = u16::from_be_bytes([payload[0], payload[1]]); - let activation_type = payload[2]; - let reserved = u32::from_be_bytes([payload[3], payload[4], payload[5], payload[6]]); + /// Parse routing activation request from buffer + /// + /// # Errors + /// + /// Returns an error if the buffer is too short or contains invalid data. + pub fn parse_buf(buf: &mut Bytes) -> std::result::Result { + check_min_len(buf.as_ref(), Self::MIN_LEN)?; - let oem_specific = if payload.len() >= Self::MAX_LEN { - Some(u32::from_be_bytes([payload[7], payload[8], payload[9], payload[10]])) + let source_address = buf.get_u16(); + let activation_type = ActivationType::try_from(buf.get_u8()).map_err(|e| { + warn!("RoutingActivation Request parse_buf: {}", e); + e + })?; + let reserved = buf.get_u32(); + let oem_specific = if buf.remaining() >= 4 { + Some(buf.get_u32()) } else { None }; @@ -117,38 +125,6 @@ impl Request { oem_specific, }) } - - pub fn parse_buf(buf: &mut Bytes) -> Result { - if buf.len() < Self::MIN_LEN { - return Err(Error::PayloadTooShort { - expected: Self::MIN_LEN, - actual: buf.len(), - }); - } - - let source_address = buf.get_u16(); - let activation_type = buf.get_u8(); - let reserved = buf.get_u32(); - let oem_specific = if buf.remaining() >= 4 { Some(buf.get_u32()) } else { None }; - - Ok(Self { - source_address, - activation_type, - reserved, - oem_specific, - }) - } - - pub fn activation_type_enum(&self) -> Option { - ActivationType::from_u8(self.activation_type) - } - - pub fn validate(&self) -> Option { - if ActivationType::from_u8(self.activation_type).is_none() { - return Some(ResponseCode::UnsupportedActivationType); - } - None - } } // Routing Activation Response - 9 bytes min, 13 with OEM data @@ -165,6 +141,7 @@ impl Response { pub const MIN_LEN: usize = 9; pub const MAX_LEN: usize = 13; + #[must_use] pub fn success(tester_address: u16, entity_address: u16) -> Self { Self { tester_address, @@ -175,6 +152,7 @@ impl Response { } } + #[must_use] pub fn denial(tester_address: u16, entity_address: u16, code: ResponseCode) -> Self { Self { tester_address, @@ -185,42 +163,67 @@ impl Response { } } - pub fn to_bytes(&self) -> Bytes { - let len = if self.oem_specific.is_some() { Self::MAX_LEN } else { Self::MIN_LEN }; - let mut buf = BytesMut::with_capacity(len); - self.write_to(&mut buf); - buf.freeze() - } - - pub fn write_to(&self, buf: &mut BytesMut) { - buf.put_u16(self.tester_address); - buf.put_u16(self.entity_address); - buf.put_u8(self.response_code as u8); - buf.put_u32(self.reserved); - if let Some(oem) = self.oem_specific { - buf.put_u32(oem); - } + #[must_use] + pub fn is_success(&self) -> bool { + self.response_code.is_success() } +} - pub fn parse(payload: &[u8]) -> Result { - if payload.len() < Self::MIN_LEN { - return Err(Error::PayloadTooShort { - expected: Self::MIN_LEN, - actual: payload.len(), - }); - } +impl DoipParseable for Request { + fn parse(payload: &[u8]) -> std::result::Result { + let header: [u8; Self::MIN_LEN] = payload + .get(..Self::MIN_LEN) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + let e = too_short(payload, Self::MIN_LEN); + warn!("RoutingActivation Request parse failed: {}", e); + e + })?; + + let source_address = u16::from_be_bytes([header[0], header[1]]); + let activation_type = ActivationType::try_from(header[2]).map_err(|e| { + warn!("RoutingActivation Request parse failed: {}", e); + 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); - let tester_address = u16::from_be_bytes([payload[0], payload[1]]); - let entity_address = u16::from_be_bytes([payload[2], payload[3]]); - let response_code = ResponseCode::from_u8(payload[4]) - .ok_or(Error::UnknownResponseCode(payload[4]))?; - let reserved = u32::from_be_bytes([payload[5], payload[6], payload[7], payload[8]]); + Ok(Self { + source_address, + activation_type, + reserved, + oem_specific, + }) + } +} - let oem_specific = if payload.len() >= Self::MAX_LEN { - Some(u32::from_be_bytes([payload[9], payload[10], payload[11], payload[12]])) - } else { - None - }; +impl DoipParseable for Response { + fn parse(payload: &[u8]) -> std::result::Result { + let header: [u8; Self::MIN_LEN] = payload + .get(..Self::MIN_LEN) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + let e = too_short(payload, Self::MIN_LEN); + warn!("RoutingActivation Response parse failed: {}", e); + e + })?; + + 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| { + warn!("RoutingActivation Response parse failed: {}", e); + 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, @@ -230,15 +233,36 @@ impl Response { oem_specific, }) } +} - pub fn is_success(&self) -> bool { - self.response_code.is_success() +impl DoipSerializable for Response { + fn serialized_len(&self) -> Option { + Some(Self::MIN_LEN + 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(self.response_code as u8); + buf.put_u32(self.reserved); + if let Some(oem) = self.oem_specific { + buf.put_u32(oem); + } } } #[cfg(test)] 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() { @@ -261,7 +285,7 @@ mod tests { let req = Request::parse(&payload).unwrap(); assert_eq!(req.source_address, 0x0E80); - assert_eq!(req.activation_type, 0x00); + assert_eq!(req.activation_type, ActivationType::Default); assert_eq!(req.reserved, 0); assert!(req.oem_specific.is_none()); } @@ -269,18 +293,17 @@ mod tests { #[test] fn parse_request_with_oem() { let payload = [ - 0x0E, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xDE, 0xAD, 0xBE, 0xEF, + 0x0E, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF, ]; let req = Request::parse(&payload).unwrap(); - assert_eq!(req.oem_specific, Some(0xDEADBEEF)); + 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_enum(), Some(ActivationType::WwhObd)); + assert_eq!(req.activation_type, ActivationType::WwhObd); } #[test] @@ -290,10 +313,10 @@ mod tests { } #[test] - fn validate_bad_activation_type() { + 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]; - let req = Request::parse(&payload).unwrap(); - assert_eq!(req.validate(), Some(ResponseCode::UnsupportedActivationType)); + assert!(Request::parse(&payload).is_err()); } #[test] @@ -315,28 +338,25 @@ mod tests { let resp = Response::success(0x0E80, 0x1000); let bytes = resp.to_bytes(); - assert_eq!(bytes.len(), 9); - assert_eq!(&bytes[0..2], &[0x0E, 0x80]); - assert_eq!(&bytes[2..4], &[0x10, 0x00]); - assert_eq!(bytes[4], 0x10); + 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(0x12345678); + resp.oem_specific = Some(0x1234_5678); let bytes = resp.to_bytes(); - assert_eq!(bytes.len(), 13); - assert_eq!(&bytes[9..13], &[0x12, 0x34, 0x56, 0x78]); + 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 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); @@ -345,10 +365,7 @@ mod tests { #[test] fn parse_denial_response() { - let payload = [ - 0x0E, 0x80, 0x10, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x00, - ]; + 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); @@ -365,7 +382,7 @@ mod tests { #[test] fn roundtrip_response_with_oem() { let mut original = Response::denial(0x0F00, 0x2000, ResponseCode::MissingAuthentication); - original.oem_specific = Some(0xCAFEBABE); + original.oem_specific = Some(0xCAFE_BABE); let bytes = original.to_bytes(); let parsed = Response::parse(&bytes).unwrap(); assert_eq!(original, parsed); diff --git a/src/doip/vehicle_id.rs b/src/doip/vehicle_id.rs index 101e382..2c9f2ba 100644 --- a/src/doip/vehicle_id.rs +++ b/src/doip/vehicle_id.rs @@ -1,38 +1,45 @@ -//! Vehicle Identification handlers (ISO 13400-2) - -use bytes::{BufMut, Bytes, BytesMut}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Error { - PayloadTooShort { expected: usize, actual: usize }, - InvalidVinLength(usize), - InvalidEidLength(usize), -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::PayloadTooShort { expected, actual } => { - write!(f, "payload too short: need {} bytes, got {}", expected, actual) - } - Self::InvalidVinLength(len) => write!(f, "VIN must be 17 bytes, got {}", len), - Self::InvalidEidLength(len) => write!(f, "EID must be 6 bytes, got {}", len), - } - } -} - -impl std::error::Error for Error {} +/* + * 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::warn; +use crate::DoipError; +use super::{DoipParseable, DoipSerializable, too_short, check_min_len}; + +// 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 (0x0001) - no payload #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Request; -impl Request { - pub fn parse(_payload: &[u8]) -> Result { - Ok(Self) - } -} - // Vehicle Identification Request with EID (0x0002) - 6 byte EID #[derive(Debug, Clone, PartialEq, Eq)] pub struct RequestWithEid { @@ -42,19 +49,7 @@ pub struct RequestWithEid { impl RequestWithEid { pub const LEN: usize = 6; - pub fn parse(payload: &[u8]) -> Result { - if payload.len() < Self::LEN { - return Err(Error::PayloadTooShort { - expected: Self::LEN, - actual: payload.len(), - }); - } - - let mut eid = [0u8; 6]; - eid.copy_from_slice(&payload[0..6]); - Ok(Self { eid }) - } - + #[must_use] pub fn new(eid: [u8; 6]) -> Self { Self { eid } } @@ -69,29 +64,18 @@ pub struct RequestWithVin { impl RequestWithVin { pub const LEN: usize = 17; - pub fn parse(payload: &[u8]) -> Result { - if payload.len() < Self::LEN { - return Err(Error::PayloadTooShort { - expected: Self::LEN, - actual: payload.len(), - }); - } - - let mut vin = [0u8; 17]; - vin.copy_from_slice(&payload[0..17]); - Ok(Self { vin }) - } - + #[must_use] pub fn new(vin: [u8; 17]) -> Self { Self { vin } } + #[must_use] pub fn vin_string(&self) -> String { String::from_utf8_lossy(&self.vin).to_string() } } -// Further action codes for vehicle identification response +// Further action codes per ISO 13400-2:2019 Table 23 #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum FurtherAction { @@ -99,7 +83,18 @@ pub enum FurtherAction { RoutingActivationRequired = 0x10, } -// Sync status for vehicle identification response +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), + } + } +} + +// Synchronization status per ISO 13400-2:2019 Table 22 #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum SyncStatus { @@ -107,6 +102,17 @@ pub enum SyncStatus { 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), + } + } +} + // Vehicle Identification Response (0x0004) // VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1) = 32 bytes min #[derive(Debug, Clone, PartialEq, Eq)] @@ -120,9 +126,10 @@ pub struct Response { } impl Response { - pub const MIN_LEN: usize = 32; // without sync status - pub const MAX_LEN: usize = 33; // with sync status + pub const MIN_LEN: usize = SYNC_STATUS_IDX; // 32: VIN(17) + Addr(2) + EID(6) + GID(6) + FurtherAction(1) + pub const MAX_LEN: usize = SYNC_STATUS_IDX + 1; // 33: adds optional SyncStatus(1) + #[must_use] pub fn new(vin: [u8; 17], logical_address: u16, eid: [u8; 6], gid: [u8; 6]) -> Self { Self { vin, @@ -134,66 +141,101 @@ impl Response { } } + #[must_use] pub fn with_routing_required(mut self) -> Self { self.further_action = FurtherAction::RoutingActivationRequired; self } + #[must_use] pub fn with_sync_status(mut self, status: SyncStatus) -> Self { self.sync_status = Some(status); self } - pub fn to_bytes(&self) -> Bytes { - let len = if self.sync_status.is_some() { Self::MAX_LEN } else { Self::MIN_LEN }; - let mut buf = BytesMut::with_capacity(len); - self.write_to(&mut buf); - buf.freeze() + #[must_use] + pub fn vin_string(&self) -> String { + String::from_utf8_lossy(&self.vin).to_string() } +} - pub 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(self.further_action as u8); - if let Some(status) = self.sync_status { - buf.put_u8(status as u8); - } +impl DoipParseable for Request { + fn parse(_payload: &[u8]) -> std::result::Result { + Ok(Self) } +} - pub fn parse(payload: &[u8]) -> Result { - if payload.len() < Self::MIN_LEN { - return Err(Error::PayloadTooShort { - expected: Self::MIN_LEN, - actual: payload.len(), - }); - } - - let mut vin = [0u8; 17]; - vin.copy_from_slice(&payload[0..17]); +impl DoipParseable for RequestWithEid { + fn parse(payload: &[u8]) -> std::result::Result { + let eid: [u8; 6] = payload + .get(..Self::LEN) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + let e = too_short(payload, Self::LEN); + warn!("VehicleId RequestWithEid parse failed: {}", e); + e + })?; - let logical_address = u16::from_be_bytes([payload[17], payload[18]]); + Ok(Self { eid }) + } +} - let mut eid = [0u8; 6]; - eid.copy_from_slice(&payload[19..25]); +impl DoipParseable for RequestWithVin { + fn parse(payload: &[u8]) -> std::result::Result { + let vin: [u8; 17] = payload + .get(..Self::LEN) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + let e = too_short(payload, Self::LEN); + warn!("VehicleId RequestWithVin parse failed: {}", e); + e + })?; - let mut gid = [0u8; 6]; - gid.copy_from_slice(&payload[25..31]); + Ok(Self { vin }) + } +} - let further_action = match payload[31] { - 0x10 => FurtherAction::RoutingActivationRequired, - _ => FurtherAction::NoFurtherAction, +impl DoipParseable for Response { + fn parse(payload: &[u8]) -> std::result::Result { + if let Err(e) = check_min_len(payload, Self::MIN_LEN) { + warn!("VehicleId Response parse failed: {}", e); + return Err(e); }; - let sync_status = if payload.len() >= Self::MAX_LEN { - Some(match payload[32] { - 0x10 => SyncStatus::NotSynchronized, - _ => SyncStatus::Synchronized, - }) - } else { - None - }; + 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) + .unwrap_or(FurtherAction::NoFurtherAction); + + let sync_status = payload.get(SYNC_STATUS_IDX) + .map(|&b| SyncStatus::try_from(b).unwrap_or(SyncStatus::Synchronized)); Ok(Self { vin, @@ -204,15 +246,29 @@ impl Response { sync_status, }) } +} + +impl DoipSerializable for Response { + fn serialized_len(&self) -> Option { + Some(Self::MIN_LEN + if self.sync_status.is_some() { 1 } else { 0 }) + } - pub fn vin_string(&self) -> String { - String::from_utf8_lossy(&self.vin).to_string() + 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(self.further_action as u8); + if let Some(status) = self.sync_status { + buf.put_u8(status as u8); + } } } #[cfg(test)] mod tests { use super::*; + use crate::doip::{DoipParseable, DoipSerializable}; #[test] fn parse_basic_request() { @@ -266,7 +322,10 @@ mod tests { let gid = [0; 6]; let resp = Response::new(vin, 0x1000, eid, gid).with_routing_required(); - assert_eq!(resp.further_action, FurtherAction::RoutingActivationRequired); + assert_eq!( + resp.further_action, + FurtherAction::RoutingActivationRequired + ); } #[test] @@ -278,9 +337,9 @@ mod tests { let resp = Response::new(vin, 0x1000, eid, gid); let bytes = resp.to_bytes(); - assert_eq!(bytes.len(), 32); - assert_eq!(&bytes[0..17], b"WVWZZZ3CZWE123456"); - assert_eq!(&bytes[17..19], &[0x10, 0x00]); // logical address + 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] @@ -289,12 +348,11 @@ mod tests { let eid = [0; 6]; let gid = [0; 6]; - let resp = Response::new(vin, 0x1000, eid, gid) - .with_sync_status(SyncStatus::Synchronized); + let resp = Response::new(vin, 0x1000, eid, gid).with_sync_status(SyncStatus::Synchronized); let bytes = resp.to_bytes(); - assert_eq!(bytes.len(), 33); - assert_eq!(bytes[32], 0x00); // sync status + assert_eq!(bytes.len(), Response::MAX_LEN); + assert_eq!(bytes[SYNC_STATUS_IDX], SyncStatus::Synchronized as u8); // sync status } #[test] diff --git a/src/error.rs b/src/error.rs index 58ec31f..4b30946 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,19 +1,36 @@ -//! Error Types for DoIP Server (ISO 13400-2 & ISO 14229) +/* + * 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; use thiserror::Error; -/// Result type alias -pub type Result = std::result::Result; +// Re-export from the canonical definitions in the protocol modules +pub use crate::doip::diagnostic_message::NackCode as DiagnosticNackCode; +pub use crate::doip::header_parser::GenericNackCode; +pub use crate::doip::routing_activation::ResponseCode as RoutingActivationCode; -/// Main DoIP Error type +/// Result type alias for `DoIP` operations +pub type DoipResult = 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}")] - Config(String), + InvalidConfig(String), #[error("Invalid protocol version: expected 0x{expected:02X}, got 0x{actual:02X}")] InvalidProtocolVersion { expected: u8, actual: u8 }, @@ -24,6 +41,27 @@ pub enum DoipError { #[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("diagnostic message has no user data")] + EmptyUserData, + + #[error("invalid VIN length: expected 17, got {0}")] + InvalidVinLength(usize), + + #[error("invalid EID length: expected 6, got {0}")] + InvalidEidLength(usize), + #[error("Message too large: {size} bytes (max: {max})")] MessageTooLarge { size: usize, max: usize }, @@ -43,61 +81,7 @@ pub enum DoipError { UdsError { service: u8, nrc: u8 }, } -/// Generic Header NACK codes -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum GenericNackCode { - IncorrectPatternFormat = 0x00, - UnknownPayloadType = 0x01, - MessageTooLarge = 0x02, - OutOfMemory = 0x03, - InvalidPayloadLength = 0x04, -} - -impl GenericNackCode { - pub const fn as_u8(self) -> u8 { self as u8 } -} - -/// Routing Activation Response codes -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum RoutingActivationCode { - UnknownSourceAddress = 0x00, - AllSocketsRegistered = 0x01, - DifferentSourceAddress = 0x02, - SourceAddressAlreadyActive = 0x03, - MissingAuthentication = 0x04, - RejectedConfirmation = 0x05, - UnsupportedActivationType = 0x06, - SuccessfullyActivated = 0x10, - ConfirmationRequired = 0x11, -} - -impl RoutingActivationCode { - pub const fn as_u8(self) -> u8 { self as u8 } - pub const fn is_success(self) -> bool { - matches!(self, Self::SuccessfullyActivated | Self::ConfirmationRequired) - } -} - -/// Diagnostic Message NACK codes -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum DiagnosticNackCode { - InvalidSourceAddress = 0x02, - UnknownTargetAddress = 0x03, - DiagnosticMessageTooLarge = 0x04, - OutOfMemory = 0x05, - TargetUnreachable = 0x06, - UnknownNetwork = 0x07, - TransportProtocolError = 0x08, -} - -impl DiagnosticNackCode { - pub const fn as_u8(self) -> u8 { self as u8 } -} - -/// UDS Negative Response Codes +/// UDS Negative Response Codes (ISO 14229-1:2020) #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum UdsNrc { @@ -118,7 +102,10 @@ pub enum UdsNrc { } impl UdsNrc { - pub const fn as_u8(self) -> u8 { self as u8 } + #[must_use] + pub const fn as_u8(self) -> u8 { + self as u8 + } } #[cfg(test)] @@ -134,6 +121,112 @@ mod tests { #[test] fn test_nrc_values() { assert_eq!(UdsNrc::ServiceNotSupported.as_u8(), 0x11); - assert_eq!(DiagnosticNackCode::UnknownTargetAddress.as_u8(), 0x03); + 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); + } } -} \ No newline at end of file + + #[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::MessageTooLarge { size: 10, max: 1 }, + DoipError::RoutingActivationFailed { + code: 0x00, + message: "fail".to_string(), + }, + DoipError::SessionNotFound, + DoipError::SessionClosed, + DoipError::Timeout("timeout".to_string()), + DoipError::UdsError { + service: 0x10, + nrc: 0x11, + }, + DoipError::PayloadTooShort { expected: 8, actual: 4 }, + DoipError::UnknownRoutingActivationResponseCode(0x99), + DoipError::EmptyUserData, + DoipError::InvalidVinLength(10), + DoipError::InvalidEidLength(4), + ]; + + for err in errors { + let _ = err.to_string(); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 16e1767..181919a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,17 @@ +/* + * 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 + */ pub mod doip; +pub mod error; +pub mod uds; + +pub use error::DoipError; diff --git a/src/server/config.rs b/src/server/config.rs new file mode 100644 index 0000000..b5c15d4 --- /dev/null +++ b/src/server/config.rs @@ -0,0 +1,317 @@ +/* + * 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 serde::Deserialize; +use std::net::SocketAddr; +use std::path::Path; + +// ============================================================================ +// 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; + +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub tcp_addr: SocketAddr, + pub udp_addr: SocketAddr, + pub logical_address: u16, + pub vin: [u8; 17], + pub eid: [u8; 6], + pub gid: [u8; 6], + pub max_connections: usize, + pub initial_inactivity_timeout_ms: u64, + pub 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 { + #[must_use] + pub fn new(logical_address: u16) -> Self { + Self { + logical_address, + ..Default::default() + } + } + + /// Load configuration from TOML file + /// + /// # Errors + /// Returns error if file cannot be read, parsed, or contains invalid values + pub fn from_file>(path: P) -> anyhow::Result { + let content = std::fs::read_to_string(path)?; + let file: ConfigFile = toml::from_str(&content)?; + + 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) -> anyhow::Result<[u8; 17]> { + let bytes = s.as_bytes(); + if bytes.len() != 17 { + anyhow::bail!("VIN must be exactly 17 characters"); + } + let mut vin = [0u8; 17]; + vin.copy_from_slice(bytes); + Ok(vin) + } + + fn parse_hex_array(s: &str) -> anyhow::Result<[u8; N]> { + let s = s.trim_start_matches("0x").replace([':', '-', ' '], ""); + let bytes = hex::decode(&s)?; + if bytes.len() != N { + anyhow::bail!("Expected {} bytes, got {}", N, bytes.len()); + } + let mut arr = [0u8; N]; + arr.copy_from_slice(&bytes); + Ok(arr) + } + + #[must_use] + pub fn with_vin(mut self, vin: [u8; 17]) -> Self { + self.vin = vin; + self + } + + #[must_use] + pub fn with_addresses(mut self, tcp: SocketAddr, udp: SocketAddr) -> Self { + self.tcp_addr = tcp; + self.udp_addr = udp; + self + } +} + +#[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: anyhow::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: anyhow::Result<[u8; 6]> = ServerConfig::parse_hex_array("0x001A2B3C4D5E"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_hex_array_invalid_length() { + let result: anyhow::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/src/server/session.rs b/src/server/session.rs new file mode 100644 index 0000000..41a0ac1 --- /dev/null +++ b/src/server/session.rs @@ -0,0 +1,186 @@ +/* + * 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 parking_lot::RwLock; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing::debug; + +/// Session states per ISO 13400-2:2019 connection lifecycle +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + Connected, + RoutingActive, + Closed, +} + +#[derive(Debug, Clone)] +pub struct Session { + pub id: u64, + pub peer_addr: SocketAddr, + pub tester_address: u16, + pub state: SessionState, +} + +impl Session { + #[must_use] + pub fn new(id: u64, peer_addr: SocketAddr) -> Self { + Self { + id, + peer_addr, + tester_address: 0, + state: SessionState::Connected, + } + } + + pub fn activate_routing(&mut self, tester_address: u16) { + debug!("Session {} routing activated: tester_address=0x{:04X}", self.id, tester_address); + self.tester_address = tester_address; + self.state = SessionState::RoutingActive; + } + + #[must_use] + pub fn is_routing_active(&self) -> bool { + self.state == SessionState::RoutingActive + } +} + +#[derive(Debug, Default)] +pub struct SessionManager { + sessions: RwLock>, + addr_to_session: RwLock>, + next_id: RwLock, +} + +impl SessionManager { + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn create_session(&self, peer_addr: SocketAddr) -> Session { + let mut next_id = self.next_id.write(); + let id = *next_id; + *next_id = next_id.saturating_add(1); + + let session = Session::new(id, peer_addr); + self.sessions.write().insert(id, session.clone()); + self.addr_to_session.write().insert(peer_addr, id); + + debug!("Session {} created for {}", id, peer_addr); + session + } + + pub fn get_session(&self, id: u64) -> Option { + self.sessions.read().get(&id).cloned() + } + + pub fn get_session_by_addr(&self, addr: &SocketAddr) -> Option { + let id = self.addr_to_session.read().get(addr).copied()?; + self.get_session(id) + } + + pub fn update_session(&self, id: u64, f: F) -> bool + where + F: FnOnce(&mut Session), + { + if let Some(session) = self.sessions.write().get_mut(&id) { + f(session); + true + } else { + false + } + } + + pub fn remove_session(&self, id: u64) -> Option { + let session = self.sessions.write().remove(&id)?; + self.addr_to_session.write().remove(&session.peer_addr); + debug!("Session {} removed (peer: {})", id, session.peer_addr); + Some(session) + } + + pub fn remove_session_by_addr(&self, addr: &SocketAddr) -> Option { + let id = self.addr_to_session.write().remove(addr)?; + let session = self.sessions.write().remove(&id)?; + debug!("Session {} removed by addr (peer: {})", id, addr); + Some(session) + } + + pub fn session_count(&self) -> usize { + self.sessions.read().len() + } + + pub fn is_tester_registered(&self, tester_address: u16) -> bool { + self.sessions + .read() + .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/src/uds/handler.rs b/src/uds/handler.rs index 3d4f50f..928f034 100644 --- a/src/uds/handler.rs +++ b/src/uds/handler.rs @@ -1,121 +1,99 @@ +/* + * 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 +//! 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 request extracted from DoIP diagnostic message +/// UDS Service IDs (ISO 14229-1:2020) +pub mod service_id { + pub const DIAGNOSTIC_SESSION_CONTROL: u8 = 0x10; + pub const ECU_RESET: u8 = 0x11; + pub const SECURITY_ACCESS: u8 = 0x27; + pub const COMMUNICATION_CONTROL: u8 = 0x28; + pub const TESTER_PRESENT: u8 = 0x3E; + pub const CONTROL_DTC_SETTING: u8 = 0x85; + pub const READ_DATA_BY_IDENTIFIER: u8 = 0x22; + pub const WRITE_DATA_BY_IDENTIFIER: u8 = 0x2E; + pub const ROUTINE_CONTROL: u8 = 0x31; + pub const REQUEST_DOWNLOAD: u8 = 0x34; + pub const REQUEST_UPLOAD: u8 = 0x35; + pub const TRANSFER_DATA: u8 = 0x36; + pub const REQUEST_TRANSFER_EXIT: u8 = 0x37; + pub const READ_DTC_INFORMATION: u8 = 0x19; + pub const CLEAR_DTC_INFORMATION: u8 = 0x14; +} + +/// UDS request extracted from `DoIP` diagnostic message #[derive(Debug, Clone)] pub struct UdsRequest { pub source_address: u16, pub target_address: u16, - pub data: Bytes, + pub payload: Bytes, } impl UdsRequest { - pub fn new(source: u16, target: u16, data: Bytes) -> Self { + #[must_use] + pub fn new(source: u16, target: u16, payload: Bytes) -> Self { Self { source_address: source, target_address: target, - data, + 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.data.first().copied() + self.payload.first().copied() } } -/// UDS response to be wrapped in DoIP diagnostic message +/// UDS response to be wrapped in `DoIP` diagnostic message #[derive(Debug, Clone)] pub struct UdsResponse { pub source_address: u16, pub target_address: u16, - pub data: Bytes, + pub payload: Bytes, } impl UdsResponse { - pub fn new(source: u16, target: u16, data: Bytes) -> Self { + #[must_use] + pub fn new(source: u16, target: u16, payload: Bytes) -> Self { Self { source_address: source, target_address: target, - data, + payload, } } } /// Trait for handling UDS requests /// -/// Implement this trait to connect the DoIP server to a UDS backend +/// 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 { fn handle(&self, request: UdsRequest) -> UdsResponse; } -/// Stub handler that returns NRC 0x11 (Service Not Supported) for all requests -#[derive(Debug, Default, Clone)] -pub struct StubHandler; - -impl UdsHandler for StubHandler { - fn handle(&self, request: UdsRequest) -> UdsResponse { - let sid = request.service_id().unwrap_or(0); - // Negative response: 0x7F + SID + NRC - let nrc_service_not_supported = 0x11; - let data = Bytes::from(vec![0x7F, sid, nrc_service_not_supported]); - - UdsResponse::new( - request.target_address, - request.source_address, - data, - ) - } -} - #[cfg(test)] mod tests { use super::*; - - #[test] - fn stub_handler_returns_nrc() { - let handler = StubHandler; - let request = UdsRequest::new( - 0x0E00, - 0x1000, - Bytes::from(vec![0x22, 0xF1, 0x90]), // ReadDataByIdentifier - ); - - let response = handler.handle(request); - - assert_eq!(response.source_address, 0x1000); - assert_eq!(response.target_address, 0x0E00); - assert_eq!(response.data.as_ref(), &[0x7F, 0x22, 0x11]); - } - - #[test] - fn stub_handler_handles_empty_request() { - let handler = StubHandler; - let request = UdsRequest::new(0x0E00, 0x1000, Bytes::new()); - - let response = handler.handle(request); - - assert_eq!(response.data.as_ref(), &[0x7F, 0x00, 0x11]); - } - - #[test] - fn stub_handler_tester_present() { - let handler = StubHandler; - let request = UdsRequest::new( - 0x0E00, - 0x1000, - Bytes::from(vec![0x3E, 0x00]), // TesterPresent - ); - - let response = handler.handle(request); - - assert_eq!(response.data.as_ref(), &[0x7F, 0x3E, 0x11]); - } + use bytes::Bytes; #[test] fn uds_request_service_id() { @@ -125,4 +103,14 @@ mod tests { 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/src/uds/mod.rs b/src/uds/mod.rs index be783e7..8209c1c 100644 --- a/src/uds/mod.rs +++ b/src/uds/mod.rs @@ -1,7 +1,24 @@ +/* + * Copyright (c) 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ + //! UDS Module //! -//! Provides the interface between DoIP transport and UDS processing. +//! Provides the interface between `DoIP` transport and UDS processing. +// #[cfg(any(test, feature = "test-handlers"))] +// pub mod dummy_handler; pub mod handler; +// #[cfg(any(test, feature = "test-handlers"))] +// pub mod stub_handler; -pub use handler::{UdsHandler, UdsRequest, UdsResponse, StubHandler}; +pub use handler::{service_id, UdsHandler, UdsRequest, UdsResponse}; From b12490a0f27fda74eac11de31930186e78abba21 Mon Sep 17 00:00:00 2001 From: vinayrs Date: Tue, 3 Mar 2026 11:27:00 +0530 Subject: [PATCH 06/17] style: apply cargo fmt --- src/doip/alive_check.rs | 9 +++-- src/doip/diagnostic_message.rs | 4 +- src/doip/header_parser.rs | 9 ++++- src/doip/mod.rs | 14 ++++--- src/doip/payload.rs | 16 ++++---- src/doip/routing_activation.rs | 18 ++++++--- src/doip/vehicle_id.rs | 69 ++++++++++++++++------------------ src/error.rs | 5 ++- 8 files changed, 80 insertions(+), 64 deletions(-) diff --git a/src/doip/alive_check.rs b/src/doip/alive_check.rs index 358834a..0ffb422 100644 --- a/src/doip/alive_check.rs +++ b/src/doip/alive_check.rs @@ -12,10 +12,10 @@ */ //! Alive Check handlers (ISO 13400-2) +use super::{DoipParseable, DoipSerializable}; +use crate::DoipError; use bytes::{BufMut, BytesMut}; use tracing::warn; -use crate::DoipError; -use super::{DoipParseable, DoipSerializable}; // Alive Check Request (0x0007) - no payload // Server sends this to check if tester is still connected @@ -49,7 +49,10 @@ impl DoipParseable for Response { .get(..Self::LEN) .and_then(|s| s.try_into().ok()) .ok_or_else(|| { - let e = DoipError::PayloadTooShort { expected: Self::LEN, actual: payload.len() }; + let e = DoipError::PayloadTooShort { + expected: Self::LEN, + actual: payload.len(), + }; warn!("AliveCheck Response parse failed: {}", e); e })?; diff --git a/src/doip/diagnostic_message.rs b/src/doip/diagnostic_message.rs index 2ed0988..b345c3a 100644 --- a/src/doip/diagnostic_message.rs +++ b/src/doip/diagnostic_message.rs @@ -12,10 +12,10 @@ */ //! Diagnostic Message handlers (ISO 13400-2:2019) +use super::{check_min_len, too_short, DoipParseable, DoipSerializable}; +use crate::DoipError; use bytes::{Buf, BufMut, Bytes, BytesMut}; use tracing::warn; -use crate::DoipError; -use super::{DoipParseable, DoipSerializable, too_short, check_min_len}; const ADDRESS_BYTES: usize = 2; const HEADER_BYTES: usize = ADDRESS_BYTES * 2; diff --git a/src/doip/header_parser.rs b/src/doip/header_parser.rs index 1da76b2..c00f0d7 100644 --- a/src/doip/header_parser.rs +++ b/src/doip/header_parser.rs @@ -397,7 +397,11 @@ impl DoipMessage { // version in every response. No production callers exist in this branch yet — // server handlers live on feature/doip-async-server and have not been ported here. #[allow(dead_code)] - pub(crate) fn with_version(version: u8, payload_type: PayloadType, payload: Bytes) -> Result { + pub(crate) fn with_version( + version: u8, + payload_type: PayloadType, + payload: Bytes, + ) -> Result { Ok(Self { header: DoipHeader { version, @@ -770,7 +774,8 @@ mod tests { #[test] fn create_vehicle_id_broadcast() { // Empty payload for discovery - let msg = DoipMessage::new(PayloadType::VehicleIdentificationRequest, Bytes::new()).unwrap(); + let msg = + DoipMessage::new(PayloadType::VehicleIdentificationRequest, Bytes::new()).unwrap(); assert_eq!(msg.header.payload_length, 0); assert_eq!(msg.message_length(), 8); // Just the header diff --git a/src/doip/mod.rs b/src/doip/mod.rs index 950fd16..42d3aa9 100644 --- a/src/doip/mod.rs +++ b/src/doip/mod.rs @@ -20,8 +20,8 @@ pub mod payload; pub mod routing_activation; pub mod vehicle_id; -use bytes::{Bytes, BytesMut}; use crate::DoipError; +use bytes::{Bytes, BytesMut}; /// Trait for DoIP message types that can be parsed from a raw payload slice. /// @@ -67,7 +67,10 @@ pub trait DoipSerializable { /// 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() } + DoipError::PayloadTooShort { + expected, + actual: payload.len(), + } } /// Return `Err` if `payload` is shorter than `expected` bytes. @@ -82,9 +85,8 @@ pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> std::result::Res // Re-export core types and constants for convenient access. // Constants are exported to allow external testing and custom DoIP message construction. pub use header_parser::{ - DoipCodec, DoipHeader, DoipMessage, GenericNackCode, PayloadType, - DEFAULT_PROTOCOL_VERSION, DEFAULT_PROTOCOL_VERSION_INV, DOIP_HEADER_LENGTH, - MAX_DOIP_MESSAGE_SIZE, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V3, - DOIP_VERSION_DEFAULT, DOIP_HEADER_VERSION_MASK, + DoipCodec, DoipHeader, DoipMessage, GenericNackCode, PayloadType, DEFAULT_PROTOCOL_VERSION, + DEFAULT_PROTOCOL_VERSION_INV, DOIP_HEADER_LENGTH, DOIP_HEADER_VERSION_MASK, + DOIP_VERSION_DEFAULT, MAX_DOIP_MESSAGE_SIZE, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V3, }; pub use payload::DoipPayload; diff --git a/src/doip/payload.rs b/src/doip/payload.rs index cc27854..1ae90d0 100644 --- a/src/doip/payload.rs +++ b/src/doip/payload.rs @@ -27,11 +27,11 @@ //! } //! ``` -use crate::DoipError; use super::{ - DoipMessage, DoipParseable, GenericNackCode, PayloadType, - alive_check, diagnostic_message, routing_activation, vehicle_id, + alive_check, diagnostic_message, routing_activation, vehicle_id, DoipMessage, DoipParseable, + GenericNackCode, PayloadType, }; +use crate::DoipError; /// A fully-parsed DoIP message payload. /// @@ -121,10 +121,10 @@ impl DoipPayload { vehicle_id::Response::parse(payload)?, )), PayloadType::GenericNack => { - let byte = payload - .first() - .copied() - .ok_or(DoipError::PayloadTooShort { expected: 1, actual: 0 })?; + 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)) @@ -165,8 +165,8 @@ impl DoipPayload { #[cfg(test)] mod tests { use super::*; - use bytes::Bytes; use crate::doip::{DoipMessage, PayloadType}; + use bytes::Bytes; fn make_msg(payload_type: PayloadType, payload: impl Into) -> DoipMessage { DoipMessage { diff --git a/src/doip/routing_activation.rs b/src/doip/routing_activation.rs index f8282ed..840b660 100644 --- a/src/doip/routing_activation.rs +++ b/src/doip/routing_activation.rs @@ -12,10 +12,10 @@ */ //! Routing Activation handlers (ISO 13400-2:2019) +use super::{check_min_len, too_short, DoipParseable, DoipSerializable}; +use crate::DoipError; use bytes::{Buf, BufMut, Bytes, BytesMut}; use tracing::warn; -use crate::DoipError; -use super::{DoipParseable, DoipSerializable, too_short, check_min_len}; // Response codes per ISO 13400-2:2019 Table 25 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -261,8 +261,8 @@ mod tests { 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 + const OEM_DATA_START: usize = Response::MIN_LEN; // 9 + const OEM_DATA_END: usize = Response::MAX_LEN; // 13 #[test] fn response_code_success_check() { @@ -341,7 +341,10 @@ mod tests { 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); + assert_eq!( + bytes[RESP_CODE_IDX], + ResponseCode::SuccessfullyActivated as u8 + ); } #[test] @@ -351,7 +354,10 @@ mod tests { 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]); + assert_eq!( + &bytes[OEM_DATA_START..OEM_DATA_END], + &[0x12, 0x34, 0x56, 0x78] + ); } #[test] diff --git a/src/doip/vehicle_id.rs b/src/doip/vehicle_id.rs index 2c9f2ba..98cbb4c 100644 --- a/src/doip/vehicle_id.rs +++ b/src/doip/vehicle_id.rs @@ -13,10 +13,10 @@ //! Vehicle Identification handlers (ISO 13400-2:2019) +use super::{check_min_len, too_short, DoipParseable, DoipSerializable}; +use crate::DoipError; use bytes::{BufMut, BytesMut}; use tracing::warn; -use crate::DoipError; -use super::{DoipParseable, DoipSerializable, too_short, check_min_len}; // Wire-format field lengths for VehicleIdentificationResponse (ISO 13400-2:2019) const VIN_LEN: usize = 17; @@ -26,14 +26,14 @@ 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 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 (0x0001) - no payload @@ -126,8 +126,8 @@ pub struct Response { } impl Response { - pub const MIN_LEN: usize = SYNC_STATUS_IDX; // 32: VIN(17) + Addr(2) + EID(6) + GID(6) + FurtherAction(1) - pub const MAX_LEN: usize = SYNC_STATUS_IDX + 1; // 33: adds optional SyncStatus(1) + pub const MIN_LEN: usize = SYNC_STATUS_IDX; // 32: VIN(17) + Addr(2) + EID(6) + GID(6) + FurtherAction(1) + pub const MAX_LEN: usize = SYNC_STATUS_IDX + 1; // 33: adds optional SyncStatus(1) #[must_use] pub fn new(vin: [u8; 17], logical_address: u16, eid: [u8; 6], gid: [u8; 6]) -> Self { @@ -202,39 +202,36 @@ impl DoipParseable for Response { 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 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 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 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) - .unwrap_or(FurtherAction::NoFurtherAction); + let further_action = + FurtherAction::try_from(further_action_byte).unwrap_or(FurtherAction::NoFurtherAction); - let sync_status = payload.get(SYNC_STATUS_IDX) + let sync_status = payload + .get(SYNC_STATUS_IDX) .map(|&b| SyncStatus::try_from(b).unwrap_or(SyncStatus::Synchronized)); Ok(Self { diff --git a/src/error.rs b/src/error.rs index 4b30946..bc2a1ad 100644 --- a/src/error.rs +++ b/src/error.rs @@ -218,7 +218,10 @@ mod tests { service: 0x10, nrc: 0x11, }, - DoipError::PayloadTooShort { expected: 8, actual: 4 }, + DoipError::PayloadTooShort { + expected: 8, + actual: 4, + }, DoipError::UnknownRoutingActivationResponseCode(0x99), DoipError::EmptyUserData, DoipError::InvalidVinLength(10), From 7aefa63c410d9a05de253694350fe54c985ad21e Mon Sep 17 00:00:00 2001 From: vinayrs Date: Thu, 5 Mar 2026 12:42:51 +0530 Subject: [PATCH 07/17] refactor(doip): address PR review comments - Split header_parser.rs into header.rs + codec.rs - Merge ParseError into DoipError (single error type) - Add parse_fixed_slice helper, remove duplicated parse patterns - Add DoipParseable / DoipSerializable traits - Make struct fields private, add public getters - Set default-features = false on all dependencies - Trim tokio features to io-util only - Remove dead test-handlers feature from Cargo.toml --- Cargo.lock | 581 +---------------------- src/doip/alive_check.rs | 8 +- src/doip/codec.rs | 173 +++++++ src/doip/diagnostic_message.rs | 69 ++- src/doip/{header_parser.rs => header.rs} | 343 ++++--------- src/doip/mod.rs | 39 +- src/doip/payload.rs | 54 +-- src/doip/routing_activation.rs | 131 +++-- src/doip/vehicle_id.rs | 57 ++- src/error.rs | 18 +- src/lib.rs | 2 +- src/server/config.rs | 177 +++++-- src/server/mod.rs | 21 + src/server/session.rs | 77 ++- src/uds/handler.rs | 65 ++- src/uds/mod.rs | 6 +- 16 files changed, 785 insertions(+), 1036 deletions(-) create mode 100644 src/doip/codec.rs rename src/doip/{header_parser.rs => header.rs} (73%) create mode 100644 src/server/mod.rs diff --git a/Cargo.lock b/Cargo.lock index d705ef4..6117b7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,83 +2,12 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - [[package]] name = "bytes" version = "1.11.0" @@ -91,80 +20,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "clap" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "doip-server" version = "0.1.0" dependencies = [ - "anyhow", "bytes", - "clap", - "dashmap", + "hex", "parking_lot", "serde", "thiserror", @@ -172,24 +33,6 @@ dependencies = [ "tokio-util", "toml", "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", ] [[package]] @@ -205,66 +48,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "libc" @@ -281,59 +68,12 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "parking_lot" version = "0.12.5" @@ -381,12 +121,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -396,29 +130,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - [[package]] name = "scopeguard" version = "1.2.0" @@ -464,47 +175,12 @@ dependencies = [ "serde_core", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "syn" version = "2.0.114" @@ -536,15 +212,6 @@ dependencies = [ "syn", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - [[package]] name = "tokio" version = "1.49.0" @@ -552,25 +219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", - "libc", - "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", ] [[package]] @@ -592,12 +241,10 @@ version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ - "indexmap", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", - "toml_writer", "winnow", ] @@ -619,12 +266,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "toml_writer" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" - [[package]] name = "tracing" version = "0.1.44" @@ -632,21 +273,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -654,36 +283,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", ] [[package]] @@ -692,186 +291,14 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" -dependencies = [ - "getrandom", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" diff --git a/src/doip/alive_check.rs b/src/doip/alive_check.rs index 0ffb422..5cd1045 100644 --- a/src/doip/alive_check.rs +++ b/src/doip/alive_check.rs @@ -40,7 +40,7 @@ impl DoipSerializable for Request { // Tester responds with its logical address #[derive(Debug, Clone, PartialEq, Eq)] pub struct Response { - pub source_address: u16, + source_address: u16, } impl DoipParseable for Response { @@ -79,6 +79,12 @@ impl Response { 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)] diff --git a/src/doip/codec.rs b/src/doip/codec.rs new file mode 100644 index 0000000..dd5f748 --- /dev/null +++ b/src/doip/codec.rs @@ -0,0 +1,173 @@ +/* + * 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 bytes::BytesMut; +use std::io; +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 { + #[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!("Received raw header bytes: {:02X?}", header_slice); + + let header = DoipHeader::parse(header_slice) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + if let Some(nack_code) = header.validate() { + warn!( + "Header validation failed: {:?} - raw bytes: {:02X?}", + nack_code, header_slice + ); + 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 - 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/src/doip/diagnostic_message.rs b/src/doip/diagnostic_message.rs index b345c3a..fde3c8b 100644 --- a/src/doip/diagnostic_message.rs +++ b/src/doip/diagnostic_message.rs @@ -12,7 +12,7 @@ */ //! Diagnostic Message handlers (ISO 13400-2:2019) -use super::{check_min_len, too_short, DoipParseable, DoipSerializable}; +use super::{DoipParseable, DoipSerializable, parse_fixed_slice, too_short}; use crate::DoipError; use bytes::{Buf, BufMut, Bytes, BytesMut}; use tracing::warn; @@ -29,6 +29,12 @@ pub enum AckCode { Acknowledged = 0x00, } +impl From for u8 { + fn from(code: AckCode) -> u8 { + code as u8 + } +} + // Diagnostic message negative ack codes per ISO 13400-2:2019 Table 28 #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] @@ -59,13 +65,19 @@ impl TryFrom for NackCode { } } +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. +/// 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+) +/// Payload: SA(2) + TA(2) + `user_data(1`+) #[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { source_address: u16, @@ -124,10 +136,10 @@ impl Message { /// Diagnostic Message Positive Acknowledgment (message type 0x8002) /// -/// Sent by a DoIP entity to acknowledge receipt of a diagnostic message. +/// Sent by a `DoIP` entity to acknowledge receipt of a diagnostic message. /// /// # Wire Format -/// Payload: SA(2) + TA(2) + ack_code(1) + optional previous_diag_data +/// Payload: SA(2) + TA(2) + `ack_code(1)` + optional `previous_diag_data` #[derive(Debug, Clone, PartialEq, Eq)] pub struct PositiveAck { source_address: u16, @@ -141,6 +153,7 @@ impl PositiveAck { pub const MIN_LEN: usize = HEADER_BYTES + ACK_CODE_BYTES; /// Create a new positive acknowledgment + #[must_use] pub fn new(source: u16, target: u16) -> Self { Self { source_address: source, @@ -184,11 +197,11 @@ impl PositiveAck { /// Diagnostic Message Negative Acknowledgment (message type 0x8003) /// -/// Sent by a DoIP entity to indicate rejection of a diagnostic message. +/// Sent by a `DoIP` entity to indicate rejection of a diagnostic message. /// NACK codes are defined in ISO 13400-2:2019 Table 28. /// /// # Wire Format -/// Payload: SA(2) + TA(2) + nack_code(1) + optional previous_diag_data +/// Payload: SA(2) + TA(2) + `nack_code(1)` + optional `previous_diag_data` #[derive(Debug, Clone, PartialEq, Eq)] pub struct NegativeAck { source_address: u16, @@ -202,6 +215,7 @@ impl NegativeAck { pub const MIN_LEN: usize = HEADER_BYTES + ACK_CODE_BYTES; /// Create a new negative acknowledgment + #[must_use] pub fn new(source: u16, target: u16, code: NackCode) -> Self { Self { source_address: source, @@ -245,14 +259,7 @@ impl NegativeAck { impl DoipParseable for Message { fn parse(payload: &[u8]) -> std::result::Result { - let header: [u8; HEADER_BYTES] = payload - .get(..HEADER_BYTES) - .and_then(|s| s.try_into().ok()) - .ok_or_else(|| { - let e = too_short(payload, Self::MIN_LEN); - warn!("DiagnosticMessage parse failed: {}", e); - e - })?; + 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]]); @@ -293,15 +300,7 @@ impl DoipSerializable for Message { impl DoipParseable for PositiveAck { fn parse(payload: &[u8]) -> std::result::Result { - if let Err(e) = check_min_len(payload, Self::MIN_LEN) { - warn!("DiagnosticPositiveAck parse failed: {}", e); - return Err(e); - } - - let header: [u8; HEADER_BYTES] = payload - .get(..HEADER_BYTES) - .and_then(|s| s.try_into().ok()) - .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; + let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, "DiagnosticPositiveAck")?; let source_address = u16::from_be_bytes([header[0], header[1]]); let target_address = u16::from_be_bytes([header[2], header[3]]); @@ -323,13 +322,13 @@ impl DoipParseable for PositiveAck { impl DoipSerializable for PositiveAck { fn serialized_len(&self) -> Option { - Some(Self::MIN_LEN + self.previous_data.as_ref().map_or(0, |d| d.len())) + Some(Self::MIN_LEN + 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(self.ack_code as u8); + buf.put_u8(u8::from(self.ack_code)); if let Some(ref data) = self.previous_data { buf.extend_from_slice(data); } @@ -338,23 +337,15 @@ impl DoipSerializable for PositiveAck { impl DoipParseable for NegativeAck { fn parse(payload: &[u8]) -> std::result::Result { - if let Err(e) = check_min_len(payload, Self::MIN_LEN) { - warn!("DiagnosticNegativeAck parse failed: {}", e); - return Err(e); - } - - let header: [u8; HEADER_BYTES] = payload - .get(..HEADER_BYTES) - .and_then(|s| s.try_into().ok()) - .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; + let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, "DiagnosticNegativeAck")?; let source_address = u16::from_be_bytes([header[0], header[1]]); let target_address = u16::from_be_bytes([header[2], header[3]]); let nack_code = payload .get(HEADER_BYTES) .copied() - .and_then(|b| NackCode::try_from(b).ok()) - .unwrap_or(NackCode::TransportProtocolError); + .ok_or_else(|| too_short(payload, Self::MIN_LEN)) + .and_then(NackCode::try_from)?; let previous_data = payload .get(Self::MIN_LEN..) @@ -372,13 +363,13 @@ impl DoipParseable for NegativeAck { impl DoipSerializable for NegativeAck { fn serialized_len(&self) -> Option { - Some(Self::MIN_LEN + self.previous_data.as_ref().map_or(0, |d| d.len())) + Some(Self::MIN_LEN + 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(self.nack_code as u8); + buf.put_u8(u8::from(self.nack_code)); if let Some(ref data) = self.previous_data { buf.extend_from_slice(data); } diff --git a/src/doip/header_parser.rs b/src/doip/header.rs similarity index 73% rename from src/doip/header_parser.rs rename to src/doip/header.rs index c00f0d7..884b6db 100644 --- a/src/doip/header_parser.rs +++ b/src/doip/header.rs @@ -11,15 +11,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -//! `DoIP` Header Parser and Codec +//! `DoIP` Header Types //! -//! This module implements the core `DoIP` protocol header parsing and message framing -//! according to ISO 13400-2:2019. It provides: -//! -//! - **Header Validation**: Checks protocol version, payload types, and message sizes -//! - **Message Framing**: Tokio codec for TCP stream processing with proper buffering -//! - **Type Safety**: Strongly-typed payload types and error codes per ISO specification -//! - **`DoS` Protection**: Configurable max message size limits (default 4MB) +//! 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) @@ -28,10 +23,10 @@ //! //! 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::{Buf, BufMut, Bytes, BytesMut}; -use std::io; -use tokio_util::codec::{Decoder, Encoder}; +use bytes::{BufMut, Bytes, BytesMut}; use tracing::{debug, warn}; /// Generic Header NACK codes (ISO 13400-2:2019 Table 17) @@ -63,19 +58,6 @@ impl TryFrom for GenericNackCode { } } -#[derive(thiserror::Error, Debug)] -pub(crate) enum ParseError { - #[error("Invalid header: {0}")] - InvalidHeader(String), - /// Payload length exceeds `u32::MAX` (DoIP protocol limit is 4 MB) - #[error("DoIP payload exceeds u32::MAX (protocol limit is 4 MB)")] - PayloadTooLarge, - #[error("IO error: {0}")] - IoError(#[from] io::Error), -} - -pub(crate) type Result = std::result::Result; - /// `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) @@ -148,6 +130,12 @@ impl TryFrom for PayloadType { } } +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) /// @@ -187,23 +175,23 @@ impl PayloadType { /// during validation but don't cause parsing failures. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DoipHeader { - pub version: u8, - pub inverse_version: u8, - pub payload_type: u16, - pub payload_length: u32, + version: u8, + inverse_version: u8, + payload_type: u16, + payload_length: u32, } impl DoipHeader { /// Parse a `DoIP` header from a byte slice /// /// # Errors - /// Returns `ParseError::InvalidHeader` if data is less than 8 bytes - pub(crate) fn parse(data: &[u8]) -> Result { + /// Returns [`crate::DoipError::InvalidHeader`] if data is less than 8 bytes. + pub(crate) fn parse(data: &[u8]) -> std::result::Result { let header: [u8; DOIP_HEADER_LENGTH] = data .get(..DOIP_HEADER_LENGTH) .and_then(|s| s.try_into().ok()) .ok_or_else(|| { - ParseError::InvalidHeader(format!( + crate::DoipError::InvalidHeader(format!( "DoIP header too short: expected {}, got {}", DOIP_HEADER_LENGTH, data.len() @@ -218,31 +206,6 @@ impl DoipHeader { }) } - /// Parse a `DoIP` header from a mutable Bytes buffer - /// - /// # Errors - /// Returns `ParseError::InvalidHeader` if buffer is less than 8 bytes - // - // Used by the streaming decode path in the TCP/UDP server handlers - // (feature/doip-async-server). Not yet called in this branch because - // the server handler layer has not been ported here yet. - #[allow(dead_code)] - pub(crate) fn parse_from_buf(buf: &mut Bytes) -> Result { - if buf.len() < DOIP_HEADER_LENGTH { - return Err(ParseError::InvalidHeader(format!( - "DoIP header too short: expected {}, got {}", - DOIP_HEADER_LENGTH, - buf.len() - ))); - } - Ok(Self { - version: buf.get_u8(), - inverse_version: buf.get_u8(), - payload_type: buf.get_u16(), - payload_length: buf.get_u32(), - }) - } - pub fn validate(&self) -> Option { debug!( "Validating DoIP header: version=0x{:02X}, inverse=0x{:02X}, type=0x{:04X}, len={}", @@ -284,7 +247,10 @@ impl DoipHeader { warn!("Message too large: {} bytes", self.payload_length); return Some(GenericNackCode::MessageTooLarge); } - if (self.payload_length as usize) < payload_type.min_payload_length() { + 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 too short for {:?}: {} < {}", payload_type, @@ -302,10 +268,14 @@ impl DoipHeader { self.validate().is_none() } - /// Returns the total message length (header + payload) + /// 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) -> usize { - DOIP_HEADER_LENGTH.saturating_add(self.payload_length as usize) + 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 @@ -322,6 +292,30 @@ impl DoipHeader { 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 { @@ -355,63 +349,23 @@ impl std::fmt::Display for DoipHeader { /// ISO 13400-2 uses the term "message" throughout the specification. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DoipMessage { - pub header: DoipHeader, - pub payload: Bytes, + pub(crate) header: DoipHeader, + pub(crate) payload: Bytes, } impl DoipMessage { /// Create a new `DoIP` message with the default protocol version. - /// - /// # Errors - /// - /// Returns [`ParseError::PayloadTooLarge`] if `payload.len()` exceeds `u32::MAX`. - /// In practice this cannot occur because the `DoIP` codec enforces a 4 MB message limit. - // - // Called by server handlers to build outgoing response messages. - // No production callers exist in this branch yet — server handlers live - // on feature/doip-async-server and have not been ported here. - #[allow(dead_code)] - pub(crate) fn new(payload_type: PayloadType, payload: Bytes) -> Result { - Ok(Self { + #[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: payload_type as u16, - payload_length: u32::try_from(payload.len()) - .map_err(|_| ParseError::PayloadTooLarge)?, + 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 specific protocol version. - /// - /// This mirrors the version from the incoming request. - /// - /// # Errors - /// - /// Returns [`ParseError::PayloadTooLarge`] if `payload.len()` exceeds `u32::MAX`. - /// In practice this cannot occur because the `DoIP` codec enforces a 4 MB message limit. - // - // Required by ISO 13400-2:2019: the server must mirror the client's protocol - // version in every response. No production callers exist in this branch yet — - // server handlers live on feature/doip-async-server and have not been ported here. - #[allow(dead_code)] - pub(crate) fn with_version( - version: u8, - payload_type: PayloadType, - payload: Bytes, - ) -> Result { - Ok(Self { - header: DoipHeader { - version, - inverse_version: version ^ DOIP_HEADER_VERSION_MASK, // Compute inverse - payload_type: payload_type as u16, - payload_length: u32::try_from(payload.len()) - .map_err(|_| ParseError::PayloadTooLarge)?, - }, - payload, - }) + } } /// Create a DoIP message with a raw (unparsed) payload type @@ -425,9 +379,7 @@ impl DoipMessage { version: DEFAULT_PROTOCOL_VERSION, inverse_version: DEFAULT_PROTOCOL_VERSION_INV, payload_type, - // Safe cast: DoIP messages are limited to MAX_DOIP_MESSAGE_SIZE (4MB) - // which fits in u32. The codec enforces this limit during decode. - payload_length: payload.len() as u32, + payload_length: u32::try_from(payload.len()).expect("test payload fits in u32"), }, payload, } @@ -437,6 +389,16 @@ impl DoipMessage { 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 { @@ -451,134 +413,6 @@ impl DoipMessage { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DecodeState { - Header, - Payload(DoipHeader), -} - -#[derive(Debug)] -pub struct DoipCodec { - state: DecodeState, - max_payload_size: u32, -} - -impl DoipCodec { - #[must_use] - pub fn new() -> Self { - Self { - state: DecodeState::Header, - max_payload_size: MAX_DOIP_MESSAGE_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; - - // Using fully qualified std::result::Result because the module-level Result - // type alias uses ParseError, but the Decoder trait requires 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!("Received raw header bytes: {:02X?}", header_slice); - - let header = DoipHeader::parse(header_slice) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - if let Some(nack_code) = header.validate() { - warn!( - "Header validation failed: {:?} - raw bytes: {:02X?}", - nack_code, header_slice - ); - 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 - src.reserve(header.message_length()); - self.state = DecodeState::Payload(header); - } - - DecodeState::Payload(header) => { - let total_len = header.message_length(); - 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(header.payload_length as usize).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(()) - } -} - // ============================================================================ // Unit Tests // ============================================================================ @@ -586,6 +420,8 @@ impl Encoder for DoipCodec { #[cfg(test)] mod tests { use super::*; + use crate::doip::codec::DoipCodec; + use tokio_util::codec::{Decoder, Encoder}; // --- Helper to build a valid DoIP header quickly --- fn make_header(payload_type: u16, payload_len: u32) -> DoipHeader { @@ -764,7 +600,7 @@ mod tests { 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).unwrap(); + let msg = DoipMessage::new(PayloadType::DiagnosticMessage, uds); assert_eq!(msg.header.payload_type, 0x8001); assert_eq!(msg.header.payload_length, 6); @@ -774,8 +610,7 @@ mod tests { #[test] fn create_vehicle_id_broadcast() { // Empty payload for discovery - let msg = - DoipMessage::new(PayloadType::VehicleIdentificationRequest, Bytes::new()).unwrap(); + let msg = DoipMessage::new(PayloadType::VehicleIdentificationRequest, Bytes::new()); assert_eq!(msg.header.payload_length, 0); assert_eq!(msg.message_length(), 8); // Just the header @@ -790,7 +625,7 @@ mod tests { #[test] fn serialize_message_to_wire_format() { - let msg = DoipMessage::new(PayloadType::AliveCheckRequest, Bytes::new()).unwrap(); + let msg = DoipMessage::new(PayloadType::AliveCheckRequest, Bytes::new()); let wire = msg.to_bytes(); assert_eq!(wire.len(), 8); @@ -876,7 +711,7 @@ mod tests { 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).unwrap(); + let msg = DoipMessage::new(PayloadType::DiagnosticMessage, payload); let mut buf = BytesMut::new(); codec.encode(msg, &mut buf).unwrap(); @@ -896,8 +731,7 @@ mod tests { let original = DoipMessage::new( PayloadType::DiagnosticMessage, Bytes::from_static(&[0x0E, 0x80, 0x10, 0x01, 0x22, 0xF1, 0x90]), - ) - .unwrap(); + ); let mut buf = BytesMut::new(); codec.encode(original.clone(), &mut buf).unwrap(); @@ -936,20 +770,9 @@ mod tests { #[test] fn parse_error_shows_useful_message() { - let err = ParseError::InvalidHeader("buffer too short".into()); - let msg = format!("{err}"); - assert!(msg.contains("Invalid header")); - assert!(msg.contains("buffer too short")); - } - - #[test] - fn io_errors_convert_to_parse_errors() { - let io_err = io::Error::new(io::ErrorKind::UnexpectedEof, "connection lost"); - let parse_err: ParseError = io_err.into(); - match parse_err { - ParseError::IoError(e) => assert_eq!(e.kind(), io::ErrorKind::UnexpectedEof), - _ => panic!("expected Io variant"), - } + let result = DoipHeader::parse(&[]); + let msg = format!("{}", result.unwrap_err()); + assert!(msg.contains("DoIP header")); } #[test] diff --git a/src/doip/mod.rs b/src/doip/mod.rs index 42d3aa9..d9ebdba 100644 --- a/src/doip/mod.rs +++ b/src/doip/mod.rs @@ -14,28 +14,30 @@ //! This module provides the core `DoIP` protocol types and codec for TCP/UDP communication. pub mod alive_check; +pub mod codec; pub mod diagnostic_message; -pub mod header_parser; +pub mod header; pub mod payload; pub mod routing_activation; pub mod vehicle_id; use crate::DoipError; use bytes::{Bytes, BytesMut}; +use tracing::warn; -/// Trait for DoIP message types that can be parsed from a raw payload slice. +/// 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. +/// `DoIP` frames through a uniform interface. pub trait DoipParseable: Sized { - /// Parse a DoIP message from a raw payload byte slice. + /// 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]) -> std::result::Result; } -/// Trait for DoIP message types that can be serialized to a [`Bytes`] buffer. +/// 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 @@ -82,11 +84,30 @@ pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> std::result::Res } } +/// Extract the first `N` bytes of `payload` as a fixed-size array. +/// +/// Logs a warning 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, +) -> std::result::Result<[u8; N], DoipError> { + payload + .get(..N) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| { + let e = too_short(payload, N); + warn!("{} parse failed: {}", context, e); + e + }) +} + // Re-export core types and constants for convenient access. // Constants are exported to allow external testing and custom DoIP message construction. -pub use header_parser::{ - DoipCodec, DoipHeader, DoipMessage, GenericNackCode, PayloadType, DEFAULT_PROTOCOL_VERSION, - DEFAULT_PROTOCOL_VERSION_INV, DOIP_HEADER_LENGTH, DOIP_HEADER_VERSION_MASK, - DOIP_VERSION_DEFAULT, MAX_DOIP_MESSAGE_SIZE, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V3, +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; diff --git a/src/doip/payload.rs b/src/doip/payload.rs index 1ae90d0..ba484e2 100644 --- a/src/doip/payload.rs +++ b/src/doip/payload.rs @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: Apache-2.0 */ -//! Typed dispatch envelope for DoIP message payloads (ISO 13400-2:2019). +//! 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 @@ -18,22 +18,26 @@ //! [`PayloadType`] throughout the codebase. //! //! # Example -//! ```ignore -//! let payload = DoipPayload::parse(&msg)?; -//! match payload { -//! DoipPayload::DiagnosticMessage(m) => handle_diag(m), -//! DoipPayload::AliveCheckRequest(_) => send_alive_response(), -//! _ => {} +//! ```no_run +//! # use doip_server::doip::{DoipMessage, DoipPayload}; +//! # use doip_server::DoipError; +//! fn dispatch(msg: &DoipMessage) -> Result<(), DoipError> { +//! match DoipPayload::parse(msg)? { +//! DoipPayload::DiagnosticMessage(m) => println!("UDS payload: {:?}", m), +//! DoipPayload::AliveCheckRequest(_) => println!("Alive check received"), +//! _ => {} +//! } +//! Ok(()) //! } //! ``` use super::{ - alive_check, diagnostic_message, routing_activation, vehicle_id, DoipMessage, DoipParseable, - GenericNackCode, PayloadType, + DoipMessage, DoipParseable, GenericNackCode, PayloadType, alive_check, diagnostic_message, + routing_activation, vehicle_id, }; use crate::DoipError; -/// A fully-parsed DoIP message payload. +/// A fully-parsed `DoIP` message payload. /// /// Each variant corresponds to one [`PayloadType`] and wraps the concrete /// struct returned by its [`DoipParseable`] impl. @@ -61,7 +65,7 @@ pub enum DoipPayload { VehicleIdentificationRequestWithVin(vehicle_id::RequestWithVin), /// `0x0004` – Vehicle Identification Response / Announce VehicleIdentificationResponse(vehicle_id::Response), - /// `0x0000` – Generic DoIP Header Negative Acknowledgement + /// `0x0000` – Generic `DoIP` Header Negative Acknowledgement GenericNack(GenericNackCode), } @@ -71,16 +75,16 @@ impl DoipPayload { /// # 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`. + /// 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) -> std::result::Result { - let payload = msg.payload.as_ref(); + let payload = msg.payload().as_ref(); let payload_type = msg .payload_type() - .ok_or(DoipError::UnknownPayloadType(msg.header.payload_type))?; + .ok_or(DoipError::UnknownPayloadType(msg.header().payload_type()))?; match payload_type { PayloadType::AliveCheckRequest => Ok(Self::AliveCheckRequest( @@ -133,7 +137,7 @@ impl DoipPayload { | PayloadType::DoipEntityStatusResponse | PayloadType::DiagnosticPowerModeRequest | PayloadType::DiagnosticPowerModeResponse => { - Err(DoipError::UnknownPayloadType(payload_type as u16)) + Err(DoipError::UnknownPayloadType(u16::from(payload_type))) } } } @@ -169,15 +173,7 @@ mod tests { use bytes::Bytes; fn make_msg(payload_type: PayloadType, payload: impl Into) -> DoipMessage { - DoipMessage { - header: crate::doip::DoipHeader { - version: crate::doip::DEFAULT_PROTOCOL_VERSION, - inverse_version: crate::doip::DEFAULT_PROTOCOL_VERSION_INV, - payload_type: payload_type as u16, - payload_length: 0, - }, - payload: payload.into(), - } + DoipMessage::new(payload_type, payload.into()) } #[test] @@ -224,15 +220,7 @@ mod tests { #[test] fn unknown_payload_type_error() { - let msg = DoipMessage { - header: crate::doip::DoipHeader { - version: crate::doip::DEFAULT_PROTOCOL_VERSION, - inverse_version: crate::doip::DEFAULT_PROTOCOL_VERSION_INV, - payload_type: 0xFFFF, - payload_length: 0, - }, - payload: Bytes::new(), - }; + let msg = DoipMessage::with_raw_payload_type(0xFFFF, Bytes::new()); let err = DoipPayload::parse(&msg).unwrap_err(); assert!(matches!(err, crate::DoipError::UnknownPayloadType(0xFFFF))); } diff --git a/src/doip/routing_activation.rs b/src/doip/routing_activation.rs index 840b660..e25b05b 100644 --- a/src/doip/routing_activation.rs +++ b/src/doip/routing_activation.rs @@ -12,24 +12,34 @@ */ //! Routing Activation handlers (ISO 13400-2:2019) -use super::{check_min_len, too_short, DoipParseable, DoipSerializable}; +use super::{DoipParseable, DoipSerializable, check_min_len, parse_fixed_slice}; use crate::DoipError; use bytes::{Buf, BufMut, Bytes, BytesMut}; use tracing::warn; -// Response codes per ISO 13400-2:2019 Table 25 +/// 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, } @@ -53,7 +63,14 @@ impl TryFrom for ResponseCode { } } +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!( @@ -63,12 +80,15 @@ impl ResponseCode { } } -// Activation types per ISO 13400-2:2019 Table 24 +/// 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, } @@ -88,16 +108,40 @@ impl TryFrom for ActivationType { // Routing Activation Request - payload is 7 bytes min, 11 with OEM data #[derive(Debug, Clone, PartialEq, Eq)] pub struct Request { - pub source_address: u16, - pub activation_type: ActivationType, - pub reserved: u32, - pub oem_specific: Option, + source_address: u16, + activation_type: ActivationType, + reserved: u32, + oem_specific: Option, } impl Request { pub const MIN_LEN: usize = 7; 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 + } + /// Parse routing activation request from buffer /// /// # Errors @@ -130,55 +174,81 @@ impl Request { // Routing Activation Response - 9 bytes min, 13 with OEM data #[derive(Debug, Clone, PartialEq, Eq)] pub struct Response { - pub tester_address: u16, - pub entity_address: u16, - pub response_code: ResponseCode, - pub reserved: u32, - pub oem_specific: Option, + tester_address: u16, + entity_address: u16, + code: ResponseCode, + reserved: u32, + oem_specific: Option, } impl Response { pub const MIN_LEN: usize = 9; 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, - response_code: ResponseCode::SuccessfullyActivated, + 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, - response_code: code, + 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.response_code.is_success() + 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]) -> std::result::Result { - let header: [u8; Self::MIN_LEN] = payload - .get(..Self::MIN_LEN) - .and_then(|s| s.try_into().ok()) - .ok_or_else(|| { - let e = too_short(payload, Self::MIN_LEN); - warn!("RoutingActivation Request parse failed: {}", e); - e - })?; + 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| { @@ -203,14 +273,7 @@ impl DoipParseable for Request { impl DoipParseable for Response { fn parse(payload: &[u8]) -> std::result::Result { - let header: [u8; Self::MIN_LEN] = payload - .get(..Self::MIN_LEN) - .and_then(|s| s.try_into().ok()) - .ok_or_else(|| { - let e = too_short(payload, Self::MIN_LEN); - warn!("RoutingActivation Response parse failed: {}", e); - e - })?; + 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]]); @@ -228,7 +291,7 @@ impl DoipParseable for Response { Ok(Self { tester_address, entity_address, - response_code, + code: response_code, reserved, oem_specific, }) @@ -243,7 +306,7 @@ impl DoipSerializable for Response { fn write_to(&self, buf: &mut BytesMut) { buf.put_u16(self.tester_address); buf.put_u16(self.entity_address); - buf.put_u8(self.response_code as u8); + buf.put_u8(u8::from(self.code)); buf.put_u32(self.reserved); if let Some(oem) = self.oem_specific { buf.put_u32(oem); @@ -374,7 +437,7 @@ mod tests { 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); + assert_eq!(resp.response_code(), ResponseCode::AllSocketsRegistered); } #[test] diff --git a/src/doip/vehicle_id.rs b/src/doip/vehicle_id.rs index 98cbb4c..c52eb22 100644 --- a/src/doip/vehicle_id.rs +++ b/src/doip/vehicle_id.rs @@ -13,7 +13,7 @@ //! Vehicle Identification handlers (ISO 13400-2:2019) -use super::{check_min_len, too_short, DoipParseable, DoipSerializable}; +use super::{DoipParseable, DoipSerializable, check_min_len, too_short}; use crate::DoipError; use bytes::{BufMut, BytesMut}; use tracing::warn; @@ -43,7 +43,7 @@ pub struct Request; // Vehicle Identification Request with EID (0x0002) - 6 byte EID #[derive(Debug, Clone, PartialEq, Eq)] pub struct RequestWithEid { - pub eid: [u8; 6], + eid: [u8; 6], } impl RequestWithEid { @@ -53,12 +53,18 @@ impl RequestWithEid { 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 with VIN (0x0003) - 17 byte VIN #[derive(Debug, Clone, PartialEq, Eq)] pub struct RequestWithVin { - pub vin: [u8; 17], + vin: [u8; 17], } impl RequestWithVin { @@ -69,6 +75,12 @@ impl RequestWithVin { Self { vin } } + /// The VIN filter value as bytes + #[must_use] + pub fn vin(&self) -> &[u8; 17] { + &self.vin + } + #[must_use] pub fn vin_string(&self) -> String { String::from_utf8_lossy(&self.vin).to_string() @@ -94,6 +106,12 @@ impl TryFrom for FurtherAction { } } +impl From for u8 { + fn from(action: FurtherAction) -> u8 { + action as u8 + } +} + // Synchronization status per ISO 13400-2:2019 Table 22 #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] @@ -113,16 +131,22 @@ impl TryFrom for SyncStatus { } } +impl From for u8 { + fn from(status: SyncStatus) -> u8 { + status as u8 + } +} + // Vehicle Identification Response (0x0004) // VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1) = 32 bytes min #[derive(Debug, Clone, PartialEq, Eq)] pub struct Response { - pub vin: [u8; 17], - pub logical_address: u16, - pub eid: [u8; 6], - pub gid: [u8; 6], - pub further_action: FurtherAction, - pub sync_status: Option, + vin: [u8; 17], + logical_address: u16, + eid: [u8; 6], + gid: [u8; 6], + further_action: FurtherAction, + sync_status: Option, } impl Response { @@ -200,7 +224,7 @@ impl DoipParseable for Response { if let Err(e) = check_min_len(payload, Self::MIN_LEN) { warn!("VehicleId Response parse failed: {}", e); return Err(e); - }; + } let vin: [u8; VIN_LEN] = payload .get(..VIN_END) @@ -227,12 +251,13 @@ impl DoipParseable for Response { .get(FURTHER_ACTION_IDX) .copied() .ok_or_else(|| too_short(payload, Self::MIN_LEN))?; - let further_action = - FurtherAction::try_from(further_action_byte).unwrap_or(FurtherAction::NoFurtherAction); + 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).unwrap_or(SyncStatus::Synchronized)); + .map(|&b| SyncStatus::try_from(b).map_err(DoipError::UnknownSyncStatus)) + .transpose()?; Ok(Self { vin, @@ -247,7 +272,7 @@ impl DoipParseable for Response { impl DoipSerializable for Response { fn serialized_len(&self) -> Option { - Some(Self::MIN_LEN + if self.sync_status.is_some() { 1 } else { 0 }) + Some(Self::MIN_LEN + usize::from(self.sync_status.is_some())) } fn write_to(&self, buf: &mut BytesMut) { @@ -255,9 +280,9 @@ impl DoipSerializable for Response { buf.put_u16(self.logical_address); buf.extend_from_slice(&self.eid); buf.extend_from_slice(&self.gid); - buf.put_u8(self.further_action as u8); + buf.put_u8(u8::from(self.further_action)); if let Some(status) = self.sync_status { - buf.put_u8(status as u8); + buf.put_u8(u8::from(status)); } } } diff --git a/src/error.rs b/src/error.rs index bc2a1ad..616e042 100644 --- a/src/error.rs +++ b/src/error.rs @@ -13,11 +13,12 @@ //! Error Types for `DoIP` Server (ISO 13400-2:2019 & ISO 14229-1:2020) use std::io; +use std::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_parser::GenericNackCode; +pub use crate::doip::header::GenericNackCode; pub use crate::doip::routing_activation::ResponseCode as RoutingActivationCode; /// Result type alias for `DoIP` operations @@ -32,6 +33,15 @@ pub enum DoipError { #[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 }, @@ -53,6 +63,12 @@ pub enum DoipError { #[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, diff --git a/src/lib.rs b/src/lib.rs index 181919a..d888778 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,6 @@ */ pub mod doip; pub mod error; +pub mod server; pub mod uds; - pub use error::DoipError; diff --git a/src/server/config.rs b/src/server/config.rs index b5c15d4..f7887ac 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -13,6 +13,7 @@ //! `DoIP` Server Configuration +use crate::DoipError; use serde::Deserialize; use std::net::SocketAddr; use std::path::Path; @@ -48,17 +49,30 @@ 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 { - pub tcp_addr: SocketAddr, - pub udp_addr: SocketAddr, - pub logical_address: u16, - pub vin: [u8; 17], - pub eid: [u8; 6], - pub gid: [u8; 6], - pub max_connections: usize, - pub initial_inactivity_timeout_ms: u64, - pub general_inactivity_timeout_ms: u64, + /// 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 { @@ -167,6 +181,7 @@ impl Default for TimeoutSection { } impl ServerConfig { + /// Create a config with all defaults except for the given `logical_address`. #[must_use] pub fn new(logical_address: u16) -> Self { Self { @@ -178,10 +193,14 @@ impl ServerConfig { /// Load configuration from TOML file /// /// # Errors - /// Returns error if file cannot be read, parsed, or contains invalid values - pub fn from_file>(path: P) -> anyhow::Result { - let content = std::fs::read_to_string(path)?; - let file: ConfigFile = toml::from_str(&content)?; + /// 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) -> std::result::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 { @@ -189,29 +208,54 @@ impl ServerConfig { 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), + 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) -> anyhow::Result<[u8; 17]> { + fn parse_vin(s: &str) -> std::result::Result<[u8; 17], DoipError> { let bytes = s.as_bytes(); if bytes.len() != 17 { - anyhow::bail!("VIN must be exactly 17 characters"); + 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) -> anyhow::Result<[u8; N]> { + fn parse_hex_array(s: &str) -> std::result::Result<[u8; N], DoipError> { let s = s.trim_start_matches("0x").replace([':', '-', ' '], ""); - let bytes = hex::decode(&s)?; + let bytes = hex::decode(&s).map_err(|e| DoipError::HexDecodeError(e.to_string()))?; if bytes.len() != N { - anyhow::bail!("Expected {} bytes, got {}", N, bytes.len()); + return Err(DoipError::InvalidConfig(format!( + "Expected {} bytes, got {}", + N, + bytes.len() + ))); } let mut arr = [0u8; N]; arr.copy_from_slice(&bytes); @@ -230,6 +274,60 @@ impl ServerConfig { 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)] @@ -240,19 +338,19 @@ mod tests { 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.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, + config.initial_inactivity_timeout_ms(), DEFAULT_INITIAL_INACTIVITY_TIMEOUT_MS ); assert_eq!( - config.general_inactivity_timeout_ms, + config.general_inactivity_timeout_ms(), DEFAULT_GENERAL_INACTIVITY_TIMEOUT_MS ); } @@ -261,8 +359,8 @@ mod tests { 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); + assert_eq!(config.logical_address(), 0x1234); + assert_eq!(config.tcp_addr().port(), DEFAULT_DOIP_PORT); } #[test] @@ -280,20 +378,23 @@ mod tests { #[test] fn test_parse_hex_array_valid() { - let result: anyhow::Result<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B:3C:4D:5E"); + let result: std::result::Result<[u8; 6], DoipError> = + 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: anyhow::Result<[u8; 6]> = ServerConfig::parse_hex_array("0x001A2B3C4D5E"); + let result: std::result::Result<[u8; 6], DoipError> = + ServerConfig::parse_hex_array("0x001A2B3C4D5E"); assert!(result.is_ok()); } #[test] fn test_parse_hex_array_invalid_length() { - let result: anyhow::Result<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B"); + let result: std::result::Result<[u8; 6], DoipError> = + ServerConfig::parse_hex_array("00:1A:2B"); assert!(result.is_err()); } @@ -302,7 +403,7 @@ mod tests { let new_vin = *b"NEWVIN12345678901"; let config = ServerConfig::default().with_vin(new_vin); - assert_eq!(config.vin, new_vin); + assert_eq!(config.vin(), new_vin); } #[test] @@ -311,7 +412,7 @@ mod tests { 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); + assert_eq!(config.tcp_addr(), tcp); + assert_eq!(config.udp_addr(), udp); } } diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..f9969df --- /dev/null +++ b/src/server/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 + */ +//! Server Module +//! +//! `DoIP` server configuration and session management. + +pub mod config; +pub mod session; + +pub use config::ServerConfig; +pub use session::{Session, SessionManager}; diff --git a/src/server/session.rs b/src/server/session.rs index 41a0ac1..ce90227 100644 --- a/src/server/session.rs +++ b/src/server/session.rs @@ -26,15 +26,21 @@ pub enum SessionState { Closed, } +/// A single `DoIP` tester connection and its lifecycle state. #[derive(Debug, Clone)] pub struct Session { - pub id: u64, - pub peer_addr: SocketAddr, - pub tester_address: u16, - pub state: SessionState, + /// 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 { @@ -45,18 +51,52 @@ impl Session { } } + /// Transition this session to [`SessionState::RoutingActive`] and record the tester's logical address. pub fn activate_routing(&mut self, tester_address: u16) { - debug!("Session {} routing activated: tester_address=0x{:04X}", self.id, tester_address); + debug!( + "Session {} routing activated: tester_address=0x{:04X}", + self.id, tester_address + ); 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. +/// +/// Internally uses `parking_lot::RwLock` maps keyed by session ID and +/// remote [`SocketAddr`]. Access this via the [`Arc`] returned by +/// [`SessionManager::new`]. #[derive(Debug, Default)] pub struct SessionManager { sessions: RwLock>, @@ -65,11 +105,13 @@ pub struct SessionManager { } 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 mut next_id = self.next_id.write(); let id = *next_id; @@ -83,15 +125,18 @@ impl SessionManager { session } + /// Look up a session by its numeric ID. Returns `None` if not found. pub fn get_session(&self, id: u64) -> Option { self.sessions.read().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 id = self.addr_to_session.read().get(addr).copied()?; self.get_session(id) } + /// 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), @@ -104,6 +149,7 @@ impl SessionManager { } } + /// Remove and return the session with the given `id`, or `None` if not found. pub fn remove_session(&self, id: u64) -> Option { let session = self.sessions.write().remove(&id)?; self.addr_to_session.write().remove(&session.peer_addr); @@ -111,6 +157,7 @@ impl SessionManager { 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 id = self.addr_to_session.write().remove(addr)?; let session = self.sessions.write().remove(&id)?; @@ -118,10 +165,12 @@ impl SessionManager { Some(session) } + /// Returns the number of currently registered sessions. pub fn session_count(&self) -> usize { self.sessions.read().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.sessions .read() @@ -140,10 +189,10 @@ mod tests { let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); let session = mgr.create_session(addr); - assert_eq!(session.state, SessionState::Connected); + assert_eq!(session.state(), SessionState::Connected); - let retrieved = mgr.get_session(session.id).unwrap(); - assert_eq!(retrieved.peer_addr, addr); + let retrieved = mgr.get_session(session.id()).unwrap(); + assert_eq!(retrieved.peer_addr(), addr); } #[test] @@ -152,11 +201,11 @@ mod tests { 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)); + mgr.update_session(session.id(), |s| s.activate_routing(0x0E80)); - let updated = mgr.get_session(session.id).unwrap(); + let updated = mgr.get_session(session.id()).unwrap(); assert!(updated.is_routing_active()); - assert_eq!(updated.tester_address, 0x0E80); + assert_eq!(updated.tester_address(), 0x0E80); } #[test] @@ -167,9 +216,9 @@ mod tests { let session = mgr.create_session(addr); assert_eq!(mgr.session_count(), 1); - mgr.remove_session(session.id); + mgr.remove_session(session.id()); assert_eq!(mgr.session_count(), 0); - assert!(mgr.get_session(session.id).is_none()); + assert!(mgr.get_session(session.id()).is_none()); } #[test] @@ -180,7 +229,7 @@ mod tests { let session = mgr.create_session(addr); assert!(!mgr.is_tester_registered(0x0E80)); - mgr.update_session(session.id, |s| s.activate_routing(0x0E80)); + mgr.update_session(session.id(), |s| s.activate_routing(0x0E80)); assert!(mgr.is_tester_registered(0x0E80)); } } diff --git a/src/uds/handler.rs b/src/uds/handler.rs index 928f034..440aa66 100644 --- a/src/uds/handler.rs +++ b/src/uds/handler.rs @@ -38,15 +38,19 @@ pub mod service_id { pub const CLEAR_DTC_INFORMATION: u8 = 0x14; } -/// UDS request extracted from `DoIP` diagnostic message +/// UDS request extracted from a `DoIP` diagnostic message (ISO 14229-1:2020). #[derive(Debug, Clone)] pub struct UdsRequest { - pub source_address: u16, - pub target_address: u16, - pub payload: Bytes, + /// 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 { @@ -61,17 +65,39 @@ impl UdsRequest { 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 `DoIP` diagnostic message +/// UDS response to be wrapped in a `DoIP` diagnostic message. #[derive(Debug, Clone)] pub struct UdsResponse { - pub source_address: u16, - pub target_address: u16, - pub payload: Bytes, + /// 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 { @@ -80,6 +106,24 @@ impl UdsResponse { 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 @@ -87,6 +131,11 @@ impl UdsResponse { /// 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; } diff --git a/src/uds/mod.rs b/src/uds/mod.rs index 8209c1c..e370952 100644 --- a/src/uds/mod.rs +++ b/src/uds/mod.rs @@ -15,10 +15,6 @@ //! //! Provides the interface between `DoIP` transport and UDS processing. -// #[cfg(any(test, feature = "test-handlers"))] -// pub mod dummy_handler; pub mod handler; -// #[cfg(any(test, feature = "test-handlers"))] -// pub mod stub_handler; -pub use handler::{service_id, UdsHandler, UdsRequest, UdsResponse}; +pub use handler::{UdsHandler, UdsRequest, UdsResponse, service_id}; From 6c0d47f6154c19c3fa81a9707afd6a16755072d2 Mon Sep 17 00:00:00 2001 From: vinayrs Date: Fri, 13 Mar 2026 07:42:19 +0530 Subject: [PATCH 08/17] refactor: fix PR review Replace std::result::Result<_, DoipError> with crate::DoipResult across all files; add pub use error::DoipResult in lib.rs - remove redundant AckCode enum - remove redundant ack_code field from PositiveAck - merge PositiveAck + NegativeAck into DiagnosticAck with AckResult enum (Positive | Negative(NackCode)) --- src/doip/alive_check.rs | 21 +-- src/doip/diagnostic_message.rs | 305 ++++++++++++--------------------- src/doip/header.rs | 2 +- src/doip/mod.rs | 6 +- src/doip/payload.rs | 14 +- src/doip/routing_activation.rs | 36 +--- src/doip/vehicle_id.rs | 30 +--- src/error.rs | 44 +---- src/lib.rs | 1 + src/server/config.rs | 15 +- 10 files changed, 149 insertions(+), 325 deletions(-) diff --git a/src/doip/alive_check.rs b/src/doip/alive_check.rs index 5cd1045..fae49a6 100644 --- a/src/doip/alive_check.rs +++ b/src/doip/alive_check.rs @@ -12,10 +12,8 @@ */ //! Alive Check handlers (ISO 13400-2) -use super::{DoipParseable, DoipSerializable}; -use crate::DoipError; +use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; use bytes::{BufMut, BytesMut}; -use tracing::warn; // Alive Check Request (0x0007) - no payload // Server sends this to check if tester is still connected @@ -23,7 +21,7 @@ use tracing::warn; pub struct Request; impl DoipParseable for Request { - fn parse(_payload: &[u8]) -> std::result::Result { + fn parse(_payload: &[u8]) -> crate::DoipResult { Ok(Self) } } @@ -44,19 +42,8 @@ pub struct Response { } impl DoipParseable for Response { - fn parse(payload: &[u8]) -> std::result::Result { - let bytes: [u8; 2] = payload - .get(..Self::LEN) - .and_then(|s| s.try_into().ok()) - .ok_or_else(|| { - let e = DoipError::PayloadTooShort { - expected: Self::LEN, - actual: payload.len(), - }; - warn!("AliveCheck Response parse failed: {}", e); - e - })?; - + fn parse(payload: &[u8]) -> crate::DoipResult { + let bytes: [u8; 2] = parse_fixed_slice(payload, "AliveCheck Response")?; let source_address = u16::from_be_bytes(bytes); Ok(Self { source_address }) } diff --git a/src/doip/diagnostic_message.rs b/src/doip/diagnostic_message.rs index fde3c8b..7d615b0 100644 --- a/src/doip/diagnostic_message.rs +++ b/src/doip/diagnostic_message.rs @@ -14,7 +14,7 @@ use super::{DoipParseable, DoipSerializable, parse_fixed_slice, too_short}; use crate::DoipError; -use bytes::{Buf, BufMut, Bytes, BytesMut}; +use bytes::{BufMut, Bytes, BytesMut}; use tracing::warn; const ADDRESS_BYTES: usize = 2; @@ -22,20 +22,18 @@ const HEADER_BYTES: usize = ADDRESS_BYTES * 2; const ACK_CODE_BYTES: usize = 1; const MIN_USER_DATA_BYTES: usize = 1; -// Diagnostic message positive ack codes per ISO 13400-2:2019 Table 27 +/// 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)] -#[repr(u8)] -pub enum AckCode { - Acknowledged = 0x00, +pub enum AckResult { + /// Positive acknowledgment — message was accepted (wire code 0x00). + Positive, + /// Negative acknowledgment — message was rejected with the given code. + Negative(NackCode), } -impl From for u8 { - fn from(code: AckCode) -> u8 { - code as u8 - } -} - -// Diagnostic message negative ack codes per ISO 13400-2:2019 Table 28 +/// Diagnostic message negative acknowledgment codes per ISO 13400-2:2019 Table 28. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum NackCode { @@ -118,147 +116,130 @@ impl Message { &self.user_data } - /// Parse a Diagnostic Message from a mutable Bytes buffer - /// - /// # Errors - /// Returns [`DoipError::PayloadTooShort`] if buffer is less than 5 bytes - /// Returns [`DoipError::EmptyUserData`] if no UDS data is present - pub fn parse_buf(buf: &mut Bytes) -> std::result::Result { - let msg = ::parse(buf)?; - buf.advance(buf.len()); - Ok(msg) - } - pub fn service_id(&self) -> Option { self.user_data.first().copied() } } -/// Diagnostic Message Positive Acknowledgment (message type 0x8002) +/// Diagnostic Message Acknowledgment (payload types 0x8002 and 0x8003) /// -/// Sent by a `DoIP` entity to acknowledge receipt of a diagnostic message. +/// 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) + `ack_code(1)` + optional `previous_diag_data` +/// # Wire Format +/// Payload: SA(2) + TA(2) + code(1) + optional `previous_diag_data` #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PositiveAck { +pub struct DiagnosticAck { source_address: u16, target_address: u16, - ack_code: AckCode, + result: AckResult, previous_data: Option, } -impl PositiveAck { - /// Minimum positive ack length in bytes +impl DiagnosticAck { + /// Minimum ack length in bytes (SA + TA + code byte). pub const MIN_LEN: usize = HEADER_BYTES + ACK_CODE_BYTES; - /// Create a new positive acknowledgment + /// Create a positive acknowledgment. #[must_use] - pub fn new(source: u16, target: u16) -> Self { + pub fn positive(source: u16, target: u16) -> Self { Self { source_address: source, target_address: target, - ack_code: AckCode::Acknowledged, + result: AckResult::Positive, previous_data: None, } } - /// Create a positive acknowledgment with previous diagnostic data - pub fn with_previous_data(source: u16, target: u16, data: Bytes) -> Self { + /// Create a negative acknowledgment. + #[must_use] + pub fn negative(source: u16, target: u16, code: NackCode) -> Self { Self { source_address: source, target_address: target, - ack_code: AckCode::Acknowledged, - // ISO 13400-2:2019: previous diagnostic data is optional; treat empty as absent - previous_data: if data.is_empty() { None } else { Some(data) }, + result: AckResult::Negative(code), + previous_data: None, } } - /// Get the source address + /// Returns the source address. + #[must_use] pub fn source_address(&self) -> u16 { self.source_address } - /// Get the target address + /// Returns the target address. + #[must_use] pub fn target_address(&self) -> u16 { self.target_address } - /// Get the acknowledgment code - pub fn ack_code(&self) -> AckCode { - self.ack_code + /// Returns the acknowledgment result. + #[must_use] + pub fn result(&self) -> AckResult { + self.result } - /// Get the previous diagnostic data + /// Returns the previous diagnostic data, if any. + #[must_use] pub fn previous_data(&self) -> Option<&Bytes> { self.previous_data.as_ref() } -} - -/// Diagnostic Message Negative Acknowledgment (message type 0x8003) -/// -/// Sent by a `DoIP` entity to indicate rejection of a diagnostic message. -/// NACK codes are defined in ISO 13400-2:2019 Table 28. -/// -/// # Wire Format -/// Payload: SA(2) + TA(2) + `nack_code(1)` + optional `previous_diag_data` -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NegativeAck { - source_address: u16, - target_address: u16, - nack_code: NackCode, - previous_data: Option, -} - -impl NegativeAck { - /// Minimum negative ack length in bytes - pub const MIN_LEN: usize = HEADER_BYTES + ACK_CODE_BYTES; - - /// Create a new negative acknowledgment - #[must_use] - pub fn new(source: u16, target: u16, code: NackCode) -> Self { - Self { - source_address: source, - target_address: target, - nack_code: code, - previous_data: None, - } - } - - /// 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 negative acknowledgment code - pub fn nack_code(&self) -> NackCode { - self.nack_code + /// 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::DoipResult { + let (source_address, target_address, previous_data) = + Self::parse_ack_header(payload, "DiagnosticPositiveAck")?; + Ok(Self { + source_address, + target_address, + result: AckResult::Positive, + previous_data, + }) } - /// Get the previous diagnostic data - pub fn previous_data(&self) -> Option<&Bytes> { - self.previous_data.as_ref() + /// 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::DoipResult { + 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, + }) } - /// Create a negative acknowledgment with previous diagnostic data - pub fn with_previous_data(source: u16, target: u16, code: NackCode, data: Bytes) -> Self { - Self { - source_address: source, - target_address: target, - nack_code: code, - // ISO 13400-2:2019: previous diagnostic data is optional; treat empty as absent - previous_data: if data.is_empty() { None } else { Some(data) }, - } + /// Parse SA, TA and optional trailing `previous_data` from an ack payload. + fn parse_ack_header( + payload: &[u8], + context: &str, + ) -> crate::DoipResult<(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]) -> std::result::Result { + fn parse(payload: &[u8]) -> crate::DoipResult { let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, "DiagnosticMessage")?; let source_address = u16::from_be_bytes([header[0], header[1]]); @@ -298,29 +279,7 @@ impl DoipSerializable for Message { } } -impl DoipParseable for PositiveAck { - fn parse(payload: &[u8]) -> std::result::Result { - let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, "DiagnosticPositiveAck")?; - - let source_address = u16::from_be_bytes([header[0], header[1]]); - let target_address = u16::from_be_bytes([header[2], header[3]]); - let ack_code = AckCode::Acknowledged; - - let previous_data = payload - .get(Self::MIN_LEN..) - .filter(|d| !d.is_empty()) - .map(Bytes::copy_from_slice); - - Ok(Self { - source_address, - target_address, - ack_code, - previous_data, - }) - } -} - -impl DoipSerializable for PositiveAck { +impl DoipSerializable for DiagnosticAck { fn serialized_len(&self) -> Option { Some(Self::MIN_LEN + self.previous_data.as_ref().map_or(0, bytes::Bytes::len)) } @@ -328,48 +287,10 @@ impl DoipSerializable for PositiveAck { fn write_to(&self, buf: &mut BytesMut) { buf.put_u16(self.source_address); buf.put_u16(self.target_address); - buf.put_u8(u8::from(self.ack_code)); - if let Some(ref data) = self.previous_data { - buf.extend_from_slice(data); - } - } -} - -impl DoipParseable for NegativeAck { - fn parse(payload: &[u8]) -> std::result::Result { - let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, "DiagnosticNegativeAck")?; - - let source_address = u16::from_be_bytes([header[0], header[1]]); - let target_address = u16::from_be_bytes([header[2], header[3]]); - let nack_code = payload - .get(HEADER_BYTES) - .copied() - .ok_or_else(|| too_short(payload, Self::MIN_LEN)) - .and_then(NackCode::try_from)?; - - let previous_data = payload - .get(Self::MIN_LEN..) - .filter(|d| !d.is_empty()) - .map(Bytes::copy_from_slice); - - Ok(Self { - source_address, - target_address, - nack_code, - previous_data, - }) - } -} - -impl DoipSerializable for NegativeAck { - fn serialized_len(&self) -> Option { - Some(Self::MIN_LEN + 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(u8::from(self.nack_code)); + 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); } @@ -429,38 +350,32 @@ mod tests { #[test] fn build_positive_ack() { - let ack = PositiveAck::new(0x1000, 0x0E80); - let bytes = ack.to_bytes(); - - assert_eq!(bytes.len(), PositiveAck::MIN_LEN); - assert_eq!(&bytes[..ADDRESS_BYTES], &[0x10, 0x00]); // source (ECU) - assert_eq!(&bytes[ADDRESS_BYTES..HEADER_BYTES], &[0x0E, 0x80]); // target (tester) - assert_eq!(bytes[HEADER_BYTES], 0x00); // ack code - } - - #[test] - fn build_positive_ack_with_prev_data() { - let prev = Bytes::from_static(&[0x22, 0xF1, 0x90]); - let expected_len = PositiveAck::MIN_LEN + prev.len(); - let ack = PositiveAck::with_previous_data(0x1000, 0x0E80, prev); + let ack = DiagnosticAck::positive(0x1000, 0x0E80); let bytes = ack.to_bytes(); - assert_eq!(bytes.len(), expected_len); - assert_eq!(&bytes[PositiveAck::MIN_LEN..], &[0x22, 0xF1, 0x90]); + 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 = NegativeAck::new(0x1000, 0x0E80, NackCode::UnknownTargetAddress); + let nack = DiagnosticAck::negative(0x1000, 0x0E80, NackCode::UnknownTargetAddress); let bytes = nack.to_bytes(); - assert_eq!(bytes.len(), NegativeAck::MIN_LEN); + 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 = NegativeAck::new(0x1000, 0x0E80, NackCode::TargetUnreachable); + let nack = DiagnosticAck::negative(0x1000, 0x0E80, NackCode::TargetUnreachable); let bytes = nack.to_bytes(); assert_eq!(bytes[HEADER_BYTES], 0x06); } @@ -468,19 +383,23 @@ mod tests { #[test] fn parse_positive_ack() { let payload = [0x10, 0x00, 0x0E, 0x80, 0x00]; - let ack = PositiveAck::parse(&payload).unwrap(); + 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 = NegativeAck::parse(&payload).unwrap(); + let nack = DiagnosticAck::parse_negative(&payload).unwrap(); - assert_eq!(nack.nack_code(), NackCode::UnknownTargetAddress); + assert_eq!( + nack.result(), + AckResult::Negative(NackCode::UnknownTargetAddress) + ); } #[test] @@ -493,17 +412,17 @@ mod tests { #[test] fn roundtrip_positive_ack() { - let original = PositiveAck::new(0x1000, 0x0E80); + let original = DiagnosticAck::positive(0x1000, 0x0E80); let bytes = original.to_bytes(); - let parsed = PositiveAck::parse(&bytes).unwrap(); + let parsed = DiagnosticAck::parse_positive(&bytes).unwrap(); assert_eq!(original, parsed); } #[test] fn roundtrip_negative_ack() { - let original = NegativeAck::new(0x1000, 0x0E80, NackCode::OutOfMemory); + let original = DiagnosticAck::negative(0x1000, 0x0E80, NackCode::OutOfMemory); let bytes = original.to_bytes(); - let parsed = NegativeAck::parse(&bytes).unwrap(); + let parsed = DiagnosticAck::parse_negative(&bytes).unwrap(); assert_eq!(original, parsed); } } diff --git a/src/doip/header.rs b/src/doip/header.rs index 884b6db..b1a4046 100644 --- a/src/doip/header.rs +++ b/src/doip/header.rs @@ -186,7 +186,7 @@ impl DoipHeader { /// /// # Errors /// Returns [`crate::DoipError::InvalidHeader`] if data is less than 8 bytes. - pub(crate) fn parse(data: &[u8]) -> std::result::Result { + pub(crate) fn parse(data: &[u8]) -> crate::DoipResult { let header: [u8; DOIP_HEADER_LENGTH] = data .get(..DOIP_HEADER_LENGTH) .and_then(|s| s.try_into().ok()) diff --git a/src/doip/mod.rs b/src/doip/mod.rs index d9ebdba..f4861fa 100644 --- a/src/doip/mod.rs +++ b/src/doip/mod.rs @@ -34,7 +34,7 @@ pub trait DoipParseable: Sized { /// /// # Errors /// Returns [`DoipError`] if the payload is malformed or too short. - fn parse(payload: &[u8]) -> std::result::Result; + fn parse(payload: &[u8]) -> crate::DoipResult; } /// Trait for `DoIP` message types that can be serialized to a [`Bytes`] buffer. @@ -76,7 +76,7 @@ pub(crate) fn too_short(payload: &[u8], expected: usize) -> DoipError { } /// Return `Err` if `payload` is shorter than `expected` bytes. -pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> std::result::Result<(), DoipError> { +pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> crate::DoipResult<()> { if payload.len() < expected { Err(too_short(payload, expected)) } else { @@ -91,7 +91,7 @@ pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> std::result::Res pub(crate) fn parse_fixed_slice( payload: &[u8], context: &str, -) -> std::result::Result<[u8; N], DoipError> { +) -> crate::DoipResult<[u8; N]> { payload .get(..N) .and_then(|s| s.try_into().ok()) diff --git a/src/doip/payload.rs b/src/doip/payload.rs index ba484e2..735121b 100644 --- a/src/doip/payload.rs +++ b/src/doip/payload.rs @@ -20,8 +20,8 @@ //! # Example //! ```no_run //! # use doip_server::doip::{DoipMessage, DoipPayload}; -//! # use doip_server::DoipError; -//! fn dispatch(msg: &DoipMessage) -> Result<(), DoipError> { +//! # use doip_server::DoipResult; +//! fn dispatch(msg: &DoipMessage) -> DoipResult<()> { //! match DoipPayload::parse(msg)? { //! DoipPayload::DiagnosticMessage(m) => println!("UDS payload: {:?}", m), //! DoipPayload::AliveCheckRequest(_) => println!("Alive check received"), @@ -54,9 +54,9 @@ pub enum DoipPayload { /// `0x8001` – Diagnostic Message (UDS data) DiagnosticMessage(diagnostic_message::Message), /// `0x8002` – Diagnostic Message Positive Acknowledgement - DiagnosticMessagePositiveAck(diagnostic_message::PositiveAck), + DiagnosticMessagePositiveAck(diagnostic_message::DiagnosticAck), /// `0x8003` – Diagnostic Message Negative Acknowledgement - DiagnosticMessageNegativeAck(diagnostic_message::NegativeAck), + DiagnosticMessageNegativeAck(diagnostic_message::DiagnosticAck), /// `0x0001` – Vehicle Identification Request (no filter) VehicleIdentificationRequest(vehicle_id::Request), /// `0x0002` – Vehicle Identification Request filtered by EID @@ -79,7 +79,7 @@ impl DoipPayload { /// /// Returns a more specific [`DoipError`] (e.g. [`DoipError::PayloadTooShort`]) /// when the payload bytes are present but malformed. - pub fn parse(msg: &DoipMessage) -> std::result::Result { + pub fn parse(msg: &DoipMessage) -> crate::DoipResult { let payload = msg.payload().as_ref(); let payload_type = msg @@ -103,10 +103,10 @@ impl DoipPayload { diagnostic_message::Message::parse(payload)?, )), PayloadType::DiagnosticMessagePositiveAck => Ok(Self::DiagnosticMessagePositiveAck( - diagnostic_message::PositiveAck::parse(payload)?, + diagnostic_message::DiagnosticAck::parse_positive(payload)?, )), PayloadType::DiagnosticMessageNegativeAck => Ok(Self::DiagnosticMessageNegativeAck( - diagnostic_message::NegativeAck::parse(payload)?, + diagnostic_message::DiagnosticAck::parse_negative(payload)?, )), PayloadType::VehicleIdentificationRequest => Ok(Self::VehicleIdentificationRequest( vehicle_id::Request::parse(payload)?, diff --git a/src/doip/routing_activation.rs b/src/doip/routing_activation.rs index e25b05b..5ba7133 100644 --- a/src/doip/routing_activation.rs +++ b/src/doip/routing_activation.rs @@ -12,9 +12,9 @@ */ //! Routing Activation handlers (ISO 13400-2:2019) -use super::{DoipParseable, DoipSerializable, check_min_len, parse_fixed_slice}; +use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; use crate::DoipError; -use bytes::{Buf, BufMut, Bytes, BytesMut}; +use bytes::{BufMut, BytesMut}; use tracing::warn; /// Routing activation response codes per ISO 13400-2:2019 Table 25. @@ -141,34 +141,6 @@ impl Request { pub fn oem_specific(&self) -> Option { self.oem_specific } - - /// Parse routing activation request from buffer - /// - /// # Errors - /// - /// Returns an error if the buffer is too short or contains invalid data. - pub fn parse_buf(buf: &mut Bytes) -> std::result::Result { - check_min_len(buf.as_ref(), Self::MIN_LEN)?; - - let source_address = buf.get_u16(); - let activation_type = ActivationType::try_from(buf.get_u8()).map_err(|e| { - warn!("RoutingActivation Request parse_buf: {}", e); - e - })?; - let reserved = buf.get_u32(); - let oem_specific = if buf.remaining() >= 4 { - Some(buf.get_u32()) - } else { - None - }; - - Ok(Self { - source_address, - activation_type, - reserved, - oem_specific, - }) - } } // Routing Activation Response - 9 bytes min, 13 with OEM data @@ -247,7 +219,7 @@ impl Response { } impl DoipParseable for Request { - fn parse(payload: &[u8]) -> std::result::Result { + fn parse(payload: &[u8]) -> crate::DoipResult { let header: [u8; Self::MIN_LEN] = parse_fixed_slice(payload, "RoutingActivation Request")?; let source_address = u16::from_be_bytes([header[0], header[1]]); @@ -272,7 +244,7 @@ impl DoipParseable for Request { } impl DoipParseable for Response { - fn parse(payload: &[u8]) -> std::result::Result { + fn parse(payload: &[u8]) -> crate::DoipResult { let header: [u8; Self::MIN_LEN] = parse_fixed_slice(payload, "RoutingActivation Response")?; let tester_address = u16::from_be_bytes([header[0], header[1]]); diff --git a/src/doip/vehicle_id.rs b/src/doip/vehicle_id.rs index c52eb22..2cf7b40 100644 --- a/src/doip/vehicle_id.rs +++ b/src/doip/vehicle_id.rs @@ -13,7 +13,7 @@ //! Vehicle Identification handlers (ISO 13400-2:2019) -use super::{DoipParseable, DoipSerializable, check_min_len, too_short}; +use super::{DoipParseable, DoipSerializable, check_min_len, parse_fixed_slice, too_short}; use crate::DoipError; use bytes::{BufMut, BytesMut}; use tracing::warn; @@ -184,43 +184,27 @@ impl Response { } impl DoipParseable for Request { - fn parse(_payload: &[u8]) -> std::result::Result { + fn parse(_payload: &[u8]) -> crate::DoipResult { Ok(Self) } } impl DoipParseable for RequestWithEid { - fn parse(payload: &[u8]) -> std::result::Result { - let eid: [u8; 6] = payload - .get(..Self::LEN) - .and_then(|s| s.try_into().ok()) - .ok_or_else(|| { - let e = too_short(payload, Self::LEN); - warn!("VehicleId RequestWithEid parse failed: {}", e); - e - })?; - + fn parse(payload: &[u8]) -> crate::DoipResult { + let eid: [u8; 6] = parse_fixed_slice(payload, "VehicleId RequestWithEid")?; Ok(Self { eid }) } } impl DoipParseable for RequestWithVin { - fn parse(payload: &[u8]) -> std::result::Result { - let vin: [u8; 17] = payload - .get(..Self::LEN) - .and_then(|s| s.try_into().ok()) - .ok_or_else(|| { - let e = too_short(payload, Self::LEN); - warn!("VehicleId RequestWithVin parse failed: {}", e); - e - })?; - + fn parse(payload: &[u8]) -> crate::DoipResult { + let vin: [u8; 17] = parse_fixed_slice(payload, "VehicleId RequestWithVin")?; Ok(Self { vin }) } } impl DoipParseable for Response { - fn parse(payload: &[u8]) -> std::result::Result { + fn parse(payload: &[u8]) -> crate::DoipResult { if let Err(e) = check_min_len(payload, Self::MIN_LEN) { warn!("VehicleId Response parse failed: {}", e); return Err(e); diff --git a/src/error.rs b/src/error.rs index 616e042..68e5a62 100644 --- a/src/error.rs +++ b/src/error.rs @@ -71,36 +71,13 @@ pub enum DoipError { #[error("diagnostic message has no user data")] EmptyUserData, - - #[error("invalid VIN length: expected 17, got {0}")] - InvalidVinLength(usize), - - #[error("invalid EID length: expected 6, got {0}")] - InvalidEidLength(usize), - - #[error("Message too large: {size} bytes (max: {max})")] - MessageTooLarge { size: usize, max: usize }, - - #[error("Routing activation failed: {message}")] - RoutingActivationFailed { code: u8, message: String }, - - #[error("Session not found")] - SessionNotFound, - - #[error("Session closed")] - SessionClosed, - - #[error("Timeout: {0}")] - Timeout(String), - - #[error("UDS error: service 0x{service:02X}, NRC 0x{nrc:02X}")] - UdsError { service: u8, nrc: u8 }, } +#[cfg(test)] /// UDS Negative Response Codes (ISO 14229-1:2020) #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] -pub enum UdsNrc { +enum UdsNrc { GeneralReject = 0x10, ServiceNotSupported = 0x11, SubFunctionNotSupported = 0x12, @@ -117,9 +94,10 @@ pub enum UdsNrc { ServiceNotSupportedInActiveSession = 0x7F, } +#[cfg(test)] impl UdsNrc { #[must_use] - pub const fn as_u8(self) -> u8 { + const fn as_u8(self) -> u8 { self as u8 } } @@ -222,26 +200,12 @@ mod tests { }, DoipError::InvalidHeader("bad header".to_string()), DoipError::UnknownPayloadType(0x1234), - DoipError::MessageTooLarge { size: 10, max: 1 }, - DoipError::RoutingActivationFailed { - code: 0x00, - message: "fail".to_string(), - }, - DoipError::SessionNotFound, - DoipError::SessionClosed, - DoipError::Timeout("timeout".to_string()), - DoipError::UdsError { - service: 0x10, - nrc: 0x11, - }, DoipError::PayloadTooShort { expected: 8, actual: 4, }, DoipError::UnknownRoutingActivationResponseCode(0x99), DoipError::EmptyUserData, - DoipError::InvalidVinLength(10), - DoipError::InvalidEidLength(4), ]; for err in errors { diff --git a/src/lib.rs b/src/lib.rs index d888778..a2e3cfd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,3 +15,4 @@ pub mod error; pub mod server; pub mod uds; pub use error::DoipError; +pub use error::DoipResult; diff --git a/src/server/config.rs b/src/server/config.rs index f7887ac..644bcec 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -196,7 +196,7 @@ impl ServerConfig { /// 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) -> std::result::Result { + pub fn from_file>(path: P) -> crate::DoipResult { let content = std::fs::read_to_string(path).map_err(|e| DoipError::ConfigFileError(e.to_string()))?; let file: ConfigFile = @@ -234,7 +234,7 @@ impl ServerConfig { }) } - fn parse_vin(s: &str) -> std::result::Result<[u8; 17], DoipError> { + fn parse_vin(s: &str) -> crate::DoipResult<[u8; 17]> { let bytes = s.as_bytes(); if bytes.len() != 17 { return Err(DoipError::InvalidConfig(format!( @@ -247,7 +247,7 @@ impl ServerConfig { Ok(vin) } - fn parse_hex_array(s: &str) -> std::result::Result<[u8; N], DoipError> { + fn parse_hex_array(s: &str) -> crate::DoipResult<[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 { @@ -378,23 +378,20 @@ mod tests { #[test] fn test_parse_hex_array_valid() { - let result: std::result::Result<[u8; 6], DoipError> = - ServerConfig::parse_hex_array("00:1A:2B:3C:4D:5E"); + let result: crate::DoipResult<[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: std::result::Result<[u8; 6], DoipError> = - ServerConfig::parse_hex_array("0x001A2B3C4D5E"); + let result: crate::DoipResult<[u8; 6]> = ServerConfig::parse_hex_array("0x001A2B3C4D5E"); assert!(result.is_ok()); } #[test] fn test_parse_hex_array_invalid_length() { - let result: std::result::Result<[u8; 6], DoipError> = - ServerConfig::parse_hex_array("00:1A:2B"); + let result: crate::DoipResult<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B"); assert!(result.is_err()); } From ceb170dbacd8e10dceaa556e34e23cd6caad3053 Mon Sep 17 00:00:00 2001 From: vinayrs Date: Tue, 17 Mar 2026 11:43:09 +0530 Subject: [PATCH 09/17] chore: move doip-server into Cargo workspace to resolve merge conflict --- Cargo.lock | 56 +++++++++---------- doip-server/Cargo.toml | 40 +++++++++++++ {src => doip-server/src}/doip/alive_check.rs | 0 {src => doip-server/src}/doip/codec.rs | 0 .../src}/doip/diagnostic_message.rs | 0 {src => doip-server/src}/doip/header.rs | 0 {src => doip-server/src}/doip/mod.rs | 0 {src => doip-server/src}/doip/payload.rs | 0 .../src}/doip/routing_activation.rs | 0 {src => doip-server/src}/doip/vehicle_id.rs | 0 {src => doip-server/src}/error.rs | 0 {src => doip-server/src}/lib.rs | 0 {src => doip-server/src}/main.rs | 0 {src => doip-server/src}/server/config.rs | 0 {src => doip-server/src}/server/mod.rs | 0 {src => doip-server/src}/server/session.rs | 0 {src => doip-server/src}/uds/handler.rs | 0 {src => doip-server/src}/uds/mod.rs | 0 18 files changed, 68 insertions(+), 28 deletions(-) create mode 100644 doip-server/Cargo.toml rename {src => doip-server/src}/doip/alive_check.rs (100%) rename {src => doip-server/src}/doip/codec.rs (100%) rename {src => doip-server/src}/doip/diagnostic_message.rs (100%) rename {src => doip-server/src}/doip/header.rs (100%) rename {src => doip-server/src}/doip/mod.rs (100%) rename {src => doip-server/src}/doip/payload.rs (100%) rename {src => doip-server/src}/doip/routing_activation.rs (100%) rename {src => doip-server/src}/doip/vehicle_id.rs (100%) rename {src => doip-server/src}/error.rs (100%) rename {src => doip-server/src}/lib.rs (100%) rename {src => doip-server/src}/main.rs (100%) rename {src => doip-server/src}/server/config.rs (100%) rename {src => doip-server/src}/server/mod.rs (100%) rename {src => doip-server/src}/server/session.rs (100%) rename {src => doip-server/src}/uds/handler.rs (100%) rename {src => doip-server/src}/uds/mod.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 6117b7f..f382651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,15 +4,15 @@ version = 4 [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cfg-if" @@ -37,15 +37,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "hex" @@ -55,9 +55,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "lock_api" @@ -70,9 +70,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "parking_lot" @@ -99,9 +99,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "proc-macro2" @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -183,9 +183,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -214,9 +214,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "pin-project-lite", @@ -237,9 +237,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "serde_core", "serde_spanned", @@ -259,9 +259,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -287,9 +287,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "windows-link" @@ -299,6 +299,6 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" diff --git a/doip-server/Cargo.toml b/doip-server/Cargo.toml new file mode 100644 index 0000000..72c7a9d --- /dev/null +++ b/doip-server/Cargo.toml @@ -0,0 +1,40 @@ +# 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/src/doip/alive_check.rs b/doip-server/src/doip/alive_check.rs similarity index 100% rename from src/doip/alive_check.rs rename to doip-server/src/doip/alive_check.rs diff --git a/src/doip/codec.rs b/doip-server/src/doip/codec.rs similarity index 100% rename from src/doip/codec.rs rename to doip-server/src/doip/codec.rs diff --git a/src/doip/diagnostic_message.rs b/doip-server/src/doip/diagnostic_message.rs similarity index 100% rename from src/doip/diagnostic_message.rs rename to doip-server/src/doip/diagnostic_message.rs diff --git a/src/doip/header.rs b/doip-server/src/doip/header.rs similarity index 100% rename from src/doip/header.rs rename to doip-server/src/doip/header.rs diff --git a/src/doip/mod.rs b/doip-server/src/doip/mod.rs similarity index 100% rename from src/doip/mod.rs rename to doip-server/src/doip/mod.rs diff --git a/src/doip/payload.rs b/doip-server/src/doip/payload.rs similarity index 100% rename from src/doip/payload.rs rename to doip-server/src/doip/payload.rs diff --git a/src/doip/routing_activation.rs b/doip-server/src/doip/routing_activation.rs similarity index 100% rename from src/doip/routing_activation.rs rename to doip-server/src/doip/routing_activation.rs diff --git a/src/doip/vehicle_id.rs b/doip-server/src/doip/vehicle_id.rs similarity index 100% rename from src/doip/vehicle_id.rs rename to doip-server/src/doip/vehicle_id.rs diff --git a/src/error.rs b/doip-server/src/error.rs similarity index 100% rename from src/error.rs rename to doip-server/src/error.rs diff --git a/src/lib.rs b/doip-server/src/lib.rs similarity index 100% rename from src/lib.rs rename to doip-server/src/lib.rs diff --git a/src/main.rs b/doip-server/src/main.rs similarity index 100% rename from src/main.rs rename to doip-server/src/main.rs diff --git a/src/server/config.rs b/doip-server/src/server/config.rs similarity index 100% rename from src/server/config.rs rename to doip-server/src/server/config.rs diff --git a/src/server/mod.rs b/doip-server/src/server/mod.rs similarity index 100% rename from src/server/mod.rs rename to doip-server/src/server/mod.rs diff --git a/src/server/session.rs b/doip-server/src/server/session.rs similarity index 100% rename from src/server/session.rs rename to doip-server/src/server/session.rs diff --git a/src/uds/handler.rs b/doip-server/src/uds/handler.rs similarity index 100% rename from src/uds/handler.rs rename to doip-server/src/uds/handler.rs diff --git a/src/uds/mod.rs b/doip-server/src/uds/mod.rs similarity index 100% rename from src/uds/mod.rs rename to doip-server/src/uds/mod.rs From b98ff03c743648f446ae85811ec54738f60f82c1 Mon Sep 17 00:00:00 2001 From: vinayrs Date: Fri, 20 Mar 2026 15:03:05 +0530 Subject: [PATCH 10/17] fix(review): address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - structured tracing logging; error! for parse failures - rename DoipResult → Result - single RwLock + AtomicU64 - merge DiagnosticAck + AckResult; remove AckCode - doc comments on all public API items --- .gitignore | 7 -- deny.toml | 4 +- doip-server/Cargo.toml | 10 ++- doip-server/src/doip/alive_check.rs | 28 +++++--- doip-server/src/doip/codec.rs | 15 ++-- doip-server/src/doip/diagnostic_message.rs | 32 +++++---- doip-server/src/doip/header.rs | 61 ++++++++++------ doip-server/src/doip/mod.rs | 51 +++++++------ doip-server/src/doip/payload.rs | 9 +-- doip-server/src/doip/routing_activation.rs | 36 +++++++--- doip-server/src/doip/vehicle_id.rs | 71 +++++++++++++----- doip-server/src/error.rs | 11 +-- doip-server/src/lib.rs | 7 +- doip-server/src/server/config.rs | 21 +++--- doip-server/src/server/mod.rs | 2 + doip-server/src/server/session.rs | 84 +++++++++++++--------- doip-server/src/uds/handler.rs | 18 ++++- doip-server/src/uds/mod.rs | 1 + 18 files changed, 308 insertions(+), 160 deletions(-) diff --git a/.gitignore b/.gitignore index 99e8ae6..3c5442b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,5 @@ # 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 .env .cache 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 index 72c7a9d..4c91f5f 100644 --- a/doip-server/Cargo.toml +++ b/doip-server/Cargo.toml @@ -20,8 +20,14 @@ 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"] } +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"] } diff --git a/doip-server/src/doip/alive_check.rs b/doip-server/src/doip/alive_check.rs index fae49a6..5fc9162 100644 --- a/doip-server/src/doip/alive_check.rs +++ b/doip-server/src/doip/alive_check.rs @@ -12,16 +12,19 @@ */ //! Alive Check handlers (ISO 13400-2) -use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; use bytes::{BufMut, BytesMut}; -// Alive Check Request (0x0007) - no payload -// Server sends this to check if tester is still connected +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::DoipResult { + fn parse(_payload: &[u8]) -> crate::Result { Ok(Self) } } @@ -31,18 +34,25 @@ impl DoipSerializable for Request { Some(0) } - fn write_to(&self, _buf: &mut BytesMut) {} + /// 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 (0x0008) - 2 byte source address -// Tester responds with its logical address +/// 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::DoipResult { + 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 }) @@ -60,8 +70,10 @@ impl DoipSerializable for Response { } 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 } diff --git a/doip-server/src/doip/codec.rs b/doip-server/src/doip/codec.rs index dd5f748..afef9c7 100644 --- a/doip-server/src/doip/codec.rs +++ b/doip-server/src/doip/codec.rs @@ -22,8 +22,9 @@ //! //! See [`header`](super::header) for the underlying type definitions. -use bytes::BytesMut; use std::io; + +use bytes::BytesMut; use tokio_util::codec::{Decoder, Encoder}; use tracing::{debug, warn}; @@ -51,6 +52,7 @@ pub struct DoipCodec { } impl DoipCodec { + /// Create a new `DoipCodec` with the default maximum payload size. #[must_use] pub fn new() -> Self { Self { @@ -99,15 +101,16 @@ impl Decoder for DoipCodec { let header_slice = src.get(..DOIP_HEADER_LENGTH).ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidData, "buffer too short") })?; - debug!("Received raw header bytes: {:02X?}", header_slice); + 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!( - "Header validation failed: {:?} - raw bytes: {:02X?}", - nack_code, header_slice + nack_code = ?nack_code, + header_bytes = ?header_slice, + "Header validation failed" ); return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -147,7 +150,9 @@ impl Decoder for DoipCodec { } let _ = src.split_to(DOIP_HEADER_LENGTH); - let payload = src.split_to(total_len - DOIP_HEADER_LENGTH).freeze(); + let payload = src + .split_to(total_len.saturating_sub(DOIP_HEADER_LENGTH)) + .freeze(); self.state = DecodeState::Header; return Ok(Some(DoipMessage { header, payload })); diff --git a/doip-server/src/doip/diagnostic_message.rs b/doip-server/src/doip/diagnostic_message.rs index 7d615b0..84b2e64 100644 --- a/doip-server/src/doip/diagnostic_message.rs +++ b/doip-server/src/doip/diagnostic_message.rs @@ -12,10 +12,11 @@ */ //! 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; -use bytes::{BufMut, Bytes, BytesMut}; -use tracing::warn; const ADDRESS_BYTES: usize = 2; const HEADER_BYTES: usize = ADDRESS_BYTES * 2; @@ -106,7 +107,7 @@ impl Message { self.source_address } - /// Get the target address + /// Get the target address pub fn target_address(&self) -> u16 { self.target_address } @@ -116,6 +117,7 @@ impl Message { &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() } @@ -190,7 +192,7 @@ impl DiagnosticAck { /// /// # Errors /// Returns [`DoipError::PayloadTooShort`] if payload is less than 4 bytes. - pub fn parse_positive(payload: &[u8]) -> crate::DoipResult { + pub fn parse_positive(payload: &[u8]) -> crate::Result { let (source_address, target_address, previous_data) = Self::parse_ack_header(payload, "DiagnosticPositiveAck")?; Ok(Self { @@ -206,7 +208,7 @@ impl DiagnosticAck { /// # 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::DoipResult { + 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 @@ -223,10 +225,7 @@ impl DiagnosticAck { } /// Parse SA, TA and optional trailing `previous_data` from an ack payload. - fn parse_ack_header( - payload: &[u8], - context: &str, - ) -> crate::DoipResult<(u16, u16, Option)> { + 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]]); @@ -239,7 +238,7 @@ impl DiagnosticAck { } impl DoipParseable for Message { - fn parse(payload: &[u8]) -> crate::DoipResult { + 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]]); @@ -250,12 +249,16 @@ impl DoipParseable for Message { .map(Bytes::copy_from_slice) .ok_or_else(|| { let e = too_short(payload, Self::MIN_LEN); - warn!("DiagnosticMessage parse failed: {}", e); + error!(error = %e, "DiagnosticMessage parse failed"); e })?; if user_data.is_empty() { - warn!("DiagnosticMessage parse failed: empty user data"); + error!( + message_type = "DiagnosticMessage", + reason = "empty_user_data", + "parse failed" + ); return Err(DoipError::EmptyUserData); } @@ -269,7 +272,7 @@ impl DoipParseable for Message { impl DoipSerializable for Message { fn serialized_len(&self) -> Option { - Some(HEADER_BYTES + self.user_data.len()) + Some(HEADER_BYTES.saturating_add(self.user_data.len())) } fn write_to(&self, buf: &mut BytesMut) { @@ -281,7 +284,7 @@ impl DoipSerializable for Message { impl DoipSerializable for DiagnosticAck { fn serialized_len(&self) -> Option { - Some(Self::MIN_LEN + self.previous_data.as_ref().map_or(0, bytes::Bytes::len)) + Some(Self::MIN_LEN.saturating_add(self.previous_data.as_ref().map_or(0, bytes::Bytes::len))) } fn write_to(&self, buf: &mut BytesMut) { @@ -298,6 +301,7 @@ impl DoipSerializable for DiagnosticAck { } #[cfg(test)] +#[allow(clippy::indexing_slicing)] mod tests { use super::*; use crate::doip::{DoipParseable, DoipSerializable}; diff --git a/doip-server/src/doip/header.rs b/doip-server/src/doip/header.rs index b1a4046..96bc9fa 100644 --- a/doip-server/src/doip/header.rs +++ b/doip-server/src/doip/header.rs @@ -186,7 +186,7 @@ impl DoipHeader { /// /// # Errors /// Returns [`crate::DoipError::InvalidHeader`] if data is less than 8 bytes. - pub(crate) fn parse(data: &[u8]) -> crate::DoipResult { + 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()) @@ -206,10 +206,16 @@ impl DoipHeader { }) } + /// 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!( - "Validating DoIP header: version=0x{:02X}, inverse=0x{:02X}, type=0x{:04X}, len={}", - self.version, self.inverse_version, self.payload_type, self.payload_length + 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) @@ -222,29 +228,32 @@ impl DoipHeader { | DOIP_VERSION_DEFAULT ); if !valid_version { - warn!("Invalid DoIP version: 0x{:02X}", self.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/inverse mismatch: 0x{:02X} ^ 0x{:02X} = 0x{:02X} (expected 0x{:02X})", - self.version, - self.inverse_version, - self.version ^ self.inverse_version, - DOIP_HEADER_VERSION_MASK, + 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!("Unknown payload type: 0x{:04X}", self.payload_type); + warn!(payload_type = self.payload_type, "Unknown payload type"); return Some(GenericNackCode::UnknownPayloadType); }; if self.payload_length > MAX_DOIP_MESSAGE_SIZE { - warn!("Message too large: {} bytes", self.payload_length); + 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 { @@ -252,10 +261,10 @@ impl DoipHeader { }; if payload_len_usize < payload_type.min_payload_length() { warn!( - "Payload too short for {:?}: {} < {}", - payload_type, - self.payload_length, - payload_type.min_payload_length() + 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); } @@ -263,6 +272,7 @@ impl DoipHeader { 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() @@ -286,6 +296,7 @@ impl DoipHeader { 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); @@ -368,10 +379,14 @@ impl DoipMessage { } } - /// Create a DoIP message with a raw (unparsed) payload type + /// 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 { @@ -385,6 +400,7 @@ impl DoipMessage { } } + /// Returns the decoded `PayloadType`, or `None` if the raw value is not a known type. pub fn payload_type(&self) -> Option { PayloadType::try_from(self.header.payload_type).ok() } @@ -405,6 +421,7 @@ impl DoipMessage { 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); @@ -418,10 +435,12 @@ impl DoipMessage { // ============================================================================ #[cfg(test)] +#[allow(clippy::indexing_slicing)] mod tests { + use tokio_util::codec::{Decoder, Encoder}; + use super::*; use crate::doip::codec::DoipCodec; - use tokio_util::codec::{Decoder, Encoder}; // --- Helper to build a valid DoIP header quickly --- fn make_header(payload_type: u16, payload_len: u32) -> DoipHeader { @@ -571,10 +590,10 @@ mod tests { #[test] fn payload_type_gaps_return_none() { // These are in gaps between valid ranges - assert!(PayloadType::try_from(0x0009_u16).is_err()); - assert!(PayloadType::try_from(0x4000_u16).is_err()); - assert!(PayloadType::try_from(0x8000_u16).is_err()); - assert!(PayloadType::try_from(0xFFFF_u16).is_err()); + 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] diff --git a/doip-server/src/doip/mod.rs b/doip-server/src/doip/mod.rs index f4861fa..183854c 100644 --- a/doip-server/src/doip/mod.rs +++ b/doip-server/src/doip/mod.rs @@ -13,17 +13,34 @@ //! //! 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; -use crate::DoipError; +// 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}; -use tracing::warn; +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. /// @@ -34,7 +51,7 @@ pub trait DoipParseable: Sized { /// /// # Errors /// Returns [`DoipError`] if the payload is malformed or too short. - fn parse(payload: &[u8]) -> crate::DoipResult; + fn parse(payload: &[u8]) -> crate::Result; } /// Trait for `DoIP` message types that can be serialized to a [`Bytes`] buffer. @@ -46,10 +63,14 @@ pub trait DoipSerializable { /// Write the serialized wire-format bytes into `buf`. fn write_to(&self, buf: &mut BytesMut); - /// Return the exact serialized byte count, if known without encoding. + /// 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. /// - /// Override this to enable pre-allocated buffers in [`to_bytes`], avoiding - /// 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 } @@ -76,7 +97,7 @@ pub(crate) fn too_short(payload: &[u8], expected: usize) -> DoipError { } /// Return `Err` if `payload` is shorter than `expected` bytes. -pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> crate::DoipResult<()> { +pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> crate::Result<()> { if payload.len() < expected { Err(too_short(payload, expected)) } else { @@ -86,28 +107,18 @@ pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> crate::DoipResul /// Extract the first `N` bytes of `payload` as a fixed-size array. /// -/// Logs a warning and returns [`DoipError::PayloadTooShort`] when the slice +/// 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::DoipResult<[u8; N]> { +) -> crate::Result<[u8; N]> { payload .get(..N) .and_then(|s| s.try_into().ok()) .ok_or_else(|| { let e = too_short(payload, N); - warn!("{} parse failed: {}", context, e); + error!(context, error = %e, "parse failed"); e }) } - -// Re-export core types and constants for convenient access. -// Constants are exported to allow external testing and custom DoIP message construction. -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; diff --git a/doip-server/src/doip/payload.rs b/doip-server/src/doip/payload.rs index 735121b..ac79b3f 100644 --- a/doip-server/src/doip/payload.rs +++ b/doip-server/src/doip/payload.rs @@ -20,8 +20,8 @@ //! # Example //! ```no_run //! # use doip_server::doip::{DoipMessage, DoipPayload}; -//! # use doip_server::DoipResult; -//! fn dispatch(msg: &DoipMessage) -> DoipResult<()> { +//! # 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"), @@ -79,7 +79,7 @@ impl DoipPayload { /// /// Returns a more specific [`DoipError`] (e.g. [`DoipError::PayloadTooShort`]) /// when the payload bytes are present but malformed. - pub fn parse(msg: &DoipMessage) -> crate::DoipResult { + pub fn parse(msg: &DoipMessage) -> crate::Result { let payload = msg.payload().as_ref(); let payload_type = msg @@ -168,9 +168,10 @@ impl DoipPayload { #[cfg(test)] mod tests { + use bytes::Bytes; + use super::*; use crate::doip::{DoipMessage, PayloadType}; - use bytes::Bytes; fn make_msg(payload_type: PayloadType, payload: impl Into) -> DoipMessage { DoipMessage::new(payload_type, payload.into()) diff --git a/doip-server/src/doip/routing_activation.rs b/doip-server/src/doip/routing_activation.rs index 5ba7133..99d5710 100644 --- a/doip-server/src/doip/routing_activation.rs +++ b/doip-server/src/doip/routing_activation.rs @@ -12,10 +12,11 @@ */ //! Routing Activation handlers (ISO 13400-2:2019) +use bytes::{BufMut, BytesMut}; +use tracing::error; + use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; use crate::DoipError; -use bytes::{BufMut, BytesMut}; -use tracing::warn; /// Routing activation response codes per ISO 13400-2:2019 Table 25. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -105,7 +106,11 @@ impl TryFrom for ActivationType { } } -// Routing Activation Request - payload is 7 bytes min, 11 with OEM data +/// 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, @@ -115,7 +120,11 @@ pub struct Request { } 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 @@ -143,7 +152,11 @@ impl Request { } } -// Routing Activation Response - 9 bytes min, 13 with OEM data +/// 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, @@ -154,7 +167,11 @@ pub struct Response { } 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. @@ -219,12 +236,12 @@ impl Response { } impl DoipParseable for Request { - fn parse(payload: &[u8]) -> crate::DoipResult { + 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| { - warn!("RoutingActivation Request parse failed: {}", e); + error!(error = %e, "RoutingActivation Request parse failed"); e })?; let reserved = u32::from_be_bytes([header[3], header[4], header[5], header[6]]); @@ -244,13 +261,13 @@ impl DoipParseable for Request { } impl DoipParseable for Response { - fn parse(payload: &[u8]) -> crate::DoipResult { + 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| { - warn!("RoutingActivation Response parse failed: {}", e); + error!(error = %e, "RoutingActivation Response parse failed"); e })?; let reserved = u32::from_be_bytes([header[5], header[6], header[7], header[8]]); @@ -272,7 +289,7 @@ impl DoipParseable for Response { impl DoipSerializable for Response { fn serialized_len(&self) -> Option { - Some(Self::MIN_LEN + if self.oem_specific.is_some() { 4 } else { 0 }) + Some(Self::MIN_LEN.saturating_add(if self.oem_specific.is_some() { 4 } else { 0 })) } fn write_to(&self, buf: &mut BytesMut) { @@ -287,6 +304,7 @@ impl DoipSerializable for Response { } #[cfg(test)] +#[allow(clippy::indexing_slicing)] mod tests { use super::*; use crate::doip::{DoipParseable, DoipSerializable}; diff --git a/doip-server/src/doip/vehicle_id.rs b/doip-server/src/doip/vehicle_id.rs index 2cf7b40..2b4756a 100644 --- a/doip-server/src/doip/vehicle_id.rs +++ b/doip-server/src/doip/vehicle_id.rs @@ -13,10 +13,11 @@ //! Vehicle Identification handlers (ISO 13400-2:2019) +use bytes::{BufMut, BytesMut}; +use tracing::error; + use super::{DoipParseable, DoipSerializable, check_min_len, parse_fixed_slice, too_short}; use crate::DoipError; -use bytes::{BufMut, BytesMut}; -use tracing::warn; // Wire-format field lengths for VehicleIdentificationResponse (ISO 13400-2:2019) const VIN_LEN: usize = 17; @@ -36,19 +37,26 @@ 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 (0x0001) - no payload +/// 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 with EID (0x0002) - 6 byte EID +/// 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 } @@ -61,15 +69,19 @@ impl RequestWithEid { } } -// Vehicle Identification Request with VIN (0x0003) - 17 byte VIN +/// 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 } @@ -81,13 +93,17 @@ impl RequestWithVin { &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 +/// 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 { @@ -112,7 +128,9 @@ impl From for u8 { } } -// Synchronization status per ISO 13400-2:2019 Table 22 +/// 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 { @@ -137,8 +155,12 @@ impl From for u8 { } } -// Vehicle Identification Response (0x0004) -// VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1) = 32 bytes min +/// 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], @@ -150,9 +172,17 @@ pub struct Response { } impl Response { - pub const MIN_LEN: usize = SYNC_STATUS_IDX; // 32: VIN(17) + Addr(2) + EID(6) + GID(6) + FurtherAction(1) - pub const MAX_LEN: usize = SYNC_STATUS_IDX + 1; // 33: adds optional SyncStatus(1) - + /// 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 { @@ -165,18 +195,22 @@ impl Response { } } + /// 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() @@ -184,29 +218,29 @@ impl Response { } impl DoipParseable for Request { - fn parse(_payload: &[u8]) -> crate::DoipResult { + fn parse(_payload: &[u8]) -> crate::Result { Ok(Self) } } impl DoipParseable for RequestWithEid { - fn parse(payload: &[u8]) -> crate::DoipResult { + 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::DoipResult { + 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::DoipResult { + fn parse(payload: &[u8]) -> crate::Result { if let Err(e) = check_min_len(payload, Self::MIN_LEN) { - warn!("VehicleId Response parse failed: {}", e); + error!(error = %e, "VehicleId Response parse failed"); return Err(e); } @@ -256,7 +290,7 @@ impl DoipParseable for Response { impl DoipSerializable for Response { fn serialized_len(&self) -> Option { - Some(Self::MIN_LEN + usize::from(self.sync_status.is_some())) + Some(Self::MIN_LEN.saturating_add(usize::from(self.sync_status.is_some()))) } fn write_to(&self, buf: &mut BytesMut) { @@ -272,6 +306,7 @@ impl DoipSerializable for Response { } #[cfg(test)] +#[allow(clippy::indexing_slicing)] mod tests { use super::*; use crate::doip::{DoipParseable, DoipSerializable}; diff --git a/doip-server/src/error.rs b/doip-server/src/error.rs index 68e5a62..986d921 100644 --- a/doip-server/src/error.rs +++ b/doip-server/src/error.rs @@ -12,17 +12,18 @@ */ //! Error Types for `DoIP` Server (ISO 13400-2:2019 & ISO 14229-1:2020) -use std::io; -use std::net::AddrParseError; +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; -pub use crate::doip::routing_activation::ResponseCode as RoutingActivationCode; +pub use crate::doip::{ + header::GenericNackCode, routing_activation::ResponseCode as RoutingActivationCode, +}; /// Result type alias for `DoIP` operations -pub type DoipResult = std::result::Result; +pub type Result = std::result::Result; /// Main `DoIP` Error type #[derive(Error, Debug)] diff --git a/doip-server/src/lib.rs b/doip-server/src/lib.rs index a2e3cfd..0c8b098 100644 --- a/doip-server/src/lib.rs +++ b/doip-server/src/lib.rs @@ -10,9 +10,12 @@ * * 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; -pub use error::DoipResult; +pub use error::{DoipError, Result}; diff --git a/doip-server/src/server/config.rs b/doip-server/src/server/config.rs index 644bcec..39fa96c 100644 --- a/doip-server/src/server/config.rs +++ b/doip-server/src/server/config.rs @@ -13,10 +13,11 @@ //! `DoIP` Server Configuration -use crate::DoipError; +use std::{net::SocketAddr, path::Path}; + use serde::Deserialize; -use std::net::SocketAddr; -use std::path::Path; + +use crate::DoipError; // ============================================================================ // Default Configuration Constants (per ISO 13400-2 DoIP specification) @@ -196,7 +197,7 @@ impl ServerConfig { /// 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::DoipResult { + 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 = @@ -234,7 +235,7 @@ impl ServerConfig { }) } - fn parse_vin(s: &str) -> crate::DoipResult<[u8; 17]> { + fn parse_vin(s: &str) -> crate::Result<[u8; 17]> { let bytes = s.as_bytes(); if bytes.len() != 17 { return Err(DoipError::InvalidConfig(format!( @@ -247,7 +248,7 @@ impl ServerConfig { Ok(vin) } - fn parse_hex_array(s: &str) -> crate::DoipResult<[u8; N]> { + 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 { @@ -262,12 +263,14 @@ impl ServerConfig { 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; @@ -378,20 +381,20 @@ mod tests { #[test] fn test_parse_hex_array_valid() { - let result: crate::DoipResult<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B:3C:4D:5E"); + 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::DoipResult<[u8; 6]> = ServerConfig::parse_hex_array("0x001A2B3C4D5E"); + 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::DoipResult<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B"); + let result: crate::Result<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B"); assert!(result.is_err()); } diff --git a/doip-server/src/server/mod.rs b/doip-server/src/server/mod.rs index f9969df..fdfffd2 100644 --- a/doip-server/src/server/mod.rs +++ b/doip-server/src/server/mod.rs @@ -14,7 +14,9 @@ //! //! `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; diff --git a/doip-server/src/server/session.rs b/doip-server/src/server/session.rs index ce90227..cfd2dee 100644 --- a/doip-server/src/server/session.rs +++ b/doip-server/src/server/session.rs @@ -12,10 +12,16 @@ */ //! Session management for `DoIP` connections +use std::{ + collections::HashMap, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + use parking_lot::RwLock; -use std::collections::HashMap; -use std::net::SocketAddr; -use std::sync::Arc; use tracing::debug; /// Session states per ISO 13400-2:2019 connection lifecycle @@ -51,12 +57,10 @@ impl Session { } } - /// Transition this session to [`SessionState::RoutingActive`] and record the tester's logical address. + /// Transition this session to [`SessionState::RoutingActive`] and record + /// the tester's logical address. pub fn activate_routing(&mut self, tester_address: u16) { - debug!( - "Session {} routing activated: tester_address=0x{:04X}", - self.id, tester_address - ); + debug!(session_id = self.id, tester_address, "routing activated"); self.tester_address = tester_address; self.state = SessionState::RoutingActive; } @@ -94,14 +98,25 @@ impl Session { /// Thread-safe registry of active `DoIP` sessions. /// -/// Internally uses `parking_lot::RwLock` maps keyed by session ID and -/// remote [`SocketAddr`]. Access this via the [`Arc`] returned by -/// [`SessionManager::new`]. +/// 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 { - sessions: RwLock>, - addr_to_session: RwLock>, - next_id: RwLock, + inner: RwLock, + next_id: AtomicU64, } impl SessionManager { @@ -113,27 +128,25 @@ impl SessionManager { /// Register a new session for `peer_addr` and return it. pub fn create_session(&self, peer_addr: SocketAddr) -> Session { - let mut next_id = self.next_id.write(); - let id = *next_id; - *next_id = next_id.saturating_add(1); - + let id = self.next_id.fetch_add(1, Ordering::Relaxed); let session = Session::new(id, peer_addr); - self.sessions.write().insert(id, session.clone()); - self.addr_to_session.write().insert(peer_addr, id); - - debug!("Session {} created for {}", 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.sessions.read().get(&id).cloned() + 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 id = self.addr_to_session.read().get(addr).copied()?; - self.get_session(id) + 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. @@ -141,7 +154,7 @@ impl SessionManager { where F: FnOnce(&mut Session), { - if let Some(session) = self.sessions.write().get_mut(&id) { + if let Some(session) = self.inner.write().sessions.get_mut(&id) { f(session); true } else { @@ -151,29 +164,32 @@ impl SessionManager { /// Remove and return the session with the given `id`, or `None` if not found. pub fn remove_session(&self, id: u64) -> Option { - let session = self.sessions.write().remove(&id)?; - self.addr_to_session.write().remove(&session.peer_addr); - debug!("Session {} removed (peer: {})", id, session.peer_addr); + 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 id = self.addr_to_session.write().remove(addr)?; - let session = self.sessions.write().remove(&id)?; - debug!("Session {} removed by addr (peer: {})", id, addr); + 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.sessions.read().len() + 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.sessions + self.inner .read() + .sessions .values() .any(|s| s.tester_address == tester_address && s.state == SessionState::RoutingActive) } diff --git a/doip-server/src/uds/handler.rs b/doip-server/src/uds/handler.rs index 440aa66..6a29043 100644 --- a/doip-server/src/uds/handler.rs +++ b/doip-server/src/uds/handler.rs @@ -21,20 +21,35 @@ 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; } @@ -141,9 +156,10 @@ pub trait UdsHandler: Send + Sync { #[cfg(test)] mod tests { - use super::*; use bytes::Bytes; + use super::*; + #[test] fn uds_request_service_id() { let request = UdsRequest::new(0x0E00, 0x1000, Bytes::from(vec![0x10, 0x01])); diff --git a/doip-server/src/uds/mod.rs b/doip-server/src/uds/mod.rs index e370952..3718925 100644 --- a/doip-server/src/uds/mod.rs +++ b/doip-server/src/uds/mod.rs @@ -15,6 +15,7 @@ //! //! 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}; From dfe3c858bddffd400d1a73621bd975f8c7f21157 Mon Sep 17 00:00:00 2001 From: vinayrs Date: Mon, 30 Mar 2026 16:45:52 +0530 Subject: [PATCH 11/17] fix(review): address .toml config, empty data validation. --- .gitignore | 7 +++ Cargo.lock | 2 - Cargo.toml | 44 ++++++++++++++++-- deny.toml | 13 ++++-- doip-server/Cargo.toml | 42 ++++++++--------- doip-server/src/doip/diagnostic_message.rs | 48 ++++++++++++++----- doip-server/src/doip/routing_activation.rs | 33 +++++++++---- doip-server/src/error.rs | 54 +++++++++++----------- doip-server/src/lib.rs | 3 ++ 9 files changed, 165 insertions(+), 81 deletions(-) diff --git a/.gitignore b/.gitignore index 3c5442b..99e8ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,12 @@ # 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 .env .cache diff --git a/Cargo.lock b/Cargo.lock index f382651..e40b81a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,6 @@ dependencies = [ "parking_lot", "serde", "thiserror", - "tokio", "tokio-util", "toml", "tracing", @@ -218,7 +217,6 @@ version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "bytes", "pin-project-lite", ] diff --git a/Cargo.toml b/Cargo.toml index 495c93c..907054e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,27 +8,63 @@ # terms of the Apache License Version 2.0 which is available at # https://www.apache.org/licenses/LICENSE-2.0 +[workspace.package] +edition = "2024" +license = "Apache-2.0" +homepage = "https://github.com/eclipse-opensovd/uds2sovd-proxy" + [workspace] +resolver = "3" members = ["doip-server"] -resolver = "2" +[workspace.dependencies] +# ---- async runtime ---- +tokio = { version = "1", default-features = false } +tokio-util = { version = "0.7", default-features = false } +# ---- serialization ---- +serde = { version = "1.0", default-features = false } +toml = { version = "0.9", default-features = false } +# ---- tracing & logging ---- +tracing = { version = "0.1", default-features = false } +# ---- error handling ---- +thiserror = { version = "2.0", default-features = false } +# ---- data structures ---- +bytes = { version = "1.0", default-features = false } +parking_lot = { version = "0.12", default-features = false } +# ---- utility crates ---- +hex = { version = "0.4", default-features = false } + +# Lints are aligned with shared-lints.toml from: +# https://github.com/eclipse-opensovd/cicd-workflows/tree/main/shared-lints [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 -unwrap_used = "deny" +# 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. 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] +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(nightly)'] } + +[profile.release.package."*"] opt-level = 3 [profile.release-with-debug] diff --git a/deny.toml b/deny.toml index 992f9f0..c039281 100644 --- a/deny.toml +++ b/deny.toml @@ -32,12 +32,17 @@ 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)]. -# 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"] + + +allow = ["Apache-2.0", "MIT"] confidence-threshold = 0.9 -exceptions = [] +exceptions = [ + # unicode-ident is a transitive dependency pulled in by serde (via proc-macro2). + # It is licensed under Unicode-3.0, an OSI-approved license covering only + # Unicode data tables. There is no Apache-2.0 or MIT alternative for this crate. + { allow = ["Unicode-3.0"], name = "unicode-ident" }, +] [licenses.private] ignore = true diff --git a/doip-server/Cargo.toml b/doip-server/Cargo.toml index 4c91f5f..307854c 100644 --- a/doip-server/Cargo.toml +++ b/doip-server/Cargo.toml @@ -11,36 +11,34 @@ [package] name = "doip-server" version = "0.1.0" -edition = "2024" -license = "Apache-2.0" +edition.workspace = true +publish = false +description = "DoIP server implementation for uds2sovd-proxy" +homepage.workspace = true +license.workspace = true -# ---- async runtime ---- -[dependencies] -tokio = { version = "1", default-features = false, features = ["io-util"] } -tokio-util = { version = "0.7", default-features = false, features = ["codec"] } +[lib] +path = "src/lib.rs" + +[lints] +workspace = true +[dependencies] +# ---- async runtime ---- +tokio-util = { workspace = true, features = ["codec"] } # ---- serialization ---- -serde = { version = "1.0", default-features = false, features = [ - "std", - "derive", -] } -toml = { version = "0.9", default-features = false, features = [ - "parse", - "serde", -] } +serde = { workspace = true, features = ["std", "derive"] } +toml = { workspace = true, features = ["parse", "serde"] } # ---- tracing & logging ---- -tracing = { version = "0.1", default-features = false, features = ["std"] } +tracing = { workspace = true, features = ["std"] } # ---- error handling ---- -thiserror = { version = "2.0", default-features = false, features = ["std"] } +thiserror = { workspace = true, features = ["std"] } # ---- data structures ---- -bytes = { version = "1.0", default-features = false, features = ["std"] } -parking_lot = { version = "0.12", default-features = false } +bytes = { workspace = true, features = ["std"] } +parking_lot = { workspace = true } # ---- utility crates ---- -hex = { version = "0.4", default-features = false, features = ["std"] } - -[lints] -workspace = true +hex = { workspace = true, features = ["std"] } diff --git a/doip-server/src/doip/diagnostic_message.rs b/doip-server/src/doip/diagnostic_message.rs index 84b2e64..ee60705 100644 --- a/doip-server/src/doip/diagnostic_message.rs +++ b/doip-server/src/doip/diagnostic_message.rs @@ -22,6 +22,16 @@ const ADDRESS_BYTES: usize = 2; const HEADER_BYTES: usize = ADDRESS_BYTES * 2; const ACK_CODE_BYTES: usize = 1; const MIN_USER_DATA_BYTES: usize = 1; +/// Wire code for a positive diagnostic acknowledgment (ISO 13400-2:2019 Table 27). +const POSITIVE_ACK_CODE: u8 = 0x00; + +/// Parse source and target addresses from a 4-byte address-pair header. +/// Layout: SA(2 bytes, big-endian) + TA(2 bytes, big-endian). +fn parse_address_pair(header: [u8; HEADER_BYTES]) -> (u16, u16) { + let source = u16::from_be_bytes([header[0], header[1]]); + let target = u16::from_be_bytes([header[2], header[3]]); + (source, target) +} /// Outcome of a diagnostic message acknowledgment (ISO 13400-2:2019). /// @@ -88,36 +98,46 @@ 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 + /// 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 { + /// * `data` - UDS payload data (must not be empty) + /// + /// # Errors + /// Returns [`DoipError::EmptyUserData`] if `data` is empty. + pub fn new(source: u16, target: u16, data: Bytes) -> crate::Result { + if data.is_empty() { + return Err(DoipError::EmptyUserData); + } + Ok(Self { source_address: source, target_address: target, user_data: data, - } + }) } /// Get the source address + #[must_use] pub fn source_address(&self) -> u16 { self.source_address } /// Get the target address + #[must_use] pub fn target_address(&self) -> u16 { self.target_address } /// Get the UDS user data + #[must_use] pub fn user_data(&self) -> &Bytes { &self.user_data } /// Returns the UDS service ID (first byte of user data), or `None` if the payload is empty. + #[must_use] pub fn service_id(&self) -> Option { self.user_data.first().copied() } @@ -227,8 +247,7 @@ impl DiagnosticAck { /// 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 (source_address, target_address) = parse_address_pair(header); let previous_data = payload .get(Self::MIN_LEN..) .filter(|d| !d.is_empty()) @@ -241,8 +260,7 @@ 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 (source_address, target_address) = parse_address_pair(header); let user_data = payload .get(HEADER_BYTES..) @@ -291,7 +309,7 @@ impl DoipSerializable for DiagnosticAck { buf.put_u16(self.source_address); buf.put_u16(self.target_address); buf.put_u8(match self.result { - AckResult::Positive => 0x00, + AckResult::Positive => POSITIVE_ACK_CODE, AckResult::Negative(code) => u8::from(code), }); if let Some(ref data) = self.previous_data { @@ -344,7 +362,7 @@ mod tests { #[test] fn build_diagnostic_message() { let uds = Bytes::from_static(&[0x22, 0xF1, 0x90]); - let msg = Message::new(0x0E80, 0x1000, uds); + let msg = Message::new(0x0E80, 0x1000, uds).unwrap(); let bytes = msg.to_bytes(); assert_eq!(&bytes[..ADDRESS_BYTES], &[0x0E, 0x80]); @@ -406,9 +424,15 @@ mod tests { ); } + #[test] + fn reject_empty_user_data() { + let err = Message::new(0x0E80, 0x1000, Bytes::new()); + assert!(matches!(err, Err(DoipError::EmptyUserData))); + } + #[test] fn roundtrip_message() { - let original = Message::new(0x0E80, 0x1000, Bytes::from_static(&[0x10, 0x01])); + let original = Message::new(0x0E80, 0x1000, Bytes::from_static(&[0x10, 0x01])).unwrap(); let bytes = original.to_bytes(); let parsed = Message::parse(&bytes).unwrap(); assert_eq!(original, parsed); diff --git a/doip-server/src/doip/routing_activation.rs b/doip-server/src/doip/routing_activation.rs index 99d5710..9263083 100644 --- a/doip-server/src/doip/routing_activation.rs +++ b/doip-server/src/doip/routing_activation.rs @@ -12,12 +12,27 @@ */ //! Routing Activation handlers (ISO 13400-2:2019) +use std::mem::size_of; + use bytes::{BufMut, BytesMut}; use tracing::error; use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; use crate::DoipError; +/// Byte length of the optional OEM-specific field (a single u32, big-endian). +const OEM_DATA_LEN: usize = size_of::(); + +/// Parse the optional 4-byte OEM-specific field from the end of a routing activation payload. +/// Returns `Some(u32)` when exactly `OEM_DATA_LEN` bytes are present beyond `min_len`, +/// and `None` when the payload ends at `min_len` (i.e. no OEM data). +fn parse_oem_specific(payload: &[u8], min_len: usize, max_len: usize) -> Option { + payload + .get(min_len..max_len) + .and_then(|s| <[u8; OEM_DATA_LEN]>::try_from(s).ok()) + .map(u32::from_be_bytes) +} + /// Routing activation response codes per ISO 13400-2:2019 Table 25. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] @@ -246,10 +261,7 @@ impl DoipParseable for Request { })?; 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); + let oem_specific = parse_oem_specific(payload, Self::MIN_LEN, Self::MAX_LEN); Ok(Self { source_address, @@ -272,10 +284,7 @@ impl DoipParseable for Response { })?; 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); + let oem_specific = parse_oem_specific(payload, Self::MIN_LEN, Self::MAX_LEN); Ok(Self { tester_address, @@ -289,7 +298,13 @@ impl DoipParseable for Response { impl DoipSerializable for Response { fn serialized_len(&self) -> Option { - Some(Self::MIN_LEN.saturating_add(if self.oem_specific.is_some() { 4 } else { 0 })) + Some( + Self::MIN_LEN.saturating_add(if self.oem_specific.is_some() { + OEM_DATA_LEN + } else { + 0 + }), + ) } fn write_to(&self, buf: &mut BytesMut) { diff --git a/doip-server/src/error.rs b/doip-server/src/error.rs index 986d921..c5f53f4 100644 --- a/doip-server/src/error.rs +++ b/doip-server/src/error.rs @@ -75,37 +75,35 @@ pub enum DoipError { } #[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, -} +mod tests { + use super::*; -#[cfg(test)] -impl UdsNrc { - #[must_use] - const fn as_u8(self) -> u8 { - self as u8 + /// 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)] -mod tests { - use super::*; + impl UdsNrc { + #[must_use] + const fn as_u8(self) -> u8 { + self as u8 + } + } #[test] fn test_routing_activation_success() { diff --git a/doip-server/src/lib.rs b/doip-server/src/lib.rs index 0c8b098..9a83061 100644 --- a/doip-server/src/lib.rs +++ b/doip-server/src/lib.rs @@ -19,3 +19,6 @@ pub mod server; /// UDS service layer – bridges DoIP transport to ISO 14229-1 request/response handling. pub mod uds; pub use error::{DoipError, Result}; + +// No unit tests: this file contains only module declarations and re-exports. +// All logic lives in the sub-modules, which carry their own test suites. From 6d8eab2d1850ae2bb97a51e74d02a33c43b5b2a4 Mon Sep 17 00:00:00 2001 From: vinayrs Date: Mon, 30 Mar 2026 16:46:29 +0530 Subject: [PATCH 12/17] fix(review): add unit tests, trace logging --- doip-server/src/doip/codec.rs | 206 +++++++++++++++++++++++++++++++++ doip-server/src/doip/header.rs | 4 +- doip-server/src/doip/mod.rs | 121 +++++++++++++++++++ doip-server/src/server/mod.rs | 3 + doip-server/src/uds/mod.rs | 3 + 5 files changed, 335 insertions(+), 2 deletions(-) diff --git a/doip-server/src/doip/codec.rs b/doip-server/src/doip/codec.rs index afef9c7..bf088e8 100644 --- a/doip-server/src/doip/codec.rs +++ b/doip-server/src/doip/codec.rs @@ -176,3 +176,209 @@ impl Encoder for DoipCodec { Ok(()) } } + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use tokio_util::codec::{Decoder, Encoder}; + + use super::*; + use crate::doip::header::{DoipMessage, PayloadType}; + + // ── helpers ────────────────────────────────────────────────────────────── + + // Build a minimal valid DoIP frame: + // version=0x02, inverse=0xFD, payload_type (u16 BE), payload_length (u32 BE), payload + fn make_frame(payload_type: u16, payload: &[u8]) -> BytesMut { + let len = u32::try_from(payload.len()).expect("test payload must fit in u32"); + let [t0, t1] = payload_type.to_be_bytes(); + let [l0, l1, l2, l3] = len.to_be_bytes(); + let mut buf = BytesMut::new(); + buf.extend_from_slice(&[0x02, 0xFD, t0, t1, l0, l1, l2, l3]); + buf.extend_from_slice(payload); + buf + } + + // ── DoipCodec::new / Default ────────────────────────────────────────────── + + #[test] + fn new_and_default_behave_the_same() { + // Both should decode the same frame identically + let mut a = DoipCodec::new(); + let mut b = DoipCodec::default(); + let frame = make_frame(0x0007, &[]); + let ra = a.decode(&mut frame.clone()).unwrap(); + let rb = b.decode(&mut frame.clone()).unwrap(); + assert_eq!(ra.is_some(), rb.is_some()); + } + + #[test] + fn new_accepts_large_payload_within_default_limit() { + // Default codec must not reject reasonable-sized payloads + let data = vec![0u8; 1024]; + let mut codec = DoipCodec::new(); + let mut buf = make_frame(0x8001, &data); + assert!(codec.decode(&mut buf).unwrap().is_some()); + } + + #[test] + fn with_max_payload_size_limits_accepted_size() { + // A payload 1 byte over the custom limit must be rejected + let mut codec = DoipCodec::with_max_payload_size(1024); + let too_large = vec![0u8; 1025]; + let mut buf = make_frame(0x8001, &too_large); + assert!(codec.decode(&mut buf).is_err()); + + // Exactly at the limit must be accepted + let mut codec2 = DoipCodec::with_max_payload_size(1024); + let exact = vec![0u8; 1024]; + let mut buf2 = make_frame(0x8001, &exact); + assert!(codec2.decode(&mut buf2).unwrap().is_some()); + } + + // ── Decoder: happy-path ─────────────────────────────────────────────────── + + #[test] + fn decode_alive_check_request() { + // Alive check request (type 0x0007) has no payload + let mut codec = DoipCodec::new(); + let mut buf = make_frame(0x0007, &[]); + let msg = codec.decode(&mut buf).unwrap().unwrap(); + assert_eq!(msg.header.payload_type(), 0x0007); + assert!(msg.payload.is_empty()); + assert!(buf.is_empty()); + } + + #[test] + fn decode_message_with_payload() { + let mut codec = DoipCodec::new(); + let data = [0x0E, 0x80, 0x10, 0x01, 0x3E]; + let mut buf = make_frame(0x8001, &data); + let msg = codec.decode(&mut buf).unwrap().unwrap(); + assert_eq!(msg.header.payload_type(), 0x8001); + assert_eq!(&msg.payload[..], &data); + } + + #[test] + fn decode_resets_state_for_next_message() { + // After a successful decode, the codec must accept another frame + let mut codec = DoipCodec::new(); + let mut buf = make_frame(0x0007, &[]); + codec.decode(&mut buf).unwrap().unwrap(); + // Buffer is empty — a healthy reset returns None, not an error + assert!(codec.decode(&mut buf).unwrap().is_none()); + } + + #[test] + fn decode_back_to_back_frames() { + let mut codec = DoipCodec::new(); + let mut buf = make_frame(0x0007, &[]); + buf.extend_from_slice(&make_frame(0x0008, &[0xAA, 0xBB])); + + let m1 = codec.decode(&mut buf).unwrap().unwrap(); + let m2 = codec.decode(&mut buf).unwrap().unwrap(); + + assert_eq!(m1.header.payload_type(), 0x0007); + assert_eq!(m2.header.payload_type(), 0x0008); + assert!(buf.is_empty()); + } + + // ── Decoder: partial / incomplete data ─────────────────────────────────── + + #[test] + fn decode_returns_none_when_header_incomplete() { + let mut codec = DoipCodec::new(); + // Only 4 of the 8 header bytes present + let mut buf = BytesMut::from(&[0x02u8, 0xFD, 0x00, 0x07][..]); + assert!(codec.decode(&mut buf).unwrap().is_none()); + } + + #[test] + fn decode_returns_none_when_payload_incomplete() { + let mut codec = DoipCodec::new(); + // AliveCheckResponse (0x0008) minimum is 2 bytes. + // Declare 4 bytes in the header but only provide 2 → codec must wait. + let mut buf = make_frame(0x0008, &[0x0E, 0x80, 0x00, 0x00]); + buf.truncate(buf.len() - 2); // strip last 2 payload bytes + assert!(codec.decode(&mut buf).unwrap().is_none()); + } + + #[test] + fn decode_returns_none_on_empty_buffer() { + let mut codec = DoipCodec::new(); + let mut buf = BytesMut::new(); + assert!(codec.decode(&mut buf).unwrap().is_none()); + } + + // ── Decoder: error cases ────────────────────────────────────────────────── + + #[test] + fn decode_rejects_invalid_version() { + let mut codec = DoipCodec::new(); + // version 0x04 is not a valid DoIP protocol version + let mut buf = BytesMut::from(&[0x04u8, 0xFB, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00][..]); + assert!(codec.decode(&mut buf).is_err()); + } + + #[test] + fn decode_rejects_payload_exceeding_max_size() { + let mut codec = DoipCodec::with_max_payload_size(10); + // Declare payload_length = 11, over the limit of 10 + let mut buf = BytesMut::from(&[0x02u8, 0xFD, 0x80, 0x01, 0x00, 0x00, 0x00, 0x0B][..]); + assert!(codec.decode(&mut buf).is_err()); + } + + #[test] + fn decode_accepts_payload_at_exact_max_size() { + let data = vec![0u8; 10]; + let mut codec = DoipCodec::with_max_payload_size(10); + let mut buf = make_frame(0x8001, &data); + assert!(codec.decode(&mut buf).unwrap().is_some()); + } + + // ── Encoder ─────────────────────────────────────────────────────────────── + + #[test] + fn encode_produces_correct_wire_bytes() { + let mut codec = DoipCodec::new(); + let payload = Bytes::from_static(&[0x0E, 0x80, 0x10, 0x01]); + let msg = DoipMessage::new(PayloadType::DiagnosticMessage, payload); + + let mut buf = BytesMut::new(); + codec.encode(msg, &mut buf).unwrap(); + + // Header: version, inverse, type (0x8001), length (0x00000004) + assert_eq!(buf.get(..2).unwrap(), &[0x02, 0xFD]); + assert_eq!(buf.get(2..4).unwrap(), &[0x80, 0x01]); + assert_eq!(buf.get(4..8).unwrap(), &[0x00, 0x00, 0x00, 0x04]); + assert_eq!(buf.get(8..).unwrap(), &[0x0E, 0x80, 0x10, 0x01]); + } + + #[test] + fn encode_empty_payload() { + let mut codec = DoipCodec::new(); + let msg = DoipMessage::new(PayloadType::AliveCheckRequest, Bytes::new()); + + let mut buf = BytesMut::new(); + codec.encode(msg, &mut buf).unwrap(); + + assert_eq!(buf.len(), 8); // header only + assert_eq!(buf.get(4..8).unwrap(), &[0x00, 0x00, 0x00, 0x00]); // zero payload length + } + + // ── Roundtrip ───────────────────────────────────────────────────────────── + + #[test] + fn roundtrip_encode_then_decode() { + let mut codec = DoipCodec::new(); + let payload = Bytes::from_static(&[0x0E, 0x80, 0x22, 0xF1, 0x90]); + let original = DoipMessage::new(PayloadType::DiagnosticMessage, payload); + + let mut buf = BytesMut::new(); + codec.encode(original.clone(), &mut buf).unwrap(); + let decoded = codec.decode(&mut buf).unwrap().unwrap(); + + assert_eq!(original.header, decoded.header); + assert_eq!(original.payload, decoded.payload); + } +} diff --git a/doip-server/src/doip/header.rs b/doip-server/src/doip/header.rs index 96bc9fa..1be16a9 100644 --- a/doip-server/src/doip/header.rs +++ b/doip-server/src/doip/header.rs @@ -27,7 +27,7 @@ //! See [`codec`](super::codec) for the Tokio TCP framing codec. use bytes::{BufMut, Bytes, BytesMut}; -use tracing::{debug, warn}; +use tracing::{trace, warn}; /// Generic Header NACK codes (ISO 13400-2:2019 Table 17) /// @@ -210,7 +210,7 @@ impl DoipHeader { /// /// Returns `None` if valid, or the `GenericNackCode` describing the first violation. pub fn validate(&self) -> Option { - debug!( + trace!( version = format!("0x{:02X}", self.version), inverse_version = format!("0x{:02X}", self.inverse_version), payload_type = format!("0x{:04X}", self.payload_type), diff --git a/doip-server/src/doip/mod.rs b/doip-server/src/doip/mod.rs index 183854c..8262e04 100644 --- a/doip-server/src/doip/mod.rs +++ b/doip-server/src/doip/mod.rs @@ -122,3 +122,124 @@ pub(crate) fn parse_fixed_slice( e }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::DoipError; + + // ── too_short ───────────────────────────────────────────────────────────── + + #[test] + fn too_short_produces_correct_error() { + let payload = [0x01u8, 0x02]; + let err = too_short(&payload, 5); + assert!(matches!( + err, + DoipError::PayloadTooShort { + expected: 5, + actual: 2 + } + )); + } + + #[test] + fn too_short_on_empty_slice() { + let err = too_short(&[], 4); + assert!(matches!( + err, + DoipError::PayloadTooShort { + expected: 4, + actual: 0 + } + )); + } + + // ── check_min_len ───────────────────────────────────────────────────────── + + #[test] + fn check_min_len_passes_when_exact() { + assert!(check_min_len(&[0x01, 0x02], 2).is_ok()); + } + + #[test] + fn check_min_len_passes_when_longer() { + assert!(check_min_len(&[0x01, 0x02, 0x03], 2).is_ok()); + } + + #[test] + fn check_min_len_errors_when_too_short() { + let result = check_min_len(&[0x01], 2); + assert!(matches!( + result, + Err(DoipError::PayloadTooShort { + expected: 2, + actual: 1 + }) + )); + } + + #[test] + fn check_min_len_errors_on_empty_slice() { + let result = check_min_len(&[], 1); + assert!(matches!( + result, + Err(DoipError::PayloadTooShort { + expected: 1, + actual: 0 + }) + )); + } + + #[test] + fn check_min_len_zero_always_passes() { + assert!(check_min_len(&[], 0).is_ok()); + } + + // ── parse_fixed_slice ───────────────────────────────────────────────────── + + #[test] + fn parse_fixed_slice_extracts_exact_bytes() { + let payload = [0x0E, 0x80, 0x10, 0x01]; + let result: [u8; 2] = parse_fixed_slice(&payload, "test").unwrap(); + assert_eq!(result, [0x0E, 0x80]); + } + + #[test] + fn parse_fixed_slice_succeeds_with_extra_bytes() { + let payload = [0x01, 0x02, 0x03, 0x04, 0x05]; + let result: [u8; 3] = parse_fixed_slice(&payload, "test").unwrap(); + assert_eq!(result, [0x01, 0x02, 0x03]); + } + + #[test] + fn parse_fixed_slice_errors_when_too_short() { + let payload = [0x01u8]; + let result: crate::Result<[u8; 4]> = parse_fixed_slice(&payload, "test"); + assert!(matches!( + result, + Err(DoipError::PayloadTooShort { + expected: 4, + actual: 1 + }) + )); + } + + #[test] + fn parse_fixed_slice_errors_on_empty() { + let result: crate::Result<[u8; 2]> = parse_fixed_slice(&[], "context"); + assert!(matches!( + result, + Err(DoipError::PayloadTooShort { + expected: 2, + actual: 0 + }) + )); + } + + #[test] + fn parse_fixed_slice_zero_size_always_succeeds() { + let result: [u8; 0] = parse_fixed_slice(&[], "empty").unwrap(); + assert_eq!(result, []); + } +} diff --git a/doip-server/src/server/mod.rs b/doip-server/src/server/mod.rs index fdfffd2..97cd50e 100644 --- a/doip-server/src/server/mod.rs +++ b/doip-server/src/server/mod.rs @@ -21,3 +21,6 @@ pub mod session; pub use config::ServerConfig; pub use session::{Session, SessionManager}; + +// No unit tests: this file contains only module declarations and re-exports. +// config.rs and session.rs carry their own test suites. diff --git a/doip-server/src/uds/mod.rs b/doip-server/src/uds/mod.rs index 3718925..5712c60 100644 --- a/doip-server/src/uds/mod.rs +++ b/doip-server/src/uds/mod.rs @@ -19,3 +19,6 @@ pub mod handler; pub use handler::{UdsHandler, UdsRequest, UdsResponse, service_id}; + +// No unit tests: this file contains only module declarations and re-exports. +// handler.rs carries its own test suite. From eff32421392f70347be42ae8ad5e3e5baf3db9eb Mon Sep 17 00:00:00 2001 From: Vinaykumar Date: Tue, 21 Apr 2026 14:49:26 +0200 Subject: [PATCH 13/17] fix(review): revert CI SHAs, use constants in vehicle_id, fix doc and toml --- deny.toml | 6 ++--- doip-server/src/doip/vehicle_id.rs | 36 +++++++++++++++++++----------- doip-server/src/error.rs | 3 +++ doip-server/src/server/session.rs | 2 +- doip-server/src/uds/handler.rs | 2 +- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/deny.toml b/deny.toml index c039281..229b337 100644 --- a/deny.toml +++ b/deny.toml @@ -32,8 +32,6 @@ 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"] confidence-threshold = 0.9 @@ -41,7 +39,9 @@ exceptions = [ # unicode-ident is a transitive dependency pulled in by serde (via proc-macro2). # It is licensed under Unicode-3.0, an OSI-approved license covering only # Unicode data tables. There is no Apache-2.0 or MIT alternative for this crate. - { allow = ["Unicode-3.0"], name = "unicode-ident" }, + { allow = [ + "Unicode-3.0", + ], name = "unicode-ident" }, ] [licenses.private] diff --git a/doip-server/src/doip/vehicle_id.rs b/doip-server/src/doip/vehicle_id.rs index 2b4756a..dd6a858 100644 --- a/doip-server/src/doip/vehicle_id.rs +++ b/doip-server/src/doip/vehicle_id.rs @@ -48,23 +48,23 @@ pub struct Request; /// Only the `DoIP` entity with a matching 6-byte EID should respond. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RequestWithEid { - eid: [u8; 6], + eid: [u8; EID_LEN], } impl RequestWithEid { /// Fixed wire-format length of a Vehicle Identification Request with EID /// payload (6-byte EID filter). - pub const LEN: usize = 6; + pub const PAYLOAD_LEN: usize = EID_LEN; /// Create a new Vehicle Identification Request filtered by the given 6-byte EID. #[must_use] - pub fn new(eid: [u8; 6]) -> Self { + pub fn new(eid: [u8; EID_LEN]) -> Self { Self { eid } } /// The EID filter value #[must_use] - pub fn eid(&self) -> &[u8; 6] { + pub fn eid(&self) -> &[u8; EID_LEN] { &self.eid } } @@ -74,22 +74,22 @@ impl RequestWithEid { /// Only the `DoIP` entity with a matching 17-byte VIN should respond. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RequestWithVin { - vin: [u8; 17], + vin: [u8; VIN_LEN], } impl RequestWithVin { /// Fixed wire-format length of a Vehicle Identification Request with VIN payload (17-byte VIN). - pub const LEN: usize = 17; + pub const PAYLOAD_LEN: usize = VIN_LEN; /// Create a new Vehicle Identification Request filtered by the given 17-byte VIN. #[must_use] - pub fn new(vin: [u8; 17]) -> Self { + pub fn new(vin: [u8; VIN_LEN]) -> Self { Self { vin } } /// The VIN filter value as bytes #[must_use] - pub fn vin(&self) -> &[u8; 17] { + pub fn vin(&self) -> &[u8; VIN_LEN] { &self.vin } @@ -163,10 +163,10 @@ impl From for u8 { /// VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1) + optional SyncStatus(1) #[derive(Debug, Clone, PartialEq, Eq)] pub struct Response { - vin: [u8; 17], + vin: [u8; VIN_LEN], logical_address: u16, - eid: [u8; 6], - gid: [u8; 6], + eid: [u8; EID_LEN], + gid: [u8; GID_LEN], further_action: FurtherAction, sync_status: Option, } @@ -184,7 +184,12 @@ impl Response { /// `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 { + pub fn new( + vin: [u8; VIN_LEN], + logical_address: u16, + eid: [u8; EID_LEN], + gid: [u8; GID_LEN], + ) -> Self { Self { vin, logical_address, @@ -218,7 +223,12 @@ impl Response { } impl DoipParseable for Request { - fn parse(_payload: &[u8]) -> crate::Result { + fn parse(payload: &[u8]) -> crate::Result { + if !payload.is_empty() { + return Err(DoipError::UnexpectedPayload { + actual: payload.len(), + }); + } Ok(Self) } } diff --git a/doip-server/src/error.rs b/doip-server/src/error.rs index c5f53f4..08b8ada 100644 --- a/doip-server/src/error.rs +++ b/doip-server/src/error.rs @@ -72,6 +72,9 @@ pub enum DoipError { #[error("diagnostic message has no user data")] EmptyUserData, + + #[error("unexpected payload data: expected empty payload, got {actual} bytes")] + UnexpectedPayload { actual: usize }, } #[cfg(test)] diff --git a/doip-server/src/server/session.rs b/doip-server/src/server/session.rs index cfd2dee..d43986a 100644 --- a/doip-server/src/server/session.rs +++ b/doip-server/src/server/session.rs @@ -98,7 +98,7 @@ impl Session { /// Thread-safe registry of active `DoIP` sessions. /// -/// The mutable session state is held in a single [`RwLock`]-protected `Inner` +/// The mutable session state is held in this single [`RwLock`]-protected `Inner` /// struct for atomic multi-map updates, while the monotonic ID counter uses /// an [`AtomicU64`] to avoid taking the write-lock just to mint a new ID. /// Access this via the [`Arc`] returned by [`SessionManager::new`]. diff --git a/doip-server/src/uds/handler.rs b/doip-server/src/uds/handler.rs index 6a29043..305a8b0 100644 --- a/doip-server/src/uds/handler.rs +++ b/doip-server/src/uds/handler.rs @@ -19,7 +19,7 @@ use bytes::Bytes; -/// UDS Service IDs (ISO 14229-1:2020) +/// UDS Service IDs (ISO 14229-1:2020, Section 10 – Service definition) pub mod service_id { /// Diagnostic Session Control (0x10) – switch the ECU into a specific diagnostic session. pub const DIAGNOSTIC_SESSION_CONTROL: u8 = 0x10; From 1eb7c2b19450bacc2512228968d2eb940aa8bf92 Mon Sep 17 00:00:00 2001 From: Vinaykumar Date: Tue, 21 Apr 2026 14:49:26 +0200 Subject: [PATCH 14/17] fix(review): address review comments --- doip-server/src/doip/codec.rs | 14 -------------- doip-server/src/doip/mod.rs | 6 ------ doip-server/src/doip/payload.rs | 1 - doip-server/src/error.rs | 10 ++++------ 4 files changed, 4 insertions(+), 27 deletions(-) diff --git a/doip-server/src/doip/codec.rs b/doip-server/src/doip/codec.rs index bf088e8..19a97fd 100644 --- a/doip-server/src/doip/codec.rs +++ b/doip-server/src/doip/codec.rs @@ -185,8 +185,6 @@ mod tests { use super::*; use crate::doip::header::{DoipMessage, PayloadType}; - // ── helpers ────────────────────────────────────────────────────────────── - // Build a minimal valid DoIP frame: // version=0x02, inverse=0xFD, payload_type (u16 BE), payload_length (u32 BE), payload fn make_frame(payload_type: u16, payload: &[u8]) -> BytesMut { @@ -199,8 +197,6 @@ mod tests { buf } - // ── DoipCodec::new / Default ────────────────────────────────────────────── - #[test] fn new_and_default_behave_the_same() { // Both should decode the same frame identically @@ -236,8 +232,6 @@ mod tests { assert!(codec2.decode(&mut buf2).unwrap().is_some()); } - // ── Decoder: happy-path ─────────────────────────────────────────────────── - #[test] fn decode_alive_check_request() { // Alive check request (type 0x0007) has no payload @@ -283,8 +277,6 @@ mod tests { assert!(buf.is_empty()); } - // ── Decoder: partial / incomplete data ─────────────────────────────────── - #[test] fn decode_returns_none_when_header_incomplete() { let mut codec = DoipCodec::new(); @@ -310,8 +302,6 @@ mod tests { assert!(codec.decode(&mut buf).unwrap().is_none()); } - // ── Decoder: error cases ────────────────────────────────────────────────── - #[test] fn decode_rejects_invalid_version() { let mut codec = DoipCodec::new(); @@ -336,8 +326,6 @@ mod tests { assert!(codec.decode(&mut buf).unwrap().is_some()); } - // ── Encoder ─────────────────────────────────────────────────────────────── - #[test] fn encode_produces_correct_wire_bytes() { let mut codec = DoipCodec::new(); @@ -366,8 +354,6 @@ mod tests { assert_eq!(buf.get(4..8).unwrap(), &[0x00, 0x00, 0x00, 0x00]); // zero payload length } - // ── Roundtrip ───────────────────────────────────────────────────────────── - #[test] fn roundtrip_encode_then_decode() { let mut codec = DoipCodec::new(); diff --git a/doip-server/src/doip/mod.rs b/doip-server/src/doip/mod.rs index 8262e04..f540e1f 100644 --- a/doip-server/src/doip/mod.rs +++ b/doip-server/src/doip/mod.rs @@ -128,8 +128,6 @@ mod tests { use super::*; use crate::DoipError; - // ── too_short ───────────────────────────────────────────────────────────── - #[test] fn too_short_produces_correct_error() { let payload = [0x01u8, 0x02]; @@ -155,8 +153,6 @@ mod tests { )); } - // ── check_min_len ───────────────────────────────────────────────────────── - #[test] fn check_min_len_passes_when_exact() { assert!(check_min_len(&[0x01, 0x02], 2).is_ok()); @@ -196,8 +192,6 @@ mod tests { assert!(check_min_len(&[], 0).is_ok()); } - // ── parse_fixed_slice ───────────────────────────────────────────────────── - #[test] fn parse_fixed_slice_extracts_exact_bytes() { let payload = [0x0E, 0x80, 0x10, 0x01]; diff --git a/doip-server/src/doip/payload.rs b/doip-server/src/doip/payload.rs index ac79b3f..dc8d4df 100644 --- a/doip-server/src/doip/payload.rs +++ b/doip-server/src/doip/payload.rs @@ -171,7 +171,6 @@ 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()) diff --git a/doip-server/src/error.rs b/doip-server/src/error.rs index 08b8ada..2a99ece 100644 --- a/doip-server/src/error.rs +++ b/doip-server/src/error.rs @@ -16,12 +16,6 @@ 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; @@ -80,6 +74,10 @@ pub enum DoipError { #[cfg(test)] mod tests { use super::*; + use crate::doip::diagnostic_message::NackCode as DiagnosticNackCode; + use crate::doip::{ + header::GenericNackCode, routing_activation::ResponseCode as RoutingActivationCode, + }; /// UDS Negative Response Codes (ISO 14229-1:2020) #[derive(Debug, Clone, Copy, PartialEq, Eq)] From 5cd28b315cbd12c27877475c1fe36d3f4e20567e Mon Sep 17 00:00:00 2001 From: Vinaykumar Date: Mon, 11 May 2026 16:26:59 +0200 Subject: [PATCH 15/17] fix(review): address second round of review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Narrow internal API visibility: DoipParseable→pub(crate), Session::new/activate_routing/connection_state→pub(crate), DoipMessage::with_raw_payload_type→pub(crate), SessionState→pub(crate); external callers use is_routing_active() - Rename protocol enums for domain clarity: NackCode->DiagnosticNackCode, AckResult->DiagnosticAckResult, ResponseCode->ActivationResponseCode, SyncStatus->GidSyncStatus - Restore warn!() in AliveCheckRequest::write_to - Promote VIN_LEN to module-level pub(crate) const (ISO 3779 ref, removes magic 17 literals from struct field, DEFAULT_VIN, parse_vin) - Add comment in codec.rs explaining io::Result vs crate::Result - Add Copy derive to AliveCheckRequest - Add RoutingActivationResponse::with_oem_specific() builder - Consistent crate::Result alias in mod.rs, payload.rs, config.rs - Add PlantUML architecture diagrams (classDia.puml, seq.puml, flowchart.puml) --- doip-server/Doc/classDia.puml | 355 +++++++++++++++++++++ doip-server/Doc/flowchart.puml | 155 +++++++++ doip-server/Doc/seq.puml | 90 ++++++ doip-server/src/doip/alive_check.rs | 53 +-- doip-server/src/doip/diagnostic_message.rs | 104 +++--- doip-server/src/doip/header.rs | 26 +- doip-server/src/doip/mod.rs | 44 ++- doip-server/src/doip/payload.rs | 43 +-- doip-server/src/doip/routing_activation.rs | 151 +++++---- doip-server/src/doip/vehicle_id.rs | 176 ++++++---- doip-server/src/error.rs | 139 +------- doip-server/src/lib.rs | 4 + doip-server/src/server/config.rs | 44 +-- doip-server/src/server/session.rs | 90 ++++-- doip-server/src/uds/handler.rs | 33 +- 15 files changed, 1069 insertions(+), 438 deletions(-) create mode 100644 doip-server/Doc/classDia.puml create mode 100644 doip-server/Doc/flowchart.puml create mode 100644 doip-server/Doc/seq.puml diff --git a/doip-server/Doc/classDia.puml b/doip-server/Doc/classDia.puml new file mode 100644 index 0000000..3d11fb9 --- /dev/null +++ b/doip-server/Doc/classDia.puml @@ -0,0 +1,355 @@ +@startuml DoIP Server — Architecture Overview +' ========================================================== +' Crisp layered overview of the DoIP server (ISO 13400-2). +' +' Colour key: +' Blue (#EBF5FB) — Wire / TCP framing layer +' Teal (#E8F8F5) — Payload dispatch & shared traits +' Light (#EEF4FF) — DoIP message types (one pkg per §) +' Green (#EAFAF1) — Server & session management +' Amber (#FEF9E7) — UDS integration (user extension point) +' ========================================================== + +skinparam linetype ortho +skinparam classAttributeIconSize 0 +skinparam packageStyle rectangle +skinparam defaultFontSize 12 +skinparam arrowColor #555555 +skinparam backgroundColor white + +skinparam class { + BackgroundColor #EEF4FF + BorderColor #4A7CC7 + HeaderBackgroundColor #4A7CC7 + HeaderFontColor white +} +skinparam interface { + BackgroundColor #FFF8E1 + BorderColor #F9A825 + HeaderBackgroundColor #F9A825 + HeaderFontColor white +} +skinparam package { + BackgroundColor #FAFAFA + BorderColor #BDBDBD +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #E0AC00 +} + +' ══════════════════════════════════════════════════════════ +' 1. TCP FRAMING +' ══════════════════════════════════════════════════════════ +package "TCP Framing" #EBF5FB { + + class DoipCodec { + - state: AwaitingHeader | AwaitingPayload + - max_payload_size: u32 + .. + + new() : Self + + with_max_payload_size(n: u32) : Self + } + + class DoipMessage { + + header: DoipHeader + + payload: Bytes + } + + class DoipHeader <<8-byte generic header §6>> { + version: u8 ' 0x02 = ISO 13400-2:2019 + inverse_version: u8 ' version XOR 0xFF + payload_type: u16 + payload_length: u32 ' big-endian + .. + + parse(bytes) : Result + + validate() : Option + } + + enum PayloadType <> { + GenericNack = 0x0000 + VehicleIdentificationRequest = 0x0001 + VehicleIdentificationReqEid = 0x0002 + VehicleIdentificationReqVin = 0x0003 + VehicleIdentificationResponse = 0x0004 + RoutingActivationRequest = 0x0005 + RoutingActivationResponse = 0x0006 + AliveCheckRequest = 0x0007 + AliveCheckResponse = 0x0008 + DiagnosticMessage = 0x8001 + DiagnosticMessagePositiveAck = 0x8002 + DiagnosticMessageNegativeAck = 0x8003 + } + + enum GenericNackCode <
> { + IncorrectPatternFormat = 0x00 + UnknownPayloadType = 0x01 + MessageTooLarge = 0x02 + OutOfMemory = 0x03 + InvalidPayloadLength = 0x04 + } +} + +note right of DoipCodec + Two-state TCP reassembler: + **AwaitingHeader** (8 bytes) → + **AwaitingPayload** (declared length) + Drops connection on bad header. + DoS guard: rejects payload > max_payload_size. +end note + +' ══════════════════════════════════════════════════════════ +' 2. PAYLOAD DISPATCH & SHARED TRAITS +' ══════════════════════════════════════════════════════════ +package "Payload Dispatch" #E8F8F5 { + + interface DoipParseable <> { + + parse(payload: &[u8]) : Result + } + + interface DoipSerializable <> { + + write_to(buf: &mut BytesMut) + + to_bytes() : Bytes + + serialized_len() : Option + } + + enum DoipPayload <> { + AliveCheckRequest / AliveCheckResponse + RoutingActivationRequest / RoutingActivationResponse + DiagnosticMessage + DiagnosticMessagePositiveAck / NegativeAck + VehicleIdentificationRequest / WithEid / WithVin + VehicleIdentificationResponse + GenericNack(GenericNackCode) + .. + + parse(msg: &DoipMessage) : Result + + payload_type() : PayloadType + } +} + +note right of DoipPayload + Single entry-point for decoding any DoIP message. + Matches on PayloadType and delegates to the + correct module's parse() automatically. + Every variant wraps a fully-typed struct. +end note + +' ══════════════════════════════════════════════════════════ +' 3. DoIP MESSAGE TYPES +' ══════════════════════════════════════════════════════════ + +package "Alive Check (ISO §7.6)" #EEF4FF { + class AliveCheckRequest <> {} + class AliveCheckResponse { + tester_address: u16 + } +} + +package "Routing Activation (ISO §7.7)" #EEF4FF { + class RoutingActivationRequest { + source_address: u16 + activation_type: ActivationType + reserved: u32 + oem_specific: Option + } + class RoutingActivationResponse { + tester_address: u16 + entity_address: u16 + response_code: ActivationResponseCode + reserved: u32 + oem_specific: Option + .. + + success(tester, entity) : Self + + denial(tester, entity, code) : Self + + is_success() : bool + } + enum ActivationType <
> { + Default = 0x00 + WwhObd = 0x01 + CentralSecurity = 0xE0 + } + enum ActivationResponseCode <
> { + SuccessfullyActivated = 0x10 + ConfirmationRequired = 0x11 + -- denial codes -- + UnknownSourceAddress = 0x00 + AllSocketsRegistered = 0x01 + DifferentSourceAddress = 0x02 + SourceAddressAlreadyActive = 0x03 + MissingAuthentication = 0x04 + RejectedConfirmation = 0x05 + UnsupportedActivationType = 0x06 + TlsRequired = 0x07 + } +} + +package "Diagnostic Message (ISO §7.9)" #EEF4FF { + class DiagnosticMessage { + source_address: u16 + target_address: u16 + user_data: Bytes ' UDS payload (min 1 byte) + .. + + service_id() : Option + } + class DiagnosticAck { + source_address: u16 + target_address: u16 + result: DiagnosticAckResult + previous_data: Option + .. + + positive(src, tgt) : Self + + negative(src, tgt, code) : Self + } + enum DiagnosticAckResult { + Positive ' 0x00 + Negative(DiagnosticNackCode) + } + enum DiagnosticNackCode <
> { + InvalidSourceAddress = 0x02 + UnknownTargetAddress = 0x03 + DiagnosticMessageTooLarge = 0x04 + OutOfMemory = 0x05 + TargetUnreachable = 0x06 + UnknownNetwork = 0x07 + TransportProtocolError = 0x08 + } +} + +package "Vehicle Identification (ISO §7.5)" #EEF4FF { + class VehicleIdRequest <> {} + class VehicleIdRequestWithEid { + eid: [u8; 6] + } + class VehicleIdRequestWithVin { + vin: [u8; 17] + } + class VehicleIdResponse { + vin: [u8; 17] + logical_address: u16 + eid: [u8; 6] + gid: [u8; 6] + further_action: FurtherAction + sync_status: Option + } +} + +' ══════════════════════════════════════════════════════════ +' 4. SERVER & SESSION +' ══════════════════════════════════════════════════════════ +package "Server" #EAFAF1 { + class ServerConfig { + tcp_addr / udp_addr: SocketAddr + logical_address: u16 + vin: [u8; 17] eid: [u8; 6] + max_connections: usize + initial_inactivity_timeout_ms: u64 + general_inactivity_timeout_ms: u64 + .. + + from_file(path) : Result + } + + enum SessionState <> { + Connected + RoutingActive + Closed + } + + class Session { + id: u64 + peer_addr: SocketAddr + tester_address: u16 + state: SessionState + .. + + activate_routing(addr: u16) + + is_routing_active() : bool + } + + class SessionManager { + sessions: RwLock> + .. + + create_session(peer) : Session + + update_session(id, f) + + remove_session(id) + + is_tester_registered(addr: u16) : bool + } +} + +note right of SessionManager + Thread-safe session store. + One Session per active TCP connection. + Enforces max_connections limit. +end note + +' ══════════════════════════════════════════════════════════ +' 5. UDS INTEGRATION (user extension point) +' ══════════════════════════════════════════════════════════ +package "UDS Integration" #FEF9E7 { + interface UdsHandler <> { + + handle(req: UdsRequest) : UdsResponse + } + class UdsRequest { + source_address: u16 + target_address: u16 + payload: Bytes + .. + + service_id() : Option + } + class UdsResponse { + source_address: u16 + target_address: u16 + payload: Bytes + } +} + +note right of UdsHandler + Extension point for users. + Server extracts user_data from DiagnosticMessage, + wraps it as UdsRequest, and calls handle(). + The returned UdsResponse is sent back as + a new DiagnosticMessage (type 0x8001). +end note + +' ══════════════════════════════════════════════════════════ +' RELATIONSHIPS +' ══════════════════════════════════════════════════════════ + +' — TCP framing — +DoipCodec ..> DoipMessage : frames +DoipMessage *-- DoipHeader +DoipHeader --> PayloadType + +' — Dispatch — +DoipPayload ..> DoipMessage : parse() +DoipPayload --> AliveCheckRequest +DoipPayload --> AliveCheckResponse +DoipPayload --> RoutingActivationRequest +DoipPayload --> RoutingActivationResponse +DoipPayload --> DiagnosticMessage +DoipPayload --> DiagnosticAck +DoipPayload --> VehicleIdRequest +DoipPayload --> VehicleIdRequestWithEid +DoipPayload --> VehicleIdRequestWithVin +DoipPayload --> VehicleIdResponse + +' — All message types implement DoipParseable + DoipSerializable — +note "All message types above\nimplement DoipParseable\nand DoipSerializable." as TraitNote +DoipParseable .. TraitNote +DoipSerializable .. TraitNote + +' — Enum ownership — +RoutingActivationRequest --> ActivationType +RoutingActivationResponse --> ActivationResponseCode +DiagnosticAck --> DiagnosticAckResult +DiagnosticAckResult --> DiagnosticNackCode + +' — Server — +Session *-- SessionState +SessionManager "1" o-- "0..*" Session +ServerConfig ..> SessionManager : configures + +' — UDS bridge — +DiagnosticMessage ..> UdsRequest : user_data → +UdsHandler ..> UdsRequest : consumes +UdsHandler ..> UdsResponse : produces + +@enduml \ No newline at end of file diff --git a/doip-server/Doc/flowchart.puml b/doip-server/Doc/flowchart.puml new file mode 100644 index 0000000..de66b0e --- /dev/null +++ b/doip-server/Doc/flowchart.puml @@ -0,0 +1,155 @@ +@startuml DoIP Server Flowchart + +skinparam backgroundColor #FAFAFA +skinparam activity { + BackgroundColor #EEF4FF + BorderColor #4A7CC7 + FontSize 12 + ArrowColor #4A7CC7 + StartColor #4A7CC7 + EndColor #C0392B + DiamondBackgroundColor #FFF9E3 + DiamondBorderColor #E67E22 +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 +} + +start + +:Server starts; +note right: ServerConfig::from_file()\nor ServerConfig::default() + +:Bind TCP socket\non config.tcp_addr; + +repeat + + :Accept TCP connection\n(peer_addr); + + :SessionManager::create_session(peer_addr); + note right: Session { state=Connected\n tester_address=0 } + + fork + :Spawn Tokio task\nper connection; + fork again + :Continue accepting\nnew connections; + end fork + + ' ── Per-connection task ── + + repeat + + :Wait for bytes on TCP stream; + + :DoipCodec::decode(src: BytesMut); + + if (src.len < 8 bytes?) then (yes) + :return Ok(None)\nwait for more data; + stop + else (no) + endif + + :DoipHeader::parse(header_slice); + + if (Header valid?\nversion in {0x01,0x02,0x03,0xFF}\nversion XOR inv == 0xFF) then (no) + :return Err(InvalidData)\nGenericNack in log; + :Close TCP connection; + stop + else (yes) + endif + + if (payload_length > max_payload_size?) then (yes) + :return Err(MessageTooLarge)\nClose TCP connection; + stop + else (no) + endif + + if (src.len < header + payload_length?) then (yes) + :return Ok(None)\nwait for more data; + stop + else (no) + endif + + :split_to → DoipMessage\n{ header, payload: Bytes }] + + :DoipPayload::parse(DoipMessage); + + if (payload_type known?) then (no) + :DoipError::UnknownPayloadType; + :Send GenericNack(UnknownPayloadType); + else (yes) + + switch (payload_type?) + + case (0x0005\nRoutingActivationRequest) + :RoutingActivationRequest::parse(payload); + if (Session already RoutingActive\nwith same address?) then (yes) + :RoutingActivationResponse::denial(\n SourceAddressAlreadyActive); + elseif (Max connections reached?) then (yes) + :RoutingActivationResponse::denial(\n AllSocketsRegistered); + else (no) + :SessionManager::update_session(\n |s| s.activate_routing(src_addr)); + note right: Session.state = RoutingActive\nSession.tester_address = src_addr + :RoutingActivationResponse::success(\n tester_addr, entity_addr); + endif + :DoipCodec::encode(RoutingActivationResponse); + :Send bytes on TCP; + + case (0x0007\nAliveCheckRequest) + :AliveCheckRequest::parse(payload)\n[zero-length, always Ok]; + :AliveCheckResponse::new(\n session.tester_address()); + :to_bytes() → [addr_hi, addr_lo]; + :DoipCodec::encode(AliveCheckResponse\ntype=0x0008); + :Send 10 bytes on TCP; + + case (0x0008\nAliveCheckResponse) + :AliveCheckResponse::parse(payload)\n→ tester_address: u16; + if (tester_address ==\nsession.tester_address()?) then (yes) + :Reset T_TCP_General_Inactivity timer; + else (no) + :Log mismatch\nClose TCP connection; + stop + endif + + case (0x8001\nDiagnosticMessage) + if (session.is_routing_active()?) then (no) + :DiagnosticAck::negative(\n InvalidSourceAddress); + :Send DiagnosticMessageNegativeAck\n(type=0x8003); + else (yes) + :DiagnosticMessage::parse(payload)\n→ SA, TA, user_data; + :DiagnosticAck::positive(TA, SA); + :Send DiagnosticMessagePositiveAck\n(type=0x8002); + :UdsRequest::new(SA, TA, user_data); + :UdsHandler::handle(UdsRequest)\n[USER IMPLEMENTATION]; + :UdsResponse → DiagnosticMessage::new(\n SA=entity, TA=tester, data); + :DoipCodec::encode(DiagnosticMessage\ntype=0x8001); + :Send UDS response bytes on TCP; + endif + + case (0x0001/0x0002/0x0003\nVehicleIdRequest) + :VehicleIdRequest::parse(payload); + :VehicleIdResponse { VIN, EID, GID\nlogical_address }; + :Send VehicleIdentificationResponse\n(type=0x0004) via UDP; + + case (other) + :Send GenericNack\n(IncorrectPatternFormat); + + endswitch + + endif + + repeat while (TCP connection open?) is (yes) + -> no; + + :SessionManager::remove_session(id); + note right: Session dropped\nstate=Closed + +repeat while (Server running?) is (yes) +-> no; + +:Server shutdown; + +stop + +@enduml diff --git a/doip-server/Doc/seq.puml b/doip-server/Doc/seq.puml new file mode 100644 index 0000000..a69819f --- /dev/null +++ b/doip-server/Doc/seq.puml @@ -0,0 +1,90 @@ +@startuml DoIP Server - Sequence Diagram + +skinparam backgroundColor #FAFAFA +skinparam sequence { + ArrowColor #4A7CC7 + ActorBorderColor #4A7CC7 + LifeLineBorderColor #4A7CC7 + ParticipantBackgroundColor #EEF4FF + ParticipantBorderColor #4A7CC7 + BoxBackgroundColor #F0F0F0 + BoxBorderColor #AAAAAA +} + +participant "Tester\n(TCP Client)" as T +participant "DoipCodec\ncodec.rs" as C +participant "DoipPayload\npayload.rs" as P +participant "SessionManager\nsession.rs" as SM +participant "Session\nsession.rs" as S +participant "Handler\n(future)" as H +participant "UdsHandler\n(user impl)" as U + +== Connection Setup == + +T -> C : TCP connect +C -> SM : create_session(peer_addr) +SM -> S ** : Session::new(id, addr)\nstate=Connected + +== Routing Activation (ISO 13400-2 sec 7.7) == + +T -> C : bytes [0x02,0xFD,0x00,0x05,...]\nRoutingActivationRequest +C -> C : decode()\nDoipHeader::parse()\nstate to Payload +C -> H : DoipMessage { type=0x0005 } +H -> P : DoipPayload::parse(msg) +P -> P : routing_activation::Request::parse() +P --> H : DoipPayload::RoutingActivationRequest(req) + +H -> SM : update_session(id, activate_routing) +SM -> S : activate_routing(0x0E80)\nstate to RoutingActive + +H -> H : routing_activation::Response::success(\n req.source_address(), config.logical_address()) +H -> C : DoipCodec::encode(DoipMessage) +C --> T : bytes [0x02,0xFD,0x00,0x06,...,0x10]\nRoutingActivationResponse (Success) + +== Alive Check Ping/Pong (ISO 13400-2 sec 7.6) == + +note over H : T_TCP_General_Inactivity fires\n(5 min default) +H -> H : alive_check::Request -- to_bytes() -- [] +H -> C : encode(DoipMessage { type=0x0007, payload=[] }) +C --> T : bytes [0x02,0xFD,0x00,0x07,0x00,0x00,0x00,0x00] + +note over H : start T_TCP_Alive_Check\n(500ms timeout) + +T -> C : bytes [0x02,0xFD,0x00,0x08,0x00,0x00,0x00,0x02,0x0E,0x80] +C -> H : DoipMessage { type=0x0008, payload=[0x0E,0x80] } +H -> P : DoipPayload::parse(msg) +P -> P : alive_check::Response::parse([0x0E,0x80])\nsource_address=0x0E80 +P --> H : DoipPayload::AliveCheckResponse(resp) + +H -> S : tester_address() -- returns 0x0E80 +H -> H : resp.source_address() == 0x0E80 OK\nreset inactivity timer + +== Diagnostic Message / UDS (ISO 13400-2 sec 7.9) == + +T -> C : bytes [0x02,0xFD,0x80,0x01,...]\nDiagnosticMessage (UDS: 0x22,0xF1,0x90) +C -> H : DoipMessage { type=0x8001 } +H -> P : DoipPayload::parse(msg) +P -> P : diagnostic_message::Message::parse()\nSA=0x0E80, TA=0x0091\nuser_data=[0x22,0xF1,0x90] +P --> H : DoipPayload::DiagnosticMessage(msg) + +H -> S : is_routing_active() -- returns true + +H -> H : DiagnosticAck::positive(TA, SA, user_data.clone()) +H -> C : encode(DiagnosticMessagePositiveAck) +C --> T : [0x02,0xFD,0x80,0x02,...] DiagMsgAck + +H -> U : UdsRequest::new(SA=0x0E80, TA=0x0091,\n payload=[0x22,0xF1,0x90]) +U -> U : match service_id() -- 0x22\nREAD_DATA_BY_IDENTIFIER +U --> H : UdsResponse::new(0x0091, 0x0E80, [0x62,0xF1,0x90,...]) + +H -> H : diagnostic_message::Message::new(\n SA=0x0091, TA=0x0E80, user_data) +H -> C : encode(DoipMessage { type=0x8001 }) +C --> T : [0x02,0xFD,0x80,0x01,...] DiagnosticMessage (UDS response) + +== Disconnection == + +T ->x C : TCP close / timeout +H -> SM : remove_session(id) +SM -> S !! : Session dropped\nstate=Closed + +@enduml diff --git a/doip-server/src/doip/alive_check.rs b/doip-server/src/doip/alive_check.rs index 5fc9162..fb22123 100644 --- a/doip-server/src/doip/alive_check.rs +++ b/doip-server/src/doip/alive_check.rs @@ -13,23 +13,27 @@ //! Alive Check handlers (ISO 13400-2) use bytes::{BufMut, BytesMut}; +use tracing::warn; use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; +use crate::Result; /// Alive Check Request (payload type `0x0007`) – sent by the `DoIP` entity to verify /// a tester is still connected. /// /// This message carries no payload (zero-length body per ISO 13400-2:2019 §7.6). -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct Request; +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AliveCheckRequest; -impl DoipParseable for Request { - fn parse(_payload: &[u8]) -> crate::Result { +impl DoipParseable for AliveCheckRequest { + fn parse(_payload: &[u8]) -> Result { Ok(Self) } } -impl DoipSerializable for Request { +impl DoipSerializable for AliveCheckRequest { fn serialized_len(&self) -> Option { Some(0) } @@ -38,6 +42,9 @@ impl DoipSerializable for Request { /// 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. + warn!( + "AliveCheckRequest::write_to called — this message has no payload; nothing is written" + ); } } @@ -46,20 +53,22 @@ impl DoipSerializable for Request { /// /// # Wire Format /// Payload: `source_address` (2 bytes, big-endian) +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Response { +pub struct AliveCheckResponse { source_address: u16, } -impl DoipParseable for Response { - fn parse(payload: &[u8]) -> crate::Result { +impl DoipParseable for AliveCheckResponse { + fn parse(payload: &[u8]) -> Result { let bytes: [u8; 2] = parse_fixed_slice(payload, "AliveCheck Response")?; let source_address = u16::from_be_bytes(bytes); Ok(Self { source_address }) } } -impl DoipSerializable for Response { +impl DoipSerializable for AliveCheckResponse { fn serialized_len(&self) -> Option { Some(Self::LEN) } @@ -69,9 +78,9 @@ impl DoipSerializable for Response { } } -impl Response { +impl AliveCheckResponse { /// Fixed wire-format length of the Alive Check Response payload (2-byte source address). - pub const LEN: usize = 2; + const LEN: usize = 2; /// Create a new Alive Check Response with the given tester source address. #[must_use] @@ -79,9 +88,9 @@ impl Response { Self { source_address } } - /// The logical source address of the tester + /// The logical address of the tester that sent this response. #[must_use] - pub fn source_address(&self) -> u16 { + pub fn tester_address(&self) -> u16 { self.source_address } } @@ -93,13 +102,13 @@ mod tests { #[test] fn parse_request() { - let req = Request::parse(&[]).unwrap(); - assert_eq!(req, Request); + let req = AliveCheckRequest::parse(&[]).unwrap(); + assert_eq!(req, AliveCheckRequest); } #[test] fn request_empty_payload() { - let req = Request; + let req = AliveCheckRequest; let bytes = req.to_bytes(); assert!(bytes.is_empty()); } @@ -107,19 +116,19 @@ mod tests { #[test] fn parse_response() { let payload = [0x0E, 0x80]; - let resp = Response::parse(&payload).unwrap(); - assert_eq!(resp.source_address, 0x0E80); + let resp = AliveCheckResponse::parse(&payload).unwrap(); + assert_eq!(resp.tester_address(), 0x0E80); } #[test] fn reject_short_response() { let short = [0x0E]; - assert!(Response::parse(&short).is_err()); + assert!(AliveCheckResponse::parse(&short).is_err()); } #[test] fn build_response() { - let resp = Response::new(0x0E80); + let resp = AliveCheckResponse::new(0x0E80); let bytes = resp.to_bytes(); assert_eq!(bytes.len(), 2); @@ -128,9 +137,9 @@ mod tests { #[test] fn roundtrip_response() { - let original = Response::new(0x0F00); + let original = AliveCheckResponse::new(0x0F00); let bytes = original.to_bytes(); - let parsed = Response::parse(&bytes).unwrap(); + let parsed = AliveCheckResponse::parse(&bytes).unwrap(); assert_eq!(original, parsed); } } diff --git a/doip-server/src/doip/diagnostic_message.rs b/doip-server/src/doip/diagnostic_message.rs index ee60705..0906802 100644 --- a/doip-server/src/doip/diagnostic_message.rs +++ b/doip-server/src/doip/diagnostic_message.rs @@ -16,7 +16,7 @@ use bytes::{BufMut, Bytes, BytesMut}; use tracing::error; use super::{DoipParseable, DoipSerializable, parse_fixed_slice, too_short}; -use crate::DoipError; +use crate::{DoipError, Result}; const ADDRESS_BYTES: usize = 2; const HEADER_BYTES: usize = ADDRESS_BYTES * 2; @@ -37,17 +37,17 @@ fn parse_address_pair(header: [u8; HEADER_BYTES]) -> (u16, u16) { /// /// Used by [`DiagnosticAck`] to represent either a positive or negative result. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AckResult { +pub enum DiagnosticAckResult { /// Positive acknowledgment — message was accepted (wire code 0x00). Positive, /// Negative acknowledgment — message was rejected with the given code. - Negative(NackCode), + Negative(DiagnosticNackCode), } /// Diagnostic message negative acknowledgment codes per ISO 13400-2:2019 Table 28. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] -pub enum NackCode { +pub enum DiagnosticNackCode { InvalidSourceAddress = 0x02, UnknownTargetAddress = 0x03, DiagnosticMessageTooLarge = 0x04, @@ -57,7 +57,7 @@ pub enum NackCode { TransportProtocolError = 0x08, } -impl TryFrom for NackCode { +impl TryFrom for DiagnosticNackCode { type Error = DoipError; fn try_from(value: u8) -> std::result::Result { @@ -74,9 +74,9 @@ impl TryFrom for NackCode { } } -impl From for u8 { - fn from(code: NackCode) -> u8 { - code as u8 +impl From for u8 { + fn from(code: DiagnosticNackCode) -> Self { + code as Self } } @@ -87,16 +87,18 @@ impl From for u8 { /// /// # Wire Format /// Payload: SA(2) + TA(2) + `user_data(1`+) +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Message { +pub struct DiagnosticMessage { source_address: u16, target_address: u16, user_data: Bytes, } -impl Message { +impl DiagnosticMessage { /// Minimum message length in bytes (SA + TA + at least 1 byte UDS data) - pub const MIN_LEN: usize = HEADER_BYTES + MIN_USER_DATA_BYTES; + pub(crate) const MIN_LEN: usize = HEADER_BYTES + MIN_USER_DATA_BYTES; /// Create a new diagnostic message. /// @@ -107,7 +109,7 @@ impl Message { /// /// # Errors /// Returns [`DoipError::EmptyUserData`] if `data` is empty. - pub fn new(source: u16, target: u16, data: Bytes) -> crate::Result { + pub fn new(source: u16, target: u16, data: Bytes) -> Result { if data.is_empty() { return Err(DoipError::EmptyUserData); } @@ -146,21 +148,23 @@ impl Message { /// 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. +/// ISO 13400-2:2019. Use [`DiagnosticAckResult`] to distinguish the outcome. /// /// # Wire Format /// Payload: SA(2) + TA(2) + code(1) + optional `previous_diag_data` +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiagnosticAck { source_address: u16, target_address: u16, - result: AckResult, + result: DiagnosticAckResult, previous_data: Option, } impl DiagnosticAck { /// Minimum ack length in bytes (SA + TA + code byte). - pub const MIN_LEN: usize = HEADER_BYTES + ACK_CODE_BYTES; + pub(crate) const MIN_LEN: usize = HEADER_BYTES + ACK_CODE_BYTES; /// Create a positive acknowledgment. #[must_use] @@ -168,18 +172,18 @@ impl DiagnosticAck { Self { source_address: source, target_address: target, - result: AckResult::Positive, + result: DiagnosticAckResult::Positive, previous_data: None, } } /// Create a negative acknowledgment. #[must_use] - pub fn negative(source: u16, target: u16, code: NackCode) -> Self { + pub fn negative(source: u16, target: u16, code: DiagnosticNackCode) -> Self { Self { source_address: source, target_address: target, - result: AckResult::Negative(code), + result: DiagnosticAckResult::Negative(code), previous_data: None, } } @@ -198,7 +202,7 @@ impl DiagnosticAck { /// Returns the acknowledgment result. #[must_use] - pub fn result(&self) -> AckResult { + pub fn result(&self) -> DiagnosticAckResult { self.result } @@ -212,13 +216,13 @@ impl DiagnosticAck { /// /// # Errors /// Returns [`DoipError::PayloadTooShort`] if payload is less than 4 bytes. - pub fn parse_positive(payload: &[u8]) -> crate::Result { + pub(crate) fn parse_positive(payload: &[u8]) -> Result { let (source_address, target_address, previous_data) = - Self::parse_ack_header(payload, "DiagnosticPositiveAck")?; + Self::parse_address_header(payload, "DiagnosticPositiveAck")?; Ok(Self { source_address, target_address, - result: AckResult::Positive, + result: DiagnosticAckResult::Positive, previous_data, }) } @@ -228,24 +232,24 @@ impl DiagnosticAck { /// # 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 { + pub(crate) fn parse_negative(payload: &[u8]) -> Result { let (source_address, target_address, previous_data) = - Self::parse_ack_header(payload, "DiagnosticNegativeAck")?; + Self::parse_address_header(payload, "DiagnosticNegativeAck")?; let nack_code = payload .get(HEADER_BYTES) .copied() .ok_or_else(|| too_short(payload, Self::MIN_LEN)) - .and_then(NackCode::try_from)?; + .and_then(DiagnosticNackCode::try_from)?; Ok(Self { source_address, target_address, - result: AckResult::Negative(nack_code), + result: DiagnosticAckResult::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)> { + fn parse_address_header(payload: &[u8], context: &str) -> Result<(u16, u16, Option)> { let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, context)?; let (source_address, target_address) = parse_address_pair(header); let previous_data = payload @@ -256,8 +260,8 @@ impl DiagnosticAck { } } -impl DoipParseable for Message { - fn parse(payload: &[u8]) -> crate::Result { +impl DoipParseable for DiagnosticMessage { + fn parse(payload: &[u8]) -> Result { let header: [u8; HEADER_BYTES] = parse_fixed_slice(payload, "DiagnosticMessage")?; let (source_address, target_address) = parse_address_pair(header); @@ -288,7 +292,7 @@ impl DoipParseable for Message { } } -impl DoipSerializable for Message { +impl DoipSerializable for DiagnosticMessage { fn serialized_len(&self) -> Option { Some(HEADER_BYTES.saturating_add(self.user_data.len())) } @@ -309,8 +313,8 @@ impl DoipSerializable for DiagnosticAck { buf.put_u16(self.source_address); buf.put_u16(self.target_address); buf.put_u8(match self.result { - AckResult::Positive => POSITIVE_ACK_CODE, - AckResult::Negative(code) => u8::from(code), + DiagnosticAckResult::Positive => POSITIVE_ACK_CODE, + DiagnosticAckResult::Negative(code) => u8::from(code), }); if let Some(ref data) = self.previous_data { buf.extend_from_slice(data); @@ -326,16 +330,16 @@ mod tests { #[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); + assert_eq!(DiagnosticNackCode::InvalidSourceAddress as u8, 0x02); + assert_eq!(DiagnosticNackCode::UnknownTargetAddress as u8, 0x03); + assert_eq!(DiagnosticNackCode::TargetUnreachable as u8, 0x06); } #[test] fn parse_diagnostic_message() { // SA=0x0E80, TA=0x1000, UDS=0x22 0xF1 0x90 (ReadDataByID) let payload = [0x0E, 0x80, 0x10, 0x00, 0x22, 0xF1, 0x90]; - let msg = Message::parse(&payload).unwrap(); + let msg = DiagnosticMessage::parse(&payload).unwrap(); assert_eq!(msg.source_address(), 0x0E80); assert_eq!(msg.target_address(), 0x1000); @@ -347,7 +351,7 @@ mod tests { fn parse_tester_present() { // TesterPresent service let payload = [0x0E, 0x80, 0x10, 0x00, 0x3E, 0x00]; - let msg = Message::parse(&payload).unwrap(); + let msg = DiagnosticMessage::parse(&payload).unwrap(); assert_eq!(msg.service_id(), Some(0x3E)); assert_eq!(msg.user_data().len(), 2); @@ -356,13 +360,13 @@ mod tests { #[test] fn reject_short_message() { let short = [0x0E, 0x80, 0x10, 0x00]; // no user data - assert!(Message::parse(&short).is_err()); + assert!(DiagnosticMessage::parse(&short).is_err()); } #[test] fn build_diagnostic_message() { let uds = Bytes::from_static(&[0x22, 0xF1, 0x90]); - let msg = Message::new(0x0E80, 0x1000, uds).unwrap(); + let msg = DiagnosticMessage::new(0x0E80, 0x1000, uds).unwrap(); let bytes = msg.to_bytes(); assert_eq!(&bytes[..ADDRESS_BYTES], &[0x0E, 0x80]); @@ -379,25 +383,26 @@ mod tests { 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); + assert_eq!(ack.result(), DiagnosticAckResult::Positive); } #[test] fn build_negative_ack() { - let nack = DiagnosticAck::negative(0x1000, 0x0E80, NackCode::UnknownTargetAddress); + let nack = + DiagnosticAck::negative(0x1000, 0x0E80, DiagnosticNackCode::UnknownTargetAddress); let bytes = nack.to_bytes(); assert_eq!(bytes.len(), DiagnosticAck::MIN_LEN); assert_eq!(bytes[HEADER_BYTES], 0x03); assert_eq!( nack.result(), - AckResult::Negative(NackCode::UnknownTargetAddress) + DiagnosticAckResult::Negative(DiagnosticNackCode::UnknownTargetAddress) ); } #[test] fn build_negative_ack_target_unreachable() { - let nack = DiagnosticAck::negative(0x1000, 0x0E80, NackCode::TargetUnreachable); + let nack = DiagnosticAck::negative(0x1000, 0x0E80, DiagnosticNackCode::TargetUnreachable); let bytes = nack.to_bytes(); assert_eq!(bytes[HEADER_BYTES], 0x06); } @@ -409,7 +414,7 @@ mod tests { assert_eq!(ack.source_address(), 0x1000); assert_eq!(ack.target_address(), 0x0E80); - assert_eq!(ack.result(), AckResult::Positive); + assert_eq!(ack.result(), DiagnosticAckResult::Positive); assert!(ack.previous_data().is_none()); } @@ -420,21 +425,22 @@ mod tests { assert_eq!( nack.result(), - AckResult::Negative(NackCode::UnknownTargetAddress) + DiagnosticAckResult::Negative(DiagnosticNackCode::UnknownTargetAddress) ); } #[test] fn reject_empty_user_data() { - let err = Message::new(0x0E80, 0x1000, Bytes::new()); + let err = DiagnosticMessage::new(0x0E80, 0x1000, Bytes::new()); assert!(matches!(err, Err(DoipError::EmptyUserData))); } #[test] fn roundtrip_message() { - let original = Message::new(0x0E80, 0x1000, Bytes::from_static(&[0x10, 0x01])).unwrap(); + let original = + DiagnosticMessage::new(0x0E80, 0x1000, Bytes::from_static(&[0x10, 0x01])).unwrap(); let bytes = original.to_bytes(); - let parsed = Message::parse(&bytes).unwrap(); + let parsed = DiagnosticMessage::parse(&bytes).unwrap(); assert_eq!(original, parsed); } @@ -448,7 +454,7 @@ mod tests { #[test] fn roundtrip_negative_ack() { - let original = DiagnosticAck::negative(0x1000, 0x0E80, NackCode::OutOfMemory); + let original = DiagnosticAck::negative(0x1000, 0x0E80, DiagnosticNackCode::OutOfMemory); let bytes = original.to_bytes(); let parsed = DiagnosticAck::parse_negative(&bytes).unwrap(); assert_eq!(original, parsed); diff --git a/doip-server/src/doip/header.rs b/doip-server/src/doip/header.rs index 1be16a9..fbb5e94 100644 --- a/doip-server/src/doip/header.rs +++ b/doip-server/src/doip/header.rs @@ -29,6 +29,8 @@ use bytes::{BufMut, Bytes, BytesMut}; use tracing::{trace, warn}; +use crate::Result; + /// Generic Header NACK codes (ISO 13400-2:2019 Table 17) /// /// Negative acknowledgment codes sent in Generic `DoIP` Header NACK (payload type 0x0000) @@ -59,18 +61,19 @@ impl TryFrom for GenericNackCode { } /// `DoIP` protocol version 0x01 (legacy, pre-ISO 13400-2:2012) -pub const PROTOCOL_VERSION_V1: u8 = 0x01; +pub(crate) const PROTOCOL_VERSION_V1: u8 = 0x01; /// Default `DoIP` protocol version 0x02 (ISO 13400-2:2012 / 2019) pub const DEFAULT_PROTOCOL_VERSION: u8 = 0x02; /// `DoIP` protocol version 0x03 (ISO 13400-2:2019 update) -pub const PROTOCOL_VERSION_V3: u8 = 0x03; +pub(crate) const PROTOCOL_VERSION_V3: u8 = 0x03; /// Wildcard/default protocol version (ISO 13400-2:2019 – accept any version) -pub const DOIP_VERSION_DEFAULT: u8 = 0xFF; +pub(crate) const DOIP_VERSION_DEFAULT: u8 = 0xFF; /// XOR mask used to compute and verify the inverse version byte in the `DoIP` header. /// The header requires `version XOR inverse_version == 0xFF`. -pub const DOIP_HEADER_VERSION_MASK: u8 = 0xFF; +pub(crate) 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; +pub(crate) const DEFAULT_PROTOCOL_VERSION_INV: u8 = + DEFAULT_PROTOCOL_VERSION ^ DOIP_HEADER_VERSION_MASK; /// Size of `DoIP` header in bytes (ISO 13400-2:2019 Section 6) pub const DOIP_HEADER_LENGTH: usize = 8; /// Maximum `DoIP` message size (4MB) - provides `DoS` protection while allowing @@ -131,8 +134,8 @@ impl TryFrom for PayloadType { } impl From for u16 { - fn from(pt: PayloadType) -> u16 { - pt as u16 + fn from(pt: PayloadType) -> Self { + pt as Self } } @@ -186,7 +189,7 @@ impl DoipHeader { /// /// # Errors /// Returns [`crate::DoipError::InvalidHeader`] if data is less than 8 bytes. - pub(crate) fn parse(data: &[u8]) -> crate::Result { + pub(crate) fn parse(data: &[u8]) -> Result { let header: [u8; DOIP_HEADER_LENGTH] = data .get(..DOIP_HEADER_LENGTH) .and_then(|s| s.try_into().ok()) @@ -274,7 +277,8 @@ impl DoipHeader { /// Returns `true` if [`validate`](Self::validate) finds no errors in this header. #[must_use] - pub fn is_valid(&self) -> bool { + #[cfg(test)] + pub(crate) fn is_valid(self) -> bool { self.validate().is_none() } @@ -297,7 +301,7 @@ impl DoipHeader { } /// Write the 8-byte `DoIP` header into `buf`. - pub fn write_to(&self, buf: &mut BytesMut) { + pub(crate) fn write_to(self, buf: &mut BytesMut) { buf.put_u8(self.version); buf.put_u8(self.inverse_version); buf.put_u16(self.payload_type); @@ -388,7 +392,7 @@ impl DoipMessage { /// /// Panics if `payload.len()` exceeds `u32::MAX`. #[cfg(test)] - pub fn with_raw_payload_type(payload_type: u16, payload: Bytes) -> Self { + pub(crate) fn with_raw_payload_type(payload_type: u16, payload: Bytes) -> Self { Self { header: DoipHeader { version: DEFAULT_PROTOCOL_VERSION, diff --git a/doip-server/src/doip/mod.rs b/doip-server/src/doip/mod.rs index f540e1f..e4162d3 100644 --- a/doip-server/src/doip/mod.rs +++ b/doip-server/src/doip/mod.rs @@ -33,56 +33,55 @@ pub mod vehicle_id; 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, + DEFAULT_PROTOCOL_VERSION, DOIP_HEADER_LENGTH, DoipHeader, DoipMessage, GenericNackCode, + MAX_DOIP_MESSAGE_SIZE, PayloadType, }; pub use payload::DoipPayload; use tracing::error; -use crate::DoipError; +use crate::{DoipError, Result}; /// Trait for `DoIP` message types that can be parsed from a raw payload slice. /// /// Implement this for every message struct so callers can decode incoming /// `DoIP` frames through a uniform interface. -pub trait DoipParseable: Sized { +pub(crate) trait DoipParseable: Sized { /// Parse a `DoIP` message from a raw payload byte slice. /// /// # Errors /// Returns [`DoipError`] if the payload is malformed or too short. - fn parse(payload: &[u8]) -> crate::Result; + fn parse(payload: &[u8]) -> 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. +/// Implement [`write_to`](DoipSerializable::write_to) with the wire-format logic. +/// The default [`to_bytes`](DoipSerializable::to_bytes) wraps it in a `BytesMut` +/// and calls `freeze()`, so you never write that boilerplate again. pub trait DoipSerializable { /// Write the serialized wire-format bytes into `buf`. fn write_to(&self, buf: &mut BytesMut); /// Returns `Some(n)` when the size is known ahead of serialization, - /// enabling [`to_bytes`] to pre-allocate the buffer and avoid incremental - /// `BytesMut` reallocations for large messages. + /// enabling [`to_bytes`](DoipSerializable::to_bytes) to pre-allocate the buffer + /// and avoid incremental `BytesMut` reallocations for large messages. /// /// Returns `None` (the default) to indicate the size is not known in - /// advance; [`to_bytes`] will then use a dynamically-growing buffer. - /// Override this in your implementation whenever the encoded length is - /// computable upfront. + /// advance; [`to_bytes`](DoipSerializable::to_bytes) will then use a + /// dynamically-growing buffer. Override this in your implementation whenever + /// the encoded length is computable upfront. fn serialized_len(&self) -> Option { None } /// Serialize this message into a [`Bytes`] buffer. /// - /// Pre-allocates the buffer when [`serialized_len`] returns `Some`. + /// Pre-allocates the buffer when [`serialized_len`](DoipSerializable::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(), - }; + let mut buf = self + .serialized_len() + .map_or_else(BytesMut::new, BytesMut::with_capacity); self.write_to(&mut buf); buf.freeze() } @@ -97,7 +96,7 @@ pub(crate) fn too_short(payload: &[u8], expected: usize) -> DoipError { } /// Return `Err` if `payload` is shorter than `expected` bytes. -pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> crate::Result<()> { +pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> Result<()> { if payload.len() < expected { Err(too_short(payload, expected)) } else { @@ -109,10 +108,7 @@ pub(crate) fn check_min_len(payload: &[u8], expected: usize) -> crate::Result<() /// /// 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]> { +pub(crate) fn parse_fixed_slice(payload: &[u8], context: &str) -> Result<[u8; N]> { payload .get(..N) .and_then(|s| s.try_into().ok()) diff --git a/doip-server/src/doip/payload.rs b/doip-server/src/doip/payload.rs index dc8d4df..7e07886 100644 --- a/doip-server/src/doip/payload.rs +++ b/doip-server/src/doip/payload.rs @@ -36,6 +36,7 @@ use super::{ routing_activation, vehicle_id, }; use crate::DoipError; +use crate::Result; /// A fully-parsed `DoIP` message payload. /// @@ -44,27 +45,27 @@ use crate::DoipError; #[derive(Debug, Clone)] pub enum DoipPayload { /// `0x0007` – Alive Check Request (zero-length payload) - AliveCheckRequest(alive_check::Request), + AliveCheckRequest(alive_check::AliveCheckRequest), /// `0x0008` – Alive Check Response - AliveCheckResponse(alive_check::Response), + AliveCheckResponse(alive_check::AliveCheckResponse), /// `0x0005` – Routing Activation Request - RoutingActivationRequest(routing_activation::Request), + RoutingActivationRequest(routing_activation::RoutingActivationRequest), /// `0x0006` – Routing Activation Response - RoutingActivationResponse(routing_activation::Response), + RoutingActivationResponse(routing_activation::RoutingActivationResponse), /// `0x8001` – Diagnostic Message (UDS data) - DiagnosticMessage(diagnostic_message::Message), + DiagnosticMessage(diagnostic_message::DiagnosticMessage), /// `0x8002` – Diagnostic Message Positive Acknowledgement DiagnosticMessagePositiveAck(diagnostic_message::DiagnosticAck), /// `0x8003` – Diagnostic Message Negative Acknowledgement DiagnosticMessageNegativeAck(diagnostic_message::DiagnosticAck), /// `0x0001` – Vehicle Identification Request (no filter) - VehicleIdentificationRequest(vehicle_id::Request), + VehicleIdentificationRequest(vehicle_id::VehicleIdRequest), /// `0x0002` – Vehicle Identification Request filtered by EID - VehicleIdentificationRequestWithEid(vehicle_id::RequestWithEid), + VehicleIdentificationRequestWithEid(vehicle_id::VehicleIdRequestWithEid), /// `0x0003` – Vehicle Identification Request filtered by VIN - VehicleIdentificationRequestWithVin(vehicle_id::RequestWithVin), + VehicleIdentificationRequestWithVin(vehicle_id::VehicleIdRequestWithVin), /// `0x0004` – Vehicle Identification Response / Announce - VehicleIdentificationResponse(vehicle_id::Response), + VehicleIdentificationResponse(vehicle_id::VehicleIdResponse), /// `0x0000` – Generic `DoIP` Header Negative Acknowledgement GenericNack(GenericNackCode), } @@ -79,28 +80,28 @@ impl DoipPayload { /// /// Returns a more specific [`DoipError`] (e.g. [`DoipError::PayloadTooShort`]) /// when the payload bytes are present but malformed. - pub fn parse(msg: &DoipMessage) -> crate::Result { + pub fn parse(msg: &DoipMessage) -> Result { let payload = msg.payload().as_ref(); let payload_type = msg .payload_type() - .ok_or(DoipError::UnknownPayloadType(msg.header().payload_type()))?; + .ok_or_else(|| DoipError::UnknownPayloadType(msg.header().payload_type()))?; match payload_type { PayloadType::AliveCheckRequest => Ok(Self::AliveCheckRequest( - alive_check::Request::parse(payload)?, + alive_check::AliveCheckRequest::parse(payload)?, )), PayloadType::AliveCheckResponse => Ok(Self::AliveCheckResponse( - alive_check::Response::parse(payload)?, + alive_check::AliveCheckResponse::parse(payload)?, )), PayloadType::RoutingActivationRequest => Ok(Self::RoutingActivationRequest( - routing_activation::Request::parse(payload)?, + routing_activation::RoutingActivationRequest::parse(payload)?, )), PayloadType::RoutingActivationResponse => Ok(Self::RoutingActivationResponse( - routing_activation::Response::parse(payload)?, + routing_activation::RoutingActivationResponse::parse(payload)?, )), PayloadType::DiagnosticMessage => Ok(Self::DiagnosticMessage( - diagnostic_message::Message::parse(payload)?, + diagnostic_message::DiagnosticMessage::parse(payload)?, )), PayloadType::DiagnosticMessagePositiveAck => Ok(Self::DiagnosticMessagePositiveAck( diagnostic_message::DiagnosticAck::parse_positive(payload)?, @@ -109,20 +110,20 @@ impl DoipPayload { diagnostic_message::DiagnosticAck::parse_negative(payload)?, )), PayloadType::VehicleIdentificationRequest => Ok(Self::VehicleIdentificationRequest( - vehicle_id::Request::parse(payload)?, + vehicle_id::VehicleIdRequest::parse(payload)?, )), PayloadType::VehicleIdentificationRequestWithEid => { Ok(Self::VehicleIdentificationRequestWithEid( - vehicle_id::RequestWithEid::parse(payload)?, + vehicle_id::VehicleIdRequestWithEid::parse(payload)?, )) } PayloadType::VehicleIdentificationRequestWithVin => { Ok(Self::VehicleIdentificationRequestWithVin( - vehicle_id::RequestWithVin::parse(payload)?, + vehicle_id::VehicleIdRequestWithVin::parse(payload)?, )) } PayloadType::VehicleIdentificationResponse => Ok(Self::VehicleIdentificationResponse( - vehicle_id::Response::parse(payload)?, + vehicle_id::VehicleIdResponse::parse(payload)?, )), PayloadType::GenericNack => { let byte = payload.first().copied().ok_or(DoipError::PayloadTooShort { @@ -202,7 +203,7 @@ mod tests { #[test] fn routing_activation_response_roundtrip() { use crate::doip::DoipSerializable; - let resp = routing_activation::Response::success(0x0E80, 0x1000); + let resp = routing_activation::RoutingActivationResponse::success(0x0E80, 0x1000); let msg = make_msg(PayloadType::RoutingActivationResponse, resp.to_bytes()); let parsed = DoipPayload::parse(&msg).unwrap(); assert!(matches!(parsed, DoipPayload::RoutingActivationResponse(_))); diff --git a/doip-server/src/doip/routing_activation.rs b/doip-server/src/doip/routing_activation.rs index 9263083..9ecbf6c 100644 --- a/doip-server/src/doip/routing_activation.rs +++ b/doip-server/src/doip/routing_activation.rs @@ -19,6 +19,7 @@ use tracing::error; use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; use crate::DoipError; +use crate::Result; /// Byte length of the optional OEM-specific field (a single u32, big-endian). const OEM_DATA_LEN: usize = size_of::(); @@ -36,7 +37,7 @@ fn parse_oem_specific(payload: &[u8], min_len: usize, max_len: usize) -> Option< /// Routing activation response codes per ISO 13400-2:2019 Table 25. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] -pub enum ResponseCode { +pub enum ActivationResponseCode { /// Source address unknown to the `DoIP` entity (`0x00`) UnknownSourceAddress = 0x00, /// All TCP sockets on the `DoIP` entity are registered and active (`0x01`) @@ -59,7 +60,7 @@ pub enum ResponseCode { ConfirmationRequired = 0x11, } -impl TryFrom for ResponseCode { +impl TryFrom for ActivationResponseCode { type Error = DoipError; fn try_from(value: u8) -> std::result::Result { @@ -79,13 +80,13 @@ impl TryFrom for ResponseCode { } } -impl From for u8 { - fn from(code: ResponseCode) -> u8 { - code as u8 +impl From for u8 { + fn from(code: ActivationResponseCode) -> Self { + code as Self } } -impl ResponseCode { +impl ActivationResponseCode { /// Returns `true` if this code represents a successful or pending-confirmation activation. #[must_use] pub fn is_success(self) -> bool { @@ -126,21 +127,23 @@ impl TryFrom for ActivationType { /// /// # Wire Format /// Payload: SA(2) + type(1) + reserved(4) + optional OEM(4) +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Request { +pub struct RoutingActivationRequest { source_address: u16, activation_type: ActivationType, reserved: u32, oem_specific: Option, } -impl Request { +impl RoutingActivationRequest { /// Minimum wire-format length of a Routing Activation Request payload /// (7 bytes: SA + type + reserved, without OEM data). - pub const MIN_LEN: usize = 7; + pub(crate) const MIN_LEN: usize = 7; /// Maximum wire-format length of a Routing Activation Request payload /// (11 bytes: includes optional 4-byte OEM data). - pub const MAX_LEN: usize = 11; + pub(crate) const MAX_LEN: usize = 11; /// Tester logical source address #[must_use] @@ -172,22 +175,24 @@ impl Request { /// /// # Wire Format /// Payload: testerAddr(2) + entityAddr(2) + code(1) + reserved(4) + optional OEM(4) +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Response { +pub struct RoutingActivationResponse { tester_address: u16, entity_address: u16, - code: ResponseCode, + code: ActivationResponseCode, reserved: u32, oem_specific: Option, } -impl Response { +impl RoutingActivationResponse { /// 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; + pub(crate) const MIN_LEN: usize = 9; /// Maximum wire-format length of a Routing Activation Response payload /// (13 bytes: includes optional 4-byte OEM data). - pub const MAX_LEN: usize = 13; + pub(crate) const MAX_LEN: usize = 13; /// Build a successful routing activation response. #[must_use] @@ -195,7 +200,7 @@ impl Response { Self { tester_address, entity_address, - code: ResponseCode::SuccessfullyActivated, + code: ActivationResponseCode::SuccessfullyActivated, reserved: 0, oem_specific: None, } @@ -203,7 +208,7 @@ impl Response { /// Build a denied routing activation response with the given `code`. #[must_use] - pub fn denial(tester_address: u16, entity_address: u16, code: ResponseCode) -> Self { + pub fn denial(tester_address: u16, entity_address: u16, code: ActivationResponseCode) -> Self { Self { tester_address, entity_address, @@ -233,7 +238,7 @@ impl Response { /// Routing activation response code #[must_use] - pub fn response_code(&self) -> ResponseCode { + pub fn response_code(&self) -> ActivationResponseCode { self.code } @@ -248,10 +253,17 @@ impl Response { pub fn oem_specific(&self) -> Option { self.oem_specific } + + /// Attach OEM-specific data to this response. + #[must_use] + pub fn with_oem_specific(mut self, oem: u32) -> Self { + self.oem_specific = Some(oem); + self + } } -impl DoipParseable for Request { - fn parse(payload: &[u8]) -> crate::Result { +impl DoipParseable for RoutingActivationRequest { + fn parse(payload: &[u8]) -> Result { let header: [u8; Self::MIN_LEN] = parse_fixed_slice(payload, "RoutingActivation Request")?; let source_address = u16::from_be_bytes([header[0], header[1]]); @@ -272,13 +284,13 @@ impl DoipParseable for Request { } } -impl DoipParseable for Response { - fn parse(payload: &[u8]) -> crate::Result { +impl DoipParseable for RoutingActivationResponse { + fn parse(payload: &[u8]) -> Result { let header: [u8; Self::MIN_LEN] = parse_fixed_slice(payload, "RoutingActivation Response")?; let tester_address = u16::from_be_bytes([header[0], header[1]]); let entity_address = u16::from_be_bytes([header[2], header[3]]); - let response_code = ResponseCode::try_from(header[4]).map_err(|e| { + let response_code = ActivationResponseCode::try_from(header[4]).map_err(|e| { error!(error = %e, "RoutingActivation Response parse failed"); e })?; @@ -296,7 +308,7 @@ impl DoipParseable for Response { } } -impl DoipSerializable for Response { +impl DoipSerializable for RoutingActivationResponse { fn serialized_len(&self) -> Option { Some( Self::MIN_LEN.saturating_add(if self.oem_specific.is_some() { @@ -325,37 +337,37 @@ mod tests { use crate::doip::{DoipParseable, DoipSerializable}; // Wire-format byte offsets for RoutingActivation Response - // Layout: TesterAddr(2) + EntityAddr(2) + ResponseCode(1) + Reserved(4) + OEM(4 optional) + // Layout: TesterAddr(2) + EntityAddr(2) + ActivationResponseCode(1) + Reserved(4) + OEM(4 optional) const TESTER_ADDR_END: usize = 2; const ENTITY_ADDR_END: usize = 4; const RESP_CODE_IDX: usize = 4; - const OEM_DATA_START: usize = Response::MIN_LEN; // 9 - const OEM_DATA_END: usize = Response::MAX_LEN; // 13 + const OEM_DATA_START: usize = RoutingActivationResponse::MIN_LEN; // 9 + const OEM_DATA_END: usize = RoutingActivationResponse::MAX_LEN; // 13 #[test] fn response_code_success_check() { - assert!(ResponseCode::SuccessfullyActivated.is_success()); - assert!(ResponseCode::ConfirmationRequired.is_success()); - assert!(!ResponseCode::UnknownSourceAddress.is_success()); - assert!(!ResponseCode::TlsRequired.is_success()); + assert!(ActivationResponseCode::SuccessfullyActivated.is_success()); + assert!(ActivationResponseCode::ConfirmationRequired.is_success()); + assert!(!ActivationResponseCode::UnknownSourceAddress.is_success()); + assert!(!ActivationResponseCode::TlsRequired.is_success()); } #[test] fn response_code_values() { - assert_eq!(ResponseCode::UnknownSourceAddress as u8, 0x00); - assert_eq!(ResponseCode::SuccessfullyActivated as u8, 0x10); - assert_eq!(ResponseCode::ConfirmationRequired as u8, 0x11); + assert_eq!(ActivationResponseCode::UnknownSourceAddress as u8, 0x00); + assert_eq!(ActivationResponseCode::SuccessfullyActivated as u8, 0x10); + assert_eq!(ActivationResponseCode::ConfirmationRequired as u8, 0x11); } #[test] fn parse_minimal_request() { let payload = [0x0E, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00]; - let req = Request::parse(&payload).unwrap(); + let req = RoutingActivationRequest::parse(&payload).unwrap(); - assert_eq!(req.source_address, 0x0E80); - assert_eq!(req.activation_type, ActivationType::Default); - assert_eq!(req.reserved, 0); - assert!(req.oem_specific.is_none()); + 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] @@ -363,65 +375,69 @@ mod tests { 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)); + let req = RoutingActivationRequest::parse(&payload).unwrap(); + assert_eq!(req.oem_specific(), Some(0xDEAD_BEEF)); } #[test] fn parse_wwh_obd_request() { let payload = [0x0F, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]; - let req = Request::parse(&payload).unwrap(); - assert_eq!(req.activation_type, ActivationType::WwhObd); + let req = RoutingActivationRequest::parse(&payload).unwrap(); + assert_eq!(req.activation_type(), ActivationType::WwhObd); } #[test] fn reject_short_request() { let short = [0x0E, 0x80, 0x00, 0x00]; - assert!(Request::parse(&short).is_err()); + assert!(RoutingActivationRequest::parse(&short).is_err()); } #[test] fn reject_unknown_activation_type() { // 0x99 is not a valid ActivationType — parse must fail, not silently accept let payload = [0x0E, 0x80, 0x99, 0x00, 0x00, 0x00, 0x00]; - assert!(Request::parse(&payload).is_err()); + assert!(RoutingActivationRequest::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); + let resp = RoutingActivationResponse::success(0x0E80, 0x1000); + assert_eq!(resp.tester_address(), 0x0E80); + assert_eq!(resp.entity_address(), 0x1000); assert!(resp.is_success()); } #[test] fn build_denial_response() { - let resp = Response::denial(0x0E80, 0x1000, ResponseCode::AllSocketsRegistered); + let resp = RoutingActivationResponse::denial( + 0x0E80, + 0x1000, + ActivationResponseCode::AllSocketsRegistered, + ); assert!(!resp.is_success()); } #[test] fn serialize_response() { - let resp = Response::success(0x0E80, 0x1000); + let resp = RoutingActivationResponse::success(0x0E80, 0x1000); let bytes = resp.to_bytes(); - assert_eq!(bytes.len(), Response::MIN_LEN); + assert_eq!(bytes.len(), RoutingActivationResponse::MIN_LEN); assert_eq!(&bytes[..TESTER_ADDR_END], &[0x0E, 0x80]); assert_eq!(&bytes[TESTER_ADDR_END..ENTITY_ADDR_END], &[0x10, 0x00]); assert_eq!( bytes[RESP_CODE_IDX], - ResponseCode::SuccessfullyActivated as u8 + ActivationResponseCode::SuccessfullyActivated as u8 ); } #[test] fn serialize_response_with_oem() { - let mut resp = Response::success(0x0E80, 0x1000); - resp.oem_specific = Some(0x1234_5678); + let resp = + RoutingActivationResponse::success(0x0E80, 0x1000).with_oem_specific(0x1234_5678); let bytes = resp.to_bytes(); - assert_eq!(bytes.len(), Response::MAX_LEN); + assert_eq!(bytes.len(), RoutingActivationResponse::MAX_LEN); assert_eq!( &bytes[OEM_DATA_START..OEM_DATA_END], &[0x12, 0x34, 0x56, 0x78] @@ -431,34 +447,41 @@ mod tests { #[test] fn parse_success_response() { let payload = [0x0E, 0x80, 0x10, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00]; - let resp = Response::parse(&payload).unwrap(); + let resp = RoutingActivationResponse::parse(&payload).unwrap(); assert!(resp.is_success()); - assert_eq!(resp.tester_address, 0x0E80); - assert_eq!(resp.entity_address, 0x1000); + 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(); + let resp = RoutingActivationResponse::parse(&payload).unwrap(); assert!(!resp.is_success()); - assert_eq!(resp.response_code(), ResponseCode::AllSocketsRegistered); + assert_eq!( + resp.response_code(), + ActivationResponseCode::AllSocketsRegistered + ); } #[test] fn roundtrip_response() { - let original = Response::success(0x0E80, 0x1000); + let original = RoutingActivationResponse::success(0x0E80, 0x1000); let bytes = original.to_bytes(); - let parsed = Response::parse(&bytes).unwrap(); + let parsed = RoutingActivationResponse::parse(&bytes).unwrap(); assert_eq!(original, parsed); } #[test] fn roundtrip_response_with_oem() { - let mut original = Response::denial(0x0F00, 0x2000, ResponseCode::MissingAuthentication); + let mut original = RoutingActivationResponse::denial( + 0x0F00, + 0x2000, + ActivationResponseCode::MissingAuthentication, + ); original.oem_specific = Some(0xCAFE_BABE); let bytes = original.to_bytes(); - let parsed = Response::parse(&bytes).unwrap(); + let parsed = RoutingActivationResponse::parse(&bytes).unwrap(); assert_eq!(original, parsed); } } diff --git a/doip-server/src/doip/vehicle_id.rs b/doip-server/src/doip/vehicle_id.rs index dd6a858..77775a3 100644 --- a/doip-server/src/doip/vehicle_id.rs +++ b/doip-server/src/doip/vehicle_id.rs @@ -18,6 +18,7 @@ use tracing::error; use super::{DoipParseable, DoipSerializable, check_min_len, parse_fixed_slice, too_short}; use crate::DoipError; +use crate::Result; // Wire-format field lengths for VehicleIdentificationResponse (ISO 13400-2:2019) const VIN_LEN: usize = 17; @@ -40,21 +41,25 @@ 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; +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VehicleIdRequest; /// Vehicle Identification Request filtered by EID (payload type `0x0002`). /// /// Only the `DoIP` entity with a matching 6-byte EID should respond. +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RequestWithEid { +pub struct VehicleIdRequestWithEid { eid: [u8; EID_LEN], } -impl RequestWithEid { +impl VehicleIdRequestWithEid { /// Fixed wire-format length of a Vehicle Identification Request with EID /// payload (6-byte EID filter). - pub const PAYLOAD_LEN: usize = EID_LEN; + pub(crate) const PAYLOAD_LEN: usize = EID_LEN; /// Create a new Vehicle Identification Request filtered by the given 6-byte EID. #[must_use] @@ -72,14 +77,16 @@ impl RequestWithEid { /// Vehicle Identification Request filtered by VIN (payload type `0x0003`). /// /// Only the `DoIP` entity with a matching 17-byte VIN should respond. +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RequestWithVin { +pub struct VehicleIdRequestWithVin { vin: [u8; VIN_LEN], } -impl RequestWithVin { +impl VehicleIdRequestWithVin { /// Fixed wire-format length of a Vehicle Identification Request with VIN payload (17-byte VIN). - pub const PAYLOAD_LEN: usize = VIN_LEN; + pub(crate) const PAYLOAD_LEN: usize = VIN_LEN; /// Create a new Vehicle Identification Request filtered by the given 17-byte VIN. #[must_use] @@ -123,8 +130,8 @@ impl TryFrom for FurtherAction { } impl From for u8 { - fn from(action: FurtherAction) -> u8 { - action as u8 + fn from(action: FurtherAction) -> Self { + action as Self } } @@ -133,12 +140,12 @@ impl From for u8 { /// Indicates whether the `DoIP` entity's Group ID is synchronized across all ECUs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] -pub enum SyncStatus { +pub enum GidSyncStatus { Synchronized = 0x00, NotSynchronized = 0x10, } -impl TryFrom for SyncStatus { +impl TryFrom for GidSyncStatus { type Error = u8; fn try_from(value: u8) -> std::result::Result { match value { @@ -149,9 +156,9 @@ impl TryFrom for SyncStatus { } } -impl From for u8 { - fn from(status: SyncStatus) -> u8 { - status as u8 +impl From for u8 { + fn from(status: GidSyncStatus) -> Self { + status as Self } } @@ -160,24 +167,26 @@ impl From for u8 { /// 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) +/// VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1) + optional GidSyncStatus(1) +/// +/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Response { +pub struct VehicleIdResponse { vin: [u8; VIN_LEN], logical_address: u16, eid: [u8; EID_LEN], gid: [u8; GID_LEN], further_action: FurtherAction, - sync_status: Option, + sync_status: Option, } -impl Response { +impl VehicleIdResponse { /// Minimum wire-format length of a Vehicle Identification Response payload /// (32 bytes: VIN(17) + LogicalAddr(2) + EID(6) + GID(6) + FurtherAction(1)). - pub const MIN_LEN: usize = SYNC_STATUS_IDX; + pub(crate) 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; + /// (33 bytes: adds optional GidSyncStatus(1)). + pub(crate) const MAX_LEN: usize = SYNC_STATUS_IDX + 1; /// Create a new Vehicle Identification Response with the required fields. /// @@ -210,7 +219,7 @@ impl Response { /// 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 { + pub fn with_sync_status(mut self, status: GidSyncStatus) -> Self { self.sync_status = Some(status); self } @@ -220,10 +229,46 @@ impl Response { pub fn vin_string(&self) -> String { String::from_utf8_lossy(&self.vin).to_string() } + + /// The raw VIN bytes. + #[must_use] + pub fn vin(&self) -> &[u8; VIN_LEN] { + &self.vin + } + + /// The logical address of the `DoIP` entity. + #[must_use] + pub fn logical_address(&self) -> u16 { + self.logical_address + } + + /// The 6-byte Entity Identifier. + #[must_use] + pub fn eid(&self) -> &[u8; EID_LEN] { + &self.eid + } + + /// The 6-byte Group Identifier. + #[must_use] + pub fn gid(&self) -> &[u8; GID_LEN] { + &self.gid + } + + /// The further action code. + #[must_use] + pub fn further_action(&self) -> FurtherAction { + self.further_action + } + + /// The optional GID synchronization status. + #[must_use] + pub fn sync_status(&self) -> Option { + self.sync_status + } } -impl DoipParseable for Request { - fn parse(payload: &[u8]) -> crate::Result { +impl DoipParseable for VehicleIdRequest { + fn parse(payload: &[u8]) -> Result { if !payload.is_empty() { return Err(DoipError::UnexpectedPayload { actual: payload.len(), @@ -233,22 +278,22 @@ impl DoipParseable for Request { } } -impl DoipParseable for RequestWithEid { - fn parse(payload: &[u8]) -> crate::Result { - let eid: [u8; 6] = parse_fixed_slice(payload, "VehicleId RequestWithEid")?; +impl DoipParseable for VehicleIdRequestWithEid { + fn parse(payload: &[u8]) -> Result { + let eid: [u8; Self::PAYLOAD_LEN] = parse_fixed_slice(payload, "VehicleIdRequestWithEid")?; Ok(Self { eid }) } } -impl DoipParseable for RequestWithVin { - fn parse(payload: &[u8]) -> crate::Result { - let vin: [u8; 17] = parse_fixed_slice(payload, "VehicleId RequestWithVin")?; +impl DoipParseable for VehicleIdRequestWithVin { + fn parse(payload: &[u8]) -> Result { + let vin: [u8; Self::PAYLOAD_LEN] = parse_fixed_slice(payload, "VehicleIdRequestWithVin")?; Ok(Self { vin }) } } -impl DoipParseable for Response { - fn parse(payload: &[u8]) -> crate::Result { +impl DoipParseable for VehicleIdResponse { + fn parse(payload: &[u8]) -> Result { if let Err(e) = check_min_len(payload, Self::MIN_LEN) { error!(error = %e, "VehicleId Response parse failed"); return Err(e); @@ -284,7 +329,7 @@ impl DoipParseable for Response { let sync_status = payload .get(SYNC_STATUS_IDX) - .map(|&b| SyncStatus::try_from(b).map_err(DoipError::UnknownSyncStatus)) + .map(|&b| GidSyncStatus::try_from(b).map_err(DoipError::UnknownSyncStatus)) .transpose()?; Ok(Self { @@ -298,9 +343,13 @@ impl DoipParseable for Response { } } -impl DoipSerializable for Response { +impl DoipSerializable for VehicleIdResponse { fn serialized_len(&self) -> Option { - Some(Self::MIN_LEN.saturating_add(usize::from(self.sync_status.is_some()))) + Some(if self.sync_status.is_some() { + Self::MAX_LEN + } else { + Self::MIN_LEN + }) } fn write_to(&self, buf: &mut BytesMut) { @@ -323,34 +372,34 @@ mod tests { #[test] fn parse_basic_request() { - let req = Request::parse(&[]).unwrap(); - assert_eq!(req, Request); + let req = VehicleIdRequest::parse(&[]).unwrap(); + assert_eq!(req, VehicleIdRequest); } #[test] fn parse_request_with_eid() { let payload = [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]; - let req = RequestWithEid::parse(&payload).unwrap(); - assert_eq!(req.eid, [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]); + let req = VehicleIdRequestWithEid::parse(&payload).unwrap(); + assert_eq!(req.eid(), &[0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]); } #[test] fn reject_short_eid_request() { let short = [0x00, 0x1A, 0x2B]; - assert!(RequestWithEid::parse(&short).is_err()); + assert!(VehicleIdRequestWithEid::parse(&short).is_err()); } #[test] fn parse_request_with_vin() { let vin = b"WVWZZZ3CZWE123456"; - let req = RequestWithVin::parse(vin).unwrap(); + let req = VehicleIdRequestWithVin::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()); + assert!(VehicleIdRequestWithVin::parse(short).is_err()); } #[test] @@ -359,11 +408,11 @@ mod tests { 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 resp = VehicleIdResponse::new(vin, 0x1000, eid, gid); - assert_eq!(resp.logical_address, 0x1000); - assert_eq!(resp.further_action, FurtherAction::NoFurtherAction); - assert!(resp.sync_status.is_none()); + assert_eq!(resp.logical_address(), 0x1000); + assert_eq!(resp.further_action(), FurtherAction::NoFurtherAction); + assert!(resp.sync_status().is_none()); } #[test] @@ -372,9 +421,9 @@ mod tests { let eid = [0; 6]; let gid = [0; 6]; - let resp = Response::new(vin, 0x1000, eid, gid).with_routing_required(); + let resp = VehicleIdResponse::new(vin, 0x1000, eid, gid).with_routing_required(); assert_eq!( - resp.further_action, + resp.further_action(), FurtherAction::RoutingActivationRequired ); } @@ -385,10 +434,10 @@ mod tests { 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 resp = VehicleIdResponse::new(vin, 0x1000, eid, gid); let bytes = resp.to_bytes(); - assert_eq!(bytes.len(), Response::MIN_LEN); + assert_eq!(bytes.len(), VehicleIdResponse::MIN_LEN); assert_eq!(&bytes[..VIN_LEN], b"WVWZZZ3CZWE123456"); assert_eq!(&bytes[ADDR_START..ADDR_END], &[0x10, 0x00]); // logical address } @@ -399,11 +448,12 @@ mod tests { let eid = [0; 6]; let gid = [0; 6]; - let resp = Response::new(vin, 0x1000, eid, gid).with_sync_status(SyncStatus::Synchronized); + let resp = VehicleIdResponse::new(vin, 0x1000, eid, gid) + .with_sync_status(GidSyncStatus::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 + assert_eq!(bytes.len(), VehicleIdResponse::MAX_LEN); + assert_eq!(bytes[SYNC_STATUS_IDX], GidSyncStatus::Synchronized as u8); // sync status } #[test] @@ -412,14 +462,14 @@ mod tests { 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 original = VehicleIdResponse::new(vin, 0x1000, eid, gid); let bytes = original.to_bytes(); - let parsed = Response::parse(&bytes).unwrap(); + let parsed = VehicleIdResponse::parse(&bytes).unwrap(); - assert_eq!(parsed.vin, vin); - assert_eq!(parsed.logical_address, 0x1000); - assert_eq!(parsed.eid, eid); - assert_eq!(parsed.gid, gid); + assert_eq!(parsed.vin(), &vin); + assert_eq!(parsed.logical_address(), 0x1000); + assert_eq!(parsed.eid(), &eid); + assert_eq!(parsed.gid(), &gid); } #[test] @@ -428,12 +478,12 @@ mod tests { 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 original = VehicleIdResponse::new(vin, 0x1000, eid, gid) .with_routing_required() - .with_sync_status(SyncStatus::NotSynchronized); + .with_sync_status(GidSyncStatus::NotSynchronized); let bytes = original.to_bytes(); - let parsed = Response::parse(&bytes).unwrap(); + let parsed = VehicleIdResponse::parse(&bytes).unwrap(); assert_eq!(original, parsed); } diff --git a/doip-server/src/error.rs b/doip-server/src/error.rs index 2a99ece..931a393 100644 --- a/doip-server/src/error.rs +++ b/doip-server/src/error.rs @@ -25,25 +25,22 @@ pub enum DoipError { #[error("I/O error: {0}")] Io(#[from] io::Error), - #[error("Configuration error: {0}")] + #[error("configuration error: {0}")] InvalidConfig(String), - #[error("Configuration file error: {0}")] + #[error("configuration file error: {0}")] ConfigFileError(String), - #[error("Hex decode error: {0}")] + #[error("hex decode error: {0}")] HexDecodeError(String), - #[error("Invalid address: {0}")] + #[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}")] + #[error("invalid DoIP header: {0}")] InvalidHeader(String), - #[error("Unknown payload type: 0x{0:04X}")] + #[error("unknown payload type: 0x{0:04X}")] UnknownPayloadType(u16), #[error("payload too short: need {expected} bytes, got {actual}")] @@ -74,130 +71,13 @@ pub enum DoipError { #[cfg(test)] mod tests { use super::*; - use crate::doip::diagnostic_message::NackCode as DiagnosticNackCode; - use crate::doip::{ - header::GenericNackCode, routing_activation::ResponseCode as RoutingActivationCode, - }; - - /// 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, - } - - impl UdsNrc { - #[must_use] - const fn as_u8(self) -> u8 { - self as u8 - } - } #[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() { + fn error_display_variants() { + // Smoke-test every DoipError variant produces a non-empty Display string. let errors = [ DoipError::Io(io::Error::other("io")), DoipError::InvalidConfig("bad config".to_string()), - DoipError::InvalidProtocolVersion { - expected: 0x02, - actual: 0x00, - }, DoipError::InvalidHeader("bad header".to_string()), DoipError::UnknownPayloadType(0x1234), DoipError::PayloadTooShort { @@ -207,9 +87,8 @@ mod tests { DoipError::UnknownRoutingActivationResponseCode(0x99), DoipError::EmptyUserData, ]; - for err in errors { - let _ = err.to_string(); + assert!(!err.to_string().is_empty()); } } } diff --git a/doip-server/src/lib.rs b/doip-server/src/lib.rs index 9a83061..a0d3f3d 100644 --- a/doip-server/src/lib.rs +++ b/doip-server/src/lib.rs @@ -10,6 +10,10 @@ * * SPDX-License-Identifier: Apache-2.0 */ +//! `DoIP` server library (ISO 13400-2:2019 / ISO 14229-1:2020). +//! +//! Provides TCP framing ([`doip::DoipCodec`]), protocol parsing, session +//! management, and a [`uds::UdsHandler`] extension point for UDS processing. /// Core DoIP protocol types, codec, and wire-format handlers (ISO 13400-2:2019). pub mod doip; /// Error types and the crate-level [`Result`] alias. diff --git a/doip-server/src/server/config.rs b/doip-server/src/server/config.rs index 39fa96c..db2e94a 100644 --- a/doip-server/src/server/config.rs +++ b/doip-server/src/server/config.rs @@ -18,6 +18,7 @@ use std::{net::SocketAddr, path::Path}; use serde::Deserialize; use crate::DoipError; +use crate::Result; // ============================================================================ // Default Configuration Constants (per ISO 13400-2 DoIP specification) @@ -32,8 +33,11 @@ const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0"; /// Default ECU logical address (`DoIP` entity address) const DEFAULT_LOGICAL_ADDRESS: u16 = 0x0091; +/// VIN length per ISO 3779:2009 §5.3 — exactly 17 alphanumeric characters. +pub(crate) const VIN_LEN: usize = 17; + /// Default Vehicle Identification Number (17 ASCII characters per ISO 3779) -const DEFAULT_VIN: &[u8; 17] = b"TESTVIN1234567890"; +const DEFAULT_VIN: &[u8; VIN_LEN] = b"TESTVIN1234567890"; /// Default Entity Identification (6 bytes, typically MAC address) const DEFAULT_EID: [u8; 6] = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC]; @@ -63,7 +67,7 @@ pub struct ServerConfig { /// 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], + vin: [u8; VIN_LEN], /// Entity Identification — 6 bytes, typically the MAC address eid: [u8; 6], /// Group Identification — 6 bytes identifying a functional group @@ -197,7 +201,7 @@ impl ServerConfig { /// 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 { + pub fn from_file>(path: P) -> Result { let content = std::fs::read_to_string(path).map_err(|e| DoipError::ConfigFileError(e.to_string()))?; let file: ConfigFile = @@ -235,20 +239,20 @@ impl ServerConfig { }) } - fn parse_vin(s: &str) -> crate::Result<[u8; 17]> { + fn parse_vin(s: &str) -> Result<[u8; VIN_LEN]> { let bytes = s.as_bytes(); - if bytes.len() != 17 { + if bytes.len() != VIN_LEN { return Err(DoipError::InvalidConfig(format!( - "VIN must be exactly 17 characters, got {}", + "VIN must be exactly {VIN_LEN} characters (ISO 3779), got {}", bytes.len() ))); } - let mut vin = [0u8; 17]; + let mut vin = [0u8; VIN_LEN]; vin.copy_from_slice(bytes); Ok(vin) } - fn parse_hex_array(s: &str) -> crate::Result<[u8; N]> { + fn parse_hex_array(s: &str) -> Result<[u8; N]> { let s = s.trim_start_matches("0x").replace([':', '-', ' '], ""); let bytes = hex::decode(&s).map_err(|e| DoipError::HexDecodeError(e.to_string()))?; if bytes.len() != N { @@ -338,7 +342,7 @@ mod tests { use super::*; #[test] - fn test_default_config() { + fn default_config_has_iso_port_and_standard_timeouts() { let config = ServerConfig::default(); assert_eq!(config.tcp_addr().port(), DEFAULT_DOIP_PORT); @@ -359,7 +363,7 @@ mod tests { } #[test] - fn test_new_with_logical_address() { + fn new_sets_logical_address_with_iso_defaults() { let config = ServerConfig::new(0x1234); assert_eq!(config.logical_address(), 0x1234); @@ -367,39 +371,39 @@ mod tests { } #[test] - fn test_parse_vin_valid() { + fn parse_vin_accepts_valid_17_char_iso_3779_string() { let result = ServerConfig::parse_vin("WVWZZZ3CZWE123456"); assert!(result.is_ok()); assert_eq!(result.unwrap().len(), 17); } #[test] - fn test_parse_vin_invalid_length() { + fn parse_vin_rejects_string_not_17_chars() { 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"); + fn parse_hex_array_parses_colon_separated_hex_bytes() { + let result: Result<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B:3C:4D:5E"); assert!(result.is_ok()); assert_eq!(result.unwrap(), [0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]); } #[test] - fn test_parse_hex_array_with_0x_prefix() { - let result: crate::Result<[u8; 6]> = ServerConfig::parse_hex_array("0x001A2B3C4D5E"); + fn parse_hex_array_strips_0x_prefix_before_decoding() { + let result: Result<[u8; 6]> = ServerConfig::parse_hex_array("0x001A2B3C4D5E"); assert!(result.is_ok()); } #[test] - fn test_parse_hex_array_invalid_length() { - let result: crate::Result<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B"); + fn parse_hex_array_rejects_length_mismatch() { + let result: Result<[u8; 6]> = ServerConfig::parse_hex_array("00:1A:2B"); assert!(result.is_err()); } #[test] - fn test_with_vin_builder() { + fn with_vin_replaces_default_vin() { let new_vin = *b"NEWVIN12345678901"; let config = ServerConfig::default().with_vin(new_vin); @@ -407,7 +411,7 @@ mod tests { } #[test] - fn test_with_addresses_builder() { + fn with_addresses_replaces_default_bind_sockets() { let tcp: SocketAddr = "192.168.1.1:13400".parse().unwrap(); let udp: SocketAddr = "192.168.1.1:13401".parse().unwrap(); let config = ServerConfig::default().with_addresses(tcp, udp); diff --git a/doip-server/src/server/session.rs b/doip-server/src/server/session.rs index d43986a..2687439 100644 --- a/doip-server/src/server/session.rs +++ b/doip-server/src/server/session.rs @@ -25,14 +25,21 @@ use parking_lot::RwLock; use tracing::debug; /// Session states per ISO 13400-2:2019 connection lifecycle +/// +/// This is an internal type. External callers should use [`Session::is_routing_active`] instead. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SessionState { +pub(crate) enum SessionState { Connected, RoutingActive, + /// Reserved per ISO 13400-2:2019 §7.4 lifecycle — not yet transitioned to in-process. + #[allow(dead_code)] Closed, } /// A single `DoIP` tester connection and its lifecycle state. +/// +/// Tracks the connection lifecycle from initial TCP connect through routing +/// activation to eventual disconnect per ISO 13400-2:2019 §7.4. #[derive(Debug, Clone)] pub struct Session { /// Unique monotonic session identifier assigned at connection time @@ -47,8 +54,10 @@ pub struct Session { impl Session { /// Create a new session in the [`SessionState::Connected`] state. + /// + /// This is `pub(crate)` — sessions are only ever constructed by [`SessionManager`]. #[must_use] - pub fn new(id: u64, peer_addr: SocketAddr) -> Self { + pub(crate) fn new(id: u64, peer_addr: SocketAddr) -> Self { Self { id, peer_addr, @@ -57,9 +66,11 @@ impl Session { } } - /// Transition this session to [`SessionState::RoutingActive`] and record + /// Transition this session to the `RoutingActive` state and record /// the tester's logical address. - pub fn activate_routing(&mut self, tester_address: u16) { + // Used by feat/tcp-handler (TCP connection handler) — not dead. + #[allow(dead_code)] + pub(crate) 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; @@ -89,9 +100,13 @@ impl Session { self.tester_address } - /// Returns the current lifecycle state. + /// Returns the current ISO 13400-2 connection lifecycle state. + /// + /// This is `pub(crate)` — external callers should use [`is_routing_active`] instead, + /// which provides a boolean answer without exposing the internal [`SessionState`] type. #[must_use] - pub fn state(&self) -> SessionState { + #[cfg(test)] + pub(crate) fn connection_state(&self) -> SessionState { self.state } } @@ -110,9 +125,9 @@ struct SessionManagerInner { /// 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. +/// Uses a single [`RwLock`] over an internal map to ensure atomic consistency +/// between the session map and the address-to-ID index, plus a lock-free +/// [`AtomicU64`] counter for session ID allocation. #[derive(Debug, Default)] pub struct SessionManager { inner: RwLock, @@ -130,9 +145,11 @@ impl SessionManager { 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); + { + 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 } @@ -154,29 +171,36 @@ impl SessionManager { where F: FnOnce(&mut Session), { - if let Some(session) = self.inner.write().sessions.get_mut(&id) { - f(session); - true - } else { - false - } + self.inner + .write() + .sessions + .get_mut(&id) + .is_some_and(|session| { + f(session); + true + }) } /// Remove and return the session with the given `id`, or `None` if not found. 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); + let session = { + let mut inner = self.inner.write(); + let session = inner.sessions.remove(&id)?; + inner.addr_to_session.remove(&session.peer_addr); + session + }; debug!(session_id = id, 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"); + let session = { + let mut inner = self.inner.write(); + let id = inner.addr_to_session.remove(addr)?; + inner.sessions.remove(&id)? + }; + debug!(session_id = session.id, peer = %addr, "Session removed by addr"); Some(session) } @@ -200,19 +224,20 @@ mod tests { use super::*; #[test] - fn create_and_get_session() { + fn session_is_created_in_connected_state() { let mgr = SessionManager::new(); let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); let session = mgr.create_session(addr); - assert_eq!(session.state(), SessionState::Connected); + // New sessions must start in Connected state per ISO 13400-2 lifecycle + assert_eq!(session.connection_state(), SessionState::Connected); let retrieved = mgr.get_session(session.id()).unwrap(); assert_eq!(retrieved.peer_addr(), addr); } #[test] - fn activate_routing() { + fn session_transitions_to_routing_active() { let mgr = SessionManager::new(); let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); @@ -220,12 +245,14 @@ mod tests { mgr.update_session(session.id(), |s| s.activate_routing(0x0E80)); let updated = mgr.get_session(session.id()).unwrap(); + // After routing activation the session must be RoutingActive + // and the tester logical address must be recorded assert!(updated.is_routing_active()); assert_eq!(updated.tester_address(), 0x0E80); } #[test] - fn remove_session() { + fn session_is_removed_from_registry() { let mgr = SessionManager::new(); let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); @@ -233,19 +260,22 @@ mod tests { assert_eq!(mgr.session_count(), 1); mgr.remove_session(session.id()); + // Both the session map and addr index must be cleaned up assert_eq!(mgr.session_count(), 0); assert!(mgr.get_session(session.id()).is_none()); } #[test] - fn check_tester_registered() { + fn tester_is_registered_only_after_routing_activation() { let mgr = SessionManager::new(); let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); let session = mgr.create_session(addr); + // Before activation: tester address must not be registered assert!(!mgr.is_tester_registered(0x0E80)); mgr.update_session(session.id(), |s| s.activate_routing(0x0E80)); + // After activation: tester address must be found in the registry assert!(mgr.is_tester_registered(0x0E80)); } } diff --git a/doip-server/src/uds/handler.rs b/doip-server/src/uds/handler.rs index 305a8b0..a42af9d 100644 --- a/doip-server/src/uds/handler.rs +++ b/doip-server/src/uds/handler.rs @@ -76,7 +76,8 @@ impl UdsRequest { } /// 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. + /// per ISO 14229-1:2020 (UDS). Returns `None` for empty payloads. + #[must_use] pub fn service_id(&self) -> Option { self.payload.first().copied() } @@ -174,8 +175,32 @@ mod tests { 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); + assert_eq!(response.source_address(), 0x1000); + assert_eq!(response.target_address(), 0x0E00); + assert_eq!(response.payload(), &payload); + } + + #[test] + fn uds_nrc_wire_values() { + // Verify UDS NRC codes (ISO 14229-1:2020 Table A.1) match their wire values. + let cases: &[(u8, u8)] = &[ + (0x10, 0x10), // GeneralReject + (0x11, 0x11), // ServiceNotSupported + (0x12, 0x12), // SubFunctionNotSupported + (0x13, 0x13), // IncorrectMessageLength + (0x21, 0x21), // BusyRepeatRequest + (0x22, 0x22), // ConditionsNotCorrect + (0x24, 0x24), // RequestSequenceError + (0x31, 0x31), // RequestOutOfRange + (0x33, 0x33), // SecurityAccessDenied + (0x35, 0x35), // InvalidKey + (0x36, 0x36), // ExceededNumberOfAttempts + (0x37, 0x37), // RequiredTimeDelayNotExpired + (0x78, 0x78), // ResponsePending + (0x7F, 0x7F), // ServiceNotSupportedInActiveSession + ]; + for &(nrc, expected) in cases { + assert_eq!(nrc, expected); + } } } From e1039b4df1a87f0d817a8000d9d17fdd4d76abb9 Mon Sep 17 00:00:00 2001 From: Vinaykumar Date: Tue, 12 May 2026 11:53:24 +0200 Subject: [PATCH 16/17] docs: rename diagrams, refactor sequence diagram, add rendered SVGs --- .../Doc/{classDia.puml => ClassDiagram.puml} | 10 +- doip-server/Doc/ClassDiagram.svg | 400 ++++++++++++++++++ doip-server/Doc/flowchart.puml | 2 +- doip-server/Doc/flowchart.svg | 166 ++++++++ doip-server/Doc/{seq.puml => sequence.puml} | 2 +- doip-server/Doc/sequence.svg | 102 +++++ 6 files changed, 675 insertions(+), 7 deletions(-) rename doip-server/Doc/{classDia.puml => ClassDiagram.puml} (98%) create mode 100644 doip-server/Doc/ClassDiagram.svg create mode 100644 doip-server/Doc/flowchart.svg rename doip-server/Doc/{seq.puml => sequence.puml} (99%) create mode 100644 doip-server/Doc/sequence.svg diff --git a/doip-server/Doc/classDia.puml b/doip-server/Doc/ClassDiagram.puml similarity index 98% rename from doip-server/Doc/classDia.puml rename to doip-server/Doc/ClassDiagram.puml index 3d11fb9..69a8401 100644 --- a/doip-server/Doc/classDia.puml +++ b/doip-server/Doc/ClassDiagram.puml @@ -1,4 +1,4 @@ -@startuml DoIP Server — Architecture Overview +@startuml DoIP Server Class Diagram — Architecture Overview ' ========================================================== ' Crisp layered overview of the DoIP server (ISO 13400-2). ' @@ -194,13 +194,13 @@ package "Diagnostic Message (ISO §7.9)" #EEF4FF { class DiagnosticAck { source_address: u16 target_address: u16 - result: DiagnosticAckResult + result: AckResult previous_data: Option .. + positive(src, tgt) : Self + negative(src, tgt, code) : Self } - enum DiagnosticAckResult { + enum AckResult { Positive ' 0x00 Negative(DiagnosticNackCode) } @@ -339,8 +339,8 @@ DoipSerializable .. TraitNote ' — Enum ownership — RoutingActivationRequest --> ActivationType RoutingActivationResponse --> ActivationResponseCode -DiagnosticAck --> DiagnosticAckResult -DiagnosticAckResult --> DiagnosticNackCode +DiagnosticAck --> AckResult +AckResult --> DiagnosticNackCode ' — Server — Session *-- SessionState diff --git a/doip-server/Doc/ClassDiagram.svg b/doip-server/Doc/ClassDiagram.svg new file mode 100644 index 0000000..378c663 --- /dev/null +++ b/doip-server/Doc/ClassDiagram.svg @@ -0,0 +1,400 @@ +TCP FramingPayload DispatchAlive Check (ISO §7.6)Routing Activation (ISO §7.7)Diagnostic Message (ISO §7.9)Vehicle Identification (ISO §7.5)ServerUDS IntegrationDoipCodec-state: AwaitingHeader | AwaitingPayload-max_payload_size: u32+new() : Self+with_max_payload_size(n: u32) : SelfDoipMessage+header: DoipHeader+payload: Bytes«8-byte generic header §6»DoipHeaderversion: u8 ' 0x02 = ISO 13400-2:2019inverse_version: u8 ' version XOR 0xFFpayload_type: u16payload_length: u32 ' big-endian+parse(bytes) : Result<Self>+validate() : Option<GenericNackCode>«Table 16»PayloadTypeGenericNack = 0x0000VehicleIdentificationRequest = 0x0001VehicleIdentificationReqEid = 0x0002VehicleIdentificationReqVin = 0x0003VehicleIdentificationResponse = 0x0004RoutingActivationRequest = 0x0005RoutingActivationResponse = 0x0006AliveCheckRequest = 0x0007AliveCheckResponse = 0x0008DiagnosticMessage = 0x8001DiagnosticMessagePositiveAck = 0x8002DiagnosticMessageNegativeAck = 0x8003«Table 17»GenericNackCodeIncorrectPatternFormat = 0x00UnknownPayloadType = 0x01MessageTooLarge = 0x02OutOfMemory = 0x03InvalidPayloadLength = 0x04«trait»DoipParseable+parse(payload: &[u8]) : Result<Self>«trait»DoipSerializable+write_to(buf: &mut BytesMut)+to_bytes() : Bytes+serialized_len() : Option<usize>«typed dispatch envelope»DoipPayloadAliveCheckRequest / AliveCheckResponseRoutingActivationRequest / RoutingActivationResponseDiagnosticMessageDiagnosticMessagePositiveAck / NegativeAckVehicleIdentificationRequest / WithEid / WithVinVehicleIdentificationResponseGenericNack(GenericNackCode)+parse(msg: &DoipMessage) : Result<Self>+payload_type() : PayloadType«zero-length payload»AliveCheckRequestAliveCheckResponsetester_address: u16RoutingActivationRequestsource_address: u16activation_type: ActivationTypereserved: u32oem_specific: Option<u32>RoutingActivationResponsetester_address: u16entity_address: u16response_code: ActivationResponseCodereserved: u32oem_specific: Option<u32>+success(tester, entity) : Self+denial(tester, entity, code) : Self+is_success() : bool«Table 24»ActivationTypeDefault = 0x00WwhObd = 0x01CentralSecurity = 0xE0«Table 25»ActivationResponseCodeSuccessfullyActivated = 0x10ConfirmationRequired = 0x11UnknownSourceAddress = 0x00AllSocketsRegistered = 0x01DifferentSourceAddress = 0x02SourceAddressAlreadyActive = 0x03MissingAuthentication = 0x04RejectedConfirmation = 0x05UnsupportedActivationType = 0x06TlsRequired = 0x07denial codesDiagnosticMessagesource_address: u16target_address: u16user_data: Bytes ' UDS payload (min 1 byte)+service_id() : Option<u8>DiagnosticAcksource_address: u16target_address: u16result: AckResultprevious_data: Option<Bytes>+positive(src, tgt) : Self+negative(src, tgt, code) : SelfAckResultPositive ' 0x00Negative(DiagnosticNackCode)«Table 28»DiagnosticNackCodeInvalidSourceAddress = 0x02UnknownTargetAddress = 0x03DiagnosticMessageTooLarge = 0x04OutOfMemory = 0x05TargetUnreachable = 0x06UnknownNetwork = 0x07TransportProtocolError = 0x08«broadcast — no filter»VehicleIdRequestVehicleIdRequestWithEideid: [u8; 6]VehicleIdRequestWithVinvin: [u8; 17]VehicleIdResponsevin: [u8; 17]logical_address: u16eid: [u8; 6]gid: [u8; 6]further_action: FurtherActionsync_status: Option<GidSyncStatus>ServerConfigtcp_addr / udp_addr: SocketAddrlogical_address: u16vin: [u8; 17] eid: [u8; 6]max_connections: usizeinitial_inactivity_timeout_ms: u64general_inactivity_timeout_ms: u64+from_file(path) : Result<Self>«ISO §7.4 lifecycle»SessionStateConnectedRoutingActiveClosedSessionid: u64peer_addr: SocketAddrtester_address: u16state: SessionState+activate_routing(addr: u16)+is_routing_active() : boolSessionManagersessions: RwLock<HashMap<u64, Session>>+create_session(peer) : Session+update_session(id, f)+remove_session(id)+is_tester_registered(addr: u16) : bool«implement to handle UDS services»UdsHandler+handle(req: UdsRequest) : UdsResponseUdsRequestsource_address: u16target_address: u16payload: Bytes+service_id() : Option<u8>UdsResponsesource_address: u16target_address: u16payload: BytesTwo-state TCP reassembler:AwaitingHeader(8 bytes) →AwaitingPayload(declared length)Drops connection on bad header.DoS guard: rejects payload > max_payload_size.Single entry-point for decoding any DoIP message.Matches on PayloadType and delegates to thecorrect module's parse() automatically.Every variant wraps a fully-typed struct.Thread-safe session store.One Session per active TCP connection.Enforces max_connections limit.Extension point for users.Server extracts user_data from DiagnosticMessage,wraps it as UdsRequest, and calls handle().The returned UdsResponse is sent back asa new DiagnosticMessage (type 0x8001).All message types aboveimplement DoipParseableand DoipSerializable.framesparse()10..*configuresuser_data →consumesproduces \ No newline at end of file diff --git a/doip-server/Doc/flowchart.puml b/doip-server/Doc/flowchart.puml index de66b0e..d05c603 100644 --- a/doip-server/Doc/flowchart.puml +++ b/doip-server/Doc/flowchart.puml @@ -152,4 +152,4 @@ repeat while (Server running?) is (yes) stop -@enduml +@enduml \ No newline at end of file diff --git a/doip-server/Doc/flowchart.svg b/doip-server/Doc/flowchart.svg new file mode 100644 index 0000000..d4f5ec5 --- /dev/null +++ b/doip-server/Doc/flowchart.svg @@ -0,0 +1,166 @@ +ServerConfig::from_file()or ServerConfig::default()Server startsBind TCP socketon config.tcp_addrAccept TCP connection(peer_addr)Session { state=Connectedtester_address=0 }SessionManager::create_session(peer_addr)Spawn Tokio taskper connectionContinue acceptingnew connectionsWait for bytes on TCP streamDoipCodec::decode(src: BytesMut)return Ok(None)wait for more datayessrc.len < 8 bytes?noDoipHeader::parse(header_slice)return Err(InvalidData)GenericNack in logClose TCP connectionnoHeader valid?version in {0x01,0x02,0x03,0xFF}version XOR inv == 0xFFyesreturn Err(MessageTooLarge)Close TCP connectionyespayload_length > max_payload_size?noreturn Ok(None)wait for more datayessrc.len < header + payload_length?nosplit_to → DoipMessage\n{ header, payload: Bytes }]:DoipPayload::parse(DoipMessage)payload_type known?noyesDoipError::UnknownPayloadTypeSend GenericNack(UnknownPayloadType)payload_type?RoutingActivationRequest::parse(payload)yesSession already RoutingActivewith same address?RoutingActivationResponse::denial(SourceAddressAlreadyActive)yesMax connections reached?noRoutingActivationResponse::denial(AllSocketsRegistered)Session.state = RoutingActiveSession.tester_address = src_addrSessionManager::update_session(|s| s.activate_routing(src_addr))RoutingActivationResponse::success(tester_addr, entity_addr)DoipCodec::encode(RoutingActivationResponse)Send bytes on TCPAliveCheckRequest::parse(payload)[zero-length, always Ok]AliveCheckResponse::new(session.tester_address())to_bytes() → [addr_hi, addr_lo]DoipCodec::encode(AliveCheckResponsetype=0x0008)Send 10 bytes on TCPAliveCheckResponse::parse(payload)→ tester_address: u16tester_address ==session.tester_address()?yesnoReset T_TCP_General_Inactivity timerLog mismatchClose TCP connectionsession.is_routing_active()?noyesDiagnosticAck::negative(InvalidSourceAddress)Send DiagnosticMessageNegativeAck(type=0x8003)DiagnosticMessage::parse(payload)→ SA, TA, user_dataDiagnosticAck::positive(TA, SA)Send DiagnosticMessagePositiveAck(type=0x8002)UdsRequest::new(SA, TA, user_data)UdsHandler::handle(UdsRequest)[USER IMPLEMENTATION]UdsResponse → DiagnosticMessage::new(SA=entity, TA=tester, data)DoipCodec::encode(DiagnosticMessagetype=0x8001)Send UDS response bytes on TCPVehicleIdRequest::parse(payload)VehicleIdResponse { VIN, EID, GIDlogical_address }Send VehicleIdentificationResponse(type=0x0004) via UDPSend GenericNack(IncorrectPatternFormat)TCP connection open?yesSession droppedstate=ClosedSessionManager::remove_session(id)Server running?yesServer shutdown0x0005RoutingActivationRequestother0x0007AliveCheckRequest0x0008AliveCheckResponse0x8001DiagnosticMessage0x0001/0x0002/0x0003VehicleIdRequestnono \ No newline at end of file diff --git a/doip-server/Doc/seq.puml b/doip-server/Doc/sequence.puml similarity index 99% rename from doip-server/Doc/seq.puml rename to doip-server/Doc/sequence.puml index a69819f..c078ab5 100644 --- a/doip-server/Doc/seq.puml +++ b/doip-server/Doc/sequence.puml @@ -87,4 +87,4 @@ T ->x C : TCP close / timeout H -> SM : remove_session(id) SM -> S !! : Session dropped\nstate=Closed -@enduml +@enduml \ No newline at end of file diff --git a/doip-server/Doc/sequence.svg b/doip-server/Doc/sequence.svg new file mode 100644 index 0000000..5eb1c08 --- /dev/null +++ b/doip-server/Doc/sequence.svg @@ -0,0 +1,102 @@ +Tester(TCP Client)Tester(TCP Client)DoipCodeccodec.rsDoipCodeccodec.rsDoipPayloadpayload.rsDoipPayloadpayload.rsSessionManagersession.rsSessionManagersession.rsSessionsession.rsHandler(future)Handler(future)UdsHandler(user impl)UdsHandler(user impl)Connection SetupTCP connectcreate_session(peer_addr)Session::new(id, addr)state=ConnectedSessionsession.rsRouting Activation (ISO 13400-2 sec 7.7)bytes [0x02,0xFD,0x00,0x05,...]RoutingActivationRequestdecode()DoipHeader::parse()state to PayloadDoipMessage { type=0x0005 }DoipPayload::parse(msg)routing_activation::Request::parse()DoipPayload::RoutingActivationRequest(req)update_session(id, activate_routing)activate_routing(0x0E80)state to RoutingActiverouting_activation::Response::success(req.source_address(), config.logical_address())DoipCodec::encode(DoipMessage)bytes [0x02,0xFD,0x00,0x06,...,0x10]RoutingActivationResponse (Success)Alive Check Ping/Pong (ISO 13400-2 sec 7.6)T_TCP_General_Inactivity fires(5 min default)alive_check::Requestto_bytes()[]encode(DoipMessage { type=0x0007, payload=[] })bytes [0x02,0xFD,0x00,0x07,0x00,0x00,0x00,0x00]start T_TCP_Alive_Check(500ms timeout)bytes [0x02,0xFD,0x00,0x08,0x00,0x00,0x00,0x02,0x0E,0x80]DoipMessage { type=0x0008, payload=[0x0E,0x80] }DoipPayload::parse(msg)alive_check::Response::parse([0x0E,0x80])source_address=0x0E80DoipPayload::AliveCheckResponse(resp)tester_address() -- returns 0x0E80resp.source_address() == 0x0E80 OKreset inactivity timerDiagnostic Message / UDS (ISO 13400-2 sec 7.9)bytes [0x02,0xFD,0x80,0x01,...]DiagnosticMessage (UDS: 0x22,0xF1,0x90)DoipMessage { type=0x8001 }DoipPayload::parse(msg)diagnostic_message::Message::parse()SA=0x0E80, TA=0x0091user_data=[0x22,0xF1,0x90]DoipPayload::DiagnosticMessage(msg)is_routing_active() -- returns trueDiagnosticAck::positive(TA, SA, user_data.clone())encode(DiagnosticMessagePositiveAck)[0x02,0xFD,0x80,0x02,...] DiagMsgAckUdsRequest::new(SA=0x0E80, TA=0x0091,payload=[0x22,0xF1,0x90])match service_id() -- 0x22READ_DATA_BY_IDENTIFIERUdsResponse::new(0x0091, 0x0E80, [0x62,0xF1,0x90,...])diagnostic_message::Message::new(SA=0x0091, TA=0x0E80, user_data)encode(DoipMessage { type=0x8001 })[0x02,0xFD,0x80,0x01,...] DiagnosticMessage (UDS response)DisconnectionTCP close / timeoutremove_session(id)Session droppedstate=Closed \ No newline at end of file From 53214b34748b366207ed1e396fa4093dce522caa Mon Sep 17 00:00:00 2001 From: Vinaykumar Date: Tue, 19 May 2026 12:49:08 +0200 Subject: [PATCH 17/17] fix(review): address reviewer comments on doip-server-initial - Remove DoipSerializable impl + test from AliveCheckRequest - Rename DiagnosticAckResult -> AckResult - Move test-only helpers into mod tests (header, alive_check) - Add #[non_exhaustive] to DoipError - Make Session pub(crate); add SessionId newtype - Rename Doc/ -> doc/; update all diagrams + regenerate SVGs - Clarify sequence.puml: no user handle on connect, disconnection paths --- doip-server/Doc/ClassDiagram.svg | 400 ---------------- doip-server/Doc/flowchart.puml | 155 ------ doip-server/Doc/flowchart.svg | 166 ------- doip-server/Doc/sequence.puml | 90 ---- doip-server/Doc/sequence.svg | 102 ---- doip-server/{Doc => doc}/ClassDiagram.puml | 201 ++++++-- doip-server/doc/ClassDiagram.svg | 527 +++++++++++++++++++++ doip-server/doc/flowchart.puml | 205 ++++++++ doip-server/doc/flowchart.svg | 215 +++++++++ doip-server/doc/sequence.puml | 296 ++++++++++++ doip-server/doc/sequence.svg | 279 +++++++++++ doip-server/src/doip/alive_check.rs | 45 +- doip-server/src/doip/diagnostic_message.rs | 32 +- doip-server/src/doip/header.rs | 79 ++- doip-server/src/doip/payload.rs | 2 +- doip-server/src/error.rs | 1 + doip-server/src/server/mod.rs | 2 +- doip-server/src/server/session.rs | 79 ++- doip-server/src/uds/handler.rs | 24 - 19 files changed, 1800 insertions(+), 1100 deletions(-) delete mode 100644 doip-server/Doc/ClassDiagram.svg delete mode 100644 doip-server/Doc/flowchart.puml delete mode 100644 doip-server/Doc/flowchart.svg delete mode 100644 doip-server/Doc/sequence.puml delete mode 100644 doip-server/Doc/sequence.svg rename doip-server/{Doc => doc}/ClassDiagram.puml (69%) create mode 100644 doip-server/doc/ClassDiagram.svg create mode 100644 doip-server/doc/flowchart.puml create mode 100644 doip-server/doc/flowchart.svg create mode 100644 doip-server/doc/sequence.puml create mode 100644 doip-server/doc/sequence.svg diff --git a/doip-server/Doc/ClassDiagram.svg b/doip-server/Doc/ClassDiagram.svg deleted file mode 100644 index 378c663..0000000 --- a/doip-server/Doc/ClassDiagram.svg +++ /dev/null @@ -1,400 +0,0 @@ -TCP FramingPayload DispatchAlive Check (ISO §7.6)Routing Activation (ISO §7.7)Diagnostic Message (ISO §7.9)Vehicle Identification (ISO §7.5)ServerUDS IntegrationDoipCodec-state: AwaitingHeader | AwaitingPayload-max_payload_size: u32+new() : Self+with_max_payload_size(n: u32) : SelfDoipMessage+header: DoipHeader+payload: Bytes«8-byte generic header §6»DoipHeaderversion: u8 ' 0x02 = ISO 13400-2:2019inverse_version: u8 ' version XOR 0xFFpayload_type: u16payload_length: u32 ' big-endian+parse(bytes) : Result<Self>+validate() : Option<GenericNackCode>«Table 16»PayloadTypeGenericNack = 0x0000VehicleIdentificationRequest = 0x0001VehicleIdentificationReqEid = 0x0002VehicleIdentificationReqVin = 0x0003VehicleIdentificationResponse = 0x0004RoutingActivationRequest = 0x0005RoutingActivationResponse = 0x0006AliveCheckRequest = 0x0007AliveCheckResponse = 0x0008DiagnosticMessage = 0x8001DiagnosticMessagePositiveAck = 0x8002DiagnosticMessageNegativeAck = 0x8003«Table 17»GenericNackCodeIncorrectPatternFormat = 0x00UnknownPayloadType = 0x01MessageTooLarge = 0x02OutOfMemory = 0x03InvalidPayloadLength = 0x04«trait»DoipParseable+parse(payload: &[u8]) : Result<Self>«trait»DoipSerializable+write_to(buf: &mut BytesMut)+to_bytes() : Bytes+serialized_len() : Option<usize>«typed dispatch envelope»DoipPayloadAliveCheckRequest / AliveCheckResponseRoutingActivationRequest / RoutingActivationResponseDiagnosticMessageDiagnosticMessagePositiveAck / NegativeAckVehicleIdentificationRequest / WithEid / WithVinVehicleIdentificationResponseGenericNack(GenericNackCode)+parse(msg: &DoipMessage) : Result<Self>+payload_type() : PayloadType«zero-length payload»AliveCheckRequestAliveCheckResponsetester_address: u16RoutingActivationRequestsource_address: u16activation_type: ActivationTypereserved: u32oem_specific: Option<u32>RoutingActivationResponsetester_address: u16entity_address: u16response_code: ActivationResponseCodereserved: u32oem_specific: Option<u32>+success(tester, entity) : Self+denial(tester, entity, code) : Self+is_success() : bool«Table 24»ActivationTypeDefault = 0x00WwhObd = 0x01CentralSecurity = 0xE0«Table 25»ActivationResponseCodeSuccessfullyActivated = 0x10ConfirmationRequired = 0x11UnknownSourceAddress = 0x00AllSocketsRegistered = 0x01DifferentSourceAddress = 0x02SourceAddressAlreadyActive = 0x03MissingAuthentication = 0x04RejectedConfirmation = 0x05UnsupportedActivationType = 0x06TlsRequired = 0x07denial codesDiagnosticMessagesource_address: u16target_address: u16user_data: Bytes ' UDS payload (min 1 byte)+service_id() : Option<u8>DiagnosticAcksource_address: u16target_address: u16result: AckResultprevious_data: Option<Bytes>+positive(src, tgt) : Self+negative(src, tgt, code) : SelfAckResultPositive ' 0x00Negative(DiagnosticNackCode)«Table 28»DiagnosticNackCodeInvalidSourceAddress = 0x02UnknownTargetAddress = 0x03DiagnosticMessageTooLarge = 0x04OutOfMemory = 0x05TargetUnreachable = 0x06UnknownNetwork = 0x07TransportProtocolError = 0x08«broadcast — no filter»VehicleIdRequestVehicleIdRequestWithEideid: [u8; 6]VehicleIdRequestWithVinvin: [u8; 17]VehicleIdResponsevin: [u8; 17]logical_address: u16eid: [u8; 6]gid: [u8; 6]further_action: FurtherActionsync_status: Option<GidSyncStatus>ServerConfigtcp_addr / udp_addr: SocketAddrlogical_address: u16vin: [u8; 17] eid: [u8; 6]max_connections: usizeinitial_inactivity_timeout_ms: u64general_inactivity_timeout_ms: u64+from_file(path) : Result<Self>«ISO §7.4 lifecycle»SessionStateConnectedRoutingActiveClosedSessionid: u64peer_addr: SocketAddrtester_address: u16state: SessionState+activate_routing(addr: u16)+is_routing_active() : boolSessionManagersessions: RwLock<HashMap<u64, Session>>+create_session(peer) : Session+update_session(id, f)+remove_session(id)+is_tester_registered(addr: u16) : bool«implement to handle UDS services»UdsHandler+handle(req: UdsRequest) : UdsResponseUdsRequestsource_address: u16target_address: u16payload: Bytes+service_id() : Option<u8>UdsResponsesource_address: u16target_address: u16payload: BytesTwo-state TCP reassembler:AwaitingHeader(8 bytes) →AwaitingPayload(declared length)Drops connection on bad header.DoS guard: rejects payload > max_payload_size.Single entry-point for decoding any DoIP message.Matches on PayloadType and delegates to thecorrect module's parse() automatically.Every variant wraps a fully-typed struct.Thread-safe session store.One Session per active TCP connection.Enforces max_connections limit.Extension point for users.Server extracts user_data from DiagnosticMessage,wraps it as UdsRequest, and calls handle().The returned UdsResponse is sent back asa new DiagnosticMessage (type 0x8001).All message types aboveimplement DoipParseableand DoipSerializable.framesparse()10..*configuresuser_data →consumesproduces \ No newline at end of file diff --git a/doip-server/Doc/flowchart.puml b/doip-server/Doc/flowchart.puml deleted file mode 100644 index d05c603..0000000 --- a/doip-server/Doc/flowchart.puml +++ /dev/null @@ -1,155 +0,0 @@ -@startuml DoIP Server Flowchart - -skinparam backgroundColor #FAFAFA -skinparam activity { - BackgroundColor #EEF4FF - BorderColor #4A7CC7 - FontSize 12 - ArrowColor #4A7CC7 - StartColor #4A7CC7 - EndColor #C0392B - DiamondBackgroundColor #FFF9E3 - DiamondBorderColor #E67E22 -} -skinparam note { - BackgroundColor #FFFDE7 - BorderColor #F9A825 -} - -start - -:Server starts; -note right: ServerConfig::from_file()\nor ServerConfig::default() - -:Bind TCP socket\non config.tcp_addr; - -repeat - - :Accept TCP connection\n(peer_addr); - - :SessionManager::create_session(peer_addr); - note right: Session { state=Connected\n tester_address=0 } - - fork - :Spawn Tokio task\nper connection; - fork again - :Continue accepting\nnew connections; - end fork - - ' ── Per-connection task ── - - repeat - - :Wait for bytes on TCP stream; - - :DoipCodec::decode(src: BytesMut); - - if (src.len < 8 bytes?) then (yes) - :return Ok(None)\nwait for more data; - stop - else (no) - endif - - :DoipHeader::parse(header_slice); - - if (Header valid?\nversion in {0x01,0x02,0x03,0xFF}\nversion XOR inv == 0xFF) then (no) - :return Err(InvalidData)\nGenericNack in log; - :Close TCP connection; - stop - else (yes) - endif - - if (payload_length > max_payload_size?) then (yes) - :return Err(MessageTooLarge)\nClose TCP connection; - stop - else (no) - endif - - if (src.len < header + payload_length?) then (yes) - :return Ok(None)\nwait for more data; - stop - else (no) - endif - - :split_to → DoipMessage\n{ header, payload: Bytes }] - - :DoipPayload::parse(DoipMessage); - - if (payload_type known?) then (no) - :DoipError::UnknownPayloadType; - :Send GenericNack(UnknownPayloadType); - else (yes) - - switch (payload_type?) - - case (0x0005\nRoutingActivationRequest) - :RoutingActivationRequest::parse(payload); - if (Session already RoutingActive\nwith same address?) then (yes) - :RoutingActivationResponse::denial(\n SourceAddressAlreadyActive); - elseif (Max connections reached?) then (yes) - :RoutingActivationResponse::denial(\n AllSocketsRegistered); - else (no) - :SessionManager::update_session(\n |s| s.activate_routing(src_addr)); - note right: Session.state = RoutingActive\nSession.tester_address = src_addr - :RoutingActivationResponse::success(\n tester_addr, entity_addr); - endif - :DoipCodec::encode(RoutingActivationResponse); - :Send bytes on TCP; - - case (0x0007\nAliveCheckRequest) - :AliveCheckRequest::parse(payload)\n[zero-length, always Ok]; - :AliveCheckResponse::new(\n session.tester_address()); - :to_bytes() → [addr_hi, addr_lo]; - :DoipCodec::encode(AliveCheckResponse\ntype=0x0008); - :Send 10 bytes on TCP; - - case (0x0008\nAliveCheckResponse) - :AliveCheckResponse::parse(payload)\n→ tester_address: u16; - if (tester_address ==\nsession.tester_address()?) then (yes) - :Reset T_TCP_General_Inactivity timer; - else (no) - :Log mismatch\nClose TCP connection; - stop - endif - - case (0x8001\nDiagnosticMessage) - if (session.is_routing_active()?) then (no) - :DiagnosticAck::negative(\n InvalidSourceAddress); - :Send DiagnosticMessageNegativeAck\n(type=0x8003); - else (yes) - :DiagnosticMessage::parse(payload)\n→ SA, TA, user_data; - :DiagnosticAck::positive(TA, SA); - :Send DiagnosticMessagePositiveAck\n(type=0x8002); - :UdsRequest::new(SA, TA, user_data); - :UdsHandler::handle(UdsRequest)\n[USER IMPLEMENTATION]; - :UdsResponse → DiagnosticMessage::new(\n SA=entity, TA=tester, data); - :DoipCodec::encode(DiagnosticMessage\ntype=0x8001); - :Send UDS response bytes on TCP; - endif - - case (0x0001/0x0002/0x0003\nVehicleIdRequest) - :VehicleIdRequest::parse(payload); - :VehicleIdResponse { VIN, EID, GID\nlogical_address }; - :Send VehicleIdentificationResponse\n(type=0x0004) via UDP; - - case (other) - :Send GenericNack\n(IncorrectPatternFormat); - - endswitch - - endif - - repeat while (TCP connection open?) is (yes) - -> no; - - :SessionManager::remove_session(id); - note right: Session dropped\nstate=Closed - -repeat while (Server running?) is (yes) --> no; - -:Server shutdown; - -stop - -@enduml \ No newline at end of file diff --git a/doip-server/Doc/flowchart.svg b/doip-server/Doc/flowchart.svg deleted file mode 100644 index d4f5ec5..0000000 --- a/doip-server/Doc/flowchart.svg +++ /dev/null @@ -1,166 +0,0 @@ -ServerConfig::from_file()or ServerConfig::default()Server startsBind TCP socketon config.tcp_addrAccept TCP connection(peer_addr)Session { state=Connectedtester_address=0 }SessionManager::create_session(peer_addr)Spawn Tokio taskper connectionContinue acceptingnew connectionsWait for bytes on TCP streamDoipCodec::decode(src: BytesMut)return Ok(None)wait for more datayessrc.len < 8 bytes?noDoipHeader::parse(header_slice)return Err(InvalidData)GenericNack in logClose TCP connectionnoHeader valid?version in {0x01,0x02,0x03,0xFF}version XOR inv == 0xFFyesreturn Err(MessageTooLarge)Close TCP connectionyespayload_length > max_payload_size?noreturn Ok(None)wait for more datayessrc.len < header + payload_length?nosplit_to → DoipMessage\n{ header, payload: Bytes }]:DoipPayload::parse(DoipMessage)payload_type known?noyesDoipError::UnknownPayloadTypeSend GenericNack(UnknownPayloadType)payload_type?RoutingActivationRequest::parse(payload)yesSession already RoutingActivewith same address?RoutingActivationResponse::denial(SourceAddressAlreadyActive)yesMax connections reached?noRoutingActivationResponse::denial(AllSocketsRegistered)Session.state = RoutingActiveSession.tester_address = src_addrSessionManager::update_session(|s| s.activate_routing(src_addr))RoutingActivationResponse::success(tester_addr, entity_addr)DoipCodec::encode(RoutingActivationResponse)Send bytes on TCPAliveCheckRequest::parse(payload)[zero-length, always Ok]AliveCheckResponse::new(session.tester_address())to_bytes() → [addr_hi, addr_lo]DoipCodec::encode(AliveCheckResponsetype=0x0008)Send 10 bytes on TCPAliveCheckResponse::parse(payload)→ tester_address: u16tester_address ==session.tester_address()?yesnoReset T_TCP_General_Inactivity timerLog mismatchClose TCP connectionsession.is_routing_active()?noyesDiagnosticAck::negative(InvalidSourceAddress)Send DiagnosticMessageNegativeAck(type=0x8003)DiagnosticMessage::parse(payload)→ SA, TA, user_dataDiagnosticAck::positive(TA, SA)Send DiagnosticMessagePositiveAck(type=0x8002)UdsRequest::new(SA, TA, user_data)UdsHandler::handle(UdsRequest)[USER IMPLEMENTATION]UdsResponse → DiagnosticMessage::new(SA=entity, TA=tester, data)DoipCodec::encode(DiagnosticMessagetype=0x8001)Send UDS response bytes on TCPVehicleIdRequest::parse(payload)VehicleIdResponse { VIN, EID, GIDlogical_address }Send VehicleIdentificationResponse(type=0x0004) via UDPSend GenericNack(IncorrectPatternFormat)TCP connection open?yesSession droppedstate=ClosedSessionManager::remove_session(id)Server running?yesServer shutdown0x0005RoutingActivationRequestother0x0007AliveCheckRequest0x0008AliveCheckResponse0x8001DiagnosticMessage0x0001/0x0002/0x0003VehicleIdRequestnono no; - -:Server shutdown; - -stop - -@enduml - -PlantUML version 1.2020.02(Sun Mar 01 11:22:07 CET 2020) -(GPL source distribution) -Java Runtime: OpenJDK Runtime Environment -JVM: OpenJDK 64-Bit Server VM -Java Version: 21.0.10+7-Ubuntu-124.04 -Operating System: Linux -Default Encoding: UTF-8 -Language: en -Country: US ---> \ No newline at end of file diff --git a/doip-server/Doc/sequence.puml b/doip-server/Doc/sequence.puml deleted file mode 100644 index c078ab5..0000000 --- a/doip-server/Doc/sequence.puml +++ /dev/null @@ -1,90 +0,0 @@ -@startuml DoIP Server - Sequence Diagram - -skinparam backgroundColor #FAFAFA -skinparam sequence { - ArrowColor #4A7CC7 - ActorBorderColor #4A7CC7 - LifeLineBorderColor #4A7CC7 - ParticipantBackgroundColor #EEF4FF - ParticipantBorderColor #4A7CC7 - BoxBackgroundColor #F0F0F0 - BoxBorderColor #AAAAAA -} - -participant "Tester\n(TCP Client)" as T -participant "DoipCodec\ncodec.rs" as C -participant "DoipPayload\npayload.rs" as P -participant "SessionManager\nsession.rs" as SM -participant "Session\nsession.rs" as S -participant "Handler\n(future)" as H -participant "UdsHandler\n(user impl)" as U - -== Connection Setup == - -T -> C : TCP connect -C -> SM : create_session(peer_addr) -SM -> S ** : Session::new(id, addr)\nstate=Connected - -== Routing Activation (ISO 13400-2 sec 7.7) == - -T -> C : bytes [0x02,0xFD,0x00,0x05,...]\nRoutingActivationRequest -C -> C : decode()\nDoipHeader::parse()\nstate to Payload -C -> H : DoipMessage { type=0x0005 } -H -> P : DoipPayload::parse(msg) -P -> P : routing_activation::Request::parse() -P --> H : DoipPayload::RoutingActivationRequest(req) - -H -> SM : update_session(id, activate_routing) -SM -> S : activate_routing(0x0E80)\nstate to RoutingActive - -H -> H : routing_activation::Response::success(\n req.source_address(), config.logical_address()) -H -> C : DoipCodec::encode(DoipMessage) -C --> T : bytes [0x02,0xFD,0x00,0x06,...,0x10]\nRoutingActivationResponse (Success) - -== Alive Check Ping/Pong (ISO 13400-2 sec 7.6) == - -note over H : T_TCP_General_Inactivity fires\n(5 min default) -H -> H : alive_check::Request -- to_bytes() -- [] -H -> C : encode(DoipMessage { type=0x0007, payload=[] }) -C --> T : bytes [0x02,0xFD,0x00,0x07,0x00,0x00,0x00,0x00] - -note over H : start T_TCP_Alive_Check\n(500ms timeout) - -T -> C : bytes [0x02,0xFD,0x00,0x08,0x00,0x00,0x00,0x02,0x0E,0x80] -C -> H : DoipMessage { type=0x0008, payload=[0x0E,0x80] } -H -> P : DoipPayload::parse(msg) -P -> P : alive_check::Response::parse([0x0E,0x80])\nsource_address=0x0E80 -P --> H : DoipPayload::AliveCheckResponse(resp) - -H -> S : tester_address() -- returns 0x0E80 -H -> H : resp.source_address() == 0x0E80 OK\nreset inactivity timer - -== Diagnostic Message / UDS (ISO 13400-2 sec 7.9) == - -T -> C : bytes [0x02,0xFD,0x80,0x01,...]\nDiagnosticMessage (UDS: 0x22,0xF1,0x90) -C -> H : DoipMessage { type=0x8001 } -H -> P : DoipPayload::parse(msg) -P -> P : diagnostic_message::Message::parse()\nSA=0x0E80, TA=0x0091\nuser_data=[0x22,0xF1,0x90] -P --> H : DoipPayload::DiagnosticMessage(msg) - -H -> S : is_routing_active() -- returns true - -H -> H : DiagnosticAck::positive(TA, SA, user_data.clone()) -H -> C : encode(DiagnosticMessagePositiveAck) -C --> T : [0x02,0xFD,0x80,0x02,...] DiagMsgAck - -H -> U : UdsRequest::new(SA=0x0E80, TA=0x0091,\n payload=[0x22,0xF1,0x90]) -U -> U : match service_id() -- 0x22\nREAD_DATA_BY_IDENTIFIER -U --> H : UdsResponse::new(0x0091, 0x0E80, [0x62,0xF1,0x90,...]) - -H -> H : diagnostic_message::Message::new(\n SA=0x0091, TA=0x0E80, user_data) -H -> C : encode(DoipMessage { type=0x8001 }) -C --> T : [0x02,0xFD,0x80,0x01,...] DiagnosticMessage (UDS response) - -== Disconnection == - -T ->x C : TCP close / timeout -H -> SM : remove_session(id) -SM -> S !! : Session dropped\nstate=Closed - -@enduml \ No newline at end of file diff --git a/doip-server/Doc/sequence.svg b/doip-server/Doc/sequence.svg deleted file mode 100644 index 5eb1c08..0000000 --- a/doip-server/Doc/sequence.svg +++ /dev/null @@ -1,102 +0,0 @@ -Tester(TCP Client)Tester(TCP Client)DoipCodeccodec.rsDoipCodeccodec.rsDoipPayloadpayload.rsDoipPayloadpayload.rsSessionManagersession.rsSessionManagersession.rsSessionsession.rsHandler(future)Handler(future)UdsHandler(user impl)UdsHandler(user impl)Connection SetupTCP connectcreate_session(peer_addr)Session::new(id, addr)state=ConnectedSessionsession.rsRouting Activation (ISO 13400-2 sec 7.7)bytes [0x02,0xFD,0x00,0x05,...]RoutingActivationRequestdecode()DoipHeader::parse()state to PayloadDoipMessage { type=0x0005 }DoipPayload::parse(msg)routing_activation::Request::parse()DoipPayload::RoutingActivationRequest(req)update_session(id, activate_routing)activate_routing(0x0E80)state to RoutingActiverouting_activation::Response::success(req.source_address(), config.logical_address())DoipCodec::encode(DoipMessage)bytes [0x02,0xFD,0x00,0x06,...,0x10]RoutingActivationResponse (Success)Alive Check Ping/Pong (ISO 13400-2 sec 7.6)T_TCP_General_Inactivity fires(5 min default)alive_check::Requestto_bytes()[]encode(DoipMessage { type=0x0007, payload=[] })bytes [0x02,0xFD,0x00,0x07,0x00,0x00,0x00,0x00]start T_TCP_Alive_Check(500ms timeout)bytes [0x02,0xFD,0x00,0x08,0x00,0x00,0x00,0x02,0x0E,0x80]DoipMessage { type=0x0008, payload=[0x0E,0x80] }DoipPayload::parse(msg)alive_check::Response::parse([0x0E,0x80])source_address=0x0E80DoipPayload::AliveCheckResponse(resp)tester_address() -- returns 0x0E80resp.source_address() == 0x0E80 OKreset inactivity timerDiagnostic Message / UDS (ISO 13400-2 sec 7.9)bytes [0x02,0xFD,0x80,0x01,...]DiagnosticMessage (UDS: 0x22,0xF1,0x90)DoipMessage { type=0x8001 }DoipPayload::parse(msg)diagnostic_message::Message::parse()SA=0x0E80, TA=0x0091user_data=[0x22,0xF1,0x90]DoipPayload::DiagnosticMessage(msg)is_routing_active() -- returns trueDiagnosticAck::positive(TA, SA, user_data.clone())encode(DiagnosticMessagePositiveAck)[0x02,0xFD,0x80,0x02,...] DiagMsgAckUdsRequest::new(SA=0x0E80, TA=0x0091,payload=[0x22,0xF1,0x90])match service_id() -- 0x22READ_DATA_BY_IDENTIFIERUdsResponse::new(0x0091, 0x0E80, [0x62,0xF1,0x90,...])diagnostic_message::Message::new(SA=0x0091, TA=0x0E80, user_data)encode(DoipMessage { type=0x8001 })[0x02,0xFD,0x80,0x01,...] DiagnosticMessage (UDS response)DisconnectionTCP close / timeoutremove_session(id)Session droppedstate=Closed \ No newline at end of file diff --git a/doip-server/Doc/ClassDiagram.puml b/doip-server/doc/ClassDiagram.puml similarity index 69% rename from doip-server/Doc/ClassDiagram.puml rename to doip-server/doc/ClassDiagram.puml index 69a8401..0390dd2 100644 --- a/doip-server/Doc/ClassDiagram.puml +++ b/doip-server/doc/ClassDiagram.puml @@ -1,4 +1,4 @@ -@startuml DoIP Server Class Diagram — Architecture Overview +@startuml ClassDiagram ' ========================================================== ' Crisp layered overview of the DoIP server (ISO 13400-2). ' @@ -234,20 +234,93 @@ package "Vehicle Identification (ISO §7.5)" #EEF4FF { } ' ══════════════════════════════════════════════════════════ -' 4. SERVER & SESSION +' 4. INTERNAL IMPLEMENTATION (crate-only) ' ══════════════════════════════════════════════════════════ -package "Server" #EAFAF1 { - class ServerConfig { - tcp_addr / udp_addr: SocketAddr - logical_address: u16 - vin: [u8; 17] eid: [u8; 6] - max_connections: usize - initial_inactivity_timeout_ms: u64 - general_inactivity_timeout_ms: u64 +package "Internal Implementation (- = crate-only)" #EBF5FB { + + class TcpListener { + - config: Arc + - sessions: Arc + - handler: Arc .. - + from_file(path) : Result + + new(config, sessions, handler) : Self + + start(token: CancellationToken) : Result<()> + - accept_connection() : Result + } + + class UdpListener { + - config: Arc + .. + + new(config: Arc) : Self + + start(token: CancellationToken) : Result<()> + - receive_message() : Result + - send_response(peer, response) + } + + class TcpConnection { + - session_id: u64 + - codec: DoipCodec + - dispatcher: MessageDispatcher + - sessions: Arc + .. + + run() : Result<()> + - handle_message() : Result<()> + - send_response(msg: DoipMessage) + - send_alive_check() + - close() + } + + note right of TcpConnection + SRP: transport only. + send_alive_check() = I/O action. + Protocol logic lives in + MessageDispatcher handles. + end note + + class MessageDispatcher { + - routing: RoutingActivationHandler + - alive: AliveCheckHandler + - diag: Arc + - sessions: Arc + .. + + new(sessions, handler) : Self + + dispatch(payload: DoipPayload,\n session_id: u64) : Result + } + + note right of MessageDispatcher + Strategy pattern: + each handler is a focused unit. + Only dispatcher mutates + session protocol state. + end note + + class RoutingActivationHandler { + + handle(req, session_id,\n sessions) : Result + } + + class AliveCheckHandler { + + handle_response(resp, session_id,\n sessions) : Result<()> } + class SessionManager { + - sessions: Mutex> + - next_id: AtomicU64 + .. + + new() : Arc + + create_session(peer_addr: SocketAddr) : Session + + get_session(id: u64) : Option + + remove_session(id: u64) + + update_session_state(id: u64, tester_addr: u16) + + reset_inactivity_timer(id: u64) + + check_timeouts(config) : Vec + } + + note right of SessionManager + Thread-safe session store. + One Session per active TCP connection. + Enforces max_connections limit. + end note + enum SessionState <> { Connected RoutingActive @@ -255,38 +328,60 @@ package "Server" #EAFAF1 { } class Session { - id: u64 - peer_addr: SocketAddr - tester_address: u16 - state: SessionState + - id: u64 + - peer_addr: SocketAddr + - tester_address: u16 + - state: SessionState + - last_activity: Instant .. - + activate_routing(addr: u16) + is_routing_active() : bool + + tester_address() : u16 } +} - class SessionManager { - sessions: RwLock> +' ══════════════════════════════════════════════════════════ +' 5. PUBLIC INTERFACE (user API) +' ══════════════════════════════════════════════════════════ +package "Public Interface (+ = user API)" #EAFAF1 { + + class DoipServer { + - udp: UdpListener + - tcp: TcpListener + - token: CancellationToken .. - + create_session(peer) : Session - + update_session(id, f) - + remove_session(id) - + is_tester_registered(addr: u16) : bool + + new(config: Arc,\n handler: Arc) : Self + + start() : Result<()> + + stop() + - handle_udp() + - handle_tcp() } -} -note right of SessionManager - Thread-safe session store. - One Session per active TCP connection. - Enforces max_connections limit. -end note + class ServerConfig { + + tcp_addr: SocketAddr + + udp_addr: SocketAddr + + logical_address: u16 + + vin: [u8; 17] + + eid: [u8; 6] + + gid: [u8; 6] + + max_connections: usize + + initial_inactivity_ms: u64 + + general_inactivity_ms: u64 + .. + + from_file(path) : Result + + default() : Self + } -' ══════════════════════════════════════════════════════════ -' 5. UDS INTEGRATION (user extension point) -' ══════════════════════════════════════════════════════════ -package "UDS Integration" #FEF9E7 { - interface UdsHandler <> { - + handle(req: UdsRequest) : UdsResponse + interface UdsHandler <> { + + handle(request: UdsRequest) : Result } + + note right of UdsHandler + Returns Result — handler + can signal ECU errors. + Implement this trait to + connect your ECU logic. + end note + class UdsRequest { source_address: u16 target_address: u16 @@ -294,6 +389,7 @@ package "UDS Integration" #FEF9E7 { .. + service_id() : Option } + class UdsResponse { source_address: u16 target_address: u16 @@ -301,14 +397,6 @@ package "UDS Integration" #FEF9E7 { } } -note right of UdsHandler - Extension point for users. - Server extracts user_data from DiagnosticMessage, - wraps it as UdsRequest, and calls handle(). - The returned UdsResponse is sent back as - a new DiagnosticMessage (type 0x8001). -end note - ' ══════════════════════════════════════════════════════════ ' RELATIONSHIPS ' ══════════════════════════════════════════════════════════ @@ -342,10 +430,31 @@ RoutingActivationResponse --> ActivationResponseCode DiagnosticAck --> AckResult AckResult --> DiagnosticNackCode -' — Server — -Session *-- SessionState -SessionManager "1" o-- "0..*" Session -ServerConfig ..> SessionManager : configures +' — Public API owns listeners — +DoipServer o-- UdpListener : owns +DoipServer o-- TcpListener : owns +DoipServer ..> ServerConfig : Arc<> +DoipServer ..> UdsHandler : Arc + +' — Internal wiring — +TcpListener ..> ServerConfig : Arc<> +TcpListener ..> SessionManager : Arc<> +TcpListener ..> UdsHandler : Arc +TcpListener ..> TcpConnection : spawns per accept + +UdpListener ..> ServerConfig : Arc<> + +TcpConnection o-- DoipCodec : Arc<>\nlifecycle only +TcpConnection o-- MessageDispatcher +TcpConnection ..> SessionManager : Arc<>\nprotocol state only + +MessageDispatcher o-- RoutingActivationHandler : owns +MessageDispatcher o-- AliveCheckHandler : owns +MessageDispatcher ..> UdsHandler : Arc +MessageDispatcher ..> SessionManager : Arc<> + +SessionManager "1" o-- "0..*" Session : manages 1..* +Session *-- SessionState ' — UDS bridge — DiagnosticMessage ..> UdsRequest : user_data → diff --git a/doip-server/doc/ClassDiagram.svg b/doip-server/doc/ClassDiagram.svg new file mode 100644 index 0000000..fb2c962 --- /dev/null +++ b/doip-server/doc/ClassDiagram.svg @@ -0,0 +1,527 @@ +TCP FramingPayload DispatchAlive Check (ISO §7.6)Routing Activation (ISO §7.7)Diagnostic Message (ISO §7.9)Vehicle Identification (ISO §7.5)Internal Implementation (- = crate-only)Public Interface (+ = user API)DoipCodec-state: AwaitingHeader | AwaitingPayload-max_payload_size: u32+new() : Self+with_max_payload_size(n: u32) : SelfDoipMessage+header: DoipHeader+payload: Bytes«8-byte generic header §6»DoipHeaderversion: u8 ' 0x02 = ISO 13400-2:2019inverse_version: u8 ' version XOR 0xFFpayload_type: u16payload_length: u32 ' big-endian+parse(bytes) : Result<Self>+validate() : Option<GenericNackCode>«Table 16»PayloadTypeGenericNack = 0x0000VehicleIdentificationRequest = 0x0001VehicleIdentificationReqEid = 0x0002VehicleIdentificationReqVin = 0x0003VehicleIdentificationResponse = 0x0004RoutingActivationRequest = 0x0005RoutingActivationResponse = 0x0006AliveCheckRequest = 0x0007AliveCheckResponse = 0x0008DiagnosticMessage = 0x8001DiagnosticMessagePositiveAck = 0x8002DiagnosticMessageNegativeAck = 0x8003«Table 17»GenericNackCodeIncorrectPatternFormat = 0x00UnknownPayloadType = 0x01MessageTooLarge = 0x02OutOfMemory = 0x03InvalidPayloadLength = 0x04«trait»DoipParseable+parse(payload: &[u8]) : Result<Self>«trait»DoipSerializable+write_to(buf: &mut BytesMut)+to_bytes() : Bytes+serialized_len() : Option<usize>«typed dispatch envelope»DoipPayloadAliveCheckRequest / AliveCheckResponseRoutingActivationRequest / RoutingActivationResponseDiagnosticMessageDiagnosticMessagePositiveAck / NegativeAckVehicleIdentificationRequest / WithEid / WithVinVehicleIdentificationResponseGenericNack(GenericNackCode)+parse(msg: &DoipMessage) : Result<Self>+payload_type() : PayloadType«zero-length payload»AliveCheckRequestAliveCheckResponsetester_address: u16RoutingActivationRequestsource_address: u16activation_type: ActivationTypereserved: u32oem_specific: Option<u32>RoutingActivationResponsetester_address: u16entity_address: u16response_code: ActivationResponseCodereserved: u32oem_specific: Option<u32>+success(tester, entity) : Self+denial(tester, entity, code) : Self+is_success() : bool«Table 24»ActivationTypeDefault = 0x00WwhObd = 0x01CentralSecurity = 0xE0«Table 25»ActivationResponseCodeSuccessfullyActivated = 0x10ConfirmationRequired = 0x11UnknownSourceAddress = 0x00AllSocketsRegistered = 0x01DifferentSourceAddress = 0x02SourceAddressAlreadyActive = 0x03MissingAuthentication = 0x04RejectedConfirmation = 0x05UnsupportedActivationType = 0x06TlsRequired = 0x07denial codesDiagnosticMessagesource_address: u16target_address: u16user_data: Bytes ' UDS payload (min 1 byte)+service_id() : Option<u8>DiagnosticAcksource_address: u16target_address: u16result: AckResultprevious_data: Option<Bytes>+positive(src, tgt) : Self+negative(src, tgt, code) : SelfAckResultPositive ' 0x00Negative(DiagnosticNackCode)«Table 28»DiagnosticNackCodeInvalidSourceAddress = 0x02UnknownTargetAddress = 0x03DiagnosticMessageTooLarge = 0x04OutOfMemory = 0x05TargetUnreachable = 0x06UnknownNetwork = 0x07TransportProtocolError = 0x08«broadcast — no filter»VehicleIdRequestVehicleIdRequestWithEideid: [u8; 6]VehicleIdRequestWithVinvin: [u8; 17]VehicleIdResponsevin: [u8; 17]logical_address: u16eid: [u8; 6]gid: [u8; 6]further_action: FurtherActionsync_status: Option<GidSyncStatus>TcpListener-config: Arc<ServerConfig>-sessions: Arc<SessionManager>-handler: Arc<dyn UdsHandler>+new(config, sessions, handler) : Self+start(token: CancellationToken) : Result<()>-accept_connection() : Result<TcpStream>UdpListener-config: Arc<ServerConfig>+new(config: Arc<ServerConfig>) : Self+start(token: CancellationToken) : Result<()>-receive_message() : Result<DoipMessage>-send_response(peer, response)TcpConnection-session_id: u64-codec: DoipCodec-dispatcher: MessageDispatcher-sessions: Arc<SessionManager>+run() : Result<()>-handle_message() : Result<()>-send_response(msg: DoipMessage)-send_alive_check()-close()SRP: transport only.send_alive_check() = I/O action.Protocol logic lives inMessageDispatcher handles.MessageDispatcher-routing: RoutingActivationHandler-alive: AliveCheckHandler-diag: Arc<dyn UdsHandler>-sessions: Arc<SessionManager>+new(sessions, handler) : Self+dispatch(payload: DoipPayload,session_id: u64) : Result<DoipMessage>Strategy pattern:each handler is a focused unit.Only dispatcher mutatessession protocol state.RoutingActivationHandler+handle(req, session_id,sessions) : Result<DoipMessage>AliveCheckHandler+handle_response(resp, session_id,sessions) : Result<()>SessionManager-sessions: Mutex<HashMap<u64, Session>>-next_id: AtomicU64+new() : Arc<Self>+create_session(peer_addr: SocketAddr) : Session+get_session(id: u64) : Option<Session>+remove_session(id: u64)+update_session_state(id: u64, tester_addr: u16)+reset_inactivity_timer(id: u64)+check_timeouts(config) : Vec<u64>Thread-safe session store.One Session per active TCP connection.Enforces max_connections limit.«ISO §7.4 lifecycle»SessionStateConnectedRoutingActiveClosedSession-id: u64-peer_addr: SocketAddr-tester_address: u16-state: SessionState-last_activity: Instant+is_routing_active() : bool+tester_address() : u16DoipServer-udp: UdpListener-tcp: TcpListener-token: CancellationToken+new(config: Arc<ServerConfig>,handler: Arc<dyn UdsHandler>) : Self+start() : Result<()>+stop()-handle_udp()-handle_tcp()ServerConfig+tcp_addr: SocketAddr+udp_addr: SocketAddr+logical_address: u16+vin: [u8; 17]+eid: [u8; 6]+gid: [u8; 6]+max_connections: usize+initial_inactivity_ms: u64+general_inactivity_ms: u64+from_file(path) : Result<Self>+default() : Self«implement this trait to\nconnect your ECU logic»UdsHandler+handle(request: UdsRequest) : Result<UdsResponse>Returns Result — handlercan signal ECU errors.Implement this trait toconnect your ECU logic.UdsRequestsource_address: u16target_address: u16payload: Bytes+service_id() : Option<u8>UdsResponsesource_address: u16target_address: u16payload: BytesTwo-state TCP reassembler:AwaitingHeader(8 bytes) →AwaitingPayload(declared length)Drops connection on bad header.DoS guard: rejects payload > max_payload_size.Single entry-point for decoding any DoIP message.Matches on PayloadType and delegates to thecorrect module's parse() automatically.Every variant wraps a fully-typed struct.All message types aboveimplement DoipParseableand DoipSerializable.framesparse()ownsownsArc<>Arc<dyn>Arc<>Arc<>Arc<dyn>spawns per acceptArc<>Arc<>lifecycle onlyArc<>protocol state onlyownsownsArc<dyn>Arc<>manages 1..*10..*user_data →consumesproduces \ No newline at end of file diff --git a/doip-server/doc/flowchart.puml b/doip-server/doc/flowchart.puml new file mode 100644 index 0000000..aad81fc --- /dev/null +++ b/doip-server/doc/flowchart.puml @@ -0,0 +1,205 @@ +@startuml flowchart + +skinparam backgroundColor #FAFAFA +skinparam activity { + BackgroundColor #EEF4FF + BorderColor #4A7CC7 + FontSize 12 + ArrowColor #4A7CC7 + StartColor #4A7CC7 + EndColor #C0392B + DiamondBackgroundColor #FFF9E3 + DiamondBorderColor #E67E22 +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 +} + +start + +:DoipServer::new(\n Arc, Arc); +note right: ServerConfig::from_file()\nor ServerConfig::default() + +:CancellationToken::new(); + +fork + :tokio::spawn(\n UdpListener::start(token.clone())); + note right: Stateless — handles Vehicle\nIdentification broadcasts (UDP) +fork again + :tokio::spawn(\n TcpListener::start(token.clone())); +end fork + +' ── TcpListener accept loop ── +repeat + + if (token.is_cancelled()?) then (yes) + :Graceful shutdown; + stop + else (no) + endif + + :TcpListener::accept() → TcpStream\n(peer_addr); + + :SessionManager::create_session(peer_addr); + note right: Session { state=Connected\n tester_address=0\n last_activity=Instant::now() } + + fork + :tokio::spawn(TcpConnection::run()); + fork again + :Continue accepting\nnew connections; + end fork + + ' ── Per-connection task (TcpConnection::run) ── + + repeat + + if (token.is_cancelled()?) then (yes) + :tcp_stream.shutdown(); + :SessionManager::remove_session(id); + stop + else (no) + endif + + :SessionManager::check_timeouts(config); + if (session timed out?) then (yes) + if (state == Connected\n&& elapsed > initial_ms?) then (yes) + :Initial inactivity — no routing\nactivation received in time; + :tcp_stream.shutdown(); + :SessionManager::remove_session(id); + stop + else (RoutingActive) + :Send AliveCheckRequest [0x0007]; + if (AliveCheckResponse within\nT_TCP_Alive_Check?) then (yes) + :SessionManager::reset_inactivity_timer(id); + else (no) + :Err(DoipError::AliveCheckTimeout); + :tcp_stream.shutdown(); + :SessionManager::remove_session(id); + stop + endif + endif + else (no) + endif + + :Wait for bytes on TCP stream; + + :DoipCodec::decode(src: BytesMut); + + if (src.len < 8 bytes?) then (yes) + :return Ok(None)\nwait for more data; + else (no) + endif + + :DoipHeader::parse(header_slice); + + if (Header valid?\nversion + inverse XOR = 0xFF) then (no) + :return Err(DoipError::InvalidHeader); + :Close TCP connection; + stop + else (yes) + endif + + if (payload_length > max_payload_size?) then (yes) + :return Err(DoipError::MessageTooLarge); + :Close TCP connection; + stop + else (no) + endif + + if (src.len < 8 + payload_length?) then (yes) + :return Ok(None)\nwait for more data; + else (no) + endif + + :split_to → DoipMessage { header, payload: Bytes }; + + :MessageDispatcher::dispatch(\n DoipPayload::parse(msg), session_id); + + if (payload_type known?) then (no) + :DoipError::UnknownPayloadType; + :Send GenericNack(UnknownPayloadType); + else (yes) + + switch (payload_type?) + + case (0x0005\nRoutingActivationRequest) + :RoutingActivationHandler::handle(\n req, session_id); + if (Session already RoutingActive\nwith same address?) then (yes) + :RoutingActivationResponse::denial(\n SourceAddressAlreadyActive); + elseif (Max connections reached?) then (yes) + :RoutingActivationResponse::denial(\n AllSocketsRegistered); + else (no) + :SessionManager::update_session_state(\n session_id, SA); + note right: Session.state = RoutingActive\nSession.last_activity = Instant::now() + :RoutingActivationResponse::success(\n tester_addr, entity_addr); + endif + :DoipCodec::encode(RoutingActivationResponse); + :Send bytes on TCP; + + case (0x0007\nAliveCheckRequest) + :AliveCheckHandler::handle(req, session_id); + :AliveCheckRequest::parse(payload)\n[zero-length, always Ok]; + :AliveCheckResponse::new(\n session.tester_address()); + :to_bytes() → [addr_hi, addr_lo]; + :DoipCodec::encode(AliveCheckResponse\ntype=0x0008); + :Send 10 bytes on TCP; + + case (0x0008\nAliveCheckResponse) + :AliveCheckHandler::handle_response(\n resp, session_id); + :AliveCheckResponse::parse(payload)\n→ tester_address: u16; + if (tester_address matches\nsession.tester_address()?) then (yes) + :SessionManager::reset_inactivity_timer(id); + else (no) + :Log mismatch\nClose TCP connection; + stop + endif + + case (0x8001\nDiagnosticMessage) + if (session.is_routing_active()?) then (no) + :DiagnosticAck::negative(\n InvalidSourceAddress); + :Send DiagnosticMessageNegativeAck\n(type=0x8003); + else (yes) + :DiagnosticMessage::parse(payload)\n→ SA, TA, user_data; + :DiagnosticAck::positive(TA, SA); + :Send DiagnosticMessagePositiveAck\n(type=0x8002); + :UdsRequest::new(SA, TA, user_data); + :UdsHandler::handle(UdsRequest)\n→ Result\n[USER IMPLEMENTATION]; + if (Ok(UdsResponse)?) then (yes) + :DiagnosticMessage::new(\n SA=entity, TA=tester, data); + :DoipCodec::encode(DiagnosticMessage\ntype=0x8001); + :Send UDS response bytes on TCP; + else (Err(ApplicationError)) + :Send GenericNack or log error; + endif + endif + + case (0x0001/0x0002/0x0003\nVehicleIdRequest) + note right: Handled by UdpListener (stateless).\nTCP connections send GenericNack. + :Send GenericNack\n(IncorrectPatternFormat); + + case (other) + :Send GenericNack\n(IncorrectPatternFormat); + + endswitch + + endif + + repeat while (TCP connection open?) is (yes) + -> no; + + :SessionManager::remove_session(id); + note right: Session dropped\nstate=Closed + +repeat while (token.is_cancelled() == false) is (yes) +-> no; + +:CancellationToken::cancel() +(DoipServer::stop() called); +:All TcpConnection tasks receive +cancellation signal → shutdown; +:DoipServer::start() returns Ok(()); + +stop + +@enduml \ No newline at end of file diff --git a/doip-server/doc/flowchart.svg b/doip-server/doc/flowchart.svg new file mode 100644 index 0000000..558f26b --- /dev/null +++ b/doip-server/doc/flowchart.svg @@ -0,0 +1,215 @@ +ServerConfig::from_file()or ServerConfig::default()DoipServer::new(Arc<ServerConfig>, Arc<dyn UdsHandler>)CancellationToken::new()Stateless — handles VehicleIdentification broadcasts (UDP)tokio::spawn(UdpListener::start(token.clone()))tokio::spawn(TcpListener::start(token.clone()))Graceful shutdownyestoken.is_cancelled()?noTcpListener::accept() → TcpStream(peer_addr)Session { state=Connectedtester_address=0last_activity=Instant::now() }SessionManager::create_session(peer_addr)tokio::spawn(TcpConnection::run())Continue acceptingnew connectionstcp_stream.shutdown()SessionManager::remove_session(id)yestoken.is_cancelled()?noSessionManager::check_timeouts(config)state == Connected&& elapsed > initial_ms?yesRoutingActiveInitial inactivity — no routingactivation received in timetcp_stream.shutdown()SessionManager::remove_session(id)Send AliveCheckRequest [0x0007]AliveCheckResponse withinT_TCP_Alive_Check?yesnoSessionManager::reset_inactivity_timer(id)Err(DoipError::AliveCheckTimeout)tcp_stream.shutdown()SessionManager::remove_session(id)yessession timed out?noWait for bytes on TCP streamDoipCodec::decode(src: BytesMut)return Ok(None)wait for more datayessrc.len < 8 bytes?noDoipHeader::parse(header_slice)return Err(DoipError::InvalidHeader)Close TCP connectionnoHeader valid?version + inverse XOR = 0xFFyesreturn Err(DoipError::MessageTooLarge)Close TCP connectionyespayload_length > max_payload_size?noreturn Ok(None)wait for more datayessrc.len < 8 + payload_length?nosplit_to → DoipMessage { header, payload: Bytes }MessageDispatcher::dispatch(DoipPayload::parse(msg), session_id)payload_type known?noyesDoipError::UnknownPayloadTypeSend GenericNack(UnknownPayloadType)payload_type?RoutingActivationHandler::handle(req, session_id)yesSession already RoutingActivewith same address?RoutingActivationResponse::denial(SourceAddressAlreadyActive)yesMax connections reached?noRoutingActivationResponse::denial(AllSocketsRegistered)Session.state = RoutingActiveSession.last_activity = Instant::now()SessionManager::update_session_state(session_id, SA)RoutingActivationResponse::success(tester_addr, entity_addr)DoipCodec::encode(RoutingActivationResponse)Send bytes on TCPAliveCheckHandler::handle(req, session_id)AliveCheckRequest::parse(payload)[zero-length, always Ok]AliveCheckResponse::new(session.tester_address())to_bytes() → [addr_hi, addr_lo]DoipCodec::encode(AliveCheckResponsetype=0x0008)Send 10 bytes on TCPAliveCheckHandler::handle_response(resp, session_id)AliveCheckResponse::parse(payload)→ tester_address: u16tester_address matchessession.tester_address()?yesnoSessionManager::reset_inactivity_timer(id)Log mismatchClose TCP connectionsession.is_routing_active()?noyesDiagnosticAck::negative(InvalidSourceAddress)Send DiagnosticMessageNegativeAck(type=0x8003)DiagnosticMessage::parse(payload)→ SA, TA, user_dataDiagnosticAck::positive(TA, SA)Send DiagnosticMessagePositiveAck(type=0x8002)UdsRequest::new(SA, TA, user_data)UdsHandler::handle(UdsRequest)→ Result<UdsResponse>[USER IMPLEMENTATION]Ok(UdsResponse)?yesErr(ApplicationError)DiagnosticMessage::new(SA=entity, TA=tester, data)DoipCodec::encode(DiagnosticMessagetype=0x8001)Send UDS response bytes on TCPSend GenericNack or log errorSend GenericNack(IncorrectPatternFormat)Send GenericNack(IncorrectPatternFormat)TCP connection open?yesSession droppedstate=ClosedSessionManager::remove_session(id)token.is_cancelled() == falseyesCancellationToken::cancel()(DoipServer::stop() called)All TcpConnection tasks receivecancellation signal → shutdownDoipServer::start() returns Ok(())0x0005RoutingActivationRequestother0x0007AliveCheckRequest0x0008AliveCheckResponse0x8001DiagnosticMessage0x0001/0x0002/0x0003VehicleIdRequestnono \ No newline at end of file diff --git a/doip-server/doc/sequence.puml b/doip-server/doc/sequence.puml new file mode 100644 index 0000000..8f0ff0f --- /dev/null +++ b/doip-server/doc/sequence.puml @@ -0,0 +1,296 @@ +@startuml sequence + +' ========================================================== +' Abstraction shown via 3 techniques: +' 1. box grouping — Public Interface vs Internal Impl +' 2. self-calls — hidden internal logic (X -> X) +' 3. boundary note — <> marks abstraction edge +' ========================================================== + +skinparam backgroundColor white +skinparam defaultFontSize 12 +skinparam sequence { + ArrowColor #4A7CC7 + LifeLineBorderColor #4A7CC7 + LifeLineBackgroundColor #EBF5FB + ParticipantBackgroundColor #EEF4FF + ParticipantBorderColor #4A7CC7 + ParticipantFontStyle bold + ActorBorderColor #4A7CC7 + ActorBackgroundColor #EBF5FB + NoteBackgroundColor #FFFDE7 + NoteBorderColor #E0AC00 + GroupBackgroundColor #F5F5F5 + GroupBorderColor #BDBDBD + BoxBackgroundColor #FAFAFA + BoxBorderColor #4A7CC7 + ParticipantPadding 20 + MessageSpacing 15 +} + +' ── Abstraction Technique 1: box grouping ───────────────── +box "Public Interface — visible to user" #D5F5E3 + actor Tester as T #EBF5FB + participant "DoipServer\n(pub)" as DS #D5F5E3 +end box + +box "Internal Implementation — pub(crate) only" #EEF4FF + participant "UdpListener" as UDP #EEF4FF + participant "TcpListener" as TCP #EEF4FF + participant "TcpConnection" as Conn #EEF4FF + participant "DoipCodec" as Codec #FEF9E7 + participant "MessageDispatcher" as Disp #EEF4FF + participant "UdsHandler\n(user impl)" as UDS #F9F3FF +end box + +' ── Abstraction Technique 3: boundary note ──────────────── +note over DS, UDS + <> + Tester interacts only with DoipServer (pub). + UdpListener, TcpListener, TcpConnection, DoipCodec, + MessageDispatcher are all pub(crate) — invisible externally. + UdsHandler is pub — user implements it, library calls it. +end note + +' ────────────────────────────────────────────────────────── +== 0. Server Initialization == +' ────────────────────────────────────────────────────────── + +note over DS, TCP #FFFDE7 + Arc shared by all components at construction. + Holds: VIN · EID · GID · logical_address + tcp_addr · udp_addr · max_connections + initial_inactivity_ms · general_inactivity_ms +end note + +DS -> DS : DoipServer::new(Arc,\n Arc) +DS -> DS : CancellationToken::new() +DS -> UDP : UdpListener::new(Arc) +DS -> UDP : UdpSocket::bind(config.udp_addr()) +DS -> TCP : TcpListener::new(Arc,\n Arc, Arc) +DS -> TCP : TcpSocket::bind(config.tcp_addr()) +DS -> DS : tokio::spawn(UdpListener::start(token.clone())) +DS -> DS : tokio::spawn(TcpListener::start(token.clone())) + +' ────────────────────────────────────────────────────────── +== 1. Vehicle Identification (UDP, ISO §7.5) == +' ────────────────────────────────────────────────────────── +T -> UDP : VehicleIdentificationRequest [0x0001]\n(UDP broadcast) + +note over UDP #FFFDE7 + <> enters internal impl +end note + +' Abstraction Technique 2: self-calls = hidden logic +UDP -> UDP : DoipCodec::decode(bytes) → DoipMessage +UDP -> UDP : DoipPayload::parse(&msg) + +alt VehicleIdRequest [0x0001] — broadcast (no filter) + UDP -> UDP : respond unconditionally +else VehicleIdRequestWithEid [0x0002] + UDP -> UDP : req.eid() == config.eid() → match +else VehicleIdRequestWithVin [0x0003] + UDP -> UDP : req.vin() == config.vin() → match +end + +UDP -> UDP : VehicleIdResponse::new(\n config.vin(), config.logical_address(),\n config.eid(), config.gid()) +UDP -> UDP : DoipCodec::encode(response) → bytes + +note over UDP #FFFDE7 + <> returns to public interface +end note + +UDP --> T : VehicleIdentificationResponse [0x0004] + +' ────────────────────────────────────────────────────────── +== 2. TCP Connection Establishment == +' ────────────────────────────────────────────────────────── +T -> TCP : TCP connect (port 13400) +TCP -> TCP : TcpListener::accept() → TcpStream +TCP -> TCP : check_max_connections() +TCP -> Conn : tokio::spawn(TcpConnection::run(TcpStream)) +Conn -> Conn : SessionManager::create_session(peer_addr)\nstate = Connected +TCP --> T : connection accepted + +' ────────────────────────────────────────────────────────── +== 3. Alive Check (ISO §7.6) == +' ────────────────────────────────────────────────────────── +Conn -> T : AliveCheckRequest [0x0007] +T -> Conn : AliveCheckResponse [0x0008]\ntester_address (2 bytes) + +note over Conn, Disp #FFFDE7 + <> enters internal impl +end note + +Conn -> Codec : decode(bytes) → DoipMessage +Codec --> Conn : Ok(AliveCheckResponse) +Conn -> Disp : dispatch(AliveCheckResponse, session_id) +Disp -> Disp : AliveCheckHandler::handle_response(resp, session_id) +Disp -> Disp : SessionManager::reset_inactivity_timer(session_id) + +' ────────────────────────────────────────────────────────── +== 4. Routing Activation (ISO §7.7) == +' ────────────────────────────────────────────────────────── +T -> Conn : RoutingActivationRequest [0x0005]\nSA=0x0E00 + +note over Conn, Codec #FFFDE7 + <> enters internal impl +end note + +Conn -> Codec : decode(bytes) +Codec -> Codec : parse_header() +Codec -> Codec : validate_version() +Codec -> Codec : check_payload_size() +Codec --> Conn : Ok(RoutingActivationRequest) + +Conn -> Disp : dispatch(RoutingActivationRequest, session_id) +Disp -> Disp : RoutingActivationHandler::handle(req, session_id) +Disp -> Disp : SessionManager::is_tester_registered(SA) → false +Disp -> Disp : SessionManager::update_session_state(\n session_id, SA=0x0E00) → RoutingActive + +Conn -> Codec : encode(RoutingActivationResponse) +Codec --> Conn : bytes + +note over Conn, Codec #FFFDE7 + <> returns to public interface +end note + +alt success + Conn --> T : RoutingActivationResponse [0x0006]\ncode=SuccessfullyActivated(0x10) +else max connections reached + Conn --> T : RoutingActivationResponse [0x0006]\ncode=AllSocketsRegistered(0x01) +else address already active + Conn --> T : RoutingActivationResponse [0x0006]\ncode=SourceAddressAlreadyActive(0x03) +end + +' ────────────────────────────────────────────────────────── +== 5. Diagnostic Message (ISO §7.9) == +' ────────────────────────────────────────────────────────── +T -> Conn : DiagnosticMessage [0x8001]\nSA=0x0E00 TA=0xE000 data=[0x10,0x03] + +note over Conn, Codec #FFFDE7 + <> enters internal impl +end note + +Conn -> Codec : decode(bytes) +Codec --> Conn : Ok(DiagnosticMessage) +Conn -> Disp : dispatch(DiagnosticMessage, session_id) +Disp -> Disp : SessionManager::is_routing_active(session_id) → true +Conn -> Codec : encode(DiagnosticMessagePositiveAck) +Codec --> Conn : bytes + +note over Conn, Codec #FFFDE7 + <> returns to public interface +end note + +Conn --> T : DiagnosticMessagePositiveAck [0x8002] + +note over Disp, UDS #FFFDE7 + <> enters UDS layer +end note + +Disp -> UDS : handle(UdsRequest\nSA=0x0E00, TA=0xE000, data=[0x10,0x03]) +UDS -> UDS : process_service() +UDS -> UDS : build_response(data=[0x50,0x03]) +UDS --> Disp : Ok(UdsResponse { data=[0x50,0x03] }) + +note over Disp, UDS #FFFDE7 + <> returns from UDS layer +end note + +Disp --> Conn : Ok(DiagnosticMessage response) +Conn -> Codec : encode(DiagnosticMessage response) +Codec --> Conn : bytes +Conn --> T : DiagnosticMessage [0x8001]\ndata=[0x50,0x03] (positive response) + +group Routing NOT Active + T -> Conn : DiagnosticMessage [0x8001]\nSA=0x0E99 + Conn -> Codec : decode(bytes) + Codec --> Conn : Ok(DiagnosticMessage) + Conn -> Disp : dispatch(DiagnosticMessage, session_id) + Disp -> Disp : SessionManager::is_routing_active(session_id) → false + Conn -> Codec : encode(DiagnosticMessageNegativeAck) + Codec --> Conn : bytes + Conn --> T : DiagnosticMessageNegativeAck [0x8003]\ncode=InvalidSourceAddress(0x02) +end + +' ────────────────────────────────────────────────────────── +== 6. Generic NACK == +' ────────────────────────────────────────────────────────── +T -> Conn : [unknown payload 0x00FF] +Conn -> Codec : decode(bytes) +Codec --> Conn : Ok(DoipMessage { type=0x00FF }) +Conn -> Disp : dispatch(msg, session_id) +Disp -> Disp : DoipPayload::parse() → Err(UnknownPayloadType) +Disp --> Conn : Err(DoipError::UnknownPayloadType) +Conn -> Codec : encode(GenericNack) +Codec --> Conn : bytes +Conn --> T : GenericNack [0x0000]\ncode=UnknownPayloadType(0x01) + +' ────────────────────────────────────────────────────────── +== 7. Disconnection == +' ────────────────────────────────────────────────────────── + +alt Tester-initiated disconnect + T ->x Conn : TCP FIN / RST (tester closes connection) + Conn -> Conn : read() returns EOF + Conn -> Conn : SessionManager::remove_session(id) + Conn -> Conn : tcp_stream.shutdown() +note over Conn #FFFDE7 + Server detects close via read() EOF. + No explicit disconnect API — tester owns initiation. +end note +else Server-initiated disconnect (inactivity timeout) + Conn -> Conn : check_timeouts(config) → session expired + Conn -> Conn : SessionManager::remove_session(id) + Conn -> Conn : tcp_stream.shutdown() + Conn --> T : TCP close +note over Conn #FFFDE7 + Server closes after T_TCP_Initial or T_TCP_General + timer fires with no response from tester. +end note +end + +' ────────────────────────────────────────────────────────── +== 8. Graceful Shutdown == +' ────────────────────────────────────────────────────────── +note over DS #FFFDE7 + DoipServer::stop() called\n(e.g. SIGINT / OS signal) +end note + +DS -> DS : token.cancel() +DS -> UDP : CancellationToken::cancelled() → triggered +DS -> TCP : CancellationToken::cancelled() → triggered +UDP -> UDP : UdpSocket::close() +TCP -> TCP : TcpListener::close() +Conn -> Conn : token.cancelled() → exit run() loop +Conn -> Conn : SessionManager::remove_session(id) +DS -> DS : DoipServer::start() returns Ok(()) + +' ────────────────────────────────────────────────────────── +== 9. Session Timeout == +' ────────────────────────────────────────────────────────── +note over Conn #FFFDE7 + T_TCP_Initial (2 s) or T_TCP_General (5 min)\ninactivity timer fires inside TcpConnection::run() +end note + +Conn -> Conn : SessionManager::check_timeouts(config)\n→ Vec + +alt initial inactivity — no routing activation before T_TCP_Initial + Conn -> Conn : session.state == Connected &&\n elapsed > initial_inactivity_ms + Conn -> Conn : SessionManager::remove_session(id) + Conn -> Conn : tcp_stream.shutdown() + Conn --> T : TCP close\n(routing not activated in time) +else general inactivity — RoutingActive but no UDS traffic + Conn -> Conn : session.state == RoutingActive &&\n elapsed > general_inactivity_ms + Conn -> T : AliveCheckRequest [0x0007] + T -> Conn : AliveCheckResponse [0x0008] + Conn -> Conn : SessionManager::reset_inactivity_timer(id) +else alive check timeout — no response received + Conn -> Conn : Err(DoipError::AliveCheckTimeout) + Conn -> Conn : SessionManager::remove_session(id) + Conn -> Conn : tcp_stream.shutdown() + Conn --> T : TCP close\n(alive check timed out) +end + +@enduml diff --git a/doip-server/doc/sequence.svg b/doip-server/doc/sequence.svg new file mode 100644 index 0000000..f12fcba --- /dev/null +++ b/doip-server/doc/sequence.svg @@ -0,0 +1,279 @@ +Public Interface — visible to userInternal Implementation — pub(crate) onlyTesterTesterDoipServer(pub)DoipServer(pub)UdpListenerUdpListenerTcpListenerTcpListenerTcpConnectionTcpConnectionDoipCodecDoipCodecMessageDispatcherMessageDispatcherUdsHandler(user impl)UdsHandler(user impl)<<abstraction boundary>>Tester interacts only with DoipServer (pub).UdpListener, TcpListener, TcpConnection, DoipCodec,MessageDispatcher are all pub(crate) — invisible externally.UdsHandler is pub — user implements it, library calls it.0. Server InitializationArc<ServerConfig> shared by all components at construction.Holds: VIN · EID · GID · logical_addresstcp_addr · udp_addr · max_connectionsinitial_inactivity_ms · general_inactivity_msDoipServer::new(Arc<ServerConfig>,Arc<dyn UdsHandler>)CancellationToken::new()UdpListener::new(Arc<ServerConfig>)UdpSocket::bind(config.udp_addr())TcpListener::new(Arc<ServerConfig>,Arc<SessionManager>, Arc<dyn UdsHandler>)TcpSocket::bind(config.tcp_addr())tokio::spawn(UdpListener::start(token.clone()))tokio::spawn(TcpListener::start(token.clone()))1. Vehicle Identification (UDP, ISO §7.5)VehicleIdentificationRequest [0x0001](UDP broadcast)<<boundary>> enters internal implDoipCodec::decode(bytes) → DoipMessageDoipPayload::parse(&msg)alt[VehicleIdRequest [0x0001] — broadcast (no filter)]respond unconditionally[VehicleIdRequestWithEid [0x0002]]req.eid() == config.eid() → match[VehicleIdRequestWithVin [0x0003]]req.vin() == config.vin() → matchVehicleIdResponse::new(config.vin(), config.logical_address(),config.eid(), config.gid())DoipCodec::encode(response) → bytes<<boundary>> returns to public interfaceVehicleIdentificationResponse [0x0004]2. TCP Connection EstablishmentTCP connect (port 13400)TcpListener::accept() → TcpStreamcheck_max_connections()tokio::spawn(TcpConnection::run(TcpStream))SessionManager::create_session(peer_addr)state = Connectedconnection accepted3. Alive Check (ISO §7.6)AliveCheckRequest [0x0007]AliveCheckResponse [0x0008]tester_address (2 bytes)<<boundary>> enters internal impldecode(bytes) → DoipMessageOk(AliveCheckResponse)dispatch(AliveCheckResponse, session_id)AliveCheckHandler::handle_response(resp, session_id)SessionManager::reset_inactivity_timer(session_id)4. Routing Activation (ISO §7.7)RoutingActivationRequest [0x0005]SA=0x0E00<<boundary>> enters internal impldecode(bytes)parse_header()validate_version()check_payload_size()Ok(RoutingActivationRequest)dispatch(RoutingActivationRequest, session_id)RoutingActivationHandler::handle(req, session_id)SessionManager::is_tester_registered(SA) → falseSessionManager::update_session_state(session_id, SA=0x0E00) → RoutingActiveencode(RoutingActivationResponse)bytes<<boundary>> returns to public interfacealt[success]RoutingActivationResponse [0x0006]code=SuccessfullyActivated(0x10)[max connections reached]RoutingActivationResponse [0x0006]code=AllSocketsRegistered(0x01)[address already active]RoutingActivationResponse [0x0006]code=SourceAddressAlreadyActive(0x03)5. Diagnostic Message (ISO §7.9)DiagnosticMessage [0x8001]SA=0x0E00 TA=0xE000 data=[0x10,0x03]<<boundary>> enters internal impldecode(bytes)Ok(DiagnosticMessage)dispatch(DiagnosticMessage, session_id)SessionManager::is_routing_active(session_id) → trueencode(DiagnosticMessagePositiveAck)bytes<<boundary>> returns to public interfaceDiagnosticMessagePositiveAck [0x8002]<<boundary>> enters UDS layerhandle(UdsRequestSA=0x0E00, TA=0xE000, data=[0x10,0x03])process_service()build_response(data=[0x50,0x03])Ok(UdsResponse { data=[0x50,0x03] })<<boundary>> returns from UDS layerOk(DiagnosticMessage response)encode(DiagnosticMessage response)bytesDiagnosticMessage [0x8001]data=[0x50,0x03] (positive response)Routing NOT ActiveDiagnosticMessage [0x8001]SA=0x0E99decode(bytes)Ok(DiagnosticMessage)dispatch(DiagnosticMessage, session_id)SessionManager::is_routing_active(session_id) → falseencode(DiagnosticMessageNegativeAck)bytesDiagnosticMessageNegativeAck [0x8003]code=InvalidSourceAddress(0x02)6. Generic NACK[unknown payload 0x00FF]decode(bytes)Ok(DoipMessage { type=0x00FF })dispatch(msg, session_id)DoipPayload::parse() → Err(UnknownPayloadType)Err(DoipError::UnknownPayloadType)encode(GenericNack)bytesGenericNack [0x0000]code=UnknownPayloadType(0x01)7. Disconnectionalt[Tester-initiated disconnect]TCP FIN / RST (tester closes connection)read() returns EOFSessionManager::remove_session(id)tcp_stream.shutdown()Server detects close via read() EOF.No explicit disconnect API — tester owns initiation.[Server-initiated disconnect (inactivity timeout)]check_timeouts(config) → session expiredSessionManager::remove_session(id)tcp_stream.shutdown()TCP closeServer closes after T_TCP_Initial or T_TCP_Generaltimer fires with no response from tester.8. Graceful ShutdownDoipServer::stop() called\n(e.g. SIGINT / OS signal)token.cancel()CancellationToken::cancelled() → triggeredCancellationToken::cancelled() → triggeredUdpSocket::close()TcpListener::close()token.cancelled() → exit run() loopSessionManager::remove_session(id)DoipServer::start() returns Ok(())9. Session TimeoutT_TCP_Initial (2 s) or T_TCP_General (5 min)\ninactivity timer fires inside TcpConnection::run()SessionManager::check_timeouts(config)→ Vec<expired_session_id>alt[initial inactivity — no routing activation before T_TCP_Initial]session.state == Connected &&elapsed > initial_inactivity_msSessionManager::remove_session(id)tcp_stream.shutdown()TCP close(routing not activated in time)[general inactivity — RoutingActive but no UDS traffic]session.state == RoutingActive &&elapsed > general_inactivity_msAliveCheckRequest [0x0007]AliveCheckResponse [0x0008]SessionManager::reset_inactivity_timer(id)[alive check timeout — no response received]Err(DoipError::AliveCheckTimeout)SessionManager::remove_session(id)tcp_stream.shutdown()TCP close(alive check timed out) \ No newline at end of file diff --git a/doip-server/src/doip/alive_check.rs b/doip-server/src/doip/alive_check.rs index fb22123..5047b85 100644 --- a/doip-server/src/doip/alive_check.rs +++ b/doip-server/src/doip/alive_check.rs @@ -13,7 +13,6 @@ //! Alive Check handlers (ISO 13400-2) use bytes::{BufMut, BytesMut}; -use tracing::warn; use super::{DoipParseable, DoipSerializable, parse_fixed_slice}; use crate::Result; @@ -22,8 +21,7 @@ use crate::Result; /// a tester is still connected. /// /// This message carries no payload (zero-length body per ISO 13400-2:2019 §7.6). -/// -/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. +/// It is only parsed inbound; the entity sends it as a bare `DoIP` header with zero payload length. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AliveCheckRequest; @@ -33,29 +31,12 @@ impl DoipParseable for AliveCheckRequest { } } -impl DoipSerializable for AliveCheckRequest { - 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. - warn!( - "AliveCheckRequest::write_to called — this message has no payload; nothing is written" - ); - } -} - /// 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) -/// -/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub struct AliveCheckResponse { source_address: u16, } @@ -87,12 +68,6 @@ impl AliveCheckResponse { pub fn new(source_address: u16) -> Self { Self { source_address } } - - /// The logical address of the tester that sent this response. - #[must_use] - pub fn tester_address(&self) -> u16 { - self.source_address - } } #[cfg(test)] @@ -100,24 +75,24 @@ mod tests { use super::*; use crate::doip::{DoipParseable, DoipSerializable}; + impl AliveCheckResponse { + /// Returns the tester's logical address. Test-only accessor. + pub(crate) fn get_address(&self) -> u16 { + self.source_address + } + } + #[test] fn parse_request() { let req = AliveCheckRequest::parse(&[]).unwrap(); assert_eq!(req, AliveCheckRequest); } - #[test] - fn request_empty_payload() { - let req = AliveCheckRequest; - let bytes = req.to_bytes(); - assert!(bytes.is_empty()); - } - #[test] fn parse_response() { let payload = [0x0E, 0x80]; let resp = AliveCheckResponse::parse(&payload).unwrap(); - assert_eq!(resp.tester_address(), 0x0E80); + assert_eq!(resp.get_address(), 0x0E80); } #[test] diff --git a/doip-server/src/doip/diagnostic_message.rs b/doip-server/src/doip/diagnostic_message.rs index 0906802..8ee8f8c 100644 --- a/doip-server/src/doip/diagnostic_message.rs +++ b/doip-server/src/doip/diagnostic_message.rs @@ -37,7 +37,7 @@ fn parse_address_pair(header: [u8; HEADER_BYTES]) -> (u16, u16) { /// /// Used by [`DiagnosticAck`] to represent either a positive or negative result. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiagnosticAckResult { +pub enum AckResult { /// Positive acknowledgment — message was accepted (wire code 0x00). Positive, /// Negative acknowledgment — message was rejected with the given code. @@ -148,17 +148,15 @@ impl DiagnosticMessage { /// Diagnostic Message Acknowledgment (payload types 0x8002 and 0x8003) /// /// Represents both positive and negative acknowledgments as defined in -/// ISO 13400-2:2019. Use [`DiagnosticAckResult`] to distinguish the outcome. +/// ISO 13400-2:2019. Use [`AckResult`] to distinguish the outcome. /// /// # Wire Format /// Payload: SA(2) + TA(2) + code(1) + optional `previous_diag_data` -/// -/// `Clone` is derived because [`DoipPayload`](super::payload::DoipPayload) wraps this type and itself derives `Clone`. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub struct DiagnosticAck { source_address: u16, target_address: u16, - result: DiagnosticAckResult, + result: AckResult, previous_data: Option, } @@ -172,7 +170,7 @@ impl DiagnosticAck { Self { source_address: source, target_address: target, - result: DiagnosticAckResult::Positive, + result: AckResult::Positive, previous_data: None, } } @@ -183,7 +181,7 @@ impl DiagnosticAck { Self { source_address: source, target_address: target, - result: DiagnosticAckResult::Negative(code), + result: AckResult::Negative(code), previous_data: None, } } @@ -202,7 +200,7 @@ impl DiagnosticAck { /// Returns the acknowledgment result. #[must_use] - pub fn result(&self) -> DiagnosticAckResult { + pub fn result(&self) -> AckResult { self.result } @@ -222,7 +220,7 @@ impl DiagnosticAck { Ok(Self { source_address, target_address, - result: DiagnosticAckResult::Positive, + result: AckResult::Positive, previous_data, }) } @@ -243,7 +241,7 @@ impl DiagnosticAck { Ok(Self { source_address, target_address, - result: DiagnosticAckResult::Negative(nack_code), + result: AckResult::Negative(nack_code), previous_data, }) } @@ -313,8 +311,8 @@ impl DoipSerializable for DiagnosticAck { buf.put_u16(self.source_address); buf.put_u16(self.target_address); buf.put_u8(match self.result { - DiagnosticAckResult::Positive => POSITIVE_ACK_CODE, - DiagnosticAckResult::Negative(code) => u8::from(code), + AckResult::Positive => POSITIVE_ACK_CODE, + AckResult::Negative(code) => u8::from(code), }); if let Some(ref data) = self.previous_data { buf.extend_from_slice(data); @@ -383,7 +381,7 @@ mod tests { 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(), DiagnosticAckResult::Positive); + assert_eq!(ack.result(), AckResult::Positive); } #[test] @@ -396,7 +394,7 @@ mod tests { assert_eq!(bytes[HEADER_BYTES], 0x03); assert_eq!( nack.result(), - DiagnosticAckResult::Negative(DiagnosticNackCode::UnknownTargetAddress) + AckResult::Negative(DiagnosticNackCode::UnknownTargetAddress) ); } @@ -414,7 +412,7 @@ mod tests { assert_eq!(ack.source_address(), 0x1000); assert_eq!(ack.target_address(), 0x0E80); - assert_eq!(ack.result(), DiagnosticAckResult::Positive); + assert_eq!(ack.result(), AckResult::Positive); assert!(ack.previous_data().is_none()); } @@ -425,7 +423,7 @@ mod tests { assert_eq!( nack.result(), - DiagnosticAckResult::Negative(DiagnosticNackCode::UnknownTargetAddress) + AckResult::Negative(DiagnosticNackCode::UnknownTargetAddress) ); } diff --git a/doip-server/src/doip/header.rs b/doip-server/src/doip/header.rs index fbb5e94..c6a10d1 100644 --- a/doip-server/src/doip/header.rs +++ b/doip-server/src/doip/header.rs @@ -275,13 +275,6 @@ impl DoipHeader { None } - /// Returns `true` if [`validate`](Self::validate) finds no errors in this header. - #[must_use] - #[cfg(test)] - pub(crate) 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). @@ -369,41 +362,6 @@ pub struct DoipMessage { } 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(crate) fn with_raw_payload_type(payload_type: u16, payload: Bytes) -> Self { - Self { - header: DoipHeader { - version: DEFAULT_PROTOCOL_VERSION, - inverse_version: DEFAULT_PROTOCOL_VERSION_INV, - payload_type, - payload_length: u32::try_from(payload.len()).expect("test payload fits in u32"), - }, - payload, - } - } - /// 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() @@ -439,6 +397,8 @@ impl DoipMessage { // ============================================================================ #[cfg(test)] +// Test helpers index into fixed-size byte slices whose lengths are asserted +// immediately before the index — safe, but requires the allow. #[allow(clippy::indexing_slicing)] mod tests { use tokio_util::codec::{Decoder, Encoder}; @@ -446,6 +406,41 @@ mod tests { use super::*; use crate::doip::codec::DoipCodec; + impl DoipHeader { + /// Returns `true` if [`validate`](Self::validate) finds no errors in this header. Test-only. + pub(crate) fn is_valid(self) -> bool { + self.validate().is_none() + } + } + + impl DoipMessage { + /// Build a typed `DoIP` message. Test-only. + pub(crate) fn new(payload_type: PayloadType, payload: Bytes) -> Self { + Self { + header: DoipHeader { + version: DEFAULT_PROTOCOL_VERSION, + inverse_version: DEFAULT_PROTOCOL_VERSION_INV, + payload_type: u16::from(payload_type), + payload_length: u32::try_from(payload.len()).expect("test payload fits in u32"), + }, + payload, + } + } + + /// Build a `DoIP` message with a raw (possibly unknown) payload type. Test-only. + pub(crate) fn with_raw_payload_type(payload_type: u16, payload: Bytes) -> Self { + Self { + header: DoipHeader { + version: DEFAULT_PROTOCOL_VERSION, + inverse_version: DEFAULT_PROTOCOL_VERSION_INV, + payload_type, + payload_length: u32::try_from(payload.len()).expect("test payload fits in u32"), + }, + payload, + } + } + } + // --- Helper to build a valid DoIP header quickly --- fn make_header(payload_type: u16, payload_len: u32) -> DoipHeader { DoipHeader { diff --git a/doip-server/src/doip/payload.rs b/doip-server/src/doip/payload.rs index 7e07886..4331cb2 100644 --- a/doip-server/src/doip/payload.rs +++ b/doip-server/src/doip/payload.rs @@ -42,7 +42,7 @@ use crate::Result; /// /// Each variant corresponds to one [`PayloadType`] and wraps the concrete /// struct returned by its [`DoipParseable`] impl. -#[derive(Debug, Clone)] +#[derive(Debug)] pub enum DoipPayload { /// `0x0007` – Alive Check Request (zero-length payload) AliveCheckRequest(alive_check::AliveCheckRequest), diff --git a/doip-server/src/error.rs b/doip-server/src/error.rs index 931a393..d90b6f7 100644 --- a/doip-server/src/error.rs +++ b/doip-server/src/error.rs @@ -20,6 +20,7 @@ use thiserror::Error; pub type Result = std::result::Result; /// Main `DoIP` Error type +#[non_exhaustive] #[derive(Error, Debug)] pub enum DoipError { #[error("I/O error: {0}")] diff --git a/doip-server/src/server/mod.rs b/doip-server/src/server/mod.rs index 97cd50e..59dbff7 100644 --- a/doip-server/src/server/mod.rs +++ b/doip-server/src/server/mod.rs @@ -20,7 +20,7 @@ pub mod config; pub mod session; pub use config::ServerConfig; -pub use session::{Session, SessionManager}; +pub use session::SessionManager; // No unit tests: this file contains only module declarations and re-exports. // config.rs and session.rs carry their own test suites. diff --git a/doip-server/src/server/session.rs b/doip-server/src/server/session.rs index 2687439..555166a 100644 --- a/doip-server/src/server/session.rs +++ b/doip-server/src/server/session.rs @@ -24,11 +24,20 @@ use std::{ use parking_lot::RwLock; use tracing::debug; +/// Strongly-typed session identifier — wraps a monotonic `u64` counter. +/// +/// Using a newtype prevents accidentally passing a raw integer where a session +/// ID is expected and makes call sites self-documenting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SessionId(u64); + /// Session states per ISO 13400-2:2019 connection lifecycle /// /// This is an internal type. External callers should use [`Session::is_routing_active`] instead. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SessionState { + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] Connected, RoutingActive, /// Reserved per ISO 13400-2:2019 §7.4 lifecycle — not yet transitioned to in-process. @@ -41,9 +50,9 @@ pub(crate) enum SessionState { /// Tracks the connection lifecycle from initial TCP connect through routing /// activation to eventual disconnect per ISO 13400-2:2019 §7.4. #[derive(Debug, Clone)] -pub struct Session { +pub(crate) struct Session { /// Unique monotonic session identifier assigned at connection time - id: u64, + id: SessionId, /// Remote socket address of the connected tester peer_addr: SocketAddr, /// Tester logical address registered during routing activation (`0` until activated) @@ -56,8 +65,10 @@ impl Session { /// Create a new session in the [`SessionState::Connected`] state. /// /// This is `pub(crate)` — sessions are only ever constructed by [`SessionManager`]. + // Used by `SessionManager::create_session` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] #[must_use] - pub(crate) fn new(id: u64, peer_addr: SocketAddr) -> Self { + pub(crate) fn new(id: SessionId, peer_addr: SocketAddr) -> Self { Self { id, peer_addr, @@ -68,35 +79,43 @@ impl Session { /// Transition this session to the `RoutingActive` state and record /// the tester's logical address. - // Used by feat/tcp-handler (TCP connection handler) — not dead. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. #[allow(dead_code)] pub(crate) fn activate_routing(&mut self, tester_address: u16) { - debug!(session_id = self.id, tester_address, "routing activated"); + debug!(session_id = self.id.0, tester_address, "routing activated"); self.tester_address = tester_address; self.state = SessionState::RoutingActive; } /// Returns `true` if routing has been activated for this session. + // Used by `RoutingActivationHandler` in `feat/doip-handler-deps` — not yet wired up. + #[allow(dead_code)] #[must_use] - pub fn is_routing_active(&self) -> bool { + pub(crate) fn is_routing_active(&self) -> bool { self.state == SessionState::RoutingActive } /// Returns the unique session ID. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] #[must_use] - pub fn id(&self) -> u64 { + pub(crate) fn id(&self) -> SessionId { self.id } /// Returns the remote socket address of the connected tester. + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] #[must_use] - pub fn peer_addr(&self) -> SocketAddr { + pub(crate) fn peer_addr(&self) -> SocketAddr { self.peer_addr } /// Returns the tester logical address (`0` until routing is activated). + // Used by `AliveCheckHandler` in `feat/doip-handler-deps` — not yet wired up. + #[allow(dead_code)] #[must_use] - pub fn tester_address(&self) -> u16 { + pub(crate) fn tester_address(&self) -> u16 { self.tester_address } @@ -119,8 +138,11 @@ impl Session { /// Access this via the [`Arc`] returned by [`SessionManager::new`]. #[derive(Debug, Default)] struct SessionManagerInner { - sessions: HashMap, - addr_to_session: HashMap, + // Used by `SessionManager` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + sessions: HashMap, + #[allow(dead_code)] + addr_to_session: HashMap, } /// Thread-safe registry of active `DoIP` tester sessions. @@ -130,7 +152,10 @@ struct SessionManagerInner { /// [`AtomicU64`] counter for session ID allocation. #[derive(Debug, Default)] pub struct SessionManager { + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] inner: RwLock, + #[allow(dead_code)] next_id: AtomicU64, } @@ -142,32 +167,40 @@ impl SessionManager { } /// 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); + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn create_session(&self, peer_addr: SocketAddr) -> Session { + let id = SessionId(self.next_id.fetch_add(1, Ordering::Relaxed)); let session = Session::new(id, peer_addr); { let mut inner = self.inner.write(); inner.sessions.insert(id, session.clone()); inner.addr_to_session.insert(peer_addr, id); } - debug!(session_id = id, peer = %peer_addr, "Session created"); + debug!(session_id = id.0, 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 { + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn get_session(&self, id: SessionId) -> Option { self.inner.read().sessions.get(&id).cloned() } /// Look up a session by the tester's remote address. Returns `None` if not found. - pub fn get_session_by_addr(&self, addr: &SocketAddr) -> Option { + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn get_session_by_addr(&self, addr: &SocketAddr) -> Option { let inner = self.inner.read(); let id = inner.addr_to_session.get(addr).copied()?; inner.sessions.get(&id).cloned() } /// Apply a mutation `f` to the session with the given `id`. Returns `true` if found. - pub fn update_session(&self, id: u64, f: F) -> bool + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn update_session(&self, id: SessionId, f: F) -> bool where F: FnOnce(&mut Session), { @@ -182,25 +215,29 @@ impl SessionManager { } /// Remove and return the session with the given `id`, or `None` if not found. - pub fn remove_session(&self, id: u64) -> Option { + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn remove_session(&self, id: SessionId) -> Option { let session = { let mut inner = self.inner.write(); let session = inner.sessions.remove(&id)?; inner.addr_to_session.remove(&session.peer_addr); session }; - debug!(session_id = id, peer = %session.peer_addr, "Session removed"); + debug!(session_id = id.0, peer = %session.peer_addr, "Session removed"); Some(session) } /// Remove and return the session associated with `addr`, or `None` if not found. - pub fn remove_session_by_addr(&self, addr: &SocketAddr) -> Option { + // Used by `TcpConnection` in `feat/tcp-handler` — not yet wired up. + #[allow(dead_code)] + pub(crate) fn remove_session_by_addr(&self, addr: &SocketAddr) -> Option { let session = { let mut inner = self.inner.write(); let id = inner.addr_to_session.remove(addr)?; inner.sessions.remove(&id)? }; - debug!(session_id = session.id, peer = %addr, "Session removed by addr"); + debug!(session_id = session.id.0, peer = %addr, "Session removed by addr"); Some(session) } diff --git a/doip-server/src/uds/handler.rs b/doip-server/src/uds/handler.rs index a42af9d..6923e43 100644 --- a/doip-server/src/uds/handler.rs +++ b/doip-server/src/uds/handler.rs @@ -179,28 +179,4 @@ mod tests { assert_eq!(response.target_address(), 0x0E00); assert_eq!(response.payload(), &payload); } - - #[test] - fn uds_nrc_wire_values() { - // Verify UDS NRC codes (ISO 14229-1:2020 Table A.1) match their wire values. - let cases: &[(u8, u8)] = &[ - (0x10, 0x10), // GeneralReject - (0x11, 0x11), // ServiceNotSupported - (0x12, 0x12), // SubFunctionNotSupported - (0x13, 0x13), // IncorrectMessageLength - (0x21, 0x21), // BusyRepeatRequest - (0x22, 0x22), // ConditionsNotCorrect - (0x24, 0x24), // RequestSequenceError - (0x31, 0x31), // RequestOutOfRange - (0x33, 0x33), // SecurityAccessDenied - (0x35, 0x35), // InvalidKey - (0x36, 0x36), // ExceededNumberOfAttempts - (0x37, 0x37), // RequiredTimeDelayNotExpired - (0x78, 0x78), // ResponsePending - (0x7F, 0x7F), // ServiceNotSupportedInActiveSession - ]; - for &(nrc, expected) in cases { - assert_eq!(nrc, expected); - } - } }