Skip to main content

bitwarden_access_connector/
config.rs

1//! Configuration loading and validation for the access connector.
2//!
3//! Unknown TOML keys are a startup error. `[targets]` entries override per-target environment
4//! variables, except `client_secret`, which only the environment can supply.
5
6use std::{
7    collections::HashMap,
8    path::{Path, PathBuf},
9    time::Duration,
10};
11
12use bitwarden_access_token::{AccessToken, AccessTokenKind};
13use uuid::Uuid;
14
15use crate::{
16    cli::RunArgs,
17    error::AccessConnectorError,
18    executor::{AccessConnectorConfig, retry::RetryCfg},
19    resolver::config::TargetEntry,
20};
21
22const MIN_POLL_INTERVAL_SECS: u64 = 15;
23
24/// Exclusive upper bound on the heartbeat interval.
25const MAX_HEARTBEAT_INTERVAL_SECS: u64 = 120;
26
27/// The `[environment]` TOML section. Each URL resolves from its env var, then its field here, then
28/// `base`. With no `base`, an unset URL fails startup unless neither URL is set, which defaults to
29/// US cloud.
30#[derive(Debug, Default, serde::Deserialize)]
31#[serde(default, deny_unknown_fields)]
32struct EnvironmentConfig {
33    /// Base URL from which `api` and `identity` derive: a self-hosted URL (e.g.
34    /// `https://bitwarden.example.com`, trailing slashes stripped) or a Bitwarden cloud host
35    /// (`bitwarden.com`, `vault.bitwarden.eu`, ...), which maps to that cloud's URLs.
36    base: Option<String>,
37    /// Bitwarden API server URL. Overrides a `base`-derived value.
38    api: Option<String>,
39    /// Bitwarden identity server URL. Overrides a `base`-derived value.
40    identity: Option<String>,
41}
42
43/// Where the Bitwarden server is.
44enum Environment {
45    Us,
46    Eu,
47    SelfHosted { api: String, identity: String },
48}
49
50impl Environment {
51    /// A cloud host picks its region; anything else is self-hosted, with `/api` and `/identity`
52    /// under `base`. Hosts match exactly, never by suffix: a suffix match would let
53    /// `bitwarden.com.evil.example` route the connector's credentials to an attacker's server.
54    fn from_base(base: &str) -> Self {
55        let url = url::Url::parse(base).ok();
56        match url.as_ref().and_then(url::Url::host_str) {
57            Some("bitwarden.com" | "vault.bitwarden.com") => Self::Us,
58            Some("bitwarden.eu" | "vault.bitwarden.eu") => Self::Eu,
59            _ => {
60                let base = base.trim_end_matches('/');
61                Self::SelfHosted {
62                    api: format!("{base}/api"),
63                    identity: format!("{base}/identity"),
64                }
65            }
66        }
67    }
68
69    fn api_url(&self) -> &str {
70        match self {
71            Self::Us => "https://api.bitwarden.com",
72            Self::Eu => "https://api.bitwarden.eu",
73            Self::SelfHosted { api, .. } => api,
74        }
75    }
76
77    fn identity_url(&self) -> &str {
78        match self {
79            Self::Us => "https://identity.bitwarden.com",
80            Self::Eu => "https://identity.bitwarden.eu",
81            Self::SelfHosted { identity, .. } => identity,
82        }
83    }
84}
85
86/// On-disk connector configuration (TOML); every key is optional. A `token` key is rejected as
87/// unknown, since the token comes only from `BWAC_TOKEN`.
88#[derive(Debug, serde::Deserialize)]
89#[serde(default, deny_unknown_fields)]
90struct FileConfig {
91    environment: EnvironmentConfig,
92    /// Poll interval in seconds.
93    poll_interval: u64,
94    /// Heartbeat interval in seconds.
95    heartbeat_interval: u64,
96    /// Offline grace period in seconds.
97    offline_grace: u64,
98    /// Total number of attempts for each retryable rotation step.
99    max_retry_attempts: u32,
100    /// Base delay for exponential backoff in seconds.
101    retry_base_delay: u64,
102    /// Root directory for custom scripts. No built-in default.
103    script_root: Option<PathBuf>,
104    /// Custom-script timeout in seconds.
105    script_timeout: u64,
106    /// PowerShell host path. `None` discovers `pwsh`, then `powershell.exe`, on `PATH`.
107    powershell_path: Option<PathBuf>,
108    /// `-ExecutionPolicy` for the PowerShell host. Defaults to `Bypass` because Windows Server's
109    /// `RemoteSigned` refuses unsigned `.ps1` and `script_root` already pins the path; sites that
110    /// sign scripts should set `AllSigned`.
111    powershell_execution_policy: String,
112    /// Whether the Entra ROPC verify probe is enabled.
113    entra_verify_probe: bool,
114    /// Per-target credential overrides from the `[targets]` section.
115    targets: HashMap<Uuid, TargetEntry>,
116}
117
118/// Built-in defaults for keys the file omits.
119impl Default for FileConfig {
120    fn default() -> Self {
121        Self {
122            environment: EnvironmentConfig::default(),
123            poll_interval: 15,
124            heartbeat_interval: 30,
125            offline_grace: 60,
126            max_retry_attempts: 5,
127            retry_base_delay: 1,
128            script_root: None,
129            script_timeout: 60,
130            powershell_path: None,
131            powershell_execution_policy: "Bypass".to_string(),
132            entra_verify_probe: false,
133            targets: HashMap::new(),
134        }
135    }
136}
137
138impl FileConfig {
139    /// Read and parse the file at `path`.
140    fn load(path: &Path) -> Result<Self, AccessConnectorError> {
141        let contents = std::fs::read_to_string(path).map_err(|e| {
142            AccessConnectorError::InvalidConfig(format!(
143                "cannot read config file {}: {e}",
144                path.display()
145            ))
146        })?;
147        Self::parse(&contents, path)
148    }
149
150    /// Parse TOML `contents`; `path` is for error messages only. A parse error keeps only its last
151    /// line, since the source snippet could echo config values.
152    fn parse(contents: &str, path: &Path) -> Result<Self, AccessConnectorError> {
153        toml::from_str(contents).map_err(|e| {
154            let summary = e.to_string();
155            let description = summary.lines().last().unwrap_or("parse error");
156            AccessConnectorError::InvalidConfig(format!(
157                "config file {} is invalid TOML: {description}",
158                path.display()
159            ))
160        })
161    }
162}
163
164/// Treat a blank value as unset.
165fn non_blank(v: Option<String>) -> Option<String> {
166    v.filter(|v| !v.trim().is_empty())
167}
168
169/// Parse the raw `BWAC_TOKEN` value. Errors never echo the token.
170fn parse_token(raw: Option<String>) -> Result<AccessToken, AccessConnectorError> {
171    let raw = non_blank(raw).ok_or_else(|| {
172        AccessConnectorError::InvalidConfig(
173            "access connector token must be supplied via the BWAC_TOKEN environment variable"
174                .into(),
175        )
176    })?;
177    AccessToken::parse(raw.trim(), AccessTokenKind::AccessConnector)
178        .map_err(|e| AccessConnectorError::InvalidToken(format!("{e}")))
179}
180
181/// Error for a service URL that no layer supplied.
182fn missing_url(name: &str) -> AccessConnectorError {
183    AccessConnectorError::InvalidConfig(format!(
184        "{name} URL must be supplied via the BWAC_{}_URL environment variable, \
185         [environment].{name}, or [environment].base in the config file",
186        name.to_uppercase()
187    ))
188}
189
190/// Validated configuration for the connector run loop, built by [`Config::from_cli`].
191pub struct Config {
192    inner: AccessConnectorConfig,
193}
194
195impl std::fmt::Debug for Config {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        f.debug_struct("Config")
198            .field("api_url", &self.inner.api_url)
199            .field("identity_url", &self.inner.identity_url)
200            .finish_non_exhaustive()
201    }
202}
203
204impl Config {
205    /// Build a validated [`Config`] from the CLI arguments. `BWAC_API_URL` and `BWAC_IDENTITY_URL`
206    /// override the file's URLs.
207    ///
208    /// # Errors
209    ///
210    /// [`AccessConnectorError::InvalidConfig`] or [`AccessConnectorError::InvalidToken`], never
211    /// echoing secrets.
212    pub fn from_cli(args: RunArgs) -> Result<Self, AccessConnectorError> {
213        // SAFETY: single-threaded startup. Removed right after reading so child processes don't
214        // inherit it.
215        let env_token = std::env::var("BWAC_TOKEN").ok();
216        if env_token.is_some() {
217            unsafe {
218                std::env::remove_var("BWAC_TOKEN");
219            }
220        }
221
222        let token = parse_token(env_token)?;
223
224        let file = match &args.config {
225            Some(path) => FileConfig::load(path)?,
226            None => FileConfig::default(),
227        };
228
229        let env_url = |name: &str| non_blank(std::env::var(name).ok());
230        Self::build(
231            token,
232            file,
233            env_url("BWAC_API_URL"),
234            env_url("BWAC_IDENTITY_URL"),
235        )
236    }
237
238    /// Resolve URLs (env, then `[environment]`, then `base`) and validate `file` into a
239    /// [`Config`]. With no URL source at all, both URLs default to US cloud.
240    fn build(
241        token: AccessToken,
242        file: FileConfig,
243        env_api: Option<String>,
244        env_identity: Option<String>,
245    ) -> Result<Self, AccessConnectorError> {
246        let api = env_api.or(file.environment.api.clone());
247        let identity = env_identity.or(file.environment.identity.clone());
248        // Without a base, a lone api or identity URL must not pair with US cloud for the other.
249        if file.environment.base.is_none() {
250            match (&api, &identity) {
251                (Some(_), None) => return Err(missing_url("identity")),
252                (None, Some(_)) => return Err(missing_url("api")),
253                _ => {}
254            }
255        }
256        let server = file
257            .environment
258            .base
259            .as_deref()
260            .map(Environment::from_base)
261            .unwrap_or(Environment::Us);
262        let api_url = api.unwrap_or_else(|| server.api_url().to_owned());
263        let identity_url = identity.unwrap_or_else(|| server.identity_url().to_owned());
264
265        if file.poll_interval < MIN_POLL_INTERVAL_SECS {
266            return Err(AccessConnectorError::InvalidConfig(format!(
267                "poll_interval must be >= {MIN_POLL_INTERVAL_SECS} seconds (got {})",
268                file.poll_interval
269            )));
270        }
271
272        if file.heartbeat_interval >= MAX_HEARTBEAT_INTERVAL_SECS {
273            return Err(AccessConnectorError::InvalidConfig(format!(
274                "heartbeat_interval must be < {MAX_HEARTBEAT_INTERVAL_SECS} seconds (got {})",
275                file.heartbeat_interval
276            )));
277        }
278
279        // A zero heartbeat panics `tokio::time::interval`, a zero grace holds every rotation, and a
280        // zero timeout kills every script.
281        for (name, value) in [
282            ("heartbeat_interval", file.heartbeat_interval),
283            ("offline_grace", file.offline_grace),
284            ("script_timeout", file.script_timeout),
285        ] {
286            if value == 0 {
287                return Err(AccessConnectorError::InvalidConfig(format!(
288                    "{name} must be > 0 seconds"
289                )));
290            }
291        }
292
293        Ok(Config {
294            inner: AccessConnectorConfig {
295                api_url,
296                identity_url,
297                token,
298                poll_interval: Duration::from_secs(file.poll_interval),
299                heartbeat_interval: Duration::from_secs(file.heartbeat_interval),
300                offline_grace: Duration::from_secs(file.offline_grace),
301                retry_cfg: RetryCfg {
302                    max_retry_attempts: file.max_retry_attempts,
303                    retry_base_delay: Duration::from_secs(file.retry_base_delay),
304                },
305                script_root: file.script_root,
306                script_timeout: Duration::from_secs(file.script_timeout),
307                powershell_path: file.powershell_path,
308                powershell_execution_policy: file.powershell_execution_policy,
309                entra_verify_probe: file.entra_verify_probe,
310                targets: file.targets,
311            },
312        })
313    }
314
315    /// Consume the [`Config`] and return the inner [`AccessConnectorConfig`].
316    pub fn into_access_connector_config(self) -> AccessConnectorConfig {
317        self.inner
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::TEST_ENV_LOCK as ENV_LOCK;
325
326    const VALID_TOKEN: &str = "0.access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
327
328    /// The common `[environment]` block.
329    const URLS: &str = r#"
330[environment]
331api      = "https://api.example.com"
332identity = "https://identity.example.com"
333"#;
334
335    fn token() -> AccessToken {
336        parse_token(Some(VALID_TOKEN.into())).expect("valid token")
337    }
338
339    fn file(toml: &str) -> FileConfig {
340        FileConfig::parse(toml, Path::new("test.toml")).expect("valid config file")
341    }
342
343    fn parse_err(toml: &str) -> AccessConnectorError {
344        match FileConfig::parse(toml, Path::new("test.toml")) {
345            Err(e) => e,
346            Ok(_) => panic!("expected parse error for {toml:?}"),
347        }
348    }
349
350    fn build(toml: &str) -> Result<Config, AccessConnectorError> {
351        Config::build(token(), file(toml), None, None)
352    }
353
354    fn invalid_config_message(e: AccessConnectorError) -> String {
355        match e {
356            AccessConnectorError::InvalidConfig(msg) => msg,
357            other => panic!("expected InvalidConfig, got {other:?}"),
358        }
359    }
360
361    #[test]
362    fn interval_bounds() {
363        let rows = [
364            ("poll 14", "poll_interval = 14", false),
365            ("poll 15", "poll_interval = 15", true),
366            ("heartbeat 119", "heartbeat_interval = 119", true),
367            ("heartbeat 120", "heartbeat_interval = 120", false),
368            ("heartbeat 0", "heartbeat_interval = 0", false),
369            ("offline_grace 0", "offline_grace = 0", false),
370            ("script_timeout 0", "script_timeout = 0", false),
371        ];
372        for (label, line, ok) in rows {
373            let result = build(&format!("{line}\n{URLS}"));
374            if ok {
375                assert!(result.is_ok(), "{label}: expected Ok, got {result:?}");
376            } else {
377                assert!(
378                    matches!(result, Err(AccessConnectorError::InvalidConfig(_))),
379                    "{label}: expected InvalidConfig, got {result:?}"
380                );
381            }
382        }
383    }
384
385    #[test]
386    fn url_resolution() {
387        const BASE: &str = "[environment]\nbase = \"https://bitwarden.example.com\"\n";
388        const ENV_API: &str = "https://api.env.example.com";
389        const ENV_ID: &str = "https://identity.env.example.com";
390        const DERIVED_API: &str = "https://bitwarden.example.com/api";
391        const DERIVED_ID: &str = "https://bitwarden.example.com/identity";
392        let blank = || Some("  ".to_string());
393        let env = |v: &str| Some(v.to_string());
394
395        // (label, toml, env api, env identity, expected api, expected identity)
396        let us_api = Environment::Us.api_url();
397        let us_id = Environment::Us.identity_url();
398        let eu_api = Environment::Eu.api_url();
399        let eu_id = Environment::Eu.identity_url();
400        let rows = [
401            ("env only", "", env(ENV_API), env(ENV_ID), ENV_API, ENV_ID),
402            ("nothing configured", "", None, None, us_api, us_id),
403            (
404                "no environment section",
405                "poll_interval = 15\n",
406                None,
407                None,
408                us_api,
409                us_id,
410            ),
411            (
412                "us cloud base",
413                "[environment]\nbase = \"https://bitwarden.com\"\n",
414                None,
415                None,
416                us_api,
417                us_id,
418            ),
419            (
420                "us vault base with trailing slash",
421                "[environment]\nbase = \"https://vault.bitwarden.com/\"\n",
422                None,
423                None,
424                us_api,
425                us_id,
426            ),
427            (
428                "scheme-less cloud host is self-hosted",
429                "[environment]\nbase = \"bitwarden.eu\"\n",
430                None,
431                None,
432                "bitwarden.eu/api",
433                "bitwarden.eu/identity",
434            ),
435            (
436                "eu base with port",
437                "[environment]\nbase = \"https://bitwarden.eu:8443\"\n",
438                None,
439                None,
440                eu_api,
441                eu_id,
442            ),
443            (
444                "cloud host as subdomain of attacker derives paths",
445                "[environment]\nbase = \"https://bitwarden.com.evil.example\"\n",
446                None,
447                None,
448                "https://bitwarden.com.evil.example/api",
449                "https://bitwarden.com.evil.example/identity",
450            ),
451            (
452                "eu base mixed case",
453                "[environment]\nbase = \"https://VAULT.Bitwarden.EU\"\n",
454                None,
455                None,
456                eu_api,
457                eu_id,
458            ),
459            (
460                "eu base with explicit api",
461                "[environment]\nbase = \"https://bitwarden.eu\"\napi = \"https://custom-api.example.com\"\n",
462                None,
463                None,
464                "https://custom-api.example.com",
465                eu_id,
466            ),
467            (
468                "cloud base with env api",
469                "[environment]\nbase = \"https://bitwarden.eu\"\n",
470                env(ENV_API),
471                None,
472                ENV_API,
473                eu_id,
474            ),
475            (
476                "cloud lookalike derives paths",
477                "[environment]\nbase = \"https://notbitwarden.com\"\n",
478                None,
479                None,
480                "https://notbitwarden.com/api",
481                "https://notbitwarden.com/identity",
482            ),
483            (
484                "env over file",
485                URLS,
486                env(ENV_API),
487                env(ENV_ID),
488                ENV_API,
489                ENV_ID,
490            ),
491            (
492                "blank env is unset",
493                URLS,
494                blank(),
495                blank(),
496                "https://api.example.com",
497                "https://identity.example.com",
498            ),
499            ("base only", BASE, None, None, DERIVED_API, DERIVED_ID),
500            (
501                "base with trailing slash",
502                "[environment]\nbase = \"https://bitwarden.example.com/\"\n",
503                None,
504                None,
505                DERIVED_API,
506                DERIVED_ID,
507            ),
508            (
509                "explicit api over base",
510                "[environment]\nbase = \"https://bitwarden.example.com\"\napi = \"https://custom-api.example.com/v2\"\n",
511                None,
512                None,
513                "https://custom-api.example.com/v2",
514                DERIVED_ID,
515            ),
516            (
517                "env over explicit and base",
518                "[environment]\nbase = \"https://bitwarden.example.com\"\napi = \"https://api.file.example.com\"\nidentity = \"https://identity.file.example.com\"\n",
519                env(ENV_API),
520                env(ENV_ID),
521                ENV_API,
522                ENV_ID,
523            ),
524        ];
525        for (label, toml, env_api, env_identity, api, identity) in rows {
526            let cfg = Config::build(
527                token(),
528                file(toml),
529                non_blank(env_api),
530                non_blank(env_identity),
531            )
532            .unwrap_or_else(|e| panic!("{label}: expected Ok, got {e:?}"))
533            .into_access_connector_config();
534            assert_eq!(cfg.api_url, api, "{label}: api");
535            assert_eq!(cfg.identity_url, identity, "{label}: identity");
536        }
537    }
538
539    #[test]
540    fn missing_urls() {
541        // (label, toml, substrings the error must contain)
542        let rows: [(&str, &str, &[&str]); 2] = [
543            (
544                "missing api",
545                "[environment]\nidentity = \"https://identity.example.com\"\n",
546                &["api URL"],
547            ),
548            (
549                "missing identity",
550                "[environment]\napi = \"https://api.example.com\"\n",
551                &["identity URL"],
552            ),
553        ];
554        for (label, toml, expected) in rows {
555            let msg = invalid_config_message(build(toml).unwrap_err());
556            for needle in expected {
557                assert!(
558                    msg.contains(needle),
559                    "{label}: expected {needle:?} in {msg:?}"
560                );
561            }
562        }
563
564        // A lone env API URL must not pair with US cloud for identity.
565        let env_api = Some("https://api.env.example.com".to_string());
566        let msg =
567            invalid_config_message(Config::build(token(), file(""), env_api, None).unwrap_err());
568        assert!(msg.contains("identity URL"), "env api only: {msg:?}");
569    }
570
571    #[test]
572    fn non_blank_treats_whitespace_as_unset() {
573        assert_eq!(non_blank(Some("  ".into())), None);
574        assert_eq!(non_blank(Some("".into())), None);
575        assert_eq!(non_blank(None), None);
576        assert_eq!(non_blank(Some("x".into())), Some("x".into()));
577    }
578
579    #[test]
580    fn rejected_keys() {
581        // (label, toml, secret value the error must not echo)
582        let rows = [
583            ("token key", "token = \"0.x.y:z\"\n", Some("0.x.y:z")),
584            (
585                "old top-level urls",
586                "api_url = \"https://api.example.com\"\nidentity_url = \"https://identity.example.com\"\n",
587                None,
588            ),
589            (
590                "target client_secret",
591                "[targets.00000000-0000-0000-0000-000000000001]\nclient_secret = \"supersecret\"\n",
592                Some("supersecret"),
593            ),
594            (
595                "target bind_password",
596                "[targets.00000000-0000-0000-0000-000000000001]\nldap_host = \"dc01.corp.example\"\nbind_password = \"bind-supersecret\"\n",
597                Some("bind-supersecret"),
598            ),
599            (
600                "target non-uuid key",
601                "[targets.not-a-uuid]\nscript = \"/some/script.sh\"\n",
602                None,
603            ),
604        ];
605        for (label, toml, secret) in rows {
606            let msg = invalid_config_message(parse_err(&format!("{toml}{URLS}")));
607            if let Some(secret) = secret {
608                assert!(!msg.contains(secret), "{label}: error echoed value: {msg}");
609            }
610        }
611    }
612
613    #[test]
614    fn token_parsing() {
615        for (label, raw) in [("missing", None), ("blank", Some("  ".to_string()))] {
616            assert!(
617                matches!(
618                    parse_token(raw),
619                    Err(AccessConnectorError::InvalidConfig(_))
620                ),
621                "{label}: expected InvalidConfig"
622            );
623        }
624
625        let bad = "not-a-valid-token-string";
626        match parse_token(Some(bad.into())) {
627            Err(AccessConnectorError::InvalidToken(msg)) => {
628                assert!(!msg.contains(bad), "malformed: error echoed token: {msg}");
629            }
630            _ => panic!("malformed: expected InvalidToken"),
631        }
632    }
633
634    #[test]
635    fn file_values_are_used() {
636        let toml = format!(
637            r#"
638poll_interval      = 30
639heartbeat_interval = 45
640offline_grace      = 90
641max_retry_attempts = 3
642retry_base_delay   = 2
643script_timeout     = 120
644entra_verify_probe = true
645{URLS}
646[targets.85808642-baba-4b8e-8c34-b48000d60a0a]
647script = "/opt/scripts/rotate.sh"
648
649[targets.00000000-0000-0000-0000-000000000001]
650tenant_id = "my-tenant"
651client_id = "my-client"
652"#
653        );
654        let cfg = build(&toml)
655            .expect("expected Ok")
656            .into_access_connector_config();
657        assert_eq!(cfg.api_url, "https://api.example.com");
658        assert_eq!(cfg.identity_url, "https://identity.example.com");
659        assert_eq!(cfg.poll_interval, Duration::from_secs(30));
660        assert_eq!(cfg.heartbeat_interval, Duration::from_secs(45));
661        assert_eq!(cfg.offline_grace, Duration::from_secs(90));
662        assert_eq!(cfg.retry_cfg.max_retry_attempts, 3);
663        assert_eq!(cfg.retry_cfg.retry_base_delay, Duration::from_secs(2));
664        assert_eq!(cfg.script_timeout, Duration::from_secs(120));
665        assert!(cfg.entra_verify_probe);
666
667        assert_eq!(cfg.targets.len(), 2);
668        let script: Uuid = "85808642-baba-4b8e-8c34-b48000d60a0a".parse().unwrap();
669        assert_eq!(
670            cfg.targets[&script].script.as_deref(),
671            Some("/opt/scripts/rotate.sh")
672        );
673        let entra: Uuid = "00000000-0000-0000-0000-000000000001".parse().unwrap();
674        assert_eq!(cfg.targets[&entra].tenant_id.as_deref(), Some("my-tenant"));
675        assert_eq!(cfg.targets[&entra].client_id.as_deref(), Some("my-client"));
676    }
677
678    #[test]
679    fn active_directory_target_values_are_used() {
680        let toml = format!(
681            r#"{URLS}
682[targets.00000000-0000-0000-0000-000000000002]
683ldap_host      = "dc01.corp.example"
684bind_dn        = "CN=svc-rotate,OU=Service Accounts,DC=corp,DC=example"
685base_dn        = "DC=corp,DC=example"
686ca_certificate = "/etc/pki/ad-ca.pem"
687"#
688        );
689        let cfg = build(&toml)
690            .expect("expected Ok")
691            .into_access_connector_config();
692        let ad: Uuid = "00000000-0000-0000-0000-000000000002".parse().unwrap();
693        let entry = &cfg.targets[&ad];
694        assert_eq!(entry.ldap_host.as_deref(), Some("dc01.corp.example"));
695        assert_eq!(
696            entry.bind_dn.as_deref(),
697            Some("CN=svc-rotate,OU=Service Accounts,DC=corp,DC=example")
698        );
699        assert_eq!(entry.base_dn.as_deref(), Some("DC=corp,DC=example"));
700        assert_eq!(entry.ca_certificate.as_deref(), Some("/etc/pki/ad-ca.pem"));
701    }
702
703    #[test]
704    fn defaults_apply_when_file_omits_tunables() {
705        let cfg = build(URLS)
706            .expect("expected Ok")
707            .into_access_connector_config();
708        let defaults = FileConfig::default();
709        assert_eq!(
710            cfg.poll_interval,
711            Duration::from_secs(defaults.poll_interval)
712        );
713        assert_eq!(
714            cfg.heartbeat_interval,
715            Duration::from_secs(defaults.heartbeat_interval)
716        );
717        assert_eq!(
718            cfg.offline_grace,
719            Duration::from_secs(defaults.offline_grace)
720        );
721        assert_eq!(
722            cfg.retry_cfg.max_retry_attempts,
723            defaults.max_retry_attempts
724        );
725        assert_eq!(
726            cfg.retry_cfg.retry_base_delay,
727            Duration::from_secs(defaults.retry_base_delay)
728        );
729        assert_eq!(
730            cfg.script_timeout,
731            Duration::from_secs(defaults.script_timeout)
732        );
733        assert_eq!(cfg.entra_verify_probe, defaults.entra_verify_probe);
734        assert!(
735            cfg.targets.is_empty(),
736            "absent [targets] must be an empty map"
737        );
738    }
739
740    #[test]
741    fn from_cli_removes_token_from_environment() {
742        let _guard = ENV_LOCK.lock().unwrap();
743        // SAFETY: guarded by ENV_LOCK.
744        unsafe {
745            std::env::set_var("BWAC_TOKEN", VALID_TOKEN);
746            std::env::set_var("BWAC_API_URL", "https://api.example.com");
747            std::env::set_var("BWAC_IDENTITY_URL", "https://identity.example.com");
748        }
749        let result = Config::from_cli(RunArgs { config: None });
750        // SAFETY: guarded by ENV_LOCK.
751        unsafe {
752            std::env::remove_var("BWAC_API_URL");
753            std::env::remove_var("BWAC_IDENTITY_URL");
754        }
755        assert!(result.is_ok(), "expected Ok, got {result:?}");
756        assert!(
757            std::env::var("BWAC_TOKEN").is_err(),
758            "BWAC_TOKEN must be absent from environment after from_cli consumes it"
759        );
760    }
761
762    /// Covers `from_cli` honouring `args.config` and treating a blank URL variable as unset.
763    #[test]
764    fn from_cli_reads_config_file_and_ignores_blank_env_urls() {
765        use std::io::Write;
766
767        let _guard = ENV_LOCK.lock().unwrap();
768        let mut config_file = tempfile::NamedTempFile::new().expect("temp config file");
769        config_file
770            .write_all(URLS.as_bytes())
771            .expect("write config file");
772        config_file.flush().expect("flush config file");
773        // SAFETY: guarded by ENV_LOCK.
774        unsafe {
775            std::env::set_var("BWAC_TOKEN", VALID_TOKEN);
776            std::env::set_var("BWAC_API_URL", "  ");
777            std::env::remove_var("BWAC_IDENTITY_URL");
778        }
779        let result = Config::from_cli(RunArgs {
780            config: Some(config_file.path().to_path_buf()),
781        });
782        // SAFETY: guarded by ENV_LOCK.
783        unsafe {
784            std::env::remove_var("BWAC_API_URL");
785            std::env::remove_var("BWAC_TOKEN");
786        }
787        let cfg = result.expect("expected Ok").into_access_connector_config();
788        assert_eq!(cfg.api_url, "https://api.example.com");
789        assert_eq!(cfg.identity_url, "https://identity.example.com");
790    }
791
792    #[test]
793    fn nonexistent_config_path_is_invalid_config() {
794        let result = FileConfig::load(Path::new("/nonexistent/path/to/config.toml"));
795        assert!(
796            matches!(result, Err(AccessConnectorError::InvalidConfig(_))),
797            "expected InvalidConfig for nonexistent config path, got {result:?}"
798        );
799    }
800}