Skip to main content

tsafe_core/
pullconfig.rs

1//! Pull configuration — parsing `.tsafe.yml` / `.tsafe.json` repo manifests.
2//!
3//! A pull config file declares one or more [`PullSource`]s (Azure Key Vault,
4//! HashiCorp Vault, 1Password) that `tsafe pull` reads secrets from.  The file
5//! is searched upward from the current directory via [`find_config`].
6//!
7//! # ADR-012 fields
8//!
9//! Every source entry may declare two optional fields defined by ADR-012:
10//!
11//! - `name`: a label used by `--source <label>` filtering.  Sources without a
12//!   `name` field are always included in unfiltered runs but cannot be selected
13//!   with `--source`.
14//! - `ns`: a namespace prefix applied to every key fetched from this source.
15//!   A key named `DB_PASSWORD` from a source with `ns: prod` is stored as
16//!   `prod.DB_PASSWORD`.  The separator is `.`, matching the vault's existing
17//!   namespace convention.
18//!
19//! # HCP Vault auth fields (task E2.4)
20//!
21//! The `hcp` source variant supports two authentication methods via the `auth`
22//! sub-key:
23//!
24//! ```yaml
25//! # Token auth (legacy default — reads VAULT_TOKEN env var at runtime):
26//! pulls:
27//!   - source: hcp
28//!     auth:
29//!       method: token
30//!
31//! # AppRole auth (machine identity):
32//! pulls:
33//!   - source: hcp
34//!     auth:
35//!       method: approle
36//!       role_id: my-role
37//!       secret_id: ${VAULT_SECRET_ID}   # env var expansion supported
38//! ```
39//!
40//! Namespace support (HCP Vault Enterprise):
41//! ```yaml
42//! pulls:
43//!   - source: hcp
44//!     vault_url: https://vault.example.com:8200
45//!     vault_namespace: team-alpha
46//! ```
47
48use std::path::{Path, PathBuf};
49
50use serde::Deserialize;
51
52use crate::errors::{SafeError, SafeResult};
53
54// ── HCP Vault auth config ─────────────────────────────────────────────────────
55
56/// Authentication method declared in the pull manifest for a HashiCorp Vault source.
57///
58/// When omitted from the YAML, the runtime falls back to environment variables
59/// (`VAULT_ROLE_ID` + `VAULT_SECRET_ID` for AppRole, then `VAULT_TOKEN` for token).
60///
61/// Values that look like `${ENV_VAR}` are expanded at parse time via
62/// [`expand_env_vars`].
63#[derive(Debug, Clone, Deserialize, PartialEq)]
64#[serde(tag = "method", rename_all = "lowercase")]
65pub enum VaultAuthConfig {
66    /// Static token (reads `VAULT_TOKEN` at runtime if `token` is not set here).
67    Token {
68        #[serde(default)]
69        token: Option<String>,
70    },
71    /// AppRole: exchange `role_id` + `secret_id` for a short-lived client token.
72    Approle { role_id: String, secret_id: String },
73}
74
75impl VaultAuthConfig {
76    /// Expand `${VAR}` placeholders in string fields using the current process
77    /// environment.  Unknown variables are left as-is (not expanded to empty string).
78    pub fn expand_env_vars(self) -> Self {
79        match self {
80            VaultAuthConfig::Approle { role_id, secret_id } => VaultAuthConfig::Approle {
81                role_id: expand_env_var_str(&role_id),
82                secret_id: expand_env_var_str(&secret_id),
83            },
84            other => other,
85        }
86    }
87}
88
89/// Expand a single `${VAR_NAME}` placeholder in `s`.
90///
91/// Only the simple `${NAME}` syntax is supported — no nested expansions,
92/// no default values.  If a variable is not set, the original `${NAME}`
93/// placeholder is left in the string.
94pub fn expand_env_var_str(s: &str) -> String {
95    // Fast path: no placeholder present.
96    if !s.contains("${") {
97        return s.to_string();
98    }
99
100    let mut result = String::with_capacity(s.len());
101    let mut rest = s;
102
103    while let Some(start) = rest.find("${") {
104        result.push_str(&rest[..start]);
105        rest = &rest[start + 2..]; // skip past "${"
106        if let Some(end) = rest.find('}') {
107            let var_name = &rest[..end];
108            match std::env::var(var_name) {
109                Ok(val) => result.push_str(&val),
110                Err(_) => {
111                    // Leave unexpanded so callers can detect missing vars.
112                    result.push_str("${");
113                    result.push_str(var_name);
114                    result.push('}');
115                }
116            }
117            rest = &rest[end + 1..];
118        } else {
119            // Malformed placeholder — emit literally and stop.
120            result.push_str("${");
121            result.push_str(rest);
122            break;
123        }
124    }
125    result.push_str(rest);
126    result
127}
128
129/// Top-level pull configuration parsed from `.tsafe.yml` or `.tsafe.json`.
130#[derive(Debug, Deserialize)]
131pub struct PullConfig {
132    pub pulls: Vec<PullSource>,
133}
134
135/// A single pull source definition.
136///
137/// Every variant includes two ADR-012 optional fields:
138/// - `name`: label for `--source <label>` filtering
139/// - `ns`: namespace prefix applied to fetched keys (separator `.`)
140#[derive(Debug, Deserialize)]
141#[serde(tag = "source")]
142pub enum PullSource {
143    /// Azure Key Vault.
144    #[serde(rename = "akv")]
145    AzureKeyVault {
146        /// Optional label for `--source <label>` filtering (ADR-012).
147        #[serde(default)]
148        name: Option<String>,
149        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
150        #[serde(default)]
151        ns: Option<String>,
152        vault_url: String,
153        #[serde(default)]
154        prefix: Option<String>,
155        #[serde(default)]
156        overwrite: bool,
157    },
158    /// HashiCorp Vault KV v2.
159    #[serde(rename = "hcp")]
160    HashiCorpVault {
161        /// Optional label for `--source <label>` filtering (ADR-012).
162        #[serde(default)]
163        name: Option<String>,
164        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
165        #[serde(default)]
166        ns: Option<String>,
167        #[serde(default = "default_hcp_addr")]
168        addr: String,
169        #[serde(default = "default_mount")]
170        mount: String,
171        #[serde(default)]
172        prefix: Option<String>,
173        #[serde(default)]
174        overwrite: bool,
175        /// Authentication method.  When absent, the runtime reads env vars
176        /// (`VAULT_ROLE_ID`+`VAULT_SECRET_ID` → AppRole; else `VAULT_TOKEN`).
177        #[serde(default)]
178        auth: Option<VaultAuthConfig>,
179        /// HCP Vault Enterprise namespace.  When set, every request carries
180        /// `X-Vault-Namespace: <vault_namespace>`.  Also read from `VAULT_NAMESPACE`.
181        #[serde(default)]
182        vault_namespace: Option<String>,
183    },
184    /// 1Password via the `op` CLI.
185    #[serde(rename = "op")]
186    OnePassword {
187        /// Optional label for `--source <label>` filtering (ADR-012).
188        #[serde(default)]
189        name: Option<String>,
190        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
191        #[serde(default)]
192        ns: Option<String>,
193        item: String,
194        #[serde(default)]
195        op_vault: Option<String>,
196        #[serde(default)]
197        overwrite: bool,
198    },
199    /// AWS Secrets Manager.
200    #[serde(rename = "aws")]
201    Aws {
202        /// Optional label for `--source <label>` filtering (ADR-012).
203        #[serde(default)]
204        name: Option<String>,
205        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
206        #[serde(default)]
207        ns: Option<String>,
208        /// AWS region, e.g. `us-east-1`. Overrides `AWS_DEFAULT_REGION`/`AWS_REGION`.
209        #[serde(default)]
210        region: Option<String>,
211        /// Only import secrets whose names start with this prefix.
212        #[serde(default)]
213        prefix: Option<String>,
214        #[serde(default)]
215        overwrite: bool,
216    },
217    /// AWS SSM Parameter Store.
218    #[serde(rename = "ssm")]
219    SsmParameterStore {
220        /// Optional label for `--source <label>` filtering (ADR-012).
221        #[serde(default)]
222        name: Option<String>,
223        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
224        #[serde(default)]
225        ns: Option<String>,
226        /// AWS region, e.g. `us-east-1`. Overrides `AWS_DEFAULT_REGION`/`AWS_REGION`.
227        #[serde(default)]
228        region: Option<String>,
229        /// Parameter path prefix (e.g. `/myapp/prod/`). Defaults to `/`.
230        #[serde(default)]
231        path: Option<String>,
232        #[serde(default)]
233        overwrite: bool,
234    },
235    /// GCP Secret Manager.
236    #[serde(rename = "gcp")]
237    Gcp {
238        /// Optional label for `--source <label>` filtering (ADR-012).
239        #[serde(default)]
240        name: Option<String>,
241        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
242        #[serde(default)]
243        ns: Option<String>,
244        /// GCP project ID. Overrides `GOOGLE_CLOUD_PROJECT`/`GCLOUD_PROJECT`.
245        #[serde(default)]
246        project: Option<String>,
247        /// Only import secrets whose names start with this prefix.
248        #[serde(default)]
249        prefix: Option<String>,
250        #[serde(default)]
251        overwrite: bool,
252    },
253    /// Bitwarden via the `bw` CLI (task E2.2).
254    ///
255    /// Cipher values in the Bitwarden REST API are always E2E encrypted
256    /// client-side; this source uses the `bw` CLI subprocess to unlock and
257    /// list items with plaintext decryption handled by the CLI.
258    ///
259    /// Auth requires `TSAFE_BW_CLIENT_ID`, `TSAFE_BW_CLIENT_SECRET`, and
260    /// `TSAFE_BW_PASSWORD` (master password for `bw unlock`).
261    #[serde(rename = "bw")]
262    Bitwarden {
263        /// Optional label for `--source <label>` filtering (ADR-012).
264        #[serde(default)]
265        name: Option<String>,
266        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
267        #[serde(default)]
268        ns: Option<String>,
269        /// Bitwarden API base URL.  Defaults to `https://api.bitwarden.com`.
270        /// Override for self-hosted Vaultwarden instances.
271        #[serde(default)]
272        api_url: Option<String>,
273        /// Bitwarden identity base URL.  Defaults to `https://identity.bitwarden.com`.
274        #[serde(default)]
275        identity_url: Option<String>,
276        /// OAuth2 client ID.  Reads `TSAFE_BW_CLIENT_ID` when not set here.
277        #[serde(default)]
278        client_id: Option<String>,
279        /// OAuth2 client secret.  Reads `TSAFE_BW_CLIENT_SECRET` when not set here.
280        #[serde(default)]
281        client_secret: Option<String>,
282        /// Bitwarden folder ID to filter items.  Imports all items when absent.
283        #[serde(default)]
284        folder: Option<String>,
285        /// Name of the env var that holds the master password for `bw unlock`.
286        /// Defaults to `TSAFE_BW_PASSWORD`.
287        #[serde(default)]
288        password_env: Option<String>,
289        #[serde(default)]
290        overwrite: bool,
291    },
292    /// KeePass `.kdbx` file (local path).
293    ///
294    /// The master password is read from the env var named by `password_env`
295    /// (never stored literally in the manifest).  An optional key file can
296    /// supplement or replace the password.
297    #[serde(rename = "kp")]
298    Keepass {
299        /// Optional label for `--source <label>` filtering (ADR-012).
300        #[serde(default)]
301        name: Option<String>,
302        /// Absolute path to the `.kdbx` file.
303        path: String,
304        /// Name of the environment variable that holds the master password.
305        /// If omitted and no `keyfile_path` is set, opening the database will fail.
306        #[serde(default)]
307        password_env: Option<String>,
308        /// Absolute path to a KeePass key file (`.keyx` or binary).
309        #[serde(default)]
310        keyfile_path: Option<String>,
311        /// Only import entries whose direct parent group has this name
312        /// (case-insensitive).  When absent, all entries from the root group
313        /// are imported (or all groups when `recursive` is true).
314        #[serde(default)]
315        group: Option<String>,
316        /// When `true`, traverse descendant groups as well as the matched
317        /// top-level group.  Defaults to `false`.
318        #[serde(default)]
319        recursive: Option<bool>,
320        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
321        #[serde(default)]
322        ns: Option<String>,
323        #[serde(default)]
324        overwrite: bool,
325    },
326}
327
328impl PullSource {
329    /// Return the `name` label for this source, if declared (ADR-012).
330    pub fn name(&self) -> Option<&str> {
331        match self {
332            PullSource::AzureKeyVault { name, .. }
333            | PullSource::HashiCorpVault { name, .. }
334            | PullSource::OnePassword { name, .. }
335            | PullSource::Aws { name, .. }
336            | PullSource::SsmParameterStore { name, .. }
337            | PullSource::Gcp { name, .. }
338            | PullSource::Bitwarden { name, .. } => name.as_deref(),
339            PullSource::Keepass { name, .. } => name.as_deref(),
340        }
341    }
342
343    /// Return the `ns` namespace prefix for this source, if declared (ADR-012).
344    ///
345    /// Keys fetched from a source with `ns` set are stored as `<ns>.KEY_NAME`.
346    pub fn ns(&self) -> Option<&str> {
347        match self {
348            PullSource::AzureKeyVault { ns, .. }
349            | PullSource::HashiCorpVault { ns, .. }
350            | PullSource::OnePassword { ns, .. }
351            | PullSource::Aws { ns, .. }
352            | PullSource::SsmParameterStore { ns, .. }
353            | PullSource::Gcp { ns, .. }
354            | PullSource::Bitwarden { ns, .. } => ns.as_deref(),
355            PullSource::Keepass { ns, .. } => ns.as_deref(),
356        }
357    }
358
359    /// Return a human-readable provider type label for display purposes.
360    pub fn provider_type(&self) -> &'static str {
361        match self {
362            PullSource::AzureKeyVault { .. } => "akv",
363            PullSource::HashiCorpVault { .. } => "hcp",
364            PullSource::OnePassword { .. } => "op",
365            PullSource::Aws { .. } => "aws",
366            PullSource::SsmParameterStore { .. } => "ssm",
367            PullSource::Gcp { .. } => "gcp",
368            PullSource::Bitwarden { .. } => "bw",
369            PullSource::Keepass { .. } => "kp",
370        }
371    }
372}
373
374fn default_hcp_addr() -> String {
375    "http://127.0.0.1:8200".into()
376}
377fn default_mount() -> String {
378    "secret".into()
379}
380
381/// Search upward from `start` for `.tsafe.yml` / `.tsafe.json`.
382pub fn find_config(start: &Path) -> Option<PathBuf> {
383    let mut dir = start.to_path_buf();
384    loop {
385        let yml = dir.join(".tsafe.yml");
386        if yml.exists() {
387            return Some(yml);
388        }
389        let json = dir.join(".tsafe.json");
390        if json.exists() {
391            return Some(json);
392        }
393        if !dir.pop() {
394            return None;
395        }
396    }
397}
398
399/// Parse a pull configuration file (YAML or JSON).
400pub fn load(path: &Path) -> SafeResult<PullConfig> {
401    let content = std::fs::read_to_string(path)?;
402    let is_json = path
403        .extension()
404        .and_then(|e| e.to_str())
405        .map(|e| e == "json")
406        .unwrap_or(false);
407    if is_json {
408        serde_json::from_str(&content).map_err(|e| SafeError::InvalidVault {
409            reason: format!("invalid pull config JSON: {e}"),
410        })
411    } else {
412        serde_yaml::from_str(&content).map_err(|e| SafeError::InvalidVault {
413            reason: format!("invalid pull config YAML: {e}"),
414        })
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use tempfile::tempdir;
422
423    #[test]
424    fn parse_yaml_config() {
425        let yaml = r#"
426pulls:
427  - source: akv
428    vault_url: https://myvault.vault.azure.net
429    prefix: MYAPP_
430    overwrite: true
431  - source: hcp
432    addr: http://vault:8200
433    mount: secret
434    prefix: myapp/
435  - source: op
436    item: Database Credentials
437    op_vault: Infrastructure
438  - source: aws
439    region: us-east-1
440    prefix: myapp/
441  - source: gcp
442    project: my-gcp-project
443    prefix: myapp-
444"#;
445        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
446        assert_eq!(cfg.pulls.len(), 5);
447        match &cfg.pulls[0] {
448            PullSource::AzureKeyVault {
449                vault_url,
450                prefix,
451                overwrite,
452                ..
453            } => {
454                assert_eq!(vault_url, "https://myvault.vault.azure.net");
455                assert_eq!(prefix.as_deref(), Some("MYAPP_"));
456                assert!(overwrite);
457            }
458            other => panic!("expected AzureKeyVault, got {other:?}"),
459        }
460    }
461
462    #[test]
463    fn parse_json_config() {
464        let json = r#"{"pulls": [{"source": "op", "item": "Test"}]}"#;
465        let cfg: PullConfig = serde_json::from_str(json).unwrap();
466        assert_eq!(cfg.pulls.len(), 1);
467    }
468
469    #[test]
470    fn find_config_walks_up() {
471        let dir = tempdir().unwrap();
472        let child = dir.path().join("a/b/c");
473        std::fs::create_dir_all(&child).unwrap();
474        let cfg_path = dir.path().join(".tsafe.yml");
475        std::fs::write(&cfg_path, "pulls: []").unwrap();
476        let found = find_config(&child).unwrap();
477        assert_eq!(found, cfg_path);
478    }
479
480    #[test]
481    fn find_config_returns_none() {
482        let dir = tempdir().unwrap();
483        assert!(find_config(dir.path()).is_none());
484    }
485
486    /// ADR-012: `name` and `ns` fields are optional and parse correctly when present.
487    #[test]
488    fn parse_name_and_ns_fields() {
489        let yaml = r#"
490pulls:
491  - source: akv
492    name: prod-akv
493    ns: prod
494    vault_url: https://prod.vault.azure.net
495  - source: aws
496    name: staging-aws
497    ns: staging
498    region: us-east-1
499  - source: gcp
500    project: my-project
501"#;
502        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
503        assert_eq!(cfg.pulls.len(), 3);
504
505        assert_eq!(cfg.pulls[0].name(), Some("prod-akv"));
506        assert_eq!(cfg.pulls[0].ns(), Some("prod"));
507        assert_eq!(cfg.pulls[0].provider_type(), "akv");
508
509        assert_eq!(cfg.pulls[1].name(), Some("staging-aws"));
510        assert_eq!(cfg.pulls[1].ns(), Some("staging"));
511        assert_eq!(cfg.pulls[1].provider_type(), "aws");
512
513        // source without name/ns defaults to None
514        assert_eq!(cfg.pulls[2].name(), None);
515        assert_eq!(cfg.pulls[2].ns(), None);
516        assert_eq!(cfg.pulls[2].provider_type(), "gcp");
517    }
518
519    /// ADR-012: existing manifests without name/ns continue to parse unchanged.
520    #[test]
521    fn name_and_ns_default_to_none() {
522        let yaml = r#"
523pulls:
524  - source: akv
525    vault_url: https://myvault.vault.azure.net
526  - source: hcp
527    addr: http://vault:8200
528    mount: secret
529  - source: op
530    item: MyItem
531  - source: aws
532    region: us-east-1
533  - source: ssm
534    region: us-east-1
535  - source: gcp
536    project: my-project
537"#;
538        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
539        for source in &cfg.pulls {
540            assert_eq!(
541                source.name(),
542                None,
543                "expected no name for {:?}",
544                source.provider_type()
545            );
546            assert_eq!(
547                source.ns(),
548                None,
549                "expected no ns for {:?}",
550                source.provider_type()
551            );
552        }
553    }
554
555    // ── VaultAuthConfig tests (task E2.4) ──────────────────────────────────────
556
557    /// Token auth parsed from YAML.
558    #[test]
559    fn parse_hcp_token_auth_from_yaml() {
560        let yaml = r#"
561pulls:
562  - source: hcp
563    addr: https://vault.example.com:8200
564    auth:
565      method: token
566      token: hvs.my-static-token
567"#;
568        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
569        assert_eq!(cfg.pulls.len(), 1);
570        match &cfg.pulls[0] {
571            PullSource::HashiCorpVault { auth, .. } => {
572                assert!(
573                    matches!(
574                        auth,
575                        Some(VaultAuthConfig::Token {
576                            token: Some(t)
577                        }) if t == "hvs.my-static-token"
578                    ),
579                    "expected Token auth with static token, got {auth:?}"
580                );
581            }
582            other => panic!("expected HashiCorpVault, got {other:?}"),
583        }
584    }
585
586    /// AppRole auth parsed from YAML.
587    #[test]
588    fn parse_hcp_approle_auth_from_yaml() {
589        let yaml = r#"
590pulls:
591  - source: hcp
592    addr: https://vault.example.com:8200
593    auth:
594      method: approle
595      role_id: my-role-123
596      secret_id: my-secret-456
597"#;
598        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
599        match &cfg.pulls[0] {
600            PullSource::HashiCorpVault { auth, .. } => {
601                assert!(
602                    matches!(
603                        auth,
604                        Some(VaultAuthConfig::Approle { role_id, secret_id })
605                        if role_id == "my-role-123" && secret_id == "my-secret-456"
606                    ),
607                    "expected AppRole auth, got {auth:?}"
608                );
609            }
610            other => panic!("expected HashiCorpVault, got {other:?}"),
611        }
612    }
613
614    /// vault_namespace field is parsed from YAML.
615    #[test]
616    fn parse_hcp_vault_namespace_from_yaml() {
617        let yaml = r#"
618pulls:
619  - source: hcp
620    addr: https://vault.example.com:8200
621    vault_namespace: team-alpha
622"#;
623        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
624        match &cfg.pulls[0] {
625            PullSource::HashiCorpVault {
626                vault_namespace, ..
627            } => {
628                assert_eq!(vault_namespace.as_deref(), Some("team-alpha"));
629            }
630            other => panic!("expected HashiCorpVault, got {other:?}"),
631        }
632    }
633
634    /// HCP source without auth or vault_namespace defaults to None.
635    #[test]
636    fn parse_hcp_defaults_auth_and_namespace_to_none() {
637        let yaml = r#"
638pulls:
639  - source: hcp
640    addr: http://127.0.0.1:8200
641"#;
642        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
643        match &cfg.pulls[0] {
644            PullSource::HashiCorpVault {
645                auth,
646                vault_namespace,
647                ..
648            } => {
649                assert!(auth.is_none(), "expected auth=None, got {auth:?}");
650                assert!(
651                    vault_namespace.is_none(),
652                    "expected vault_namespace=None, got {vault_namespace:?}"
653                );
654            }
655            other => panic!("expected HashiCorpVault, got {other:?}"),
656        }
657    }
658
659    // ── expand_env_var_str tests ───────────────────────────────────────────────
660
661    /// Simple `${VAR}` placeholder is expanded when the var is set.
662    #[test]
663    fn expand_env_var_str_replaces_placeholder() {
664        temp_env::with_var("TEST_SECRET_ID", Some("s-abc-123"), || {
665            let result = expand_env_var_str("${TEST_SECRET_ID}");
666            assert_eq!(result, "s-abc-123");
667        });
668    }
669
670    /// Strings without `${` are returned unchanged (fast path).
671    #[test]
672    fn expand_env_var_str_no_placeholder_passthrough() {
673        let result = expand_env_var_str("plain-secret-id");
674        assert_eq!(result, "plain-secret-id");
675    }
676
677    /// Unknown variable placeholder is left unexpanded.
678    #[test]
679    fn expand_env_var_str_unknown_var_left_as_is() {
680        temp_env::with_var("VAULT_UNKNOWN_9999", None::<&str>, || {
681            let result = expand_env_var_str("${VAULT_UNKNOWN_9999}");
682            assert_eq!(result, "${VAULT_UNKNOWN_9999}");
683        });
684    }
685
686    /// Env var expansion in AppRole auth config via `expand_env_vars()`.
687    #[test]
688    fn vault_auth_config_expand_env_vars_in_approle() {
689        temp_env::with_var("MY_SECRET_ID", Some("expanded-sid"), || {
690            let auth = VaultAuthConfig::Approle {
691                role_id: "static-role".into(),
692                secret_id: "${MY_SECRET_ID}".into(),
693            };
694            let expanded = auth.expand_env_vars();
695            assert!(
696                matches!(
697                    expanded,
698                    VaultAuthConfig::Approle { ref role_id, ref secret_id }
699                    if role_id == "static-role" && secret_id == "expanded-sid"
700                ),
701                "expected expanded secret_id, got {expanded:?}"
702            );
703        });
704    }
705
706    /// Token auth is not modified by expand_env_vars().
707    #[test]
708    fn vault_auth_config_expand_env_vars_token_unchanged() {
709        let auth = VaultAuthConfig::Token {
710            token: Some("hvs.static".into()),
711        };
712        let expanded = auth.expand_env_vars();
713        assert!(
714            matches!(expanded, VaultAuthConfig::Token { token: Some(ref t) } if t == "hvs.static"),
715            "expected token unchanged, got {expanded:?}"
716        );
717    }
718}