1/*
2 * SPDX-FileCopyrightText: 2025 Copyright (c) Contributors to the Eclipse Foundation
3 *
4 * See the NOTICE file(s) distributed with this work for additional
5 * information regarding copyright ownership.
6 *
7 * This program and the accompanying materials are made available under the
8 * terms of the Apache License Version 2.0 which is available at
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * SPDX-License-Identifier: Apache-2.0
12 */
13
14// `CanConfig` is always available: the `config` module of cda-comm-can is not
15// gated on the `can` feature, only the SocketCAN transport code is. This keeps
16// the configuration schema (and all public function signatures that mention
17// `CanConfig`) identical across feature combinations, which matters because
18// cargo feature unification can otherwise produce mismatched signatures
19// between crates (e.g. integration-tests vs. opensovd_cda_lib under
20// `--all-features`). A `[can]` section in a non-`can` build is rejected with
21// an actionable error in `Configuration::validate_sanity` instead.
22pub use cda_comm_can::config::{CanAddressingMode, CanConfig, CanEcuMapping, TransportOverride};
23pub use cda_comm_doip::config::DoipConfig;
24pub use cda_database::DatabaseConfig;
25use cda_interfaces::{
26 FunctionalDescriptionConfig, HashMap,
27 config::{ConfigSanity, ConfigSanityError},
28 datatypes::{
29 ComParams, ComponentsConfig, FaultConfig, FlatbBufConfig, SdBoolMappings,
30 SdMappingsTruthyValue,
31 },
32 lock_config::LockConfig,
33};
34pub use cda_interfaces::{
35 TransportType,
36 communication_control::{
37 CommunicationInitMode, CommunicationSettings, PostUpdateCommunicationMode,
38 },
39};
40pub use cda_plugin_runtime_update::config::RuntimeUpdateConfig;
41use serde::{Deserialize, Serialize};
42
43pub use super::com_params::EcuComParams;
44
45/// Strict-mode flags that opt in to stricter runtime validation.
46///
47/// When `enabled` is `true`, every individual check is activated regardless
48/// of its own value. Individual flags can also be turned on independently
49/// for more granular control.
50#[derive(Deserialize, Serialize, Clone, Debug, Default, schemars::JsonSchema)]
51pub struct StrictConfig {
52 /// Master switch - when `true`, all individual strict checks are enabled.
53 pub enabled: bool,
54 /// Reject requests containing parameters not defined in the diagnostic
55 /// service with a `BadPayload` error (HTTP 400).
56 pub parameter_validation: bool,
57 /// Exit with an error if any key under `[ecu.<name>]` does not match a
58 /// loaded MDD database.
59 pub ecu_config: bool,
60}
61
62impl StrictConfig {
63 #[must_use]
64 pub fn parameter_validation(&self) -> bool {
65 self.enabled || self.parameter_validation
66 }
67
68 #[must_use]
69 pub fn ecu_config(&self) -> bool {
70 self.enabled || self.ecu_config
71 }
72}
73
74/// Top-level application configuration.
75#[derive(Deserialize, Serialize, Clone, Debug, schemars::JsonSchema)]
76pub struct Configuration {
77 /// SOVD HTTP server bind settings.
78 pub server: ServerTransport,
79 /// `DoIP` (Diagnostics over IP) transport layer settings.
80 pub doip: DoipConfig,
81 /// Optional CAN bus transport configuration.
82 /// When enabled, the adapter can communicate with ECUs over CAN bus.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub can: Option<CanConfig>,
85 /// Diagnostic database loading and naming settings.
86 pub database: DatabaseConfig,
87 /// Logging, file output, and tracing backend settings.
88 pub logging: cda_tracing::LoggingConfig,
89 /// Path to the directory containing flash files.
90 pub flash_files_path: String,
91 /// Default functional class used to resolve the flash services (0x34, 0x36, 0x37)
92 /// when the client does not provide `flashClass`. Compared case-insensitively.
93 pub flash_functional_class_default: String,
94 /// Default communication parameters for UDS and `DoIP` protocols.
95 pub com_params: ComParams,
96 /// `FlatBuffers` verification settings for MDD database parsing.
97 pub flat_buf: FlatbBufConfig,
98 /// Functional group description and lookup settings.
99 pub functional_description: FunctionalDescriptionConfig,
100 /// Component response customization.
101 pub components: ComponentsConfig,
102 /// Health check endpoint settings.
103 #[cfg(feature = "health")]
104 pub health: cda_health::config::HealthConfig,
105 /// DTC (Diagnostic Trouble Code) fault memory settings.
106 pub faults: FaultConfig,
107 /// Per-ECU configuration blocks keyed by ECU short name (case-insensitive
108 /// match against the MDD short name returned by `load_proto_data`).
109 pub ecu: HashMap<String, EcuConfig>,
110 /// Configuration for update plugin, i.e. storage paths
111 pub runtime_update_config: RuntimeUpdateConfig,
112 /// Diagnostic communication initialization and post-update behavior.
113 pub communication: CommunicationSettings,
114 /// Lock priority behavior and limits.
115 pub locks: LockConfig,
116 /// Strict-mode validation flags.
117 pub strict: StrictConfig,
118}
119
120/// Per-ECU configuration block.
121#[derive(Deserialize, Serialize, Clone, Debug, Default, schemars::JsonSchema)]
122pub struct EcuConfig {
123 /// Per-ECU communication parameter overrides, merged over the global
124 /// `com_params` at database load time. Only explicitly specified fields
125 /// override the global values.
126 #[serde(default)]
127 pub com_params: Option<EcuComParams>,
128 /// When `true`, overrides `database.ignore_protocol` for this ECU only.
129 #[serde(default)]
130 pub ignore_protocol: Option<bool>,
131 /// Override the protocol short-name used to look up the protocol layer in
132 /// the MDD for this ECU. Replaces the global `doip.protocol_name`.
133 #[serde(default)]
134 pub protocol: Option<String>,
135}
136
137/// SOVD HTTP server bind configuration.
138///
139/// `transport` selects which of the two mutually exclusive variants below is
140/// active. Each variant also captures the other transport's field(s),
141/// unused for binding, purely so `ConfigSanity` can reject a config that
142/// explicitly configures both (e.g. `transport = "unix_socket"` with a
143/// non-default `address`/`port` also present).
144#[derive(Deserialize, Serialize, Clone, Debug, schemars::JsonSchema)]
145#[serde(tag = "transport", rename_all = "snake_case")]
146pub enum ServerTransport {
147 /// Bind to this IP address and TCP port.
148 Tcp {
149 /// IP address the server listens on.
150 address: String,
151 /// TCP port the server listens on.
152 port: u16,
153 // Not used when `transport = "tcp"`; only present so a config that
154 // also sets this alongside `transport = "tcp"` can be rejected by
155 // `ConfigSanity`. Deliberately not a doc comment: this field should
156 // not appear as documented/user-facing in the generated reference
157 // config.
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 unix_socket: Option<String>,
160 },
161 /// Bind to a Unix domain socket instead of TCP.
162 UnixSocket {
163 /// Path to the Unix domain socket to bind the server to, e.g.
164 /// `/run/cda.sock`.
165 ///
166 /// The parent directory of this path must already exist; binding
167 /// fails with an error if it does not.
168 unix_socket: String,
169 // Not used when `transport = "unix_socket"`; only present so a
170 // config that also sets this alongside `transport = "unix_socket"`
171 // can be rejected by `ConfigSanity`. Deliberately not a doc comment:
172 // see `Tcp::unix_socket` above.
173 #[serde(default = "default_server_address")]
174 address: String,
175 // See `address` above.
176 #[serde(default = "default_server_port")]
177 port: u16,
178 },
179}
180
181pub(crate) fn default_server_address() -> String {
182 "0.0.0.0".to_owned()
183}
184
185pub(crate) fn default_server_port() -> u16 {
186 20002
187}
188
189impl Default for ServerTransport {
190 fn default() -> Self {
191 Self::Tcp {
192 address: default_server_address(),
193 port: default_server_port(),
194 unix_socket: None,
195 }
196 }
197}
198
199impl ConfigSanity for ServerTransport {
200 fn validate_sanity(&self) -> Result<(), ConfigSanityError> {
201 fn validate_address_port(address: &str, port: u16) -> Result<(), ConfigSanityError> {
202 address
203 .parse::<std::net::IpAddr>()
204 .map_err(|_| ConfigSanityError::InvalidValue {
205 field: "server.address".to_owned(),
206 reason: format!("{address} is neither a valid IPv4 nor IPv6 address"),
207 })?;
208 if port == 0 {
209 return Err(ConfigSanityError::InvalidValue {
210 field: "server.port".to_owned(),
211 reason: "Port must be greater than 0".to_owned(),
212 });
213 }
214 Ok(())
215 }
216
217 match self {
218 Self::Tcp {
219 address,
220 port,
221 unix_socket,
222 } => {
223 validate_address_port(address, *port)?;
224 if unix_socket.is_some() {
225 return Err(ConfigSanityError::InvalidValue {
226 field: "server".to_owned(),
227 reason: "transport = \"tcp\" but unix_socket is also set; remove one"
228 .to_owned(),
229 });
230 }
231 Ok(())
232 }
233 Self::UnixSocket {
234 unix_socket,
235 address,
236 port,
237 } => {
238 if unix_socket.trim().is_empty() {
239 return Err(ConfigSanityError::InvalidValue {
240 field: "server.unix_socket".to_owned(),
241 reason: "Path must not be empty".to_owned(),
242 });
243 }
244 if address != &default_server_address() || *port != default_server_port() {
245 return Err(ConfigSanityError::InvalidValue {
246 field: "server".to_owned(),
247 reason: "transport = \"unix_socket\" but a non-default address/port is \
248 also set; remove one"
249 .to_owned(),
250 });
251 }
252 Ok(())
253 }
254 }
255 }
256}
257
258impl ServerTransport {
259 /// The configured TCP address, regardless of which transport is active.
260 /// When `transport = "unix_socket"`, this is the (unused-for-binding)
261 /// address captured alongside it - see the variant's doc comment.
262 #[must_use]
263 pub fn address(&self) -> &str {
264 match self {
265 Self::Tcp { address, .. } | Self::UnixSocket { address, .. } => address,
266 }
267 }
268
269 /// The configured TCP port, regardless of which transport is active.
270 /// When `transport = "unix_socket"`, this is the (unused-for-binding)
271 /// port captured alongside it - see the variant's doc comment.
272 #[must_use]
273 pub fn port(&self) -> u16 {
274 match self {
275 Self::Tcp { port, .. } | Self::UnixSocket { port, .. } => *port,
276 }
277 }
278}
279
280impl Default for Configuration {
281 fn default() -> Self {
282 Configuration {
283 database: DatabaseConfig::default(),
284 flash_files_path: ".".to_owned(),
285 flash_functional_class_default: "flash_download_upload".to_owned(),
286 server: ServerTransport::default(),
287 #[cfg(feature = "health")]
288 health: cda_health::config::HealthConfig::default(),
289 doip: DoipConfig {
290 tester_address: "10.2.1.240".to_owned(),
291 ..Default::default()
292 },
293 can: None,
294 logging: cda_tracing::LoggingConfig::default(),
295 com_params: ComParams::default(),
296 flat_buf: FlatbBufConfig::default(),
297 functional_description: FunctionalDescriptionConfig::default(),
298 components: ComponentsConfig {
299 additional_fields: HashMap::from_iter([
300 (
301 "x-sovd2uds-can-ecus".into(),
302 SdBoolMappings::from_iter([(
303 "CAN".to_owned(),
304 SdMappingsTruthyValue::new(
305 ["yes"].into_iter().map(ToOwned::to_owned).collect::<_>(),
306 true,
307 ),
308 )]),
309 ),
310 (
311 "x-sovd2uds-lin-ecus".into(),
312 SdBoolMappings::from_iter([(
313 "LIN".to_owned(),
314 SdMappingsTruthyValue::new(
315 ["yes"].into_iter().map(ToOwned::to_owned).collect::<_>(),
316 true,
317 ),
318 )]),
319 ),
320 ]),
321 },
322 faults: FaultConfig::default(),
323 ecu: HashMap::default(),
324 runtime_update_config: RuntimeUpdateConfig::default(),
325 communication: CommunicationSettings::default(),
326 locks: LockConfig::default(),
327 strict: StrictConfig::default(),
328 }
329 }
330}
331
332impl Configuration {
333 /// A CDA without any transport cannot talk to a single ECU; refuse to
334 /// start instead of serving a healthy-looking API that can do nothing.
335 fn validate_transport_presence(&self) -> Result<(), ConfigSanityError> {
336 // CAN support is compile-time optional. The config type itself parses
337 // in every build (see the `CanConfig` re-export above), so reject a
338 // configured [can] section here with an actionable message instead of
339 // failing later during gateway setup.
340 #[cfg(not(feature = "can"))]
341 if self.can.is_some() {
342 return Err(ConfigSanityError::InvalidValue {
343 field: "can".to_owned(),
344 reason: "[can] is configured, but this binary was built without CAN support. \
345 Rebuild with `--features can` or remove the [can] section."
346 .to_owned(),
347 });
348 }
349
350 if !self.doip.enabled && self.can.is_none() {
351 return Err(ConfigSanityError::InvalidValue {
352 field: "doip.enabled".to_owned(),
353 reason: "No transport configured: doip.enabled = false and no [can] section. \
354 Enable DoIP or configure CAN."
355 .to_owned(),
356 });
357 }
358 Ok(())
359 }
360
361 /// CAN ECU mappings must be unambiguous: a duplicate ECU name silently
362 /// loses one mapping, two ECUs sharing an arbitration-ID pair open two
363 /// ISO-TP sockets on identical IDs (cross-talk with undefined delivery),
364 /// and `request_id == response_id` makes an ECU answer itself. All three
365 /// are config footguns that are expensive to debug from the bus side.
366 fn validate_can_mappings(&self) -> Result<(), ConfigSanityError> {
367 let Some(ref can) = self.can else {
368 return Ok(());
369 };
370
371 let mut names = cda_interfaces::HashSet::default();
372 let mut id_pairs = cda_interfaces::HashSet::default();
373 for mapping in &can.ecu_mappings {
374 if mapping.request_id == mapping.response_id {
375 return Err(ConfigSanityError::InvalidValue {
376 field: "can.ecu_mappings".to_owned(),
377 reason: format!(
378 "ECU '{}' uses the same CAN ID {:#X} as request_id and response_id",
379 mapping.ecu_name, mapping.request_id
380 ),
381 });
382 }
383 if !names.insert(mapping.ecu_name.to_lowercase()) {
384 return Err(ConfigSanityError::InvalidValue {
385 field: "can.ecu_mappings".to_owned(),
386 reason: format!(
387 "Duplicate mapping for ECU '{}' (names are matched case-insensitively)",
388 mapping.ecu_name
389 ),
390 });
391 }
392 if !id_pairs.insert((mapping.request_id, mapping.response_id)) {
393 return Err(ConfigSanityError::InvalidValue {
394 field: "can.ecu_mappings".to_owned(),
395 reason: format!(
396 "ECU '{}' reuses the CAN ID pair {:#X}/{:#X} of another mapping",
397 mapping.ecu_name, mapping.request_id, mapping.response_id
398 ),
399 });
400 }
401 }
402 Ok(())
403 }
404
405 /// Transport pins must be satisfiable: a pin to a transport that cannot
406 /// serve the ECU would only surface as `EcuOffline` at runtime.
407 fn validate_transport_overrides(&self) -> Result<(), ConfigSanityError> {
408 let Some(ref can) = self.can else {
409 return Ok(());
410 };
411 for transport_override in &can.transport_overrides {
412 match transport_override.transport {
413 // A pin to CAN is validated at gateway setup, not here: the
414 // ECU may get its CAN addressing from the MDD com-params,
415 // which the config layer cannot see.
416 TransportType::Can => {}
417 TransportType::DoIP => {
418 if !self.doip.enabled {
419 return Err(ConfigSanityError::InvalidValue {
420 field: "can.transport_overrides".to_owned(),
421 reason: format!(
422 "Pins ECU '{}' to DoIP, but doip.enabled = false",
423 transport_override.ecu_name
424 ),
425 });
426 }
427 }
428 }
429 }
430 Ok(())
431 }
432
433 /// An empty default functional class would make every flash request without
434 /// `flashClass` fail at runtime with a misleading "not found" error.
435 fn validate_flash_functional_class_default(&self) -> Result<(), ConfigSanityError> {
436 if self.flash_functional_class_default.trim().is_empty() {
437 return Err(ConfigSanityError::InvalidValue {
438 field: "flash_functional_class_default".to_owned(),
439 reason: "must not be empty".to_owned(),
440 });
441 }
442 Ok(())
443 }
444}
445
446impl ConfigSanity for Configuration {
447 fn validate_sanity(&self) -> Result<(), ConfigSanityError> {
448 self.database.naming_convention.validate_sanity()?;
449 self.doip.validate_sanity()?;
450 self.server.validate_sanity()?;
451 self.validate_transport_presence()?;
452 self.validate_can_mappings()?;
453 self.validate_transport_overrides()?;
454 self.locks.validate_sanity()?;
455 self.validate_flash_functional_class_default()?;
456 // Add more checks for Configuration fields here if needed
457 Ok(())
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use cda_interfaces::{
464 datatypes::DiagnosticServiceAffixPosition, lock_config::LockExclusivityPolicy,
465 };
466 use figment::{
467 Figment,
468 providers::{Format, Serialized, Toml},
469 };
470
471 use super::*;
472
473 #[test]
474 fn empty_flash_functional_class_default_is_rejected() {
475 assert!(
476 Configuration::default()
477 .validate_flash_functional_class_default()
478 .is_ok()
479 );
480 for value in ["", " "] {
481 let config = Configuration {
482 flash_functional_class_default: value.to_owned(),
483 ..Configuration::default()
484 };
485 assert!(matches!(
486 config.validate_flash_functional_class_default(),
487 Err(ConfigSanityError::InvalidValue { field, .. })
488 if field == "flash_functional_class_default"
489 ));
490 }
491 }
492
493 #[tokio::test]
494 async fn load_config_toml() -> Result<(), Box<dyn std::error::Error>> {
495 let config_str = r#"
496flash_files_path = "/app/flash"
497flash_functional_class_default = "flash_bootloader"
498
499[database]
500path = "/app/database"
501
502[database.naming_convention]
503short_name_affix_position = "Prefix"
504long_name_affix_position = "Prefix"
505configuration_service_parameter_semantic_id = "ID"
506short_name_affixes = [ "Read_", "Write_" ]
507long_name_affixes = [ "Read ", "Write " ]
508
509[database.naming_convention.service_affixes]
5100x10 = ["Prefix", ["Control_"]]
511
512[logging.tokio_tracing]
513server = "0.0.0.0:6669"
514
515[logging.otel]
516enabled = true
517endpoint = "http://jaeger:4317"
518
519[com_params.doip]
520nack_number_of_retries.value = {"0x03" = 42, "0x04" = 43}
521nack_number_of_retries.name = "CP_TEST"
522
523[functional_description]
524description_database = "teapot"
525
526[locks]
527lock_exclusivity_policy = "non_exclusive_by_default"
528priority_policy_timeout_ms = 750
529priority_policy_stale_retries = 2
530priority_lifecycle_timeout_ms = 500
531priority_lifecycle_queue_capacity = 32
532
533"#;
534
535 let figment = Figment::from(Serialized::defaults(Configuration::default()))
536 .merge(Toml::string(config_str));
537 let config: Configuration = figment.extract()?;
538 config.validate_sanity().map_err(|err| err.to_string())?;
539 assert_eq!(config.flash_functional_class_default, "flash_bootloader");
540 assert_eq!(
541 Configuration::default().flash_functional_class_default,
542 "flash_download_upload"
543 );
544 assert_eq!(
545 config
546 .com_params
547 .doip
548 .nack_number_of_retries
549 .value
550 .get("0x03"),
551 Some(&42)
552 );
553 assert_eq!(
554 config
555 .com_params
556 .doip
557 .nack_number_of_retries
558 .value
559 .get("0x04"),
560 Some(&43)
561 );
562 assert_eq!(
563 config.com_params.doip.nack_number_of_retries.name,
564 "CP_TEST"
565 );
566
567 assert_eq!(
568 config.database.naming_convention.short_name_affix_position,
569 DiagnosticServiceAffixPosition::Prefix,
570 );
571
572 assert_eq!(
573 config.database.naming_convention.long_name_affix_position,
574 DiagnosticServiceAffixPosition::Prefix,
575 );
576 assert_eq!(
577 config.locks.lock_exclusivity_policy,
578 LockExclusivityPolicy::NonExclusiveByDefault
579 );
580 assert_eq!(config.locks.priority_policy_timeout_ms, 750);
581 assert_eq!(config.locks.priority_policy_stale_retries, 2);
582 assert_eq!(config.locks.priority_lifecycle_timeout_ms, 500);
583 assert_eq!(config.locks.priority_lifecycle_queue_capacity, 32);
584
585 assert_eq!(
586 config
587 .database
588 .naming_convention
589 .configuration_service_parameter_semantic_id,
590 "ID".to_owned(),
591 );
592 assert_eq!(
593 config.functional_description.description_database,
594 "teapot".to_owned()
595 );
596 assert_eq!(
597 config
598 .database
599 .naming_convention
600 .service_affixes
601 .get(&0x10.to_string()),
602 Some(&(
603 DiagnosticServiceAffixPosition::Prefix,
604 vec!["Control_".to_string()]
605 ))
606 );
607 Ok(())
608 }
609
610 /// `server` defaults to the `Tcp` transport, keeping today's TCP-only
611 /// behavior unchanged when `transport` is absent from the TOML file.
612 #[tokio::test]
613 async fn load_config_toml_server_unix_socket_defaults_to_none()
614 -> Result<(), Box<dyn std::error::Error>> {
615 let config_str = r#"
616[server]
617address = "127.0.0.1"
618port = 12345
619"#;
620 let figment = Figment::from(Serialized::defaults(Configuration::default()))
621 .merge(Toml::string(config_str));
622 let config: Configuration = figment.extract()?;
623 assert!(matches!(
624 config.server,
625 ServerTransport::Tcp { ref address, port, unix_socket: None }
626 if address == "127.0.0.1" && port == 12345
627 ));
628 config.server.validate_sanity()?;
629 Ok(())
630 }
631
632 /// `server.transport = "unix_socket"` selects the Unix domain socket
633 /// variant.
634 #[tokio::test]
635 async fn load_config_toml_server_unix_socket() -> Result<(), Box<dyn std::error::Error>> {
636 let config_str = r#"
637[server]
638transport = "unix_socket"
639unix_socket = "/run/cda.sock"
640"#;
641 let figment = Figment::from(Serialized::defaults(Configuration::default()))
642 .merge(Toml::string(config_str));
643 let config: Configuration = figment.extract()?;
644 assert!(matches!(
645 config.server,
646 ServerTransport::UnixSocket { ref unix_socket, .. } if unix_socket == "/run/cda.sock"
647 ));
648 config.server.validate_sanity()?;
649 Ok(())
650 }
651
652 /// Setting `unix_socket` alongside `transport = "tcp"` still parses (the
653 /// field is captured but unused for binding), but `validate_sanity`
654 /// rejects the conflicting combination.
655 #[tokio::test]
656 async fn load_config_toml_server_rejects_unix_socket_with_tcp_transport()
657 -> Result<(), Box<dyn std::error::Error>> {
658 let config_str = r#"
659[server]
660transport = "tcp"
661address = "127.0.0.1"
662port = 12345
663unix_socket = "/run/cda.sock"
664"#;
665 let figment = Figment::from(Serialized::defaults(Configuration::default()))
666 .merge(Toml::string(config_str));
667 let config: Configuration = figment.extract()?;
668 assert!(
669 config.server.validate_sanity().is_err(),
670 "expected validate_sanity to reject unix_socket set alongside transport = \"tcp\""
671 );
672 Ok(())
673 }
674
675 /// Setting a non-default `address`/`port` alongside `transport =
676 /// "unix_socket"` is rejected by `validate_sanity`.
677 #[tokio::test]
678 async fn load_config_toml_server_rejects_tcp_fields_with_unix_socket_transport()
679 -> Result<(), Box<dyn std::error::Error>> {
680 let config_str = r#"
681[server]
682transport = "unix_socket"
683unix_socket = "/run/cda.sock"
684address = "127.0.0.1"
685"#;
686 let figment = Figment::from(Serialized::defaults(Configuration::default()))
687 .merge(Toml::string(config_str));
688 let config: Configuration = figment.extract()?;
689 assert!(
690 config.server.validate_sanity().is_err(),
691 "expected validate_sanity to reject a non-default address set alongside transport = \
692 \"unix_socket\""
693 );
694 Ok(())
695 }
696
697 fn assert_invalid_lock_config(
698 configure: impl FnOnce(&mut Configuration),
699 expected_field: &str,
700 ) {
701 let mut config = Configuration::default();
702 configure(&mut config);
703
704 let error = config
705 .validate_sanity()
706 .expect_err("Invalid lock configuration must be rejected");
707 let ConfigSanityError::InvalidValue { field, .. } = error else {
708 panic!("Expected invalid-value error, got {error:?}");
709 };
710 assert_eq!(field, expected_field);
711 }
712
713 #[test]
714 fn zero_priority_policy_timeout_is_rejected() {
715 assert_invalid_lock_config(
716 |config| config.locks.priority_policy_timeout_ms = 0,
717 "locks.priority_policy_timeout_ms",
718 );
719 }
720
721 #[test]
722 fn zero_priority_lifecycle_timeout_is_rejected() {
723 assert_invalid_lock_config(
724 |config| config.locks.priority_lifecycle_timeout_ms = 0,
725 "locks.priority_lifecycle_timeout_ms",
726 );
727 }
728
729 #[test]
730 fn zero_priority_lifecycle_queue_capacity_is_rejected() {
731 assert_invalid_lock_config(
732 |config| config.locks.priority_lifecycle_queue_capacity = 0,
733 "locks.priority_lifecycle_queue_capacity",
734 );
735 }
736
737 /// A `[can]` section must parse in every build (the config type is not
738 /// feature-gated), but `validate_sanity` must reject it when the binary
739 /// was built without CAN support.
740 #[tokio::test]
741 async fn can_section_parses_and_sanity_depends_on_feature()
742 -> Result<(), Box<dyn std::error::Error>> {
743 let config_str = r#"
744[can]
745interface = "vcan0"
746"#;
747 let figment = Figment::from(Serialized::defaults(Configuration::default()))
748 .merge(Toml::string(config_str));
749 let config: Configuration = figment.extract()?;
750 let can = config.can.as_ref().expect("can section should be parsed");
751 assert_eq!(can.interface, "vcan0");
752
753 #[cfg(feature = "can")]
754 config
755 .validate_sanity()
756 .expect("can section should pass sanity with can feature");
757 #[cfg(not(feature = "can"))]
758 {
759 let err = config
760 .validate_sanity()
761 .expect_err("can section should fail sanity without can feature");
762 assert!(
763 err.to_string().contains("--features can"),
764 "error should tell the user how to enable CAN support, got: {err}"
765 );
766 }
767 Ok(())
768 }
769
770 /// Ambiguous `[[can.ecu_mappings]]` configurations must be rejected:
771 /// duplicate ECU names, reused arbitration-ID pairs, and
772 /// `request_id == response_id` all lead to silent misbehavior on the bus.
773 #[cfg(feature = "can")]
774 #[tokio::test]
775 async fn can_ecu_mappings_sanity_rejects_ambiguity() -> Result<(), Box<dyn std::error::Error>> {
776 fn config_with_mappings(
777 mappings: &str,
778 ) -> Result<Configuration, Box<dyn std::error::Error>> {
779 let config_str = format!(
780 r#"
781[can]
782interface = "vcan0"
783{mappings}"#
784 );
785 let figment = Figment::from(Serialized::defaults(Configuration::default()))
786 .merge(Toml::string(&config_str));
787 Ok(figment.extract()?)
788 }
789
790 let valid = config_with_mappings(
791 r#"
792[[can.ecu_mappings]]
793ecu_name = "ECU1"
794request_id = 0x7E0
795response_id = 0x7E8
796
797[[can.ecu_mappings]]
798ecu_name = "ECU2"
799request_id = 0x7E1
800response_id = 0x7E9
801"#,
802 )?;
803 valid
804 .validate_sanity()
805 .expect("distinct mappings should pass sanity");
806
807 let duplicate_name = config_with_mappings(
808 r#"
809[[can.ecu_mappings]]
810ecu_name = "ECU1"
811request_id = 0x7E0
812response_id = 0x7E8
813
814[[can.ecu_mappings]]
815ecu_name = "ecu1"
816request_id = 0x7E1
817response_id = 0x7E9
818"#,
819 )?;
820 let err = duplicate_name
821 .validate_sanity()
822 .expect_err("case-insensitive duplicate ECU name should fail sanity");
823 assert!(err.to_string().contains("Duplicate mapping"), "got: {err}");
824
825 let duplicate_pair = config_with_mappings(
826 r#"
827[[can.ecu_mappings]]
828ecu_name = "ECU1"
829request_id = 0x7E0
830response_id = 0x7E8
831
832[[can.ecu_mappings]]
833ecu_name = "ECU2"
834request_id = 0x7E0
835response_id = 0x7E8
836"#,
837 )?;
838 let err = duplicate_pair
839 .validate_sanity()
840 .expect_err("reused CAN ID pair should fail sanity");
841 assert!(err.to_string().contains("CAN ID pair"), "got: {err}");
842
843 let self_answering = config_with_mappings(
844 r#"
845[[can.ecu_mappings]]
846ecu_name = "ECU1"
847request_id = 0x7E0
848response_id = 0x7E0
849"#,
850 )?;
851 let err = self_answering
852 .validate_sanity()
853 .expect_err("request_id == response_id should fail sanity");
854 assert!(
855 err.to_string().contains("request_id and response_id"),
856 "got: {err}"
857 );
858 Ok(())
859 }
860
861 #[tokio::test]
862 async fn load_config_toml_sanityfail_short_name() -> Result<(), Box<dyn std::error::Error>> {
863 let config_str = r#"
864[database.naming_convention]
865short_name_affix_position = "Prefix"
866short_name_affixes = [ " Read", " Write_" ]
867"#;
868 let figment = Figment::from(Serialized::defaults(Configuration::default()))
869 .merge(Toml::string(config_str));
870 let config: Configuration = figment.extract()?;
871 assert!(config.validate_sanity().is_err());
872 Ok(())
873 }
874
875 #[tokio::test]
876 async fn load_config_toml_service_affixes_hex_keys() -> Result<(), Box<dyn std::error::Error>> {
877 let config_str = r#"
878[database.naming_convention]
879short_name_affix_position = "Suffix"
880long_name_affix_position = "Suffix"
881
882[database.naming_convention.service_affixes]
8830x31 = ["Suffix", ["_start", "_stop", "_requestresults", "_start_func", "_stop_func", "_requestresults_func"]]
8840x85 = ["Prefix", ["DTC_Setting_Mode_"]]
885"#;
886
887 let figment = Figment::from(Serialized::defaults(Configuration::default()))
888 .merge(Toml::string(config_str));
889 let config: Configuration = figment.extract()?;
890
891 assert_eq!(
892 config
893 .database
894 .naming_convention
895 .service_affixes
896 .get(&0x31u8.to_string()),
897 Some(&(
898 DiagnosticServiceAffixPosition::Suffix,
899 vec![
900 "_start".to_owned(),
901 "_stop".to_owned(),
902 "_requestresults".to_owned(),
903 "_start_func".to_owned(),
904 "_stop_func".to_owned(),
905 "_requestresults_func".to_owned(),
906 ]
907 ))
908 );
909 assert_eq!(
910 config
911 .database
912 .naming_convention
913 .service_affixes
914 .get(&0x85u8.to_string()),
915 Some(&(
916 DiagnosticServiceAffixPosition::Prefix,
917 vec!["DTC_Setting_Mode_".to_owned()]
918 ))
919 );
920 Ok(())
921 }
922
923 #[tokio::test]
924 async fn load_config_toml_sanityfail_long_name() -> Result<(), Box<dyn std::error::Error>> {
925 let config_str = r#"
926[database.naming_convention]
927long_name_affix_position = "Suffix"
928long_name_affixes = [ "Read ", "Write_ " ]
929"#;
930 let figment = Figment::from(Serialized::defaults(Configuration::default()))
931 .merge(Toml::string(config_str));
932 let config: Configuration = figment.extract()?;
933 assert!(config.validate_sanity().is_err());
934 Ok(())
935 }
936
937 #[tokio::test]
938 async fn load_config_toml_additional_fields_ignore_case_lowercases_values()
939 -> Result<(), Box<dyn std::error::Error>> {
940 let config_str = r#"
941[components.additional_fields.x-sovd2uds-time-travel-ecus.FluxCapacitor]
942values = ["Flux Capacitor Mark II", "Flux Capacitor Mark III", "yes"]
943ignore_case = true
944
945[components.additional_fields.x-sovd2uds-power-source-ecus.PowerSource]
946values = ["Plutonium", "Mr. Fusion"]
947ignore_case = true
948"#;
949 let figment = Figment::from(Serialized::defaults(Configuration::default()))
950 .merge(Toml::string(config_str));
951 let config: Configuration = figment.extract()?;
952
953 // Verify the FluxCapacitor additional field was loaded and values match case-insensitively
954 let flux_field = config
955 .components
956 .additional_fields
957 .get("x-sovd2uds-time-travel-ecus")
958 .expect("x-sovd2uds-time-travel-ecus should exist");
959 let flux_mapping = flux_field
960 .get("FluxCapacitor")
961 .expect("FluxCapacitor mapping should exist");
962 assert!(
963 flux_mapping.contains("flux capacitor mark ii"),
964 "Should match lowercase"
965 );
966 assert!(
967 flux_mapping.contains("Flux Capacitor Mark II"),
968 "Should match original case"
969 );
970 assert!(
971 flux_mapping.contains("FLUX CAPACITOR MARK II"),
972 "Should match uppercase"
973 );
974 assert!(flux_mapping.contains("yes"), "Should match lowercase 'yes'");
975 assert!(flux_mapping.contains("YES"), "Should match uppercase 'YES'");
976
977 // Verify the PowerSource additional field
978 let power_field = config
979 .components
980 .additional_fields
981 .get("x-sovd2uds-power-source-ecus")
982 .expect("x-sovd2uds-power-source-ecus should exist");
983 let power_mapping = power_field
984 .get("PowerSource")
985 .expect("PowerSource mapping should exist");
986 assert!(power_mapping.contains("Plutonium"));
987 assert!(power_mapping.contains("plutonium"));
988 assert!(power_mapping.contains("Mr. Fusion"));
989 assert!(power_mapping.contains("mr. fusion"));
990
991 Ok(())
992 }
993
994 #[test]
995 fn schemars_captures_doc_comments() {
996 let schema = schemars::schema_for!(DatabaseConfig);
997 let schema_json = serde_json::to_value(&schema).unwrap();
998 let desc = schema_json
999 .get("properties")
1000 .and_then(|v| v.get("exit_no_database_loaded"))
1001 .and_then(|v| v.get("description"))
1002 .and_then(|v| v.as_str())
1003 .expect("description should be present");
1004 assert!(
1005 desc.contains("the application will exit if no database could be loaded"),
1006 "Expected doc comment in description, got: {desc}"
1007 );
1008 }
1009
1010 #[test]
1011 fn strict_config_master_switch_enables_all() {
1012 let cfg = StrictConfig {
1013 enabled: true,
1014 parameter_validation: false,
1015 ecu_config: false,
1016 };
1017 assert!(cfg.parameter_validation());
1018 assert!(cfg.ecu_config());
1019 }
1020
1021 #[test]
1022 fn strict_config_individual_flags_work_independently() {
1023 let cfg = StrictConfig {
1024 enabled: false,
1025 parameter_validation: true,
1026 ecu_config: false,
1027 };
1028 assert!(cfg.parameter_validation());
1029 assert!(!cfg.ecu_config());
1030 }
1031
1032 #[test]
1033 fn strict_config_defaults_to_all_disabled() {
1034 let cfg = StrictConfig::default();
1035 assert!(!cfg.parameter_validation());
1036 assert!(!cfg.ecu_config());
1037 }
1038
1039 #[tokio::test]
1040 async fn strict_config_parsed_from_toml() {
1041 let config_str = r"
1042[strict]
1043enabled = false
1044parameter_validation = true
1045";
1046 let figment = Figment::from(Serialized::defaults(Configuration::default()))
1047 .merge(Toml::string(config_str));
1048 let config: Configuration = figment.extract().unwrap();
1049 assert!(config.strict.parameter_validation());
1050 assert!(!config.strict.ecu_config());
1051 }
1052
[docs]1053 /// [[ test~deferred-communication-config, Deferred communication modes are configurable, test ]]
1054 #[test]
1055 fn communication_modes_are_parsed_from_toml() {
1056 let defaults: Configuration = Figment::from(Serialized::defaults(Configuration::default()))
1057 .extract()
1058 .unwrap();
1059 assert_eq!(
1060 defaults.communication.init_mode,
1061 CommunicationInitMode::Always
1062 );
1063 assert_eq!(
1064 defaults.communication.post_update_mode,
1065 PostUpdateCommunicationMode::Enabled
1066 );
1067
1068 for init_mode in ["OnDemand", "Disabled"] {
1069 let config_str = format!(
1070 "[communication]\ninit_mode = \"{init_mode}\"\npost_update_mode = \
1071 \"Deferred\"\ndeferred_retry_after_seconds = 7\n"
1072 );
1073 let config: Configuration =
1074 Figment::from(Serialized::defaults(Configuration::default()))
1075 .merge(Toml::string(&config_str))
1076 .extract()
1077 .unwrap();
1078
1079 let expected_init_mode = if init_mode == "OnDemand" {
1080 CommunicationInitMode::OnDemand
1081 } else {
1082 CommunicationInitMode::Disabled
1083 };
1084 assert_eq!(config.communication.init_mode, expected_init_mode);
1085 assert_eq!(
1086 config.communication.post_update_mode,
1087 PostUpdateCommunicationMode::Deferred
1088 );
1089 assert_eq!(config.communication.deferred_retry_after_seconds, 7);
1090 }
1091 }
1092}