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}