diff --git a/.gitignore b/.gitignore index 99e8ae6..4464952 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) +# 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. @@ -7,6 +6,8 @@ # 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 .env .cache diff --git a/CODESTYLE.md b/CODESTYLE.md index f8c83e6..5fb9599 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -12,12 +12,22 @@ https://www.apache.org/licenses/LICENSE-2.0 # Code Style Guide +## Quick Links + +- **View API Documentation** (recommended): + ```sh + cargo doc-lib + ``` + Opens the main library documentation with architecture overview, diagrams, and all modules + +Available cargo aliases are defined in `.cargo/config.toml`; use the README for build and documentation entry points. + ## Linting & Clippy - **Clippy**: Always run with `clippy::pedantic` enabled for stricter linting. - - Example: `cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic` + - example: `cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic` - **Allow/Forbid**: Use `#[allow(...)]` only when necessary, and always document the reason. - - Example: `#[allow(clippy::ref_option)] // Not compatible with serde derive` + - example: `#[allow(clippy::ref_option)] // Not compatible with serde derive` - **Warnings**: Treat all warnings as errors. ## Formatting @@ -39,7 +49,7 @@ cargo +nightly fmt -- --check --config error_on_unformatted=true,error_on_line_o ``` It is recommended to configure your IDE to use nightly rustfmt with these settings as well. -Example for VS Code: +example for VS Code: ```json "rust-analyzer.rustfmt.overrideCommand": [ "rustfmt", @@ -79,7 +89,24 @@ Additionally the import granularity is set to `crate` to group all imports from ## Documentation -- Document all public items with `///` doc comments. -- Use clear, concise language and provide context for complex logic. +### Rustdoc Standards + +- **Module-level documentation**: All public modules must have `//!` comments explaining: + - Purpose and role of the module + - Key types and functions + - Relationships to other modules (if helpful) + +- **Public items**: All public structs, enums, traits, and functions must have `///` documentation: + - Explain *what* the item does and *when* it should be used + - Include an "# Errors" section for fallible operations + - Add "# example" sections only where usage patterns aren't obvious + +- **Language and clarity**: + - Use clear, concise language without unnecessary verbosity + - Avoid long paragraphs; prefer short, focused explanations + - Link to existing architecture documents instead of duplicating large explanations ---- +- **Build documentation locally**: + ```sh + cargo doc --no-deps --open + ``` diff --git a/Cargo.lock b/Cargo.lock index 55e3925..c10e2c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,797 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "doip-tester" +version = "0.1.0" + +[[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", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "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 = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "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.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + [[package]] name = "uds2sovd-proxy" version = "0.1.0" +dependencies = [ + "tokio", + "tracing", + "tracing-subscriber", + "uds2sovd-proxy-lib", +] + +[[package]] +name = "uds2sovd-proxy-lib" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +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.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 529569d..eb4ad8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,4 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) +# 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. @@ -7,40 +6,14 @@ # 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 -[package] -name = "uds2sovd-proxy" -version = "0.1.0" -edition = "2024" -license = "Apache-2.0" -homepage = "https://github.com/eclipse-opensovd/uds2sovd-proxy" - -[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" - -## 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. -arithmetic_side_effects = "deny" +[workspace] +members = ["src", "app", "tools/doip-tester"] +resolver = "3" -## 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" +# Metadata for documentation generation and tooling +[workspace.package] +authors = ["The Contributors to Eclipse OpenSOVD"] +license = "Apache-2.0" diff --git a/README.md b/README.md index 0cb94f5..abc92f1 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,74 @@ SOVD-enabled vehicle architectures without modification. - 🤏 low memory and disk-space consumption - 🛡️ safe & secure - ⚡ fast startup + +## Conceptual Architecture -## introduction +The UDS-to-SOVD Proxy consists of three components: +1. **DoIP Server** - frontend interface for the UDS tester, handles DoIP discovery and diagnostic sessions, parses incoming DoIP messages, and dispatches them to the appropriate protocol handlers. +2. **UDS-to-SOVD translation** - translates UDS requests into SOVD REST API calls and vice-versa. +3. **SOVD Proxy** - backend to send HTTP requests to SOVD server & handles responses. -### usage +![UDS-to-SOVD Proxy Components](docs/components.svg) -### prerequisites +At a high level, testers use UDP for discovery and TCP for diagnostic sessions. Incoming DoIP messages are parsed and dispatched to protocol handlers. Diagnostic payloads are then transformed into SOVD REST API calls and sent to the SOVD server. The responses are then translated back into UDS format and returned to the tester. +The **DoIP Server** consists of below modules: +1. **Transport handling (server)** - UDP for discovery & TCP for diagnostic sessions. +2. **Protocol processing (doip)** - DoIP protocol specific processing by dispatching requests to the handlers. -### build the executable +![DoIP Server](docs/doip_server.svg) +Refer to [detailed design](docs/detailed_design.md) for detailed design and runtime flows. + +## Getting Started + +```sh +cargo build + +cargo run -p uds2sovd-proxy +``` + +To run uds2sovd-proxy with custom configuration refer to [Usage](docs/usage.md). + +## Documentation + +### Code Documentation (Rustdoc) + +The core library documentation is the primary API reference. + +```sh +# View the library documentation (main entry point) +cargo doc --lib + +# Or without dependencies documentation: +cargo doc --package uds2sovd-proxy-lib --no-deps --open +``` + +This includes: +- API reference for all core modules +- Quick start examples +- Backend implementation guide + +```sh +# View the server binary documentation +cargo doc --package uds2sovd-proxy --no-deps --open + +# View the testing client +cargo doc --package doip-tester --no-deps --open + +# View all workspace crates at once +cargo doc-all +``` + +### Further Reading + +| Document | Description | +| --- | --- | +| [Detailed design](docs/detailed_design.md) | System architecture, design rationale and runtime behaviour | +| [Usage](docs/usage.md) | Build, configuration, and run instructions | +| [Limitations](docs/limitation.md) | Current functional and operational constraints and assumptions | +| [Future work](docs/todo.md) | Planned enhancements and roadmap | ## developing @@ -72,3 +130,5 @@ cargo test --locked --lib ``` #### integration tests + +No standalone integration-test harness is published yet for this workspace. diff --git a/app/Cargo.toml b/app/Cargo.toml new file mode 100644 index 0000000..8c26c97 --- /dev/null +++ b/app/Cargo.toml @@ -0,0 +1,27 @@ +# 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 + +[package] +name = "uds2sovd-proxy" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "UDS-to-SOVD Proxy - composition of DoIP Server, UDS-to-SOVD Translation and SOVD proxy" + +[[bin]] +name = "uds2sovd-proxy" +path = "main.rs" + +[dependencies] +uds2sovd_proxy_lib = { path = "../src", package = "uds2sovd-proxy-lib" } +tokio = { version = "1", features = ["net", "io-util", "macros", "rt-multi-thread", "signal"] } +tracing = "0.1" +tracing-subscriber = "0.3" diff --git a/app/config.toml b/app/config.toml new file mode 100644 index 0000000..641f4dc --- /dev/null +++ b/app/config.toml @@ -0,0 +1,41 @@ +# 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 + +# Sample Configuration +# Usage: cargo run -p uds2sovd-proxy -- config.toml + +[tcp] +# TCP listener bind address and port (ISO 13400-2 default: 13400) +address = "127.0.0.1:13400" +# Maximum concurrent TCP connections +max_connections = 10 +# DoIP logical address of this entity +logical_address = 1 +# Read buffer size in bytes per connection +read_buffer_size = 4096 + +[udp] +# UDP socket bind address and port (ISO 13400-2 default: 13400) +address = "0.0.0.0:13400" +# DoIP logical address of this entity +logical_address = 1 + +[ecu] +# Vehicle Identification Number - 17 ASCII bytes (ISO 3779) +# Each value is the ASCII code of the character: '0' = 48, 'A' = 65, etc. +# Below represents VIN "00000000000000000" +vin = [48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48] +# Entity Identifier - 6 bytes, typically the MAC address of the DoIP interface +# example: MAC 00:00:00:00:00:00 +eid = [0, 0, 0, 0, 0, 0] +# Group Identifier - 6 bytes, groups DoIP entities on the same subnet +# example: GID 00:00:00:00:00:00 +gid = [0, 0, 0, 0, 0, 0] diff --git a/app/main.rs b/app/main.rs new file mode 100644 index 0000000..ecdc461 --- /dev/null +++ b/app/main.rs @@ -0,0 +1,86 @@ +// 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 + +//! UDS-to-SOVD Proxy application. +//! +//! Application entry point for the UDS-to-SOVD Proxy. +//! +//! This crate is the operational starting point of the system and defines +//! the full runtime lifecycle: +//! 1. initialize logging +//! 2. load runtime configuration +//! 3. construct transport and protocol handlers +//! 4. start serving traffic and react to shutdown signals +//! +//! It uses the `uds2sovd-proxy-lib` crate for the core protocol and backend logic, +//! and provides a simple command-line interface for running the server. +//! +//! # Running +//! +//! ```sh +//! # With default configuration +//! cargo run --bin uds2sovd-proxy +//! +//! # With custom TOML configuration +//! cargo run --bin uds2sovd-proxy -- /path/to/config.toml +//! ``` +//! +//! # Configuration +//! +//! Server loads configuration in this order (first match wins): +//! 1. CLI argument: `uds2sovd-proxy ` +//! 2. Default built-in configuration from [`config::DefaultConfigProvider`] +//! +//! # See Also +//! +//! - Main library: the `uds2sovd-proxy-lib` crate documentation for API details +//! - Tester tool: `cargo run --bin doip-tester` for end-to-end checks +use std::sync::Arc; +use uds2sovd_proxy_lib::{config, doip, error, proxy, server}; + +use config::{ConfigProvider, DefaultConfigProvider, ServerConfig, TomlConfigProvider}; +use error::AppError; +use proxy::stub::StubProxy; +use server::Server; +use server::tcp::Tcp; +use server::udp::Udp; + +#[tokio::main] +async fn main() -> Result<(), AppError> { + tracing_subscriber::fmt::init(); + + let config = match std::env::args().nth(1) { + //TODO : Add CLI arg parsing with clap or similar for better UX and error handling. + Some(path) => TomlConfigProvider::new(path.into()).load()?, + None => DefaultConfigProvider::new(ServerConfig::default()).load()?, + }; + + tracing::info!("Starting DoIP server"); + + // TODO: Replace StubProxy with real UDS-to-SOVD Proxy implementation. + let (tcp_config, udp_config, ecu_config) = config.into_parts(); + let tcp_dispatcher = doip::tcp_dispatcher(tcp_config.logical_address(), Arc::new(StubProxy)); + let udp_dispatcher = doip::udp_dispatcher(udp_config.logical_address(), &ecu_config); + + let tcp = Tcp::new(tcp_config, tcp_dispatcher); + let udp = Udp::new(udp_config, udp_dispatcher); + + let server = Server::new(tcp, udp); + + // As of now shutdown is triggered by Ctrl+C, but this can be extended to support other signals or programmatic shutdown in the future. + tokio::select! { + result = server.start() => { result?; } + _ = tokio::signal::ctrl_c() => { + tracing::info!("Received shutdown signal, stopping server"); + } + } + + Ok(()) +} diff --git a/docs/architecture.puml b/docs/architecture.puml new file mode 100644 index 0000000..547db72 --- /dev/null +++ b/docs/architecture.puml @@ -0,0 +1,176 @@ +@startuml architecture + +/' Copyright (c) 2026 Contributors to the Eclipse Foundation + + 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 + + + SPDX-License-Identifier: Apache-2.0 '/ + +top to bottom direction +skinparam linetype ortho +skinparam shadowing false +skinparam DefaultFontSize 30 +skinparam packageStyle rectangle + +title DoIP Server - Architecture + +package "Application" { + class "app/main.rs" as Main { + +main() -> Result<(), AppError> + } + class AppError +} + +package "Configuration" { + interface ConfigProvider { + +load() -> Result + } + + class DefaultConfigProvider { + +new(config: ServerConfig) + +load() + } + + class TomlConfigProvider { + +new(path: PathBuf) + +load() + } + + class ServerConfig { + +into_parts() -> (TcpConfig, UdpConfig, EcuConfig) + } + + class TcpConfig + class UdpConfig + class EcuConfig + + ConfigProvider <|.. DefaultConfigProvider + ConfigProvider <|.. TomlConfigProvider + ConfigProvider --> ServerConfig : produces + ServerConfig --> TcpConfig + ServerConfig --> UdpConfig + ServerConfig --> EcuConfig +} + +package "Server" { + interface Transport { + +start() async + } + + class Server { + +new(tcp: Tcp, udp: Udp) + +start() async + } + + class Tcp { + +new(config: TcpConfig, dispatcher: TcpDispatcher) + +start() async + } + + class Udp { + +new(config: UdpConfig, dispatcher: UdpDispatcher) + +start() async + } + + class SessionManager { + +new(max_sessions) + +try_acquire() + } + + class Session { + +new(slot) + +run(stream, dispatcher, buf_size) async + } + + class UdpHandler { + +new(dispatcher) + +handle(data) -> Result + } + + Server --> Tcp + Server --> Udp + Tcp ..|> Transport + Udp ..|> Transport + + Tcp --> SessionManager + Tcp --> Session : spawns per connection + Udp --> UdpHandler +} + +package "Message Model" { + enum TcpPayloadType + enum UdpPayloadType + + class TcpRequest + class UdpRequest + + class Response { + +new(payload_type, payload) + +doip_header_nack(code) + +to_bytes() + } + + TcpRequest --> TcpPayloadType + UdpRequest --> UdpPayloadType +} + +package "Protocol Processing" { + class "Dispatcher" as Dispatcher { + +new() + +register(handler) + +dispatch(req) + } + + class TcpDispatcher <> + class UdpDispatcher <> + + interface PayloadHandler { + +handle(req) + } + + class AliveCheckHandler + class RoutingActivationHandler + class DiagnosticsHandler + class EntityStatusHandler + class VehicleIdentificationHandler + + TcpDispatcher ..> Dispatcher + UdpDispatcher ..> Dispatcher + Dispatcher --> PayloadHandler : routes + + AliveCheckHandler ..|> PayloadHandler + RoutingActivationHandler ..|> PayloadHandler + DiagnosticsHandler ..|> PayloadHandler + EntityStatusHandler ..|> PayloadHandler + VehicleIdentificationHandler ..|> PayloadHandler + + Dispatcher ..> TcpRequest + Dispatcher ..> UdpRequest + Dispatcher ..> Response +} + +package "Proxy" { + interface SovdProxy { + +process(uds_request: &[u8]) -> Result, SovdProxyError> + } + + class StubProxy + SovdProxy <|.. StubProxy +} + +Main --> ConfigProvider : load config +Main --> Server : construct + start +Main --> AppError : returns + +Session --> TcpDispatcher +UdpHandler --> UdpDispatcher + +DiagnosticsHandler --> SovdProxy : forwards UDS payload + + +@enduml diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..07c3803 --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1 @@ +DoIP Server - ArchitectureApplicationConfigurationServerMessage ModelProtocol ProcessingProxyapp/main.rsmain() -> Result<(), AppError>AppErrorConfigProviderload() -> Result<ServerConfig, ConfigError>DefaultConfigProvidernew(config: ServerConfig)load()TomlConfigProvidernew(path: PathBuf)load()ServerConfiginto_parts() -> (TcpConfig, UdpConfig, EcuConfig)TcpConfigUdpConfigEcuConfigTransportstart() asyncServernew(tcp: Tcp, udp: Udp)start() asyncTcpnew(config: TcpConfig, dispatcher: TcpDispatcher)start() asyncUdpnew(config: UdpConfig, dispatcher: UdpDispatcher)start() asyncSessionManagernew(max_sessions)try_acquire()Sessionnew(slot)run(stream, dispatcher, buf_size) asyncUdpHandlernew(dispatcher)handle(data) -> Result<Response, Error>TcpPayloadTypeUdpPayloadTypeTcpRequestUdpRequestResponsenew(payload_type, payload)doip_header_nack(code)to_bytes()DispatcherPayloadType, Requestnew()register(handler)dispatch(req)«type alias»TcpDispatcher«type alias»UdpDispatcherPayloadHandlerhandle(req)AliveCheckHandlerRoutingActivationHandlerDiagnosticsHandlerEntityStatusHandlerVehicleIdentificationHandlerSovdProxyprocess(uds_request: &[u8]) -> Result<Vec<u8>, SovdProxyError>StubProxyproducesspawns per connectionroutesload configconstruct + startreturnsforwards UDS payload \ No newline at end of file diff --git a/docs/components.puml b/docs/components.puml new file mode 100644 index 0000000..cac4b35 --- /dev/null +++ b/docs/components.puml @@ -0,0 +1,41 @@ +@startuml components +/' Copyright (c) 2026 Contributors to the Eclipse Foundation + +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 + + +SPDX-License-Identifier: Apache-2.0 '/ + +left to right direction +skinparam backgroundColor #White +skinparam defaultFontSize 12 +skinparam componentStyle rectangle +skinparam packageStyle rectangle + +component "UDS Tester" as uds_tester + +package "UDS-to-SOVD Proxy" as uds2sovd_proxy #Azure { + component "DoIP Server" as doip_server + component "UDS-to-SOVD Translator" as uds2sovd_translator + component "SOVD Proxy" as sovd_proxy +} + +component "SOVD Gateway/Server" as sovd_server + +sovd_proxy --> sovd_server: SOVD Request +sovd_server --> sovd_proxy: SOVD Response + +uds2sovd_translator --> sovd_proxy: SOVD Request +sovd_proxy --> uds2sovd_translator: SOVD Response + +doip_server --> uds2sovd_translator: Valid UDS Request +uds2sovd_translator --> doip_server: UDS Response + +uds_tester --> doip_server: UDS Request +doip_server -right-> uds_tester: UDS Response + +@enduml diff --git a/docs/components.svg b/docs/components.svg new file mode 100644 index 0000000..01612f4 --- /dev/null +++ b/docs/components.svg @@ -0,0 +1 @@ +UDS-to-SOVD ProxyDoIP ServerUDS-to-SOVD TranslatorSOVD ProxyUDS TesterSOVD Gateway/ServerSOVD RequestSOVD ResponseSOVD RequestSOVD ResponseValid UDS RequestUDS ResponseUDS RequestUDS Response \ No newline at end of file diff --git a/docs/detailed_design.md b/docs/detailed_design.md new file mode 100644 index 0000000..00479f1 --- /dev/null +++ b/docs/detailed_design.md @@ -0,0 +1,282 @@ + +# High-Level Design + +This document describes the system architecture, component responsibilities, runtime behaviour, and design rationale of the UDS-to-SOVD Proxy. + +## Contents + +- [System Context](#system-context) +- [Scope](#scope) +- [Architectural Goals](#architectural-goals) +- [Architecture Overview](#architecture-overview) +- [System Components](#system-components) +- [Component Responsibilities](#component-responsibilities) +- [Module Responsibilities](#module-responsibilities) +- [Runtime Flow](#runtime-flow) +- [Protocol Flow](#protocol-flow) +- [Design Decisions](#design-decisions) +- [Extension Points](#extension-points) +- [Related Documentation](#related-documentation) + +## System Context + +Modern vehicle diagnostics are transitioning from ECU-centric UDS communication toward service-oriented architectures. Diagnostic testers continue to use UDS over DoIP, while backends increasingly expose capabilities through SOVD interfaces. + +The UDS-to-SOVD Proxy bridges these environments. It accepts DoIP communication from diagnostic tools, handles the transport and protocol concerns, and forwards diagnostic requests to a backend integration component (SOVD). + +```text +Diagnostic Tester (UDS over DoIP) + | + v + UDS-to-SOVD Proxy + (this project) + | + v + SOVD Backend +``` + +The proxy is not responsible for executing UDS services, managing diagnostic sessions at the application level, or implementing security access algorithms. Those responsibilities belong to the backend. + +## Scope + +**Supported:** + +- Vehicle discovery +- Entity status requests +- Routing activation +- Alive checks +- Diagnostic message forwarding + +Known functional, protocol, and operational limitations are documented in [Limitation](limitation.md). + +## Architectural Goals + +| Goal | What it means | +| --- | --- | +| **Modularity** | Components are organized by responsibility and communicate through explicit interfaces. | +| **Extensibility** | New handlers, configuration providers, and backend integrations can be added without modifying existing components. | +| **Protocol independence** | Transport, protocol, and backend modules are decoupled from each other. | +| **Testability** | All major components can be tested independently using stubs and mock implementations. | +| **Standards compliance** | Transport and protocol behaviour follow ISO 13400-2 (DoIP). | + +## Architecture Overview + +![UDS-to-SOVD Proxy Component Architecture](architecture.svg) + +The application entry point wires all components together at startup. Configuration flows downward through the system, while diagnostic requests flow upward from the transport runtime through protocol processing and into backend integration components. + +## System Components + +| Component | Responsibility | +| --- | --- | +| Application | Startup and runtime wiring | +| Configuration | Configuration loading | +| Transport Runtime | TCP and UDP communication | +| Protocol Processing | Message dispatching and handler execution | +| Backend Integration | Diagnostic forwarding and backend abstraction | + +## Component Responsibilities + +### Application + +The application entry point selects a configuration source, builds the transport services and their associated dispatchers, and starts the server runtime. It is the only place in the system where all components are wired together. + +### Configuration + +Responsible for loading and providing runtime configuration to the server. + +**Key interfaces:** + +- `ConfigProvider` - abstracts where configuration is loaded from +- `ServerConfig` - the complete runtime configuration model, split into TCP, UDP, and ECU sections + +**Implementations:** + +- `DefaultConfigProvider` - returns programmatic defaults; used for development and testing without a config file +- `TomlConfigProvider` - deserializes a TOML file; primary provider for production deployments + +The server consumes a fully-constructed `ServerConfig`. It is unaware of how the configuration was produced or where it came from. + +### Transport Runtime + +Responsible for all network-level communication. + +**TCP Runtime:** + +- Accepts incoming connections and enforces the configured session limit +- Manages the lifecycle of each active session independently +- Frames the DoIP byte stream into individual messages and dispatches them +- Sends NACK responses when session capacity is exceeded or message parsing fails + +**UDP Runtime:** + +- Receives and parses individual DoIP datagrams +- Dispatches each datagram independently - there is no persistent session state on UDP + +Both TCP and UDP implement the same `Transport` interface so they can be started concurrently without the server needing to know about their internal differences. + +### Protocol Processing + +Responsible for routing DoIP messages to the correct handler and generating responses. + +**Dispatcher:** + +The dispatcher routes protocol messages to handlers based on payload type. Separate dispatch paths are maintained for TCP and UDP traffic to preserve protocol correctness and reduce coupling between transports. + +**Payload Handlers:** + +Each handler is responsible for exactly one DoIP message type. + +| Handler | Transport | Responsibility | +| --- | --- | --- | +| `RoutingActivationHandler` | TCP | Processes routing activation requests | +| `AliveCheckHandler` | TCP | Responds to keep-alive | +| `DiagnosticsHandler` | TCP | Forwards UDS payloads to the backend proxy | +| `IdentifyVehicleHandler` | UDP | Responds to general vehicle identification requests | +| `IdentifyVehicleByEidHandler` | UDP | Responds to vehicle identification by EID | +| `IdentifyVehicleByVinHandler` | UDP | Responds to vehicle identification by VIN | +| `EntityStatusHandler` | UDP | Reports DoIP entity status | + +Handlers are registered with the dispatcher at startup. Adding support for a new DoIP message type requires only implementing a new handler and registering it. + +### Backend Integration + +Defines the stable boundary between protocol processing and backend implementation. + +**Key interface:** + +- `SovdProxy` - receives raw UDS request bytes and returns raw UDS response bytes + +**Current implementation:** + +- `StubProxy` - returns a UDS negative response (NRC serviceNotSupported) for every request. This allows the server to run end-to-end without a real backend. + +The `DiagnosticsHandler` calls the proxy without knowing which implementation is active. Replacing the stub with a real SOVD backend requires only providing a new `SovdProxy` implementation - no handler or transport code changes. + +## Module Responsibilities + +The following Rust modules implement the System Components defined above. + +| Module | Responsibility | +| --- | --- | +| `config` | Implements the Configuration component: configuration loading traits, provider implementations, and runtime configuration model | +| `server` | Implements the Transport Runtime component: TCP and UDP runtime, session management, and connection lifecycle | +| `doip` | Implements the Protocol Processing component: dispatching, handler execution, and protocol error handling | +| `proxy` | Implements the Backend Integration component: backend abstraction trait and stub implementation | +| `error` | Supports cross-component error aggregation at the application boundary | + +## Runtime Flow + +### Startup + +![Startup sequence](sequence/startup.svg) + +Intent: initialize configuration and start TCP/UDP runtimes together. + +Primary flow: +1. Select configuration source from CLI path or built-in defaults. +2. Load and validate `ServerConfig`. +3. Build TCP and UDP dispatchers with registered handlers. +4. Construct TCP and UDP transport services. +5. Start server runtime and run both transports concurrently. +6. Keep running until shutdown is triggered. + +Important branches: +- If TCP listener bind fails, startup fails and the application exits with an error. +- If UDP socket bind fails, startup fails and the application exits with an error. + +Guarantees: +- Startup either reaches a running state with both transports active or fails fast. +- Configuration is resolved before any network service starts. + +### TCP Request Processing + +![TCP connection sequence](sequence/tcp_connection.svg) + +Intent: process DoIP requests per session while enforcing session limits. + +Primary flow: +1. Accept incoming TCP connection. +2. Check session capacity. +3. Create a session task for accepted connections. +4. Read stream bytes and frame complete DoIP messages. +5. Dispatch each frame to the registered TCP handler. +6. Write handler response back to the client. + +Important branches: +- If maximum sessions are reached, reject connection and close it. +- If a request is invalid, return a negative acknowledgment (NACK). +- If request is diagnostic, forward payload to backend proxy and return proxy response. +- Communication failure terminates the session loop. + +Guarantees: +- Session slot is released automatically when the session ends. +- Capacity limits are enforced before request processing continues. + +### UDP Request Processing + +![UDP request sequence](sequence/udp_request.svg) + +Intent: process each UDP datagram independently without session state. + +Primary flow: +1. Receive UDP datagram. +2. Parse it as a complete DoIP message. +3. Dispatch to the matching UDP handler. +4. Send response to the originating address when applicable. + +Important branches: +- For EID/VIN mismatch in discovery, silently drop (no response) per ISO 13400-2. +- For invalid requests, return a negative acknowledgment (NACK). + +Guarantees: +- No persistent session state is created for UDP processing. +- Datagrams are handled independently. + +## Design Decisions + +### Configuration Provider Abstraction + +Configuration loading is separated from the server runtime through a provider interface. The server receives a fully-constructed configuration object and remains unaware of how it was produced. This allows configuration sources (TOML file, programmatic defaults, future remote sources) to be swapped without touching server or transport code. + +### Transport Segregation + +Separate dispatch paths for TCP and UDP traffic ensure that protocol-specific logic remains isolated. This prevents the two transport paths from developing divergent behaviour over time and clarifies which handlers are appropriate for each transport. + +### Automatic Session Lifecycle Management + +Session resources are automatically released when connections terminate, reducing the risk of resource leaks. This approach removes the need for explicit cleanup calls regardless of how a session ends (clean close, network error, or internal failure). + +### Backend Abstraction via `SovdProxy` + +The `SovdProxy` interface isolates the diagnostic handler from any specific backend. The stub implementation allows the server to run fully without a real backend, which is useful for protocol-level testing and early development. Replacing the backend requires only a new implementation of the interface. + +### Error-to-NACK Mapping + +Each protocol error maps explicitly to the correct DoIP Generic Header NACK code. The mapping is centralized so that transport code does not need to make decisions about which NACK code applies to which error condition. + +## Extension Points + +| Extension Point | How to extend | +| --- | --- | +| `ConfigProvider` | Implement the trait to add new configuration sources (environment variables, remote config, etc.) | +| `PayloadHandler` | Implement the trait and register with the dispatcher to handle new DoIP message types | +| `SovdProxy` | Implement the trait to connect a real SOVD backend, simulation, or alternative diagnostic system | + +## Related Documentation + +| Document | Purpose | +| --- | --- | +| [Usage](usage.md) | Build, configuration, and operation | +| [Limitations](limitation.md) | Current functional, protocol, and operational constraints | +| [TODO](todo.md) | Planned enhancements and roadmap | diff --git a/docs/doip_server.puml b/docs/doip_server.puml new file mode 100644 index 0000000..c201d8e --- /dev/null +++ b/docs/doip_server.puml @@ -0,0 +1,47 @@ +@startuml doip_server +/' Copyright (c) 2026 Contributors to the Eclipse Foundation + +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 + + +SPDX-License-Identifier: Apache-2.0 '/ + +skinparam componentStyle rectangle +skinparam backgroundColor #White +skinparam defaultFontSize 12 +skinparam packageStyle rectangle + +component "UDS Tester" as uds_tester + +package "UDS-to-SOVD Proxy" as uds2sovd_proxy #Azure { + package "DoIP Server" as doip_server #LightGoldenRodYellow { + component "server" as server #LightCyan { + component "tcp" + component "udp" + } + component "doip" as doip #LightCyan { + component "tcp_dispatcher" + component "udp_dispatcher" + component "handlers" as handlers { + component "diagnostic " as diagnostic + component "discovery" as discovery + } + } + } + + component "UDS-to-SOVD Translation" as translation +} + +uds_tester <--> udp: Discovery +uds_tester <--> tcp: Diagnostic +tcp --> tcp_dispatcher: forwards\nTCP messages +udp --> udp_dispatcher: forwards\nUDP messages +diagnostic -up-> tcp_dispatcher: registered with +discovery -up-> udp_dispatcher: registered with +diagnostic <--> translation: diagnostic payloads + +@enduml diff --git a/docs/doip_server.svg b/docs/doip_server.svg new file mode 100644 index 0000000..86bfce7 --- /dev/null +++ b/docs/doip_server.svg @@ -0,0 +1 @@ +UDS-to-SOVD ProxyDoIP ServerserverdoiphandlersUDS-to-SOVD Translationtcpudptcp_dispatcherudp_dispatcherdiagnosticdiscoveryUDS TesterDiscoveryDiagnosticforwardsTCP messagesforwardsUDP messagesregistered withregistered withdiagnostic payloads \ No newline at end of file diff --git a/docs/limitation.md b/docs/limitation.md new file mode 100644 index 0000000..e2dd6be --- /dev/null +++ b/docs/limitation.md @@ -0,0 +1,81 @@ + + +This document lists the current constraints of the UDS-to-SOVD Proxy. +Any known mitigations are documented in the [TODO](todo.md). + +# Limitations + +## Stub backend only + +**Current state** +- Diagnostic forwarding uses a stub `SovdProxy` implementation. +- A production SOVD backend adapter is not included yet. + +**Impact** +- End-to-end transport and protocol flow can be validated. +- Real diagnostic service execution is not available. + +## Partial DoIP message coverage + +**Currently supported** +- Vehicle identification (general / by VIN / by EID) +- Entity status request +- Routing activation +- Alive check +- Diagnostic message forwarding + +**Impact** +- Message types outside this set are not supported. +- Interoperability depends on whether external tools require unsupported messages. + +## No TLS-secured DoIP + +**Current state** +- TLS transport security is not implemented. + +**Impact** +- Traffic is unencrypted on the network path. + +## No asynchronous diagnostic processing + +**Current state** +- Diagnostic forwarding assumes a request/response interaction. +- Response pending handling is not implemented. + +**Impact** +- Long-running diagnostic operations cannot be represented correctly. + +## No explicit graceful drain on shutdown + +**Current state** +- The application does not provide an explicit session-drain phase before termination. + +**Impact** +- In-flight requests may be interrupted during process stop. + +## Synchronous backend interface + +**Current state** +- `SovdProxy` uses a synchronous processing contract. + +**Impact** +- Backend implementations that rely on remote I/O may block execution. + +# Related Documentation + +| Document | Purpose | +| --- | --- | +| [README](../README.md) | Project overview and getting started | +| [Detailed Design](detailed_design.md) | System architecture and design rationale | +| [TODO](todo.md) | Planned enhancements and roadmap | +| [Usage](usage.md) | Installation, configuration, and operation | diff --git a/docs/sequence/startup.puml b/docs/sequence/startup.puml new file mode 100644 index 0000000..a90fbb0 --- /dev/null +++ b/docs/sequence/startup.puml @@ -0,0 +1,73 @@ +/' Copyright (c) 2026 Contributors to the Eclipse Foundation + + 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 + + + SPDX-License-Identifier: Apache-2.0 '/ + +@startuml doip_server_startup +title DoIP Server Startup + + + +participant "DoIP Server App" as DoipServerApp +participant "Server" as Server +participant "TCP Service" as Tcp +participant "UDP Service" as Udp + +== Load Configuration == + +DoipServerApp -> DoipServerApp: load configuration +||| +note right of DoipServerApp + CLI config file or defaults +end note + +== Initialize Services == + +DoipServerApp -> Tcp: create TCP service +||| +DoipServerApp -> Udp: create UDP service +||| +DoipServerApp -> Server: create server + +== Start Runtime == + +DoipServerApp -> Server: start() +||| + +Server -> Tcp: bind TCP listener +alt TCP bind failure + Tcp --> Server: startup error + Server --> DoipServerApp: startup failed +end +||| + +Server -> Udp: bind UDP socket +alt UDP bind failure + Udp --> Server: startup error + Server --> DoipServerApp: startup failed +end + +== Running State == + +Server -> Tcp: start connection loop +Server -> Udp: start discovery loop +||| + +note over Tcp, Udp + Services running concurrently +end note + +@enduml diff --git a/docs/sequence/startup.svg b/docs/sequence/startup.svg new file mode 100644 index 0000000..fe39e9b --- /dev/null +++ b/docs/sequence/startup.svg @@ -0,0 +1 @@ +DoIP Server StartupDoIP Server AppServerTCP ServiceUDP ServiceDoIP Server AppDoIP Server AppServerServerTCP ServiceTCP ServiceUDP ServiceUDP ServiceLoad Configurationload configurationCLI config file or defaultsInitialize Servicescreate TCP servicecreate UDP servicecreate serverStart Runtimestart()bind TCP listeneralt[TCP bind failure]startup errorstartup failedbind UDP socketalt[UDP bind failure]startup errorstartup failedRunning Statestart connection loopstart discovery loopServices running concurrently \ No newline at end of file diff --git a/docs/sequence/tcp_connection.puml b/docs/sequence/tcp_connection.puml new file mode 100644 index 0000000..17e9a0e --- /dev/null +++ b/docs/sequence/tcp_connection.puml @@ -0,0 +1,75 @@ +/' Copyright (c) 2026 Contributors to the Eclipse Foundation + + 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 + + + SPDX-License-Identifier: Apache-2.0 '/ + + +@startuml doip_server_tcp_connection +title DoIP Server TCP Connection + + + +participant "Client" as Client +participant "TCP Service" as Tcp +participant "Session" as Sess +participant "UDS-to-SOVD" as Proxy + +== Connection Establishment == + +Client -> Tcp: connect (port 13400) + +alt maximum sessions reached +Tcp -> Client: connection rejected +Tcp ->x Client: close connection + +else session accepted +Tcp -> Sess: spawn session +end + +== Request Processing == + +loop until disconnect or failure + + +Client -> Sess: DoIP request + +alt invalid request + Sess -> Client: negative acknowledgment + +else diagnostic message + Sess -> Proxy: forward diagnostic payload + Proxy --> Sess: diagnostic response + Sess -> Client: diagnostic response + +else supported request + Sess -> Client: response +end + +note right of Sess + communication failure + terminates session +end note + + +end + +== Session Cleanup == + +note over Sess + session ends + slot dropped automatically +end note +@enduml diff --git a/docs/sequence/tcp_connection.svg b/docs/sequence/tcp_connection.svg new file mode 100644 index 0000000..9032302 --- /dev/null +++ b/docs/sequence/tcp_connection.svg @@ -0,0 +1 @@ +DoIP Server TCP ConnectionClientTCP ServiceSessionUDS-to-SOVDClientClientTCP ServiceTCP ServiceSessionSessionUDS-to-SOVDUDS-to-SOVDConnection Establishmentconnect (port 13400)alt[maximum sessions reached]connection rejectedclose connection[session accepted]spawn sessionRequest Processingloop[until disconnect or failure]DoIP requestalt[invalid request]negative acknowledgment[diagnostic message]forward diagnostic payloaddiagnostic responsediagnostic response[supported request]responsecommunication failureterminates sessionSession Cleanupsession endsslot dropped automatically \ No newline at end of file diff --git a/docs/sequence/udp_request.puml b/docs/sequence/udp_request.puml new file mode 100644 index 0000000..a36daa9 --- /dev/null +++ b/docs/sequence/udp_request.puml @@ -0,0 +1,45 @@ +/' Copyright (c) 2026 Contributors to the Eclipse Foundation + + 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 + + + SPDX-License-Identifier: Apache-2.0 '/ +@startuml doip_server_udp_request +title DoIP Server UDP Request Handling + + +participant "Client" as Client +participant "UDP Service" as Udp + +== Receive Loop == + +loop waiting for datagrams + + Client -> Udp: discovery request (port 13400) + + alt valid identification request + Udp -> Client: vehicle announcement (VIN, EID, address) + else EID/VIN does not match + note right of Udp + silent drop (ISO 7.6.1) + end note + else entity status request + Udp -> Client: node type and capacity + else invalid request + Udp -> Client: negative acknowledgment + end + +end + +@enduml diff --git a/docs/sequence/udp_request.svg b/docs/sequence/udp_request.svg new file mode 100644 index 0000000..914c405 --- /dev/null +++ b/docs/sequence/udp_request.svg @@ -0,0 +1 @@ +DoIP Server UDP Request HandlingClientUDP ServiceClientClientUDP ServiceUDP ServiceReceive Looploop[waiting for datagrams]discovery request (port 13400)alt[valid identification request]vehicle announcement (VIN, EID, address)[EID/VIN does not match]silent drop (ISO 7.6.1)[entity status request]node type and capacity[invalid request]negative acknowledgment \ No newline at end of file diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..8a0a860 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,127 @@ + + +This document tracks planned implementation work and longer-term improvements. This list is not exhaustive and is subject to change as the project evolves. + +# TODOs + +Near-term items that are directly actionable in the current codebase. + +## Replace `StubProxy` with real backend integration + +**Why** +- Diagnostic forwarding currently ends at `StubProxy`. +- Real UDS-to-SOVD execution path is not available. + +**Actions** +- Implement production `SovdProxy` integration. +- Add backend configuration and connection handling. +- Validate end-to-end diagnostic flows. + +## Improve shutdown control model + +**Why** +- Shutdown is currently triggered by `Ctrl+C`. +- Signal handling should be extensible. + +**Actions** +- Support additional termination signals. +- Add programmatic shutdown trigger. +- Add graceful drain behaviour before full stop. + +## Add CLI argument parsing + +**Why** +- Current startup argument handling can be improved for usability. + +**Actions** +- Introduce argument parser (`clap` or equivalent). +- Improve argument validation and error messages. +- Standardize help output and startup options. + +## Add future configuration sources + +**Why** +- Current providers cover defaults and TOML. +- Future deployments may require alternative sources. + +**Actions** +- Add environment-variable provider. +- Evaluate remote configuration service integration. +- Keep the `ConfigProvider` abstraction unchanged. + +## Code quality improvement candidates + +**Why** +- Repeated constructor-style patterns may appear as the codebase grows. + +**Actions** +- Consider `#[derive(new)]` where it improves consistency. +- Apply only when it reduces boilerplate without harming readability. + +## Routing activation state machine + +**Why** +- Routing activation is currently handled by a simple success response handler. +- A protocol-aware state machine is still missing. + +**Actions** +- Model routing activation states and transitions. +- Validate activation requests against the current session context. +- Return protocol-correct activation responses and failures. + +# Future work + +Longer-term enhancements that depend on architectural evolution, broader interoperability goals, or future deployment requirements. + +## Asynchronous backend processing + +**Why** +- `SovdProxy::process()` is synchronous. +- Future backend I/O can block and reduce throughput. + +**Actions** +- Introduce async backend contract. +- Update diagnostic handling path to async flow. +- Add integration tests for latency and backpressure. + +## UDS Response Pending support (`0x78`) + +**Why** +- Current flow assumes immediate request/response. +- Long-running diagnostics need response-pending behaviour. + +**Actions** +- Add response-pending handling in diagnostic path. +- Validate client interoperability for delayed completion. + +## TLS-secured DoIP transport + +**Why** +- Current transport is not encrypted. + +**Actions** +- Define TLS architecture. +- Add certificate and key management. +- Validate secure client interoperability. + +## Additional DoIP message support + +**Actions** +- Expand handler coverage for required ISO 13400-2 flows. +- Add protocol compatibility tests. + +## Configuration validation hardening + +**Actions** +- Strengthen startup validation and error reporting. +- Add invalid-configuration test coverage. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..aa77f3e --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,85 @@ + + +# Usage Guide + +This document explains how to build, configure, and run the UDS-to-SOVD Proxy. + +## Command Line Arguments + +The server accepts the following arguments: + +```bash +cargo run -p uds2sovd-proxy [CONFIG_FILE] +``` + +**Arguments:** +- `CONFIG_FILE` (optional): Path to a TOML configuration file. If not provided, the server uses built-in defaults. + +**Examples:** +```bash +# Run with default configuration +cargo run -p uds2sovd-proxy + +# Run with custom config file (absolute path) +cargo run -p uds2sovd-proxy -- /etc/doip/config.toml + +# Run with relative path +cargo run -p uds2sovd-proxy -- app/config.toml + +# Run from app directory +cd app +cargo run -- config.toml +``` + +## Configuration + +The server supports two configuration modes: + +- **Default configuration**: Built-in programmatic defaults (no file needed) +- **TOML configuration file**: Custom configuration from a TOML file + +## Default Configuration + +Run the server without arguments to use the built-in defaults: + +```bash +cargo run -p uds2sovd-proxy +``` + +It uses the default configuration defined in [defaults.rs](../src/config/defaults.rs). + +## TOML Configuration + +Refer to the sample configuration file at [app/config.toml](../app/config.toml). Specify the path to the configuration file when starting the server: + +```bash +cargo run -p uds2sovd-proxy -- app/config.toml +``` + +You can also run from the `app/` directory: + +```bash +cd app +cargo run -- config.toml +``` + +Both configuration modes provide the same runtime behaviour once the server starts. + +## Related Documentation + +| Document | Purpose | +| --- | --- | +| [README](../README.md) | Project overview and quick start | +| [Detailed design](detailed_design.md) | Architecture and runtime behaviour | +| [Limitations](limitation.md) | Current functional and operational constraints | +| [TODO](todo.md) | Planned enhancements and roadmap | diff --git a/src/Cargo.toml b/src/Cargo.toml new file mode 100644 index 0000000..26640ea --- /dev/null +++ b/src/Cargo.toml @@ -0,0 +1,30 @@ +# 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 + +[package] +name = "uds2sovd-proxy-lib" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "UDS-to-SOVD Proxy library - DoIP Server, Translation, and SOVD Proxy" + +[dependencies] +tokio = { version = "1", features = ["net", "io-util", "macros", "rt-multi-thread", "signal"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +uuid = { version = "1", features = ["v4"] } +tracing = "0.1" +tracing-subscriber = "0.3" +thiserror = "2" + +[lib] +name = "uds2sovd_proxy_lib" +path = "lib.rs" diff --git a/src/config/defaults.rs b/src/config/defaults.rs new file mode 100644 index 0000000..8992992 --- /dev/null +++ b/src/config/defaults.rs @@ -0,0 +1,60 @@ +// 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 + +//! Default configuration values. +//! +//! These constants provide compile-time defaults for transport +//! settings and ECU identity values used when configuration +//! fields are omitted. + +use crate::doip::types::{Eid, Gid, LogicalAddress, Vin}; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; + +/// Default TCP listen address. +/// +/// Uses the standard DoIP TCP port (`13400`) and binds to +/// the loopback interface. +pub const TCP_ADDRESS: SocketAddr = + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 13400)); + +/// Default UDP listen address (all interfaces, standard DoIP port 13400). +pub const UDP_ADDRESS: SocketAddr = + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), 13400)); + +/// Default maximum number of concurrent TCP sessions. +pub const MAX_CONNECTIONS: usize = 10; + +// TODO: Add DEFAULT_MAX_DATA_SIZE constant for entity status response. + +/// Default TCP read buffer size in bytes. +pub const READ_BUFFER_SIZE: usize = 4096; + +/// Default DoIP logical address advertised by this entity. +/// +/// Used in routing activation and diagnostic communication. +pub const LOGICAL_ADDRESS: LogicalAddress = LogicalAddress::new(0x0001); + +/// Default Vehicle Identification Number (VIN). +/// +/// The default value consists of 17 ASCII zero characters and +/// should be replaced with the actual vehicle VIN in production. +pub const VIN: Vin = Vin::new(*b"00000000000000000"); + +/// Default Entity Identifier (EID). +/// +/// The default value is all zeros and should be replaced with +/// a unique identifier, typically derived from the MAC address +/// of the DoIP network interface. +pub const EID: Eid = Eid::new([0u8; 6]); + +/// Default Group Identifier (GID). +/// +/// The default value is all zeros. +pub const GID: Gid = Gid::new([0u8; 6]); diff --git a/src/config/error.rs b/src/config/error.rs new file mode 100644 index 0000000..3e31427 --- /dev/null +++ b/src/config/error.rs @@ -0,0 +1,23 @@ +// 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 + +//! Errors returned while loading configuration. + +/// Errors produced while loading server configuration. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + /// Returned when the configuration source cannot be read. + #[error("Failed to read config file: {0}")] + FileRead(#[from] std::io::Error), + + /// Returned when TOML content cannot be deserialized into a server configuration. + #[error("Failed to parse TOML config: {0}")] + ParseError(#[from] toml::de::Error), +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..f63b3f7 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,39 @@ +// 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 + +//! Configuration types and loaders. +//! +//! Provides the runtime configuration model ([`ServerConfig`]) and the abstractions +//! used to load it from different sources. Use the [`ConfigProvider`] trait to implement +//! custom configuration sources, or use the built-in providers: +//! - [`DefaultConfigProvider`]: Load built-in defaults +//! - [`TomlConfigProvider`]: Load from a TOML file + +pub mod defaults; +pub mod error; +pub mod provider; +pub mod types; + +pub use error::ConfigError; +pub use provider::{DefaultConfigProvider, TomlConfigProvider}; +pub use types::{EcuConfig, ServerConfig, TcpConfig, UdpConfig}; + +/// Loads a complete [`ServerConfig`]. +/// +/// Implementations may obtain configuration from different sources, such as +/// TOML files or in-memory defaults. +pub trait ConfigProvider { + /// Loads and returns a complete [`ServerConfig`]. + /// + /// # Errors + /// + /// Returns [`ConfigError`] if configuration loading fails. + fn load(&self) -> Result; +} diff --git a/src/config/provider/default_config.rs b/src/config/provider/default_config.rs new file mode 100644 index 0000000..158d673 --- /dev/null +++ b/src/config/provider/default_config.rs @@ -0,0 +1,68 @@ +// 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 + +//! Default configuration provider. +//! +//! This provider wraps an already constructed [`ServerConfig`] and +//! returns it on every call to [`ConfigProvider::load`]. +//! It performs no file I/O. + +use super::super::{ConfigError, ConfigProvider}; +use crate::config::types::ServerConfig; + +/// [`ConfigProvider`] backed by a stored [`ServerConfig`]. +pub struct DefaultConfigProvider { + config: ServerConfig, +} + +impl DefaultConfigProvider { + /// Creates a provider that always returns the supplied configuration. + pub fn new(config: ServerConfig) -> Self { + Self { config } + } +} + +impl ConfigProvider for DefaultConfigProvider { + /// Returns a clone of the stored configuration. + /// + /// This operation is infallible because it performs no I/O and no parsing. + fn load(&self) -> Result { + Ok(self.config.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_returns_stored_config() { + let config = ServerConfig::default(); + let provider = DefaultConfigProvider::new(config); + // load() should return a valid config without panicking + let loaded = provider + .load() + .expect("Default config should always be valid"); + let (_tcp, _udp, _ecu) = loaded.into_parts(); + } + + #[test] + fn load_returns_identical_config_on_repeated_calls() { + let config = ServerConfig::default(); + let provider = DefaultConfigProvider::new(config); + let first = provider.load().expect("Default config should be valid"); + let second = provider.load().expect("Default config should be valid"); + // Ensures the provider is stateless and idempotent. + assert_eq!( + first.into_parts().0.address(), + second.into_parts().0.address() + ); + } +} diff --git a/src/config/provider/mod.rs b/src/config/provider/mod.rs new file mode 100644 index 0000000..2197fe8 --- /dev/null +++ b/src/config/provider/mod.rs @@ -0,0 +1,21 @@ +// 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 + +//! Configuration provider implementations. +//! +//! Provides: +//! - [`DefaultConfigProvider`] for in-memory configuration +//! - [`TomlConfigProvider`] for TOML-backed configuration + +pub mod default_config; +pub mod toml; + +pub use default_config::DefaultConfigProvider; +pub use toml::TomlConfigProvider; diff --git a/src/config/provider/toml.rs b/src/config/provider/toml.rs new file mode 100644 index 0000000..a027ea2 --- /dev/null +++ b/src/config/provider/toml.rs @@ -0,0 +1,47 @@ +// 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 + +//! TOML-backed configuration provider. +//! +//! This provider loads server configuration from a TOML file +//! and deserializes it into a [`ServerConfig`]. + +use std::path::PathBuf; + +use crate::config::types::ServerConfig; +use crate::config::{ConfigError, ConfigProvider}; + +/// [`ConfigProvider`] that reads configuration from a TOML file. +pub struct TomlConfigProvider { + /// Path to the TOML configuration file. + path: PathBuf, +} + +impl TomlConfigProvider { + /// Creates a provider for the specified TOML file path. + pub fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl ConfigProvider for TomlConfigProvider { + /// Loads configuration from the configured TOML file. + /// + /// # Errors + /// + /// Returns [`ConfigError::FileRead`] when the file cannot be read, or + /// [`ConfigError::ParseError`] when TOML deserialization into + /// [`ServerConfig`] fails. + fn load(&self) -> Result { + let content = std::fs::read_to_string(&self.path)?; + let config = toml::from_str(&content)?; + Ok(config) + } +} diff --git a/src/config/types.rs b/src/config/types.rs new file mode 100644 index 0000000..e5b7c24 --- /dev/null +++ b/src/config/types.rs @@ -0,0 +1,152 @@ +// 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 + +//! Configuration data model. +//! +//! Defines the TCP, UDP, and ECU settings consumed by the server at startup. +use serde::Deserialize; +use std::net::SocketAddr; + +use super::defaults; +use crate::doip::types::{Eid, Gid, LogicalAddress, Vin}; + +/// Complete runtime configuration for the server. +/// +/// The configuration is split into TCP, UDP, and ECU sections. +/// `#[serde(default)]` allows omitted sections/fields to fall back to +/// compile-time defaults. + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +pub struct ServerConfig { + tcp: TcpConfig, + udp: UdpConfig, + ecu: EcuConfig, +} + +impl ServerConfig { + /// Consumes this config and returns `(tcp, udp, ecu)` in that order. + pub fn into_parts(self) -> (TcpConfig, UdpConfig, EcuConfig) { + (self.tcp, self.udp, self.ecu) + } +} + +/// TCP transport settings. + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct TcpConfig { + address: SocketAddr, + max_connections: usize, + logical_address: LogicalAddress, + read_buffer_size: usize, +} + +impl TcpConfig { + /// Returns the TCP listen address. + pub fn address(&self) -> SocketAddr { + self.address + } + + /// Returns the maximum number of concurrent TCP sessions. + pub fn max_connections(&self) -> usize { + self.max_connections + } + + /// Returns the DoIP logical address used on the TCP path. + pub fn logical_address(&self) -> LogicalAddress { + self.logical_address + } + + /// Returns the TCP read buffer size in bytes. + pub fn read_buffer_size(&self) -> usize { + self.read_buffer_size + } +} + +/// UDP transport settings. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct UdpConfig { + address: SocketAddr, + logical_address: LogicalAddress, +} + +impl UdpConfig { + /// Returns the UDP listen address. + pub fn address(&self) -> SocketAddr { + self.address + } + + /// Returns the DoIP logical address used on the UDP path. + pub fn logical_address(&self) -> LogicalAddress { + self.logical_address + } +} + +impl Default for TcpConfig { + fn default() -> Self { + Self { + address: defaults::TCP_ADDRESS, + max_connections: defaults::MAX_CONNECTIONS, + logical_address: defaults::LOGICAL_ADDRESS, + read_buffer_size: defaults::READ_BUFFER_SIZE, + } + } +} + +impl Default for UdpConfig { + fn default() -> Self { + Self { + address: defaults::UDP_ADDRESS, + logical_address: defaults::LOGICAL_ADDRESS, + } + } +} + +/// ECU identity values advertised in DoIP responses. +#[derive(Debug, Clone, Deserialize)] +pub struct EcuConfig { + vin: Vin, + eid: Eid, + gid: Gid, +} + +impl EcuConfig { + /// Creates ECU identity settings from VIN, EID, and GID values. + pub fn new(vin: Vin, eid: Eid, gid: Gid) -> Self { + Self { vin, eid, gid } + } + + /// Returns the configured VIN. + pub fn vin(&self) -> Vin { + self.vin + } + + /// Returns the configured EID. + pub fn eid(&self) -> Eid { + self.eid + } + + /// Returns the configured GID. + pub fn gid(&self) -> Gid { + self.gid + } +} + +impl Default for EcuConfig { + fn default() -> Self { + Self { + vin: defaults::VIN, + eid: defaults::EID, + gid: defaults::GID, + } + } +} diff --git a/src/doip/constants.rs b/src/doip/constants.rs new file mode 100644 index 0000000..52cd863 --- /dev/null +++ b/src/doip/constants.rs @@ -0,0 +1,88 @@ +// 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 + +//! DoIP protocol constants per ISO 13400-2. +//! +//! Organized by protocol section: +//! - Header constants ( 7.3) +//! - Response codes ( 9.x) +//! - Field lengths +//! - Message size limits + +/// DoIP protocol version byte (byte 0 of the generic header), ISO 13400-2 #7.3. +pub const PROTOCOL_VERSION: u8 = 0xFD; + +/// Inverse of the protocol version byte (byte 1 of the generic header). +/// Must equal `!PROTOCOL_VERSION` for the header to be considered valid. +pub const INVERSE_VERSION: u8 = 0x02; // !0xFD + +/// Size of the DoIP generic header in bytes (ISO 13400-2 #7.3). +pub const HEADER_LEN: usize = 8; + +// RoutingActivation response codes (ISO 13400-2 #9.9, Table 28) + +/// Routing activation successful. +pub const ROUTING_ACTIVATION_CODE_SUCCESS: u8 = 0x10; + +// DiagnosticMessage ACK codes (ISO 13400-2 #9.11, Table 33) + +/// Diagnostic message received and forwarded to the target network. +pub const DIAGNOSTIC_MESSAGE_ACK: u8 = 0x00; + +// VehicleIdentification / VehicleAnnouncement (ISO 13400-2 #7.6.2) + +/// No further action is required from the client. +pub const NO_FURTHER_ACTION: u8 = 0x00; + +/// Receive buffer size for UDP DoIP datagrams. +/// All ISO 13400-2 defined UDP messages fit within a single Ethernet frame (MTU 1500 bytes). +/// The largest defined message is VehicleAnnouncementResponse at 40 bytes. +pub const UDP_RECV_BUF_SIZE: usize = 1500; + +/// Field lengths (ISO 13400-2) +/// VIN (Vehicle Identification Number) length in bytes. +pub const VIN_LEN: usize = 17; + +/// EID (Entity Identification / MAC address) length in bytes. +pub const EID_LEN: usize = 6; + +// Entity status (ISO 13400-2 7.6.3) + +/// DoIP node type: DoIP gateway (0x00) or DoIP node (0x01). +pub const DOIP_NODE_TYPE: u8 = 0x01; + +/// Entity status response payload length: 1 (node type) + 1 (max TCP) + 1 (current TCP) + 4 (max data size). +pub const ENTITY_STATUS_RESPONSE_LEN: usize = 7; + +// Maximum payload (ISO 13400-2 7.3) + +/// Maximum DoIP payload length accepted by this implementation. +pub const MAX_DOIP_PAYLOAD_LEN: usize = 65_535; + +// Routing Activation (ISO 13400-2 9.9) + +/// Minimum length of a routing activation request payload (bytes). +pub const ROUTING_ACTIVATION_REQUEST_MIN_LEN: usize = 11; + +// Diagnostic Message (ISO 13400-2 9.11) + +/// Minimum diagnostic message payload length: 2 (source addr) + 2 (target addr). +pub const DIAG_MSG_MIN_PAYLOAD_LEN: usize = 4; + +/// Diagnostic message positive ACK header length: 2 (source) + 2 (target) + 1 (ACK code). +pub const DIAG_ACK_HEADER_LEN: usize = 5; + +// UDS response codes (ISO 14229-1) + +/// UDS negative response service ID. +pub const UDS_NEGATIVE_RESPONSE: u8 = 0x7F; + +/// UDS NRC: service not supported. +pub const NRC_SERVICE_NOT_SUPPORTED: u8 = 0x11; diff --git a/src/doip/dispatch.rs b/src/doip/dispatch.rs new file mode 100644 index 0000000..355a50a --- /dev/null +++ b/src/doip/dispatch.rs @@ -0,0 +1,150 @@ +// 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 + +//! Registry and router for DoIP payload-type handlers. +//! +//! The [`Dispatcher`] receives incoming requests, looks up the registered handler +//! for the request's payload type, and routes the request to that handler. + +use std::collections::HashMap; +use std::hash::Hash; + +use crate::doip::error::Error; +use crate::doip::message::{ + HasPayloadType, Response, TcpPayloadType, TcpRequest, UdpPayloadType, UdpRequest, +}; + +/// Handles one DoIP payload type for one transport. +pub trait PayloadHandler: Send + Sync { + /// The payload type this handler is registered for. + fn payload_type(&self) -> PayloadType; + + /// Processes a request and returns a response. + /// + /// # Errors + /// + /// Returns [`Error`] if the request is invalid or cannot be handled. + fn handle(&self, req: Request) -> Result; +} + +/// Registry and router for payload-type handlers. +pub struct Dispatcher +where + PayloadType: Eq + Hash, +{ + handlers: HashMap + Send + Sync>>, +} + +impl Dispatcher +where + PayloadType: Eq + Hash + Into, + Request: HasPayloadType, +{ + /// Creates an empty dispatcher. + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + + /// Registers a handler for its declared payload type. + pub fn register(&mut self, handler: impl PayloadHandler + 'static) { + let payload_type = handler.payload_type(); + self.handlers.insert(payload_type, Box::new(handler)); + } + + /// Routes a request to the registered handler for its payload type. + /// + /// # Errors + /// + /// Returns [`Error::UnknownPayloadType`] if no handler is registered. + pub fn dispatch(&self, req: Request) -> Result { + let payload_type = req.payload_type(); + self.handlers + .get(&payload_type) + .ok_or_else(|| Error::UnknownPayloadType(payload_type.into()))? + .handle(req) + } +} + +impl Default for Dispatcher +where + PayloadType: Eq + Hash + Into, + Request: HasPayloadType, +{ + fn default() -> Self { + Self::new() + } +} + +/// Dispatcher specialized for TCP payload types. +/// +/// The type parameters prevent registering UDP handlers on the TCP path. For +/// example, a TCP handler cannot be registered on a UDP dispatcher: +/// +/// ```compile_fail +/// use uds2sovd::doip::dispatch::UdpDispatcher; +/// use uds2sovd::doip::handlers::AliveCheckHandler; +/// use uds2sovd::doip::types::LogicalAddress; +/// +/// let mut dispatcher = UdpDispatcher::new(); +/// // AliveCheckHandler implements PayloadHandler, +/// // so this will not compile on a UdpDispatcher. +/// dispatcher.register(AliveCheckHandler::new(LogicalAddress::new(0x0001))); +/// ``` +pub type TcpDispatcher = Dispatcher; + +/// Dispatcher specialized for UDP payload types. +pub type UdpDispatcher = Dispatcher; + +#[cfg(test)] +mod tests { + use super::*; + use crate::doip::error::Error; + use crate::doip::message::{Response, TcpPayloadType, TcpRequest}; + + struct AliveEchoHandler; + + impl PayloadHandler for AliveEchoHandler { + fn payload_type(&self) -> TcpPayloadType { + TcpPayloadType::AliveCheckRequest + } + fn handle(&self, _req: TcpRequest) -> Result { + Ok(Response::new( + TcpPayloadType::AliveCheckResponse as u16, + vec![], + )) + } + } + + fn make_req(pt: TcpPayloadType) -> TcpRequest { + TcpRequest::new(pt, vec![]) + } + + #[test] + fn dispatch_routes_to_registered_handler() { + let mut dispatcher = TcpDispatcher::new(); + dispatcher.register(AliveEchoHandler); + let resp = dispatcher + .dispatch(make_req(TcpPayloadType::AliveCheckRequest)) + .unwrap(); + assert_eq!( + resp.payload_type(), + TcpPayloadType::AliveCheckResponse as u16 + ); + } + + #[test] + fn dispatch_rejects_unknown_type() { + let dispatcher = TcpDispatcher::new(); + let result = dispatcher.dispatch(make_req(TcpPayloadType::DiagnosticMessage)); + assert!(matches!(result, Err(Error::UnknownPayloadType(0x8001)))); + } +} diff --git a/src/doip/error.rs b/src/doip/error.rs new file mode 100644 index 0000000..b37122b --- /dev/null +++ b/src/doip/error.rs @@ -0,0 +1,76 @@ +// 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 + +//! DoIP protocol and handler errors. + +use crate::doip::message::DoipNackCode; +use crate::proxy::SovdProxyError; + +/// Errors returned while parsing or handling DoIP messages. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Returned when the protocol version byte is invalid. + #[error("invalid header version: expected 0xFD, got {0:#x}")] + InvalidHeaderVersion(u8), + + /// Returned when the inverse protocol version byte is invalid. + #[error("invalid inverse version: expected 0x02, got {0:#x}")] + InvalidInverseVersion(u8), + + /// Returned when no handler or payload enum variant exists for a payload type. + #[error("unknown DoIP payload type: {0:#06x}")] + UnknownPayloadType(u16), + + /// Returned when the payload length does not match the protocol requirement. + #[error("invalid payload length: expected {expected}, got {actual}")] + InvalidPayloadLength { expected: u32, actual: usize }, + + /// Returned when a payload is shorter than required. + #[error("payload too short: expected at least {expected} bytes, got {actual}")] + PayloadTooShort { expected: usize, actual: usize }, + + /// Returned when a payload exceeds the accepted size. + #[error("payload length {0} exceeds maximum allowed size")] + PayloadTooLarge(usize), + + /// Returned when a payload is present where none is allowed. + #[error("unexpected payload: expected {expected} bytes, got {actual}")] + UnexpectedPayload { expected: usize, actual: usize }, + + /// Returned when backend diagnostic processing fails. + #[error("SOVD proxy error: {0}")] + Proxy(#[from] SovdProxyError), + + /// Returned when a vehicle-identification EID does not match this entity. + #[error("no matching EID for request")] + EIDNotMatched, + + /// Returned when a vehicle-identification VIN does not match this entity. + #[error("no matching VIN for request")] + VinNotMatched, +} + +impl Error { + /// Returns the Generic Header NACK code associated with this error. + pub fn nack_code(&self) -> DoipNackCode { + match self { + Error::InvalidHeaderVersion(_) | Error::InvalidInverseVersion(_) => { + DoipNackCode::IncorrectPattern + } + Error::UnknownPayloadType(_) => DoipNackCode::UnknownPayloadType, + Error::PayloadTooLarge(_) => DoipNackCode::MessageTooLarge, + Error::InvalidPayloadLength { .. } + | Error::PayloadTooShort { .. } + | Error::UnexpectedPayload { .. } => DoipNackCode::InvalidPayloadLength, + Error::Proxy(_) => DoipNackCode::IncorrectPattern, + Error::EIDNotMatched | Error::VinNotMatched => DoipNackCode::IncorrectPattern, + } + } +} diff --git a/src/doip/handlers/alive_check.rs b/src/doip/handlers/alive_check.rs new file mode 100644 index 0000000..e35432a --- /dev/null +++ b/src/doip/handlers/alive_check.rs @@ -0,0 +1,90 @@ +// 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 + +use crate::doip::{ + PayloadHandler, + error::Error, + message::{Response, TcpPayloadType, TcpRequest}, + types::LogicalAddress, +}; + +/// Handles `AliveCheckRequest` messages. +pub struct AliveCheckHandler { + logical_address: LogicalAddress, +} + +impl AliveCheckHandler { + /// Creates an alive-check handler for the supplied logical address. + pub fn new(logical_address: LogicalAddress) -> Self { + Self { logical_address } + } + + /// Builds an `AliveCheckResponse` payload. + fn respond(&self) -> Response { + let mut payload = Vec::with_capacity(2); + payload.extend_from_slice(&self.logical_address.to_be_bytes()); + Response::new(TcpPayloadType::AliveCheckResponse as u16, payload) + } +} + +impl PayloadHandler for AliveCheckHandler { + fn payload_type(&self) -> TcpPayloadType { + TcpPayloadType::AliveCheckRequest + } + fn handle(&self, tcp_request: TcpRequest) -> Result { + if !tcp_request.payload().is_empty() { + return Err(Error::UnexpectedPayload { + expected: 0, + actual: tcp_request.payload().len(), + }); + } + Ok(self.respond()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doip::types::LogicalAddress; + + #[test] + fn handle_empty_payload_returns_logical_address() { + let handler = AliveCheckHandler::new(LogicalAddress::new(0x0001)); + let req = TcpRequest::new(TcpPayloadType::AliveCheckRequest, vec![]); + let resp = handler.handle(req).unwrap(); + assert_eq!( + resp.payload_type(), + TcpPayloadType::AliveCheckResponse as u16 + ); + assert_eq!(resp.payload(), &[0x00, 0x01]); + } + + #[test] + fn handle_encodes_address_big_endian() { + let handler = AliveCheckHandler::new(LogicalAddress::new(0x1234)); + let req = TcpRequest::new(TcpPayloadType::AliveCheckRequest, vec![]); + let resp = handler.handle(req).unwrap(); + assert_eq!(resp.payload(), &[0x12, 0x34]); + } + + #[test] + fn handle_rejects_non_empty_payload() { + let handler = AliveCheckHandler::new(LogicalAddress::new(0x0001)); + let req = TcpRequest::new(TcpPayloadType::AliveCheckRequest, vec![0xAA]); + let resp = handler.handle(req); + assert!(matches!( + resp, + Err(Error::UnexpectedPayload { + expected: 0, + actual: 1 + }) + )); + } +} diff --git a/src/doip/handlers/diagnostics.rs b/src/doip/handlers/diagnostics.rs new file mode 100644 index 0000000..73a2309 --- /dev/null +++ b/src/doip/handlers/diagnostics.rs @@ -0,0 +1,104 @@ +// 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 + +use std::sync::Arc; + +use crate::doip::{ + PayloadHandler, + constants::{DIAG_ACK_HEADER_LEN, DIAG_MSG_MIN_PAYLOAD_LEN, DIAGNOSTIC_MESSAGE_ACK}, + error::Error, + message::{Response, TcpPayloadType, TcpRequest}, +}; +use crate::proxy::SovdProxy; + +/// Handles `DiagnosticMessage` requests. +/// +/// The handler forwards the UDS payload to a [`SovdProxy`] and wraps the +/// returned bytes in a DoIP acknowledgment response. +pub struct DiagnosticsHandler { + proxy: Arc, +} + +impl DiagnosticsHandler { + /// Creates a diagnostic handler backed by the provided proxy. + pub fn new(proxy: Arc) -> Self { + Self { proxy } + } + + /// Forwards a diagnostic payload and builds the response frame. + fn forward(&self, src: u16, tgt: u16, uds: &[u8]) -> Result { + let ecu_response = self.proxy.process(uds)?; + + let mut payload = Vec::with_capacity(DIAG_ACK_HEADER_LEN + ecu_response.len()); + payload.extend_from_slice(&tgt.to_be_bytes()); // server address + payload.extend_from_slice(&src.to_be_bytes()); // client address + payload.push(DIAGNOSTIC_MESSAGE_ACK); + payload.extend_from_slice(&ecu_response); + Ok(Response::new( + TcpPayloadType::DiagnosticMessagePositiveAck as u16, + payload, + )) + } +} + +impl PayloadHandler for DiagnosticsHandler { + fn payload_type(&self) -> TcpPayloadType { + TcpPayloadType::DiagnosticMessage + } + + fn handle(&self, tcp_request: TcpRequest) -> Result { + if tcp_request.payload().len() < DIAG_MSG_MIN_PAYLOAD_LEN { + return Err(Error::PayloadTooShort { + expected: DIAG_MSG_MIN_PAYLOAD_LEN, + actual: tcp_request.payload().len(), + }); + } + let source_address = + u16::from_be_bytes([tcp_request.payload()[0], tcp_request.payload()[1]]); + let target_address = + u16::from_be_bytes([tcp_request.payload()[2], tcp_request.payload()[3]]); + self.forward(source_address, target_address, &tcp_request.payload()[4..]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proxy::mock::MockProxy; + + #[test] + fn handle_forwards_uds_and_returns_ack() { + let handler = DiagnosticsHandler::new(Arc::new(MockProxy)); + let uds = vec![0x22, 0xF1, 0x90]; // ReadDataByIdentifier + let mut payload = vec![0x00, 0x01, 0x10, 0x00]; // src=0x0001 tgt=0x1000 + payload.extend_from_slice(&uds); + + let resp = handler + .handle(TcpRequest::new(TcpPayloadType::DiagnosticMessage, payload)) + .unwrap(); + + assert_eq!( + resp.payload_type(), + TcpPayloadType::DiagnosticMessagePositiveAck as u16 + ); + assert_eq!(resp.payload()[4], 0x00, "ACK code must be 0x00"); + assert_eq!(&resp.payload()[5..], &uds, "MockProxy echoes UDS bytes"); + } + + #[test] + fn handle_rejects_short_payload() { + let handler = DiagnosticsHandler::new(Arc::new(MockProxy)); + let resp = handler.handle(TcpRequest::new( + TcpPayloadType::DiagnosticMessage, + vec![0x00, 0x01], + )); + assert!(matches!(resp, Err(Error::PayloadTooShort { .. }))); + } +} diff --git a/src/doip/handlers/entity_status.rs b/src/doip/handlers/entity_status.rs new file mode 100644 index 0000000..c273895 --- /dev/null +++ b/src/doip/handlers/entity_status.rs @@ -0,0 +1,96 @@ +// 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 + +use crate::doip::{ + PayloadHandler, + constants::{DOIP_NODE_TYPE, ENTITY_STATUS_RESPONSE_LEN}, + error::Error, + message::{Response, UdpPayloadType, UdpRequest}, +}; + +/// Handles `DoipEntityStatusRequest` messages. +pub struct EntityStatusHandler { + /// Maximum concurrent TCP connections reported in the response. + max_connections: u8, + + /// Maximum DoIP data size reported in the response. + max_data_size: u32, +} + +impl EntityStatusHandler { + /// Creates an entity-status handler with the reported capacity values. + pub fn new(max_connections: u8, max_data_size: u32) -> Self { + Self { + max_connections, + max_data_size, + } + } +} + +impl PayloadHandler for EntityStatusHandler { + fn payload_type(&self) -> UdpPayloadType { + UdpPayloadType::DoipEntityStatusRequest + } + + fn handle(&self, udp_request: UdpRequest) -> Result { + if !udp_request.payload().is_empty() { + return Err(Error::UnexpectedPayload { + expected: 0, + actual: udp_request.payload().len(), + }); + } + let mut payload = Vec::with_capacity(ENTITY_STATUS_RESPONSE_LEN); + payload.push(DOIP_NODE_TYPE); + payload.push(self.max_connections); + payload.push(0x00); + payload.extend_from_slice(&self.max_data_size.to_be_bytes()); + Ok(Response::new( + UdpPayloadType::DoipEntityStatusResponse as u16, + payload, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handle_returns_7_byte_status_response() { + let handler = EntityStatusHandler::new(10, 65_535); + let req = UdpRequest::new(UdpPayloadType::DoipEntityStatusRequest, vec![]); + let resp = handler.handle(req).unwrap(); + + assert_eq!( + resp.payload_type(), + UdpPayloadType::DoipEntityStatusResponse as u16 + ); + assert_eq!(resp.payload().len(), ENTITY_STATUS_RESPONSE_LEN); + assert_eq!(resp.payload()[0], DOIP_NODE_TYPE); + assert_eq!(resp.payload()[1], 10); + assert_eq!(resp.payload()[2], 0x00); + assert_eq!( + u32::from_be_bytes([ + resp.payload()[3], + resp.payload()[4], + resp.payload()[5], + resp.payload()[6] + ]), + 65_535 + ); + } + + #[test] + fn handle_rejects_non_empty_payload() { + let handler = EntityStatusHandler::new(10, 65_535); + let req = UdpRequest::new(UdpPayloadType::DoipEntityStatusRequest, vec![0x01]); + assert!(handler.handle(req).is_err()); + } +} diff --git a/src/doip/handlers/mod.rs b/src/doip/handlers/mod.rs new file mode 100644 index 0000000..735b7ef --- /dev/null +++ b/src/doip/handlers/mod.rs @@ -0,0 +1,54 @@ +// 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 + +//! Public DoIP payload handlers. +//! +//! Handlers process incoming DoIP requests and produce responses. Each handler +//! implements the [`crate::doip::PayloadHandler`] trait for a specific payload type. +//! +//! # Built-in Handlers +//! +//! - [`AliveCheckHandler`]: Responds to alive check requests to verify connectivity +//! - [`RoutingActivationHandler`]: Manages client registration and session lifecycle +//! - [`DiagnosticsHandler`]: Routes UDS diagnostic requests to the backend proxy +//! - [`IdentifyVehicleHandler`]: General vehicle identification for UDP +//! - [`IdentifyVehicleByVinHandler`]: VIN-based vehicle identification +//! - [`IdentifyVehicleByEidHandler`]: EID-based vehicle identification +//! - [`EntityStatusHandler`]: Reports server status and session limits +//! +//! # Extension +//! +//! Implement the [`crate::doip::PayloadHandler`] trait to add custom message handling: +//! +//! ```ignore +//! use uds2sovd_proxy_lib::doip::{PayloadHandler, Response}; +//! use uds2sovd_proxy_lib::doip::types::LogicalAddress; +//! +//! struct MyHandler; +//! +//! impl PayloadHandler for MyHandler { +//! fn payload_type(&self) -> MyPayloadType { /* ... */ } +//! fn handle(&self, req: MyRequestType) -> Result { /* ... */ } +//! } +//! ``` + +pub mod alive_check; +pub mod diagnostics; +pub mod entity_status; +pub mod routing_activation; +pub mod vehicle_identification; + +pub use alive_check::AliveCheckHandler; +pub use diagnostics::DiagnosticsHandler; +pub use entity_status::EntityStatusHandler; +pub use routing_activation::RoutingActivationHandler; +pub use vehicle_identification::{ + IdentifyVehicleByEidHandler, IdentifyVehicleByVinHandler, IdentifyVehicleHandler, +}; diff --git a/src/doip/handlers/routing_activation.rs b/src/doip/handlers/routing_activation.rs new file mode 100644 index 0000000..4bdce34 --- /dev/null +++ b/src/doip/handlers/routing_activation.rs @@ -0,0 +1,113 @@ +// 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 + +use crate::doip::{ + PayloadHandler, + constants::{ROUTING_ACTIVATION_CODE_SUCCESS, ROUTING_ACTIVATION_REQUEST_MIN_LEN}, + error::Error, + message::{Response, TcpPayloadType, TcpRequest}, + types::LogicalAddress, +}; + +/// Handles `RoutingActivationRequest` messages. +pub struct RoutingActivationHandler { + server_logical_address: LogicalAddress, +} + +impl RoutingActivationHandler { + /// Creates a routing-activation handler for the supplied server address. + pub fn new(server_logical_address: LogicalAddress) -> Self { + Self { + server_logical_address, + } + } + + /// Builds a `RoutingActivationResponse` payload. + fn activate(&self, client_address: u16, _activation_type: u8) -> Response { + let mut payload = Vec::with_capacity(13); + payload.extend_from_slice(&client_address.to_be_bytes()); + payload.extend_from_slice(&self.server_logical_address.to_be_bytes()); + payload.push(ROUTING_ACTIVATION_CODE_SUCCESS); + payload.extend_from_slice(&[0u8; 4]); // reserved ISO + payload.extend_from_slice(&[0u8; 4]); // reserved OEM + Response::new(TcpPayloadType::RoutingActivationResponse as u16, payload) + } +} + +impl PayloadHandler for RoutingActivationHandler { + fn payload_type(&self) -> TcpPayloadType { + TcpPayloadType::RoutingActivationRequest + } + + fn handle(&self, tcp_request: TcpRequest) -> Result { + if tcp_request.payload().len() < ROUTING_ACTIVATION_REQUEST_MIN_LEN { + return Err(Error::PayloadTooShort { + expected: ROUTING_ACTIVATION_REQUEST_MIN_LEN, + actual: tcp_request.payload().len(), + }); + } + let client_address = + u16::from_be_bytes([tcp_request.payload()[0], tcp_request.payload()[1]]); + let activation_type = tcp_request.payload()[2]; + Ok(self.activate(client_address, activation_type)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doip::types::LogicalAddress; + + fn make_req(payload: Vec) -> TcpRequest { + TcpRequest::new(TcpPayloadType::RoutingActivationRequest, payload) + } + + #[test] + fn handle_valid_request_returns_activation_success() { + let handler = RoutingActivationHandler::new(LogicalAddress::new(0x0001)); + let resp = handler + .handle(make_req(vec![0x00, 0x42, 0x00, 0, 0, 0, 0, 0, 0, 0, 0])) + .unwrap(); + assert_eq!( + resp.payload_type(), + TcpPayloadType::RoutingActivationResponse as u16 + ); + assert_eq!( + resp.payload()[4], + 0x10, + "response code must be 0x10 (success)" + ); + // client address echoed back + assert_eq!(&resp.payload()[0..2], &[0x00, 0x42]); + // server address + assert_eq!(&resp.payload()[2..4], &[0x00, 0x01]); + } + + #[test] + fn handle_rejects_short_payload() { + let handler = RoutingActivationHandler::new(LogicalAddress::new(0x0001)); + assert!(matches!( + handler.handle(make_req(vec![0x00])), + Err(Error::PayloadTooShort { .. }) + )); + } + + #[test] + fn handle_rejects_partial_payload() { + let handler = RoutingActivationHandler::new(LogicalAddress::new(0x0001)); + assert!(matches!( + handler.handle(make_req(vec![0x00, 0x42, 0x00])), + Err(Error::PayloadTooShort { + expected: 11, + actual: 3 + }) + )); + } +} diff --git a/src/main.rs b/src/doip/handlers/vehicle_identification/mod.rs similarity index 62% rename from src/main.rs rename to src/doip/handlers/vehicle_identification/mod.rs index bf8ee3c..6c376b2 100644 --- a/src/main.rs +++ b/src/doip/handlers/vehicle_identification/mod.rs @@ -8,4 +8,13 @@ // terms of the Apache License Version 2.0 which is available at // https://www.apache.org/licenses/LICENSE-2.0 -fn main() {} +//! Handlers for UDP vehicle-identification requests. + +mod request; +mod request_by_eid; +mod request_by_vin; +mod utils; + +pub use request::IdentifyVehicleHandler; +pub use request_by_eid::IdentifyVehicleByEidHandler; +pub use request_by_vin::IdentifyVehicleByVinHandler; diff --git a/src/doip/handlers/vehicle_identification/request.rs b/src/doip/handlers/vehicle_identification/request.rs new file mode 100644 index 0000000..90de615 --- /dev/null +++ b/src/doip/handlers/vehicle_identification/request.rs @@ -0,0 +1,92 @@ +// 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 + +//! Handler for general vehicle-identification requests. + +use super::utils::create_vi_response; +use crate::config::EcuConfig; +use crate::doip::{ + PayloadHandler, + error::Error, + message::{Response, UdpPayloadType, UdpRequest}, + types::LogicalAddress, +}; + +/// Handles `VehicleIdentificationRequest` messages. +pub struct IdentifyVehicleHandler { + ecu_config: EcuConfig, + logical_address: LogicalAddress, +} + +impl IdentifyVehicleHandler { + /// Creates a handler from ECU identity values and the server logical address. + pub fn new(ecu_config: EcuConfig, logical_address: LogicalAddress) -> Self { + Self { + ecu_config, + logical_address, + } + } +} + +impl PayloadHandler for IdentifyVehicleHandler { + fn payload_type(&self) -> UdpPayloadType { + UdpPayloadType::VehicleIdentificationRequest + } + + fn handle(&self, udp_request: UdpRequest) -> Result { + if !udp_request.payload().is_empty() { + return Err(Error::UnexpectedPayload { + expected: 0, + actual: udp_request.payload().len(), + }); + } + Ok(create_vi_response(&self.ecu_config, self.logical_address)) + } +} + +#[cfg(test)] +mod tests { + use super::super::utils::fixtures::*; + use super::*; + + fn handler() -> IdentifyVehicleHandler { + IdentifyVehicleHandler::new(test_ecu_config(), TEST_ADDR) + } + + #[test] + fn empty_payload_returns_announcement() { + let resp = handler() + .handle(UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequest, + vec![], + )) + .unwrap(); + assert_eq!( + resp.payload_type(), + UdpPayloadType::VehicleAnnouncementResponse as u16 + ); + assert_eq!(resp.payload().len(), VI_RESPONSE_LEN); + } + + #[test] + fn non_empty_payload_returns_error() { + let result = handler().handle(UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequest, + vec![0x01], + )); + assert!(matches!( + result, + Err(Error::UnexpectedPayload { + expected: 0, + actual: 1 + }) + )); + } +} diff --git a/src/doip/handlers/vehicle_identification/request_by_eid.rs b/src/doip/handlers/vehicle_identification/request_by_eid.rs new file mode 100644 index 0000000..0b78618 --- /dev/null +++ b/src/doip/handlers/vehicle_identification/request_by_eid.rs @@ -0,0 +1,120 @@ +// 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 + +use crate::config::EcuConfig; +use crate::doip::{ + PayloadHandler, + constants::EID_LEN, + error::Error, + message::{Response, UdpPayloadType, UdpRequest}, + types::{Eid, LogicalAddress}, +}; + +use super::utils::create_vi_response; + +/// Handles `VehicleIdentificationRequestWithEid` messages. +pub struct IdentifyVehicleByEidHandler { + ecu_config: EcuConfig, + logical_address: LogicalAddress, +} + +impl IdentifyVehicleByEidHandler { + /// Creates a handler from ECU identity values and the server logical address. + pub fn new(ecu_config: EcuConfig, logical_address: LogicalAddress) -> Self { + Self { + ecu_config, + logical_address, + } + } +} + +impl PayloadHandler for IdentifyVehicleByEidHandler { + fn payload_type(&self) -> UdpPayloadType { + UdpPayloadType::VehicleIdentificationRequestWithEid + } + + fn handle(&self, udp_request: UdpRequest) -> Result { + if udp_request.payload().len() != EID_LEN { + return Err(Error::InvalidPayloadLength { + expected: EID_LEN as u32, + actual: udp_request.payload().len(), + }); + } + let requested = Eid::new([ + udp_request.payload()[0], + udp_request.payload()[1], + udp_request.payload()[2], + udp_request.payload()[3], + udp_request.payload()[4], + udp_request.payload()[5], + ]); + if requested != self.ecu_config.eid() { + return Err(Error::EIDNotMatched); + } + Ok(create_vi_response(&self.ecu_config, self.logical_address)) + } +} + +#[cfg(test)] +mod tests { + use super::super::utils::fixtures::*; + use super::*; + + fn handler() -> IdentifyVehicleByEidHandler { + IdentifyVehicleByEidHandler::new(test_ecu_config(), TEST_ADDR) + } + + #[test] + fn matching_eid_returns_announcement() { + let req = UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequestWithEid, + TEST_EID.as_bytes().to_vec(), + ); + assert!(handler().handle(req).is_ok()); + } + + #[test] + fn non_matching_eid_returns_no_match() { + let req = UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequestWithEid, + NON_MATCHING_EID.as_bytes().to_vec(), + ); + assert!(matches!(handler().handle(req), Err(Error::EIDNotMatched))); + } + + #[test] + fn wrong_length_returns_error() { + let req = UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequestWithEid, + vec![0x00; 3], // 3 bytes - less than required 6 + ); + assert!(matches!( + handler().handle(req), + Err(Error::InvalidPayloadLength { + expected: 6, + actual: 3 + }) + )); + } + #[test] + fn payload_too_long_returns_error() { + let req = UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequestWithEid, + vec![0x00; 7], // 7 bytes - more than required 6 + ); + assert!(matches!( + handler().handle(req), + Err(Error::InvalidPayloadLength { + expected: 6, + actual: 7 + }) + )); + } +} diff --git a/src/doip/handlers/vehicle_identification/request_by_vin.rs b/src/doip/handlers/vehicle_identification/request_by_vin.rs new file mode 100644 index 0000000..e0a2ed6 --- /dev/null +++ b/src/doip/handlers/vehicle_identification/request_by_vin.rs @@ -0,0 +1,115 @@ +// 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 + +//! Handler for VIN-filtered vehicle-identification requests. + +use super::utils::create_vi_response; +use crate::config::EcuConfig; +use crate::doip::{ + PayloadHandler, + constants::VIN_LEN, + error::Error, + message::{Response, UdpPayloadType, UdpRequest}, + types::{LogicalAddress, Vin}, +}; + +/// Handles `VehicleIdentificationRequestWithVin` messages. +pub struct IdentifyVehicleByVinHandler { + ecu_config: EcuConfig, + logical_address: LogicalAddress, +} + +impl IdentifyVehicleByVinHandler { + /// Creates a handler from ECU identity values and the server logical address. + pub fn new(ecu_config: EcuConfig, logical_address: LogicalAddress) -> Self { + Self { + ecu_config, + logical_address, + } + } +} + +impl PayloadHandler for IdentifyVehicleByVinHandler { + fn payload_type(&self) -> UdpPayloadType { + UdpPayloadType::VehicleIdentificationRequestWithVin + } + + fn handle(&self, udp_request: UdpRequest) -> Result { + if udp_request.payload().len() != VIN_LEN { + return Err(Error::InvalidPayloadLength { + expected: VIN_LEN as u32, + actual: udp_request.payload().len(), + }); + } + let mut bytes = [0u8; 17]; + bytes.copy_from_slice(udp_request.payload()); + if Vin::new(bytes) != self.ecu_config.vin() { + return Err(Error::VinNotMatched); + } + Ok(create_vi_response(&self.ecu_config, self.logical_address)) + } +} + +#[cfg(test)] +mod tests { + use super::super::utils::fixtures::*; + use super::*; + + fn handler() -> IdentifyVehicleByVinHandler { + IdentifyVehicleByVinHandler::new(test_ecu_config(), TEST_ADDR) + } + + #[test] + fn matching_vin_returns_announcement() { + let req = UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequestWithVin, + TEST_VIN.as_bytes().to_vec(), + ); + assert!(handler().handle(req).is_ok()); + } + + #[test] + fn non_matching_vin_returns_no_match() { + let req = UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequestWithVin, + NON_MATCHING_VIN.as_bytes().to_vec(), + ); + assert!(matches!(handler().handle(req), Err(Error::VinNotMatched))); + } + + #[test] + fn wrong_length_returns_error() { + let req = UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequestWithVin, + vec![0x00; 3], // 3 bytes - less than required 17 + ); + assert!(matches!( + handler().handle(req), + Err(Error::InvalidPayloadLength { + expected: 17, + actual: 3 + }) + )); + } + #[test] + fn payload_too_long_returns_error() { + let req = UdpRequest::new( + UdpPayloadType::VehicleIdentificationRequestWithVin, + vec![0x00; 18], // 18 bytes - more than required 17 + ); + assert!(matches!( + handler().handle(req), + Err(Error::InvalidPayloadLength { + expected: 17, + actual: 18 + }) + )); + } +} diff --git a/src/doip/handlers/vehicle_identification/utils.rs b/src/doip/handlers/vehicle_identification/utils.rs new file mode 100644 index 0000000..52911a3 --- /dev/null +++ b/src/doip/handlers/vehicle_identification/utils.rs @@ -0,0 +1,79 @@ +// 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 + +//! Shared response builder for Vehicle Identification handlers (ISO 13400-2 7.6.2). + +use crate::config::EcuConfig; +use crate::doip::{ + constants::NO_FURTHER_ACTION, + message::{Response, UdpPayloadType}, + types::LogicalAddress, +}; + +/// 17 (VIN) + 2 (addr) + 6 (EID) + 6 (GID) + 1 (action byte) = 32 +const VI_RESPONSE_LEN: usize = 32; + +/// Builds the 32-byte Vehicle Identification Response / Announcement payload. +/// +/// payload layout: +/// ```text +/// [0..17] VIN +/// [17..19] logical address (big-endian) +/// [19..25] EID +/// [25..31] GID +/// [31] further action required (0x00 = none) +/// ``` +pub(super) fn create_vi_response( + ecu_config: &EcuConfig, + logical_address: LogicalAddress, +) -> Response { + let mut payload = Vec::with_capacity(VI_RESPONSE_LEN); + payload.extend_from_slice(ecu_config.vin().as_bytes()); + payload.extend_from_slice(&logical_address.to_be_bytes()); + payload.extend_from_slice(ecu_config.eid().as_bytes()); + payload.extend_from_slice(ecu_config.gid().as_bytes()); + payload.push(NO_FURTHER_ACTION); + Response::new(UdpPayloadType::VehicleAnnouncementResponse as u16, payload) +} + +#[cfg(test)] +// pub(super) so sibling handler test modules can import these fixtures, +// but they are not exposed outside the vehicle_identification module. +pub(super) mod fixtures { + use crate::config::EcuConfig; + use crate::doip::types::{Eid, Gid, LogicalAddress, Vin}; + + /// ISO example VIN (17 ASCII characters, valid format) + pub const TEST_VIN: Vin = Vin::new(*b"1HGBH41JXMN109186"); + + // Sample MAC address used as EID (6 bytes) + pub const TEST_EID: Eid = Eid::new([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x01]); + + /// Group ID (6 bytes, all-zero = no grouping per ISO 13400-2) + pub const TEST_GID: Gid = Gid::new([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + + /// ECU logical address - intentionally outside the standard ECU range + /// ECU logical address (ISO 13400-2 range 0x0001–0x0DFF) + pub const TEST_ADDR: LogicalAddress = LogicalAddress::new(0x0E01); + + /// EID that does NOT match TEST_EID (broadcast address, clearly different) + pub const NON_MATCHING_EID: Eid = Eid::new([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); + + /// VIN that does NOT match TEST_VIN (valid format, different vehicle) + pub const NON_MATCHING_VIN: Vin = Vin::new(*b"WVWZZZ3CZWE123456"); + + /// Test ECU config with the above VIN, EID, GID + pub fn test_ecu_config() -> EcuConfig { + EcuConfig::new(TEST_VIN, TEST_EID, TEST_GID) + } + + /// Expected response payload length + pub const VI_RESPONSE_LEN: usize = super::VI_RESPONSE_LEN; +} diff --git a/src/doip/header.rs b/src/doip/header.rs new file mode 100644 index 0000000..3c8fca5 --- /dev/null +++ b/src/doip/header.rs @@ -0,0 +1,103 @@ +// 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 + +//! DoIP generic header parsing. + +use crate::doip::constants::{HEADER_LEN, INVERSE_VERSION, PROTOCOL_VERSION}; +use crate::doip::error::Error; + +/// Parsed DoIP generic header. +/// +/// Contains the raw payload type and declared payload length. +#[derive(Debug, Clone, Copy)] +pub struct DoipHeader { + /// Raw payload type from the header. + pub payload_type_raw: u16, + /// Declared payload length. + pub payload_len: usize, +} + +impl DoipHeader { + /// Parses an 8-byte DoIP generic header. + /// + /// # Errors + /// + /// Returns [`Error::InvalidHeaderVersion`] or [`Error::InvalidInverseVersion`] + /// if the version fields are invalid. + pub fn parse(data: &[u8]) -> Result { + debug_assert!( + data.len() >= HEADER_LEN, + "DoipHeader::parse requires at least {HEADER_LEN} bytes, got {}", + data.len() + ); + + if data[0] != PROTOCOL_VERSION { + return Err(Error::InvalidHeaderVersion(data[0])); + } + if data[1] != INVERSE_VERSION { + return Err(Error::InvalidInverseVersion(data[1])); + } + + let payload_type_raw = u16::from_be_bytes([data[2], data[3]]); + let payload_len = u32::from_be_bytes([data[4], data[5], data[6], data[7]]) as usize; + + Ok(Self { + payload_type_raw, + payload_len, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_header(payload_type: u16, payload_len: u32) -> Vec { + let mut buf = vec![PROTOCOL_VERSION, INVERSE_VERSION]; + buf.extend_from_slice(&payload_type.to_be_bytes()); + buf.extend_from_slice(&payload_len.to_be_bytes()); + buf + } + + #[test] + fn parse_valid_header() { + let data = valid_header(0x0005, 11); + let header = DoipHeader::parse(&data).unwrap(); + assert_eq!(header.payload_type_raw, 0x0005); + assert_eq!(header.payload_len, 11); + } + + #[test] + fn parse_rejects_bad_version() { + let mut data = valid_header(0x0007, 0); + data[0] = 0x01; + assert!(matches!( + DoipHeader::parse(&data), + Err(Error::InvalidHeaderVersion(0x01)) + )); + } + + #[test] + fn parse_rejects_bad_inverse() { + let mut data = valid_header(0x0007, 0); + data[1] = 0xAB; + assert!(matches!( + DoipHeader::parse(&data), + Err(Error::InvalidInverseVersion(0xAB)) + )); + } + + #[test] + fn parse_preserves_large_payload_len() { + let data = valid_header(0x8001, 65_536); + let header = DoipHeader::parse(&data).unwrap(); + assert_eq!(header.payload_len, 65_536); + } +} diff --git a/src/doip/message.rs b/src/doip/message.rs new file mode 100644 index 0000000..4fefe8b --- /dev/null +++ b/src/doip/message.rs @@ -0,0 +1,343 @@ +// 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 + +//! DoIP message and payload types. + +use crate::doip::constants::{INVERSE_VERSION, PROTOCOL_VERSION}; + +/// Generic DoIP header NACK codes (ISO 13400-2 9.4, Table 18). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum DoipNackCode { + /// Header fields do not match the expected pattern (bad version or inverse byte). + IncorrectPattern = 0x00, + /// Payload type is not supported by this entity. + UnknownPayloadType = 0x01, + /// Message is too large to be processed. + MessageTooLarge = 0x02, + /// Server ran out of memory. + OutOfMemory = 0x03, + /// Payload length field does not match actual payload size. + InvalidPayloadLength = 0x04, +} + +impl From for u8 { + fn from(code: DoipNackCode) -> Self { + code as u8 + } +} + +/// Unique identifier for a TCP session, assigned at connection accept time. +#[derive(Debug)] +pub struct ConnectionId(uuid::Uuid); + +impl ConnectionId { + /// Generates a new random connection identifier. + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl Default for ConnectionId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for ConnectionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// DoIP payload types valid on TCP connections. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TcpPayloadType { + /// Generic DoIP header NACK. + GenericDoipHeaderNack = 0x0000, + /// Routing activation request. + RoutingActivationRequest = 0x0005, + /// Routing activation response. + RoutingActivationResponse = 0x0006, + /// Alive check request. + AliveCheckRequest = 0x0007, + /// Alive check response. + AliveCheckResponse = 0x0008, + /// Diagnostic message request. + DiagnosticMessage = 0x8001, + /// Diagnostic message positive acknowledgment. + DiagnosticMessagePositiveAck = 0x8002, + /// Diagnostic message negative acknowledgment. + DiagnosticMessageNegativeAck = 0x8003, +} + +impl TryFrom for TcpPayloadType { + type Error = u16; + + /// Converts a raw payload type into a TCP payload enum variant. + /// + /// # Errors + /// + /// Returns the original value if it is not a supported TCP payload type. + fn try_from(v: u16) -> Result { + match v { + 0x0000 => Ok(Self::GenericDoipHeaderNack), + 0x0005 => Ok(Self::RoutingActivationRequest), + 0x0006 => Ok(Self::RoutingActivationResponse), + 0x0007 => Ok(Self::AliveCheckRequest), + 0x0008 => Ok(Self::AliveCheckResponse), + 0x8001 => Ok(Self::DiagnosticMessage), + 0x8002 => Ok(Self::DiagnosticMessagePositiveAck), + 0x8003 => Ok(Self::DiagnosticMessageNegativeAck), + other => Err(other), + } + } +} + +/// DoIP payload types valid on UDP. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum UdpPayloadType { + /// Generic DoIP header NACK. + GenericDoipHeaderNack = 0x0000, + /// Vehicle-identification request. + VehicleIdentificationRequest = 0x0001, + /// Vehicle-identification request filtered by EID. + VehicleIdentificationRequestWithEid = 0x0002, + /// Vehicle-identification request filtered by VIN. + VehicleIdentificationRequestWithVin = 0x0003, + /// Vehicle announcement response. + VehicleAnnouncementResponse = 0x0004, + /// Entity-status request. + DoipEntityStatusRequest = 0x4001, + /// Entity-status response. + DoipEntityStatusResponse = 0x4002, +} + +impl TryFrom for UdpPayloadType { + type Error = u16; + + /// Converts a raw payload type into a UDP payload enum variant. + /// + /// # Errors + /// + /// Returns the original value if it is not a supported UDP payload type. + fn try_from(v: u16) -> Result { + match v { + 0x0000 => Ok(Self::GenericDoipHeaderNack), + 0x0001 => Ok(Self::VehicleIdentificationRequest), + 0x0002 => Ok(Self::VehicleIdentificationRequestWithEid), + 0x0003 => Ok(Self::VehicleIdentificationRequestWithVin), + 0x0004 => Ok(Self::VehicleAnnouncementResponse), + 0x4001 => Ok(Self::DoipEntityStatusRequest), + 0x4002 => Ok(Self::DoipEntityStatusResponse), + other => Err(other), + } + } +} + +/// DoIP request received over TCP. +#[derive(Debug)] +pub struct TcpRequest { + payload_type: TcpPayloadType, + payload: Vec, +} + +impl TcpRequest { + /// Creates a TCP request from a payload type and payload bytes. + pub fn new(payload_type: TcpPayloadType, payload: Vec) -> Self { + Self { + payload_type, + payload, + } + } + + /// Returns the payload bytes without the DoIP header. + pub fn payload(&self) -> &[u8] { + &self.payload + } +} + +/// DoIP request received over UDP. +pub struct UdpRequest { + payload_type: UdpPayloadType, + payload: Vec, +} + +impl UdpRequest { + /// Creates a UDP request from a payload type and payload bytes. + pub fn new(payload_type: UdpPayloadType, payload: Vec) -> Self { + Self { + payload_type, + payload, + } + } + + /// Returns the payload bytes without the DoIP header. + pub fn payload(&self) -> &[u8] { + &self.payload + } +} + +/// DoIP response payload and payload type. +/// +/// The same type is used for TCP and UDP responses. +#[derive(Debug)] +pub struct Response { + payload_type: u16, + payload: Vec, +} + +impl Response { + /// Creates a response from a numeric payload type and payload bytes. + pub fn new(payload_type: u16, payload: Vec) -> Self { + Self { + payload_type, + payload, + } + } + + /// Creates a Generic DoIP Header NACK response. + pub fn doip_header_nack(code: DoipNackCode) -> Self { + Self::new(0x0000, vec![u8::from(code)]) + } + + /// Returns the numeric payload type for this response. + pub fn payload_type(&self) -> u16 { + self.payload_type + } + + /// Returns the payload bytes without the DoIP header. + pub fn payload(&self) -> &[u8] { + &self.payload + } + + /// Serializes the response into DoIP wire format. + /// + /// Encoding layout: + /// - byte 0: protocol version (`0xFD`) + /// - byte 1: inverse protocol version (`0x02`) + /// - bytes 2..4: payload type (`u16`, big-endian) + /// - bytes 4..8: payload length (`u32`, big-endian) + /// - bytes 8..: payload bytes + pub fn to_bytes(&self) -> Vec { + let len = self.payload().len() as u32; + let mut buf = Vec::with_capacity(crate::doip::constants::HEADER_LEN + self.payload().len()); + buf.push(PROTOCOL_VERSION); + buf.push(INVERSE_VERSION); + buf.extend_from_slice(&self.payload_type().to_be_bytes()); + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(self.payload()); + buf + } +} + +/// Extracts the payload type from a request. +pub trait HasPayloadType { + /// Returns the request payload type. + fn payload_type(&self) -> PayloadType; +} + +impl HasPayloadType for TcpRequest { + fn payload_type(&self) -> TcpPayloadType { + self.payload_type + } +} + +impl HasPayloadType for UdpRequest { + fn payload_type(&self) -> UdpPayloadType { + self.payload_type + } +} + +impl From for u16 { + fn from(payload_type: TcpPayloadType) -> Self { + payload_type as u16 + } +} + +impl From for u16 { + fn from(payload_type: UdpPayloadType) -> Self { + payload_type as u16 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nack_response_has_correct_payload_type_and_code() { + let resp = Response::doip_header_nack(DoipNackCode::MessageTooLarge); + assert_eq!(resp.payload_type(), 0x0000); + assert_eq!(resp.payload(), &[0x02]); + } + + #[test] + fn response_to_bytes_has_correct_header() { + let resp = Response::new(0x0004, vec![0xAA, 0xBB]); + let bytes = resp.to_bytes(); + assert_eq!(bytes[0], 0xFD); // protocol version + assert_eq!(bytes[1], 0x02); // inverse version + assert_eq!(&bytes[2..4], &0x0004u16.to_be_bytes()); // payload type + assert_eq!(&bytes[4..8], &2u32.to_be_bytes()); // payload length + assert_eq!(&bytes[8..], &[0xAA, 0xBB]); // payload + } + + #[test] + fn tcp_payload_type_try_from_valid() { + assert_eq!( + TcpPayloadType::try_from(0x0005), + Ok(TcpPayloadType::RoutingActivationRequest) + ); + assert_eq!( + TcpPayloadType::try_from(0x8001), + Ok(TcpPayloadType::DiagnosticMessage) + ); + } + + #[test] + fn tcp_payload_type_try_from_invalid() { + assert_eq!(TcpPayloadType::try_from(0xFFFF), Err(0xFFFF)); + } + + #[test] + fn udp_payload_type_try_from_valid() { + assert_eq!( + UdpPayloadType::try_from(0x0001), + Ok(UdpPayloadType::VehicleIdentificationRequest) + ); + assert_eq!( + UdpPayloadType::try_from(0x4001), + Ok(UdpPayloadType::DoipEntityStatusRequest) + ); + } + + #[test] + fn udp_payload_type_try_from_invalid() { + assert_eq!(UdpPayloadType::try_from(0x9999), Err(0x9999)); + } + + #[test] + fn payload_type_into_u16_roundtrip() { + let tcp: u16 = TcpPayloadType::DiagnosticMessage.into(); + assert_eq!(tcp, 0x8001); + let udp: u16 = UdpPayloadType::DoipEntityStatusRequest.into(); + assert_eq!(udp, 0x4001); + } + #[test] + fn response_to_bytes_empty_payload() { + let resp = Response::new(0x0006, vec![]); + let bytes = resp.to_bytes(); + assert_eq!(bytes.len(), 8); // header only, no payload + assert_eq!(&bytes[4..8], &0u32.to_be_bytes()); + } +} diff --git a/src/doip/mod.rs b/src/doip/mod.rs new file mode 100644 index 0000000..7d8378e --- /dev/null +++ b/src/doip/mod.rs @@ -0,0 +1,92 @@ +// 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 + +//! DoIP protocol types, handlers, and dispatcher construction. + +pub mod constants; +pub mod dispatch; +pub mod error; +pub mod handlers; +pub mod header; +pub mod message; +pub mod types; + +pub use dispatch::{PayloadHandler, TcpDispatcher, UdpDispatcher}; +pub use types::{Eid, Gid, LogicalAddress, Vin}; + +use std::sync::Arc; + +/// Builds the TCP dispatcher with these handlers registered: +/// - [`handlers::RoutingActivationHandler`] +/// - [`handlers::AliveCheckHandler`] +/// - [`handlers::DiagnosticsHandler`] +/// +/// # example +/// +/// ```no_run +/// use uds2sovd_proxy_lib::doip; +/// use uds2sovd_proxy_lib::proxy::stub::StubProxy; +/// use uds2sovd_proxy_lib::doip::types::LogicalAddress; +/// use std::sync::Arc; +/// +/// let stub_proxy = StubProxy; +/// let dispatcher = doip::tcp_dispatcher( +/// LogicalAddress::new(0x0001), +/// Arc::new(stub_proxy), +/// ); +/// ``` +pub fn tcp_dispatcher( + logical_addr: LogicalAddress, + proxy: Arc, +) -> TcpDispatcher { + use handlers::{AliveCheckHandler, DiagnosticsHandler, RoutingActivationHandler}; + let mut dispatcher = TcpDispatcher::new(); + dispatcher.register(RoutingActivationHandler::new(logical_addr)); + dispatcher.register(AliveCheckHandler::new(logical_addr)); + dispatcher.register(DiagnosticsHandler::new(proxy)); + dispatcher +} + +/// Builds the UDP dispatcher with these handlers registered: +/// - [`handlers::IdentifyVehicleHandler`] +/// - [`handlers::IdentifyVehicleByEidHandler`] +/// - [`handlers::IdentifyVehicleByVinHandler`] +/// - [`handlers::EntityStatusHandler`] with `max_connections = 10` and +/// `max_data_size = 65_535` +/// +/// # example +/// +/// ```no_run +/// use uds2sovd_proxy_lib::doip; +/// use uds2sovd_proxy_lib::config::EcuConfig; +/// use uds2sovd_proxy_lib::doip::types::LogicalAddress; +/// +/// let ecu = EcuConfig::default(); +/// let dispatcher = doip::udp_dispatcher( +/// LogicalAddress::new(0x0001), +/// &ecu, +/// ); +/// ``` +pub fn udp_dispatcher( + logical_addr: LogicalAddress, + ecu: &crate::config::EcuConfig, +) -> UdpDispatcher { + use handlers::{ + EntityStatusHandler, IdentifyVehicleByEidHandler, IdentifyVehicleByVinHandler, + IdentifyVehicleHandler, + }; + let ecu = ecu.clone(); + let mut dispatcher = UdpDispatcher::new(); + dispatcher.register(IdentifyVehicleHandler::new(ecu.clone(), logical_addr)); + dispatcher.register(IdentifyVehicleByEidHandler::new(ecu.clone(), logical_addr)); + dispatcher.register(IdentifyVehicleByVinHandler::new(ecu, logical_addr)); + dispatcher.register(EntityStatusHandler::new(10, 65_535)); + dispatcher +} diff --git a/src/doip/types.rs b/src/doip/types.rs new file mode 100644 index 0000000..8222303 --- /dev/null +++ b/src/doip/types.rs @@ -0,0 +1,82 @@ +// 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 + +//! DoIP domain value types. + +use serde::Deserialize; + +/// DoIP logical address. +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct LogicalAddress(u16); + +impl LogicalAddress { + /// Creates a logical address from a raw `u16`. + pub const fn new(addr: u16) -> Self { + Self(addr) + } + + /// Returns the address as big-endian bytes. + pub fn to_be_bytes(self) -> [u8; 2] { + self.0.to_be_bytes() + } +} + +impl From for LogicalAddress { + fn from(addr: u16) -> Self { + Self(addr) + } +} + +/// Vehicle identification number stored as 17 bytes. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct Vin([u8; 17]); + +impl Vin { + /// Creates a VIN from a 17-byte array. + pub const fn new(bytes: [u8; 17]) -> Self { + Self(bytes) + } + + /// Returns the raw VIN bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +/// Entity identifier stored as 6 bytes. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct Eid([u8; 6]); + +impl Eid { + /// Creates an EID from a 6-byte array. + pub const fn new(bytes: [u8; 6]) -> Self { + Self(bytes) + } + + /// Returns the raw EID bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +/// Group identifier stored as 6 bytes. +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct Gid([u8; 6]); + +impl Gid { + /// Create a GID from a 6-byte array. + pub const fn new(bytes: [u8; 6]) -> Self { + Self(bytes) + } + /// Raw bytes for on-wire serialisation. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..3c515df --- /dev/null +++ b/src/error.rs @@ -0,0 +1,33 @@ +//! Top-level application error types. +//! +//! Aggregates errors from configuration loading, transport I/O, and protocol processing. +//! Applications should handle [`AppError`] to report issues during server operation. + +// 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 + +use crate::config::ConfigError; +use crate::doip::error::Error; + +/// Top-level application error. +#[derive(Debug, thiserror::Error)] +pub enum AppError { + /// Returned when DoIP parsing or handler execution fails. + #[error(transparent)] + DoIP(#[from] Error), + + /// Returned when a runtime I/O operation fails. + #[error("io error: {0}")] + IO(#[from] std::io::Error), + + /// Returned when configuration cannot be loaded. + #[error("config error: {0}")] + Config(#[from] ConfigError), +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3c91a00 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,77 @@ +// 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 + +#![doc = include_str!("../docs/detailed_design.md")] + +//! # UDS-to-SOVD Library +//! +//! Implements ISO 13400-2 Diagnostics over Internet Protocol (DoIP) as a bridge +//! between UDS (Unified Diagnostic Services) and SOVD backends. +//! +//! This library provides the core protocol implementation and is the foundation for the application: +//! +//! - `uds2sovd-proxy-lib`: the library crate with the DoIP protocol and backend logic +//! - `uds2sovd-proxy`: the standalone UDS-to-SOVD Proxy application +//! - `doip-tester`: the manual tester tool used to exercise the application +//! +//! For application-level workflows, use the server application and tester tool. +//! +//! # Quick Start +//! +//! ```no_run +//! use uds2sovd_proxy_lib::{config, doip, proxy}; +//! use uds2sovd_proxy_lib::config::ConfigProvider; +//! use std::sync::Arc; +//! +//! let cfg = config::DefaultConfigProvider::new(config::ServerConfig::default()).load()?; +//! let (tcp_cfg, udp_cfg, ecu_cfg) = cfg.into_parts(); +//! let tcp_dispatcher = doip::tcp_dispatcher(tcp_cfg.logical_address(), Arc::new(proxy::stub::StubProxy)); +//! let udp_dispatcher = doip::udp_dispatcher(udp_cfg.logical_address(), &ecu_cfg); +//! # Ok::<(), Box>(()) +//! ``` +//! +//! # Core Modules +//! +//! - [`config`]: Configuration loading from defaults or TOML files +//! - [`doip`]: Protocol types, dispatchers, and message handling +//! - [`server`]: TCP and UDP transport runtimes +//! - [`proxy`]: Backend diagnostic interface and implementations +//! - [`error`]: Application-level error aggregation +//! +//! # Implementing a Backend +//! +//! Implement the [`proxy::SovdProxy`] trait to connect your diagnostic system: +//! +//! ```ignore +//! use uds2sovd_proxy_lib::proxy::SovdProxy; +//! +//! pub struct MyBackend; +//! +//! impl SovdProxy for MyBackend { +//! fn process(&self, uds_request: &[u8]) -> Result, _> { +//! // Forward UDS request to your diagnostic backend +//! // Return the response bytes +//! Ok(Vec::new()) +//! } +//! } +//! ``` +//! +//! # Learn More +//! +//! - **API Documentation**: Explore modules and types above +//! - **Architecture**: See embedded design documentation below +//! - **Running the Server**: See the `uds2sovd-proxy` crate docs for the standalone server +//! - **Testing**: See the `doip-tester` crate docs for the tester tool + +pub mod config; +pub mod doip; +pub mod error; +pub mod proxy; +pub mod server; diff --git a/src/proxy/error.rs b/src/proxy/error.rs new file mode 100644 index 0000000..dcb28bf --- /dev/null +++ b/src/proxy/error.rs @@ -0,0 +1,19 @@ +// 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 + +//! Errors returned by [`SovdProxy`](super::SovdProxy) implementations. + +/// Errors reported by a [`SovdProxy`](super::SovdProxy). +#[derive(Debug, thiserror::Error)] +pub enum SovdProxyError { + /// Returned when the backend response cannot be interpreted as valid UDS bytes. + #[error("SOVD returned an invalid UDS response")] + InvalidResponse, +} diff --git a/src/proxy/mock.rs b/src/proxy/mock.rs new file mode 100644 index 0000000..88b8370 --- /dev/null +++ b/src/proxy/mock.rs @@ -0,0 +1,20 @@ +// 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 + +use super::{SovdProxy, SovdProxyError}; + +/// Test-only [`SovdProxy`] implementation that echoes the request bytes. +pub struct MockProxy; + +impl SovdProxy for MockProxy { + fn process(&self, uds_request: &[u8]) -> Result, SovdProxyError> { + Ok(uds_request.to_vec()) + } +} diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs new file mode 100644 index 0000000..a061069 --- /dev/null +++ b/src/proxy/mod.rs @@ -0,0 +1,33 @@ +// 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 + +//! Backend proxy traits and implementations. + +pub mod error; +#[cfg(test)] +pub mod mock; +pub mod stub; + +pub use error::SovdProxyError; + +/// Processes a raw UDS request and returns a raw UDS response. +/// +/// The input and output contain UDS payload bytes only and do not include DoIP +/// framing. +/// +/// This trait is synchronous; callers invoke it from the transport handler path. +pub trait SovdProxy: Send + Sync { + /// Processes a UDS request. + /// + /// # Errors + /// + /// Returns [`SovdProxyError`] if a response cannot be produced. + fn process(&self, uds_request: &[u8]) -> Result, SovdProxyError>; +} diff --git a/src/proxy/stub.rs b/src/proxy/stub.rs new file mode 100644 index 0000000..a969daf --- /dev/null +++ b/src/proxy/stub.rs @@ -0,0 +1,42 @@ +// 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 + +use super::{SovdProxy, SovdProxyError}; + +/// [`SovdProxy`] implementation that returns UDS NRC `0x11` for every request. +pub struct StubProxy; + +impl SovdProxy for StubProxy { + fn process(&self, uds_request: &[u8]) -> Result, SovdProxyError> { + if uds_request.is_empty() { + return Err(SovdProxyError::InvalidResponse); + } + // UDS Negative Response: 0x7F 0x11 (serviceNotSupported) + Ok(vec![0x7F, uds_request[0], 0x11]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stub_returns_nrc_service_not_supported() { + let proxy = StubProxy; + let resp = proxy.process(&[0x22, 0xF1, 0x90]).unwrap(); + assert_eq!(resp, vec![0x7F, 0x22, 0x11]); + } + + #[test] + fn stub_errors_on_empty_request() { + let proxy = StubProxy; + assert!(proxy.process(&[]).is_err()); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..af5eccb --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,59 @@ +// 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 + +//! Transport runtimes for the DoIP server. + +pub mod tcp; +pub mod udp; + +use std::io; +use tcp::Tcp; +use udp::Udp; + +/// Starts a transport runtime. +#[allow(async_fn_in_trait)] +pub trait Transport: Send + Sync { + /// Starts the transport runtime. + /// + /// Implementations typically run an internal loop until task cancellation. + /// Recoverable per-connection or per-datagram failures may be handled + /// internally and logged without returning from this method. + /// + /// # Errors + /// + /// Returns [`io::Error`] when startup fails (for example, socket bind + /// failures) or when the transport chooses to surface a fatal runtime error. + async fn start(&self) -> Result<(), io::Error>; +} + +/// Runs the TCP and UDP transports together. +pub struct Server { + tcp: Tcp, + udp: Udp, +} + +impl Server { + /// Creates a server from TCP and UDP transport instances. + pub fn new(tcp: Tcp, udp: Udp) -> Self { + Self { tcp, udp } + } + + /// Starts both transports concurrently. + /// + /// # Errors + /// + /// Uses `tokio::try_join!` and returns the first [`io::Error`] surfaced by + /// either transport. When one transport returns an error, the sibling future + /// is dropped. + pub async fn start(&self) -> Result<(), io::Error> { + tokio::try_join!(self.tcp.start(), self.udp.start())?; + Ok(()) + } +} diff --git a/src/server/tcp/framer.rs b/src/server/tcp/framer.rs new file mode 100644 index 0000000..0e0487e --- /dev/null +++ b/src/server/tcp/framer.rs @@ -0,0 +1,263 @@ +// 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 + +use crate::doip::constants::{HEADER_LEN, MAX_DOIP_PAYLOAD_LEN}; +use crate::doip::error::Error; +use crate::doip::header::DoipHeader; +use crate::doip::message::TcpPayloadType; + +/// A fully validated DoIP frame parsed from the TCP byte stream. +#[derive(Debug)] +pub struct Frame { + payload_type: TcpPayloadType, + payload: Vec, +} + +impl Frame { + /// Consume the frame, returning the payload type and raw bytes. + pub fn into_parts(self) -> (TcpPayloadType, Vec) { + (self.payload_type, self.payload) + } +} + +/// Stateful byte-stream framer for DoIP over TCP. +/// +/// # Framing strategy +/// +/// DoIP has no start-of-frame markers or escape sequences. Framing relies +/// entirely on the length field in the 8-byte header. Once a frame is parsed, +/// the framer commits to that interpretation - there's no way to resynchronize +/// mid-stream if corruption occurs. +/// +/// # Error handling +/// +/// - **Protocol errors** (`InvalidHeaderVersion`, `InvalidInverseVersion`): +/// Parsing stops immediately. The connection must be closed per ISO 13400-2 7.2. +/// - **Oversized frames**: Entire frame is skipped when complete. Partial frames +/// wait for more data to avoid misinterpreting payload as header. +/// - **Unknown payload types**: Frame is consumed and error returned, but parsing +/// continues for subsequent frames. +pub struct Framer { + buffer: Vec, +} + +impl Framer { + /// Creates a framer with an empty internal buffer. + pub fn new() -> Self { + Self { buffer: Vec::new() } + } + + /// Feed raw bytes in; receive zero or more complete frames (or per-frame errors) out. + /// + /// # Framing guarantees + /// + /// - A framing error on one frame does NOT discard subsequent buffered data + /// (exception: protocol version errors trigger immediate stop). + /// - Partial frames remain in buffer until complete. + /// - Oversized frames are only skipped after receiving all declared bytes. + /// + /// # Protocol version errors + /// + /// If `InvalidHeaderVersion` or `InvalidInverseVersion` is returned, the caller + /// MUST close the TCP connection. DoIP provides no recovery mechanism for these errors. + pub fn feed(&mut self, bytes: &[u8]) -> Vec> { + self.buffer.extend_from_slice(bytes); + let mut frames = Vec::new(); + + loop { + if self.buffer.len() < HEADER_LEN { + break; // not enough bytes for a header yet + } + + // Parse and validate the 8-byte DoIP generic header + let header = match DoipHeader::parse(&self.buffer[..HEADER_LEN]) { + Ok(h) => h, + Err(e) => { + // Protocol version errors - no recovery possible mid-stream. + // Return error and let connection handler close per ISO 13400-2 7.2 + frames.push(Err(e)); + break; + } + }; + + let payload_type_raw = header.payload_type_raw; + let payload_len = header.payload_len; + + // ISO 13400-2: max DoIP payload size is 64KB for standard diagnostics. + + if payload_len > MAX_DOIP_PAYLOAD_LEN { + frames.push(Err(Error::PayloadTooLarge(payload_len))); + // Corner case: Must drain ENTIRE frame (header + payload), not just header. + // Draining only header would cause payload bytes to be misinterpreted as + // the next header, breaking frame synchronization. + // + // Additional edge case: If payload hasn't fully arrived yet, we must wait + // rather than drain partial data, otherwise we'd skip into the middle of + // the oversized payload and lose sync. + let total_len = HEADER_LEN + payload_len; + if self.buffer.len() >= total_len { + self.buffer.drain(..total_len); + } else { + break; // Wait for complete frame before skipping + } + continue; + } + + let total_len = HEADER_LEN + payload_len; + if self.buffer.len() < total_len { + break; // payload not yet fully received - wait for more data + } + + let payload_type = match TcpPayloadType::try_from(payload_type_raw) { + Ok(parsed_type) => parsed_type, + Err(raw) => { + frames.push(Err(Error::UnknownPayloadType(raw))); + self.buffer.drain(..total_len); + continue; + } + }; + + let payload = self.buffer[HEADER_LEN..total_len].to_vec(); + self.buffer.drain(..total_len); + frames.push(Ok(Frame { + payload_type, + payload, + })); + } + + frames + } +} + +impl Default for Framer { + /// Note: `#[derive(new)]` can be considered in future if similar patterns grow across the codebase. + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw_frame(payload_type: u16, payload: &[u8]) -> Vec { + let mut buf = vec![0xFD, 0x02]; + buf.extend_from_slice(&payload_type.to_be_bytes()); + buf.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + buf.extend_from_slice(payload); + buf + } + + #[test] + fn single_complete_frame_emitted() { + let mut f = Framer::new(); + let raw = raw_frame(0x0007, &[]); // AliveCheckRequest, empty payload + let mut out = f.feed(&raw); + assert_eq!(out.len(), 1); + let (pt, payload) = out.remove(0).unwrap().into_parts(); + assert_eq!(pt, TcpPayloadType::AliveCheckRequest); + assert!(payload.is_empty()); + } + + #[test] + fn header_split_across_two_feeds() { + let mut f = Framer::new(); + let raw = raw_frame(0x0007, &[]); + assert!( + f.feed(&raw[..4]).is_empty(), + "partial header yields no frame" + ); + let out = f.feed(&raw[4..]); + assert_eq!(out.len(), 1); + assert!(out[0].is_ok()); + } + + #[test] + fn payload_split_across_two_feeds() { + let mut f = Framer::new(); + let payload = vec![0xAA, 0xBB, 0xCC, 0xDD]; + let raw = raw_frame(0x0007, &payload); + let mid = raw.len() / 2; + assert!( + f.feed(&raw[..mid]).is_empty(), + "partial payload yields no frame" + ); + let mut out = f.feed(&raw[mid..]); + assert_eq!(out.len(), 1); + let (_, frame_payload) = out.remove(0).unwrap().into_parts(); + assert_eq!(frame_payload, payload); + } + + #[test] + fn two_messages_packed_in_one_feed() { + let mut f = Framer::new(); + let mut raw = raw_frame(0x0007, &[]); // AliveCheckRequest + raw.extend(raw_frame( + 0x0005, + &[0x00, 0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0], + )); // RoutingActivation + let out = f.feed(&raw); + assert_eq!(out.len(), 2); + assert!(out[0].is_ok()); + assert!(out[1].is_ok()); + } + + #[test] + fn invalid_protocol_version_returns_error() { + let mut f = Framer::new(); + let mut raw = raw_frame(0x0007, &[]); + raw[0] = 0x01; // corrupt version byte + let out = f.feed(&raw); + assert_eq!(out.len(), 1); + assert!(matches!(out[0], Err(Error::InvalidHeaderVersion(0x01)))); + } + + #[test] + fn unknown_payload_type_returns_error() { + let mut f = Framer::new(); + let raw = raw_frame(0xDEAD, &[]); // not a valid TcpPayloadType + let out = f.feed(&raw); + assert_eq!(out.len(), 1); + assert!(matches!(out[0], Err(Error::UnknownPayloadType(0xDEAD)))); + } + + #[test] + fn good_frame_after_bad_frame_is_recovered() { + let mut f = Framer::new(); + let mut raw = raw_frame(0xDEAD, &[]); // bad frame + raw.extend(raw_frame(0x0007, &[])); // good frame after + let out = f.feed(&raw); + assert_eq!(out.len(), 2); + assert!(out[0].is_err()); + assert!(out[1].is_ok()); + } + + #[test] + fn invalid_inverse_version_returns_error() { + let mut f = Framer::new(); + let mut raw = raw_frame(0x0007, &[]); + raw[1] = 0xAB; // corrupt inverse version byte + let out = f.feed(&raw); + assert_eq!(out.len(), 1); + assert!(matches!(out[0], Err(Error::InvalidInverseVersion(0xAB)))); + } + + #[test] + fn payload_too_large_returns_error() { + let mut f = Framer::new(); + // Header declaring 65536 bytes (exceeds MAX_PAYLOAD_LEN of 65535) + let mut raw = vec![0xFD, 0x02]; + raw.extend_from_slice(&0x0007u16.to_be_bytes()); + raw.extend_from_slice(&65_536u32.to_be_bytes()); + let out = f.feed(&raw); + assert_eq!(out.len(), 1); + assert!(matches!(out[0], Err(Error::PayloadTooLarge(65_536)))); + } +} diff --git a/src/server/tcp/mod.rs b/src/server/tcp/mod.rs new file mode 100644 index 0000000..5647d21 --- /dev/null +++ b/src/server/tcp/mod.rs @@ -0,0 +1,89 @@ +// 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 + +//! TCP transport runtime. + +mod framer; +mod session; + +use std::io; +use std::sync::Arc; + +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; + +use super::Transport; +use crate::config::TcpConfig; +use crate::doip::TcpDispatcher; +use crate::doip::message::{DoipNackCode, Response}; +use session::{Session, SessionManager}; + +/// TCP transport implementation. +/// +/// Accepts connections, enforces the configured session limit, and dispatches +/// framed DoIP messages to the TCP dispatcher. +/// +/// When the session limit is reached, the transport sends a Generic Header +/// NACK with [`DoipNackCode::OutOfMemory`] and then closes the connection. +pub struct Tcp { + config: TcpConfig, + manager: SessionManager, + dispatcher: Arc, +} + +impl Tcp { + /// Creates a TCP transport from the provided configuration and dispatcher. + pub fn new(config: TcpConfig, dispatcher: TcpDispatcher) -> Self { + let manager = SessionManager::new(config.max_connections()); + Self { + config, + manager, + dispatcher: Arc::new(dispatcher), + } + } +} + +impl Transport for Tcp { + /// Binds the configured TCP address and runs the accept loop. + /// + /// Most accept/session errors are logged and the loop continues. + /// + /// # Errors + /// + /// Returns [`io::Error`] if the listener cannot be bound. + async fn start(&self) -> Result<(), io::Error> { + let listener = TcpListener::bind(self.config.address()).await?; + tracing::info!(address = %self.config.address(), "TCP server listening"); + + loop { + match listener.accept().await { + Ok((mut stream, peer_addr)) => match self.manager.try_acquire() { + Some(slot) => { + tracing::info!(peer = %peer_addr, id = %slot.id(), "new TCP connection"); + let session = Session::new(slot); + let dispatcher = Arc::clone(&self.dispatcher); + let buf_size = self.config.read_buffer_size(); + tokio::spawn(async move { + session.run(stream, dispatcher, buf_size).await; + }); + } + // Server is at capacity: send OutOfMemory NACK, then close. + None => { + tracing::warn!(peer = %peer_addr, "connection rejected: max sessions reached"); + let nack = Response::doip_header_nack(DoipNackCode::OutOfMemory); + let _ = AsyncWriteExt::write_all(&mut stream, &nack.to_bytes()).await; + drop(stream); + } + }, + Err(err) => tracing::error!(error = %err, "TCP accept error"), + } + } + } +} diff --git a/src/server/tcp/session/manager.rs b/src/server/tcp/session/manager.rs new file mode 100644 index 0000000..8c03b21 --- /dev/null +++ b/src/server/tcp/session/manager.rs @@ -0,0 +1,111 @@ +// 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 + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::slot::ConnectionSlot; +use crate::doip::message::ConnectionId; + +/// Tracks active TCP sessions and enforces the configured connection limit. +pub(in crate::server::tcp) struct SessionManager { + max: usize, + active: Arc, +} + +impl SessionManager { + pub(in crate::server::tcp) fn new(max: usize) -> Self { + Self { + max, + active: Arc::new(AtomicUsize::new(0)), + } + } + + /// Attempt to acquire a connection slot. + /// + /// Returns `Some(ConnectionSlot)` if capacity is available, `None` if the + /// maximum is already reached. The returned slot auto-decrements the counter + /// when dropped. + pub(in crate::server::tcp) fn try_acquire(&self) -> Option { + self.active + // .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + if current < self.max { + Some(current + 1) + } else { + None + } + }) + .map(|previous| { + let id = ConnectionId::new(); + let new_count = previous + 1; + // Use the actual incremented value to avoid race condition in logging + tracing::debug!(id = %id, active = new_count, "session slot acquired"); + // Improvement: Consider having ConnectionSlot hold Arc with a + // release_slot() method instead of directly sharing the atomic counter, + // if session management grows more complex in future iterations + ConnectionSlot::new(id, Arc::clone(&self.active)) + }) + .map_err(|_| { + tracing::warn!(max = self.max, "max sessions reached - connection rejected"); + }) + .ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl SessionManager { + /// Number of sessions currently active (test-only helper). + fn active_count(&self) -> usize { + self.active.load(Ordering::SeqCst) + } + } + + #[test] + fn acquire_increments_active_count() { + let mgr = SessionManager::new(2); + assert_eq!(mgr.active_count(), 0); + let _slot = mgr.try_acquire().unwrap(); + assert_eq!(mgr.active_count(), 1); + } + + #[test] + fn drop_slot_decrements_active_count() { + let mgr = SessionManager::new(2); + let slot = mgr.try_acquire().unwrap(); + assert_eq!(mgr.active_count(), 1); + drop(slot); + assert_eq!(mgr.active_count(), 0); + } + + #[test] + fn acquire_fails_at_max_capacity() { + let mgr = SessionManager::new(1); + let _slot = mgr.try_acquire().unwrap(); + assert!(mgr.try_acquire().is_none()); + } + + #[test] + fn acquire_succeeds_after_slot_released() { + let mgr = SessionManager::new(1); + let slot = mgr.try_acquire().unwrap(); + drop(slot); + assert!(mgr.try_acquire().is_some()); + } + + #[test] + fn zero_max_always_rejects() { + let mgr = SessionManager::new(0); + assert!(mgr.try_acquire().is_none()); + } +} diff --git a/src/server/tcp/session/mod.rs b/src/server/tcp/session/mod.rs new file mode 100644 index 0000000..d537674 --- /dev/null +++ b/src/server/tcp/session/mod.rs @@ -0,0 +1,161 @@ +// 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 + +//! Per-connection TCP session handling. +//! +//! Each accepted TCP connection spawns a Session that: +//! 1. Owns a ConnectionSlot (RAII session counter) +//! 2. Reads bytes from the socket +//! 3. Feeds bytes to Framer for DoIP frame extraction +//! 4. Dispatches complete frames to registered handlers +//! 5. Writes responses back to the client +//! +//! When the session ends (clean close, error, or client disconnect), the +//! ConnectionSlot is dropped, automatically decrementing the active session count. + +pub(super) mod manager; +pub(super) mod slot; + +pub(super) use manager::SessionManager; +pub(super) use slot::ConnectionSlot; + +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::doip::TcpDispatcher; +use crate::doip::error::Error; +use crate::doip::message::{ConnectionId, Response, TcpRequest}; +use crate::server::tcp::framer::{Frame, Framer}; + +/// Represents an accepted TCP connection. +/// +/// Owns the `ConnectionSlot`. When `run()` returns, dropping the slot releases +/// the tracked session capacity. +pub(super) struct Session { + slot: ConnectionSlot, +} + +impl Session { + /// Creates a session that owns the given connection slot. + pub(super) fn new(slot: ConnectionSlot) -> Self { + Self { slot } + } + + /// Drive the session I/O loop until disconnection or error. + /// + /// # Session lifecycle + /// + /// 1. Read bytes from TCP stream + /// 2. Feed to framer for frame extraction + /// 3. Dispatch frames to handlers + /// 4. Write responses back + /// 5. Repeat until EOF or I/O failure + /// + /// # Error handling + /// + /// - **Framing errors**: NACK sent, session continues for next frame + /// - **Dispatch errors**: NACK sent to client, session continues + /// - **Write errors**: Session terminates immediately. + /// - **Read errors**: Connection closed, error logged + /// + /// When this function returns, the `ConnectionSlot` is dropped, + /// automatically decrementing the session counter. + pub(crate) async fn run( + self, + mut stream: TcpStream, + dispatcher: Arc, + buf_size: usize, + ) { + let id = self.slot.id(); + let mut framer = Framer::new(); + let mut buf = vec![0u8; buf_size]; + + loop { + match stream.read(&mut buf).await { + Ok(0) => { + tracing::info!(id = %id, "client disconnected"); + break; + } + Ok(bytes_read) => { + for frame_result in framer.feed(&buf[..bytes_read]) { + if handle_frame_result(frame_result, &mut stream, id, &dispatcher) + .await + .is_err() + { + return; + } + } + } + Err(err) => { + tracing::error!(id = %id, error = %err, "read error"); + // TODO: propagate error to caller instead of silently disconnecting + break; + } + } + } + // self drops here → slot drops → counter decremented + } +} + +/// Handle a single frame result from the framer. +/// +/// Returns `Err(())` on fatal write errors (connection should be closed). +async fn handle_frame_result( + result: Result, + stream: &mut TcpStream, + id: &ConnectionId, + dispatcher: &TcpDispatcher, +) -> Result<(), ()> { + match result { + Ok(frame) => process_frame(frame, stream, id, dispatcher).await, + Err(err) => { + tracing::warn!(id = %id, error = %err, "framing error"); + let nack = Response::doip_header_nack(err.nack_code()); + if let Err(write_err) = stream.write_all(&nack.to_bytes()).await { + tracing::error!(id = %id, error = %write_err, "nack write error"); + return Err(()); + } + Ok(()) + } + } +} + +/// Dispatch a complete DoIP frame and write the response. +/// +/// Returns `Err(())` if the response write fails (connection should be closed). +async fn process_frame( + frame: Frame, + stream: &mut TcpStream, + id: &ConnectionId, + dispatcher: &TcpDispatcher, +) -> Result<(), ()> { + // TODO: Consider creating TcpRequest directly from the buffer to avoid the intermediate Frame. + let (payload_type, payload) = frame.into_parts(); + let req = TcpRequest::new(payload_type, payload); + match dispatcher.dispatch(req) { + Ok(resp) => { + if let Err(err) = stream.write_all(&resp.to_bytes()).await { + tracing::error!(id = %id, error = %err, "write error"); + return Err(()); + } + } + Err(err) => { + tracing::warn!(id = %id, error = %err, "dispatch error"); + let nack = Response::doip_header_nack(err.nack_code()); + if let Err(write_err) = stream.write_all(&nack.to_bytes()).await { + tracing::error!(id = %id, error = %write_err, "nack write error"); + return Err(()); + } + } + } + Ok(()) +} diff --git a/src/server/tcp/session/slot.rs b/src/server/tcp/session/slot.rs new file mode 100644 index 0000000..8c34542 --- /dev/null +++ b/src/server/tcp/session/slot.rs @@ -0,0 +1,42 @@ +// 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 + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::doip::message::ConnectionId; + +/// RAII guard for an accepted session slot. +/// +/// Holds the connection's unique ID and a shared reference to the session +/// counter. When dropped (session thread exits, error, or clean close), the +/// counter is automatically decremented - no explicit cleanup required. +pub(in crate::server::tcp) struct ConnectionSlot { + id: ConnectionId, + counter: Arc, +} + +impl ConnectionSlot { + pub(super) fn new(id: ConnectionId, counter: Arc) -> Self { + Self { id, counter } + } + + /// The unique ID assigned to this connection. + pub(in crate::server::tcp) fn id(&self) -> &ConnectionId { + &self.id + } +} + +impl Drop for ConnectionSlot { + fn drop(&mut self) { + self.counter.fetch_sub(1, Ordering::SeqCst); + tracing::debug!(id = %self.id, "session slot released"); + } +} diff --git a/src/server/udp/handler.rs b/src/server/udp/handler.rs new file mode 100644 index 0000000..8ecdb56 --- /dev/null +++ b/src/server/udp/handler.rs @@ -0,0 +1,166 @@ +//! UDP datagram parsing and dispatch. + +// 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 +use std::sync::Arc; + +use crate::doip::UdpDispatcher; +use crate::doip::constants::HEADER_LEN; +use crate::doip::error::Error; +use crate::doip::header::DoipHeader; +use crate::doip::message::{Response, UdpPayloadType, UdpRequest}; + +/// Parses and dispatches a single UDP DoIP datagram. +pub(crate) struct Handler { + dispatcher: Arc, +} + +impl Handler { + /// Creates a UDP datagram handler backed by the given dispatcher. + pub(crate) fn new(dispatcher: Arc) -> Self { + Self { dispatcher } + } + + /// Parses one UDP datagram and dispatches it to the registered handler. + /// + /// The datagram must contain exactly one complete DoIP frame: + /// - at least an 8-byte generic header, + /// - a payload length that matches the datagram body exactly, + /// - and a payload type valid for UDP. + /// + /// Returns the response to send back, or an error if the datagram is malformed + /// or the payload type is unrecognized. + pub(crate) fn handle(&self, data: &[u8]) -> Result { + if data.len() < HEADER_LEN { + return Err(Error::InvalidPayloadLength { + expected: 0, + actual: data.len(), + }); + } + + let header = DoipHeader::parse(&data[..HEADER_LEN])?; + let payload_len = header.payload_len; + + if data.len() != HEADER_LEN + payload_len { + return Err(Error::InvalidPayloadLength { + expected: payload_len as u32, + actual: data.len().saturating_sub(HEADER_LEN), + }); + } + + let payload_type = + UdpPayloadType::try_from(header.payload_type_raw).map_err(Error::UnknownPayloadType)?; + + let udp_request = UdpRequest::new( + payload_type, + data[HEADER_LEN..HEADER_LEN + payload_len].to_vec(), + ); + + self.dispatcher.dispatch(udp_request) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::EcuConfig; + use crate::doip::UdpDispatcher; + use crate::doip::handlers::vehicle_identification::IdentifyVehicleHandler; + use crate::doip::message::UdpPayloadType; + use crate::doip::types::{Eid, Gid, LogicalAddress, Vin}; + + /// Build a well-formed raw datagram with the given payload type and payload. + fn raw_frame(payload_type: u16, payload: &[u8]) -> Vec { + let mut buf = vec![0xFD, 0x02]; + buf.extend_from_slice(&payload_type.to_be_bytes()); + buf.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + buf.extend_from_slice(payload); + buf + } + + fn empty_handler() -> Handler { + Handler::new(Arc::new(UdpDispatcher::new())) + } + + #[test] + fn too_short_data_returns_error() { + let result = empty_handler().handle(&[0xFD, 0x02, 0x00]); + assert!(matches!(result, Err(Error::InvalidPayloadLength { .. }))); + } + + #[test] + fn bad_protocol_version_returns_error() { + let mut data = raw_frame(0x0001, &[]); + data[0] = 0xAB; + let result = empty_handler().handle(&data); + assert!(matches!(result, Err(Error::InvalidHeaderVersion(0xAB)))); + } + + #[test] + fn bad_inverse_version_returns_error() { + let mut data = raw_frame(0x0001, &[]); + data[1] = 0xAB; + let result = empty_handler().handle(&data); + assert!(matches!(result, Err(Error::InvalidInverseVersion(0xAB)))); + } + + #[test] + fn unknown_payload_type_returns_error() { + let data = raw_frame(0xDEAD, &[]); + let result = empty_handler().handle(&data); + assert!(matches!(result, Err(Error::UnknownPayloadType(0xDEAD)))); + } + + #[test] + fn handle_rejects_truncated_payload() { + // Header declares 4 bytes of payload but the datagram is truncated. + let mut data = raw_frame(0x0001, &[0x00, 0x00, 0x00, 0x00]); + data.truncate(10); + let result = empty_handler().handle(&data); + assert!(matches!(result, Err(Error::InvalidPayloadLength { .. }))); + } + + #[test] + fn handle_rejects_trailing_bytes() { + let mut data = raw_frame(0x0001, &[]); + data.extend_from_slice(&[0xAA, 0xBB]); + let result = empty_handler().handle(&data); + assert!(matches!( + result, + Err(Error::InvalidPayloadLength { + expected: 0, + actual: 2 + }) + )); + } + + #[test] + fn handle_valid_vin_request_returns_announcement() { + let mut dispatcher = UdpDispatcher::new(); + dispatcher.register(IdentifyVehicleHandler::new( + EcuConfig::new( + Vin::new(*b"00000000000000000"), + Eid::new([0u8; 6]), + Gid::new([0u8; 6]), + ), + LogicalAddress::new(0x0001), + )); + + let handler = Handler::new(Arc::new(dispatcher)); + let data = raw_frame(UdpPayloadType::VehicleIdentificationRequest as u16, &[]); + let resp = handler.handle(&data).unwrap(); + + assert_eq!( + resp.payload_type(), + UdpPayloadType::VehicleAnnouncementResponse as u16 + ); + assert_eq!(resp.payload().len(), 32); + } +} diff --git a/src/server/udp/mod.rs b/src/server/udp/mod.rs new file mode 100644 index 0000000..65f0e17 --- /dev/null +++ b/src/server/udp/mod.rs @@ -0,0 +1,92 @@ +// 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 + +//! UDP transport runtime. +//! +//! Handles incoming datagrams, dispatches them through registered handlers, +//! and sends responses back to the sender. + +pub mod handler; + +use std::io; +use std::sync::Arc; + +use tokio::net::UdpSocket; + +use super::Transport; +use crate::config::UdpConfig; +use crate::doip::UdpDispatcher; +use crate::doip::constants::UDP_RECV_BUF_SIZE; +use crate::doip::error::Error; +use handler::Handler; + +/// UDP transport implementation. +/// +/// Receives datagrams, dispatches them through the UDP dispatcher, and sends +/// any generated response back to the sender. +/// +/// Runtime behavior per datagram: +/// - Successful dispatch: send handler response. +/// - [`Error::EIDNotMatched`] or [`Error::VinNotMatched`]: send no response. +/// - Other dispatch/parsing errors: send Generic Header NACK. +pub struct Udp { + config: UdpConfig, + handler: Handler, +} + +impl Udp { + /// Creates a UDP transport from the provided configuration and dispatcher. + pub fn new(config: UdpConfig, dispatcher: UdpDispatcher) -> Self { + Self { + config, + handler: Handler::new(Arc::new(dispatcher)), + } + } +} + +impl Transport for Udp { + /// Binds the configured UDP address and runs the receive loop. + /// + /// Most receive/send/dispatch errors are logged and the loop continues. + /// + /// # Errors + /// + /// Returns [`io::Error`] if the UDP socket cannot be bound. + async fn start(&self) -> Result<(), io::Error> { + let socket = UdpSocket::bind(self.config.address()).await?; + tracing::info!(address = %self.config.address(), "UDP server listening"); + + let mut buf = vec![0u8; UDP_RECV_BUF_SIZE]; + loop { + match socket.recv_from(&mut buf).await { + Ok((bytes_received, src_addr)) => { + match self.handler.handle(&buf[..bytes_received]) { + Ok(resp) => { + if let Err(err) = socket.send_to(&resp.to_bytes(), src_addr).await { + tracing::error!(error = %err, peer = %src_addr, "UDP send error"); + } + } + // Non-matching EID/VIN request: intentionally no response. + Err(Error::EIDNotMatched) | Err(Error::VinNotMatched) => { + tracing::debug!(peer = %src_addr, "no matching entity, not responding"); + } + Err(err) => { + tracing::warn!(error = %err, peer = %src_addr, "UDP dispatch error"); + let nack = + crate::doip::message::Response::doip_header_nack(err.nack_code()); + let _ = socket.send_to(&nack.to_bytes(), src_addr).await; + } + } + } + Err(err) => tracing::error!(error = %err, "UDP recv error"), + } + } + } +} diff --git a/tools/doip-tester/Cargo.toml b/tools/doip-tester/Cargo.toml new file mode 100644 index 0000000..6cdcf2c --- /dev/null +++ b/tools/doip-tester/Cargo.toml @@ -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 + +[package] +name = "doip-tester" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "DoIP tester client - simulates the external UDS tester" + +[[bin]] +name = "doip-tester" +path = "main.rs" diff --git a/tools/doip-tester/main.rs b/tools/doip-tester/main.rs new file mode 100644 index 0000000..06f3da7 --- /dev/null +++ b/tools/doip-tester/main.rs @@ -0,0 +1,361 @@ +// 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 + +//! DoIP tester tool - simulates the external UDS tester. +//! +//! The server (`uds2sovd-proxy`) must be running in order for this tester tool +//! to connect and verify the functionality. +//! +//! # Usage +//! ```sh +//! # Terminal 1: start the server +//! cargo run --bin uds2sovd-proxy +//! +//! # Terminal 2: run the tester +//! cargo run --bin doip-tester +//! ``` +//! + +use std::io::{Read, Write}; +use std::net::{TcpStream, UdpSocket}; +use std::time::Duration; + +const PROTOCOL_VERSION: u8 = 0xFD; +const INVERSE_VERSION: u8 = 0x02; +const HEADER_LEN: usize = 8; +const SERVER_TCP: &str = "127.0.0.1:13400"; +const SERVER_UDP: &str = "127.0.0.1:13400"; +const TIMEOUT: Duration = Duration::from_secs(2); + +// Helpers + +/// Constructs an 8-byte DoIP generic header followed by the payload. +fn build_frame(payload_type: u16, payload: &[u8]) -> Vec { + let len = payload.len() as u32; + let mut frame = Vec::with_capacity(HEADER_LEN + payload.len()); + frame.push(PROTOCOL_VERSION); + frame.push(INVERSE_VERSION); + frame.extend_from_slice(&payload_type.to_be_bytes()); + frame.extend_from_slice(&len.to_be_bytes()); + frame.extend_from_slice(payload); + frame +} + +/// Sends a UDP frame and returns the parsed (payload_type, payload) from the response. +fn udp_roundtrip(payload_type: u16, payload: &[u8]) -> Result<(u16, Vec), String> { + let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| format!("bind: {e}"))?; + socket + .set_read_timeout(Some(TIMEOUT)) + .map_err(|e| format!("timeout: {e}"))?; + socket + .send_to(&build_frame(payload_type, payload), SERVER_UDP) + .map_err(|e| format!("send: {e}"))?; + let mut buf = [0u8; 256]; + let n = socket.recv(&mut buf).map_err(|e| format!("recv: {e}"))?; + parse_response(&buf[..n]) +} + +/// Sends raw bytes over UDP and returns the parsed response. +fn udp_raw_roundtrip(raw: &[u8]) -> Result<(u16, Vec), String> { + let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| format!("bind: {e}"))?; + socket + .set_read_timeout(Some(TIMEOUT)) + .map_err(|e| format!("timeout: {e}"))?; + socket + .send_to(raw, SERVER_UDP) + .map_err(|e| format!("send: {e}"))?; + let mut buf = [0u8; 256]; + let n = socket.recv(&mut buf).map_err(|e| format!("recv: {e}"))?; + parse_response(&buf[..n]) +} + +/// Sends a TCP frame on an existing stream and returns the parsed response. +fn tcp_roundtrip( + stream: &mut TcpStream, + payload_type: u16, + payload: &[u8], +) -> Result<(u16, Vec), String> { + stream + .write_all(&build_frame(payload_type, payload)) + .map_err(|e| format!("write: {e}"))?; + let mut buf = [0u8; 256]; + let n = stream.read(&mut buf).map_err(|e| format!("read: {e}"))?; + parse_response(&buf[..n]) +} + +/// Sends raw bytes on a fresh TCP connection and returns the parsed response. +fn tcp_raw_roundtrip(raw: &[u8]) -> Result<(u16, Vec), String> { + let mut stream = TcpStream::connect(SERVER_TCP).map_err(|e| format!("connect: {e}"))?; + stream + .set_read_timeout(Some(TIMEOUT)) + .map_err(|e| format!("timeout: {e}"))?; + stream.write_all(raw).map_err(|e| format!("write: {e}"))?; + let mut buf = [0u8; 256]; + let n = stream.read(&mut buf).map_err(|e| format!("read: {e}"))?; + parse_response(&buf[..n]) +} + +/// Parses a DoIP response buffer into (payload_type, payload_bytes). +fn parse_response(data: &[u8]) -> Result<(u16, Vec), String> { + if data.len() < HEADER_LEN { + return Err("response too short for header".into()); + } + let payload_type = u16::from_be_bytes([data[2], data[3]]); + let payload_len = u32::from_be_bytes([data[4], data[5], data[6], data[7]]) as usize; + if data.len() < HEADER_LEN + payload_len { + return Err(format!( + "response truncated: have {}, need {}", + data.len(), + HEADER_LEN + payload_len + )); + } + Ok(( + payload_type, + data[HEADER_LEN..HEADER_LEN + payload_len].to_vec(), + )) +} + +/// Asserts the response payload type matches the expected value. +fn expect_type(actual: u16, expected: u16) -> Result<(), String> { + if actual != expected { + Err(format!( + "expected type 0x{expected:04X}, got 0x{actual:04X}" + )) + } else { + Ok(()) + } +} + +/// Asserts the response is a NACK (0x0000) with the expected code byte. +fn expect_nack(response_type: u16, payload: &[u8], expected_code: u8) -> Result<(), String> { + expect_type(response_type, 0x0000)?; + let actual = payload.first().copied().unwrap_or(0xFF); + if actual != expected_code { + Err(format!( + "expected NACK code 0x{expected_code:02X}, got 0x{actual:02X}" + )) + } else { + Ok(()) + } +} + +// UDP Tests -- + +/// 0x0001 VehicleIdentificationRequest → 0x0004 VehicleAnnouncement (32 bytes). +fn test_udp_vehicle_id() -> Result<(), String> { + let (ptype, payload) = udp_roundtrip(0x0001, &[])?; + expect_type(ptype, 0x0004)?; + if payload.len() < 32 { + return Err(format!("payload {}/32 bytes", payload.len())); + } + let addr = u16::from_be_bytes([payload[17], payload[18]]); + if addr != 0x0001 { + return Err(format!("logical address 0x{addr:04X}, expected 0x0001")); + } + Ok(()) +} + +/// 0x0002 VehicleIdentificationRequestWithEid → 0x0004 VehicleAnnouncement. +fn test_udp_vehicle_id_by_eid() -> Result<(), String> { + let (ptype, payload) = udp_roundtrip(0x0002, &[0x00; 6])?; + expect_type(ptype, 0x0004)?; + if payload.len() < 32 { + return Err(format!("payload {}/32 bytes", payload.len())); + } + Ok(()) +} + +/// 0x0003 VehicleIdentificationRequestWithVin → 0x0004 VehicleAnnouncement. +fn test_udp_vehicle_id_by_vin() -> Result<(), String> { + let (ptype, payload) = udp_roundtrip(0x0003, b"00000000000000000")?; + expect_type(ptype, 0x0004)?; + if payload.len() < 32 { + return Err(format!("payload {}/32 bytes", payload.len())); + } + Ok(()) +} + +/// 0x4001 EntityStatusRequest → 0x4002 EntityStatusResponse (7 bytes). +fn test_udp_entity_status() -> Result<(), String> { + let (ptype, payload) = udp_roundtrip(0x4001, &[])?; + expect_type(ptype, 0x4002)?; + if payload.len() < 7 { + return Err(format!("payload {}/7 bytes", payload.len())); + } + Ok(()) +} + +/// Invalid protocol version (0xFF) over UDP → NACK 0x00 (incorrect pattern). +fn test_udp_invalid_version() -> Result<(), String> { + let mut frame = build_frame(0x0001, &[]); + frame[0] = 0xFF; + let (ptype, payload) = udp_raw_roundtrip(&frame)?; + expect_nack(ptype, &payload, 0x00) +} + +// TCP Tests -- + +/// 0x0005 RoutingActivationRequest → 0x0006 RoutingActivationResponse (code 0x10). +/// Returns the stream for reuse by subsequent TCP tests. +fn test_tcp_routing_activation() -> Result { + let mut stream = TcpStream::connect(SERVER_TCP).map_err(|e| format!("connect: {e}"))?; + stream + .set_read_timeout(Some(TIMEOUT)) + .map_err(|e| format!("timeout: {e}"))?; + let (ptype, resp) = tcp_roundtrip( + &mut stream, + 0x0005, + &[ + 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ], + )?; + expect_type(ptype, 0x0006)?; + if resp[0] != 0x0E || resp[1] != 0x00 { + return Err(format!( + "echoed source 0x{:02X}{:02X}, expected 0x0E00", + resp[0], resp[1] + )); + } + if resp[4] != 0x10 { + return Err(format!("activation code 0x{:02X}, expected 0x10", resp[4])); + } + Ok(stream) +} + +/// 0x0007 AliveCheckRequest → 0x0008 AliveCheckResponse (2-byte logical address). +fn test_tcp_alive_check(stream: &mut TcpStream) -> Result<(), String> { + let (ptype, payload) = tcp_roundtrip(stream, 0x0007, &[])?; + expect_type(ptype, 0x0008)?; + let addr = u16::from_be_bytes([payload[0], payload[1]]); + if addr != 0x0001 { + return Err(format!("logical address 0x{addr:04X}, expected 0x0001")); + } + Ok(()) +} + +/// 0x8001 DiagnosticMessage (TesterPresent 0x3E) → 0x8002 PositiveAck. +fn test_tcp_diagnostic_tester_present(stream: &mut TcpStream) -> Result<(), String> { + let (ptype, _) = tcp_roundtrip(stream, 0x8001, &[0x0E, 0x00, 0x00, 0x01, 0x3E, 0x00])?; + expect_type(ptype, 0x8002) +} + +/// Invalid protocol version (0xFF) over TCP → NACK 0x00 (incorrect pattern). +fn test_tcp_invalid_version() -> Result<(), String> { + let mut frame = build_frame( + 0x0005, + &[ + 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ], + ); + frame[0] = 0xFF; + let (ptype, payload) = tcp_raw_roundtrip(&frame)?; + expect_nack(ptype, &payload, 0x00) +} + +/// Unknown payload type (0xBEEF) over TCP → NACK 0x01 (unknown payload type). +fn test_tcp_unknown_payload_type() -> Result<(), String> { + let (ptype, payload) = tcp_raw_roundtrip(&build_frame(0xBEEF, &[]))?; + expect_nack(ptype, &payload, 0x01) +} + +// Main - + +fn main() { + println!("=== DoIP Tester ===\n"); + let mut passed = 0u32; + let mut failed = 0u32; + + // UDP tests + for (name, test_fn) in [ + ( + "udp_vehicle_id", + test_udp_vehicle_id as fn() -> Result<(), String>, + ), + ("udp_vehicle_id_by_eid", test_udp_vehicle_id_by_eid), + ("udp_vehicle_id_by_vin", test_udp_vehicle_id_by_vin), + ("udp_entity_status", test_udp_entity_status), + ("udp_invalid_version", test_udp_invalid_version), + ] { + match test_fn() { + Ok(()) => { + println!("[PASS] {name}"); + passed += 1; + } + Err(e) => { + println!("[FAIL] {name} - {e}"); + failed += 1; + } + } + } + + // TCP happy-path tests (shared connection: routing → alive → diagnostic) + let stream = match test_tcp_routing_activation() { + Ok(s) => { + println!("[PASS] tcp_routing_activation"); + passed += 1; + Some(s) + } + Err(e) => { + println!("[FAIL] tcp_routing_activation - {e}"); + failed += 1; + None + } + }; + if let Some(mut s) = stream { + for (name, test_fn) in [ + ( + "tcp_alive_check", + test_tcp_alive_check as fn(&mut TcpStream) -> Result<(), String>, + ), + ( + "tcp_diagnostic_tester_present", + test_tcp_diagnostic_tester_present, + ), + ] { + match test_fn(&mut s) { + Ok(()) => { + println!("[PASS] {name}"); + passed += 1; + } + Err(e) => { + println!("[FAIL] {name} - {e}"); + failed += 1; + } + } + } + } else { + println!("[SKIP] tcp_alive_check - no TCP connection"); + println!("[SKIP] tcp_diagnostic_tester_present - no TCP connection"); + } + + // TCP error tests (separate connections) + for (name, test_fn) in [ + ( + "tcp_invalid_version", + test_tcp_invalid_version as fn() -> Result<(), String>, + ), + ("tcp_unknown_payload_type", test_tcp_unknown_payload_type), + ] { + match test_fn() { + Ok(()) => { + println!("[PASS] {name}"); + passed += 1; + } + Err(e) => { + println!("[FAIL] {name} - {e}"); + failed += 1; + } + } + } + + let total = passed + failed; + println!("\n=== {passed}/{total} passed ==="); + std::process::exit(if failed > 0 { 1 } else { 0 }); +}