Skip to main content

braze_sync/config/
mod.rs

1//! Configuration loading and environment resolution.
2//!
3//! Two-step model:
4//! 1. [`ConfigFile::load`] reads the YAML, parses it, and runs structural
5//!    validation that doesn't need environment variables.
6//! 2. [`ConfigFile::resolve`] picks an environment, looks up the API key
7//!    via the OS environment, and produces a [`ResolvedConfig`] which is
8//!    what the rest of the system consumes.
9//!
10//! The split exists so tests can drive [`ConfigFile::resolve_with`] with a
11//! fake env-lookup closure instead of mutating process-global `std::env`.
12//!
13//! The api key is wrapped in [`secrecy::SecretString`] from the moment
14//! it leaves the OS so that `Debug`, `tracing`, and panic messages
15//! cannot leak it.
16
17pub mod schema;
18
19pub use schema::{
20    ApplyOrder, ConfigFile, Defaults, EnvironmentConfig, NamingConfig, ResourceConfig,
21    ResourcesConfig,
22};
23
24use crate::error::{Error, Result};
25use crate::resource::ResourceKind;
26use regex_lite::Regex;
27use secrecy::SecretString;
28use std::collections::HashMap;
29use std::path::Path;
30use url::Url;
31
32/// Fully-resolved config: an environment has been picked and the API key
33/// has been pulled out of the OS environment.
34#[derive(Debug)]
35pub struct ResolvedConfig {
36    pub environment_name: String,
37    pub api_endpoint: Url,
38    /// API key, secrecy-wrapped. Use [`secrecy::ExposeSecret`] at the call
39    /// site that needs the plaintext (typically only the BrazeClient
40    /// constructor).
41    pub api_key: SecretString,
42    pub resources: ResourcesConfig,
43    pub naming: NamingConfig,
44    /// Compiled `exclude_patterns` per resource kind. Populated by
45    /// [`ConfigFile::resolve_with`] so callers can look up a `&[Regex]`
46    /// without recompiling on every invocation.
47    pub excludes: HashMap<ResourceKind, Vec<Regex>>,
48}
49
50impl ResolvedConfig {
51    /// Compiled exclude patterns for `kind`.
52    pub fn excludes_for(&self, kind: ResourceKind) -> &[Regex] {
53        self.excludes.get(&kind).map(Vec::as_slice).unwrap_or(&[])
54    }
55}
56
57impl ConfigFile {
58    /// Load and structurally validate a config file.
59    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
60        let path = path.as_ref();
61        let bytes = std::fs::read_to_string(path)?;
62        let cfg: ConfigFile =
63            serde_norway::from_str(&bytes).map_err(|source| Error::YamlParse {
64                path: path.to_path_buf(),
65                source,
66            })?;
67        cfg.validate_static()?;
68        Ok(cfg)
69    }
70
71    fn validate_static(&self) -> Result<()> {
72        if self.version != 1 {
73            return Err(Error::Config(format!(
74                "unsupported config version {} (this binary supports version 1; \
75                 see the forward-compat policy)",
76                self.version
77            )));
78        }
79        if !self.environments.contains_key(&self.default_environment) {
80            return Err(Error::Config(format!(
81                "default_environment '{}' is not declared in the environments map",
82                self.default_environment
83            )));
84        }
85        // Validate that all endpoint URLs use http or https. Non-hierarchical
86        // schemes (mailto:, data:, etc.) would panic in BrazeClient::url_for
87        // when calling path_segments_mut().
88        for (name, env) in &self.environments {
89            if env.api_key_env.trim().is_empty() {
90                return Err(Error::Config(format!(
91                    "environment '{name}': api_key_env must not be empty"
92                )));
93            }
94            match env.api_endpoint.scheme() {
95                "http" | "https" => {}
96                scheme => {
97                    return Err(Error::Config(format!(
98                        "environment '{name}': api_endpoint must use http or https \
99                         (got '{scheme}')"
100                    )));
101                }
102            }
103        }
104        // Compile every resource's exclude_patterns at load time so
105        // malformed regexes fail fast instead of at first use.
106        for kind in ResourceKind::all() {
107            let rc = self.resources.for_kind(*kind);
108            compile_exclude_patterns(&rc.exclude_patterns, kind.as_str())?;
109        }
110        Ok(())
111    }
112
113    /// Resolve to a [`ResolvedConfig`] using the real process environment.
114    pub fn resolve(self, env_override: Option<&str>) -> Result<ResolvedConfig> {
115        self.resolve_with(env_override, |k| std::env::var(k).ok())
116    }
117
118    /// Resolve using a caller-supplied env-var lookup closure. Used by
119    /// tests so they don't have to touch process-global `std::env`.
120    pub fn resolve_with(
121        mut self,
122        env_override: Option<&str>,
123        env_lookup: impl Fn(&str) -> Option<String>,
124    ) -> Result<ResolvedConfig> {
125        let env_name = env_override
126            .map(str::to_string)
127            .unwrap_or_else(|| self.default_environment.clone());
128
129        if !self.environments.contains_key(&env_name) {
130            let known: Vec<&str> = self.environments.keys().map(String::as_str).collect();
131            return Err(Error::Config(format!(
132                "unknown environment '{}'; declared: [{}]",
133                env_name,
134                known.join(", ")
135            )));
136        }
137        let env_cfg = self
138            .environments
139            .remove(&env_name)
140            .expect("presence checked immediately above");
141
142        let api_key_str = env_lookup(&env_cfg.api_key_env)
143            .ok_or_else(|| Error::MissingEnv(env_cfg.api_key_env.clone()))?;
144        if api_key_str.is_empty() {
145            return Err(Error::Config(format!(
146                "environment variable '{}' is set but empty",
147                env_cfg.api_key_env
148            )));
149        }
150
151        let mut excludes: HashMap<ResourceKind, Vec<Regex>> = HashMap::new();
152        for kind in ResourceKind::all() {
153            let rc = self.resources.for_kind(*kind);
154            excludes.insert(
155                *kind,
156                compile_exclude_patterns(&rc.exclude_patterns, kind.as_str())?,
157            );
158        }
159
160        Ok(ResolvedConfig {
161            environment_name: env_name,
162            api_endpoint: env_cfg.api_endpoint,
163            api_key: SecretString::from(api_key_str),
164            resources: self.resources,
165            naming: self.naming,
166            excludes,
167        })
168    }
169}
170
171/// `context` label is used in error messages (e.g. `"custom_attribute"`).
172pub fn compile_exclude_patterns(patterns: &[String], context: &str) -> Result<Vec<Regex>> {
173    patterns
174        .iter()
175        .enumerate()
176        .map(|(i, p)| {
177            Regex::new(p).map_err(|e| {
178                Error::Config(format!(
179                    "{context}.exclude_patterns[{i}]: invalid regex {p:?}: {e}"
180                ))
181            })
182        })
183        .collect()
184}
185
186pub fn is_excluded(name: &str, patterns: &[Regex]) -> bool {
187    patterns.iter().any(|r| r.is_match(name))
188}
189
190/// CWD only, no parent traversal. A missing file is not an error.
191pub fn load_dotenv() -> Result<()> {
192    match dotenvy::from_path(".env") {
193        Ok(()) => Ok(()),
194        Err(e) if e.not_found() => Ok(()),
195        Err(e) => Err(Error::Config(format!(".env load error: {e}"))),
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use secrecy::ExposeSecret;
203    use std::io::Write;
204
205    fn write_config(content: &str) -> tempfile::NamedTempFile {
206        let mut f = tempfile::NamedTempFile::new().unwrap();
207        f.write_all(content.as_bytes()).unwrap();
208        f
209    }
210
211    const MINIMAL: &str = r#"
212version: 1
213default_environment: dev
214environments:
215  dev:
216    api_endpoint: https://rest.fra-02.braze.eu
217    api_key_env: BRAZE_DEV_API_KEY
218"#;
219
220    #[test]
221    fn loads_minimal_config_with_all_defaults() {
222        let f = write_config(MINIMAL);
223        let cfg = ConfigFile::load(f.path()).unwrap();
224        assert_eq!(cfg.version, 1);
225        assert_eq!(cfg.default_environment, "dev");
226        assert_eq!(cfg.environments.len(), 1);
227        // resources defaulted in full
228        assert!(cfg.resources.catalog_schema.enabled);
229        assert_eq!(
230            cfg.resources.catalog_schema.path,
231            std::path::PathBuf::from("catalogs/")
232        );
233        assert_eq!(
234            cfg.resources.custom_attribute.path,
235            std::path::PathBuf::from("custom_attributes/registry.yaml")
236        );
237    }
238
239    #[test]
240    fn loads_full_config_from_section_10() {
241        const FULL: &str = r#"
242version: 1
243default_environment: dev
244environments:
245  dev:
246    api_endpoint: https://rest.fra-02.braze.eu
247    api_key_env: BRAZE_DEV_API_KEY
248  prod:
249    api_endpoint: https://rest.fra-02.braze.eu
250    api_key_env: BRAZE_PROD_API_KEY
251resources:
252  catalog_schema:
253    enabled: true
254    path: catalogs/
255  content_block:
256    enabled: true
257    path: content_blocks/
258  email_template:
259    enabled: false
260    path: email_templates/
261  custom_attribute:
262    enabled: true
263    path: custom_attributes/registry.yaml
264naming:
265  catalog_name_pattern: "^[a-z][a-z0-9_]*$"
266"#;
267        let f = write_config(FULL);
268        let cfg = ConfigFile::load(f.path()).unwrap();
269        assert_eq!(cfg.environments.len(), 2);
270        assert!(!cfg.resources.email_template.enabled);
271        assert_eq!(
272            cfg.naming.catalog_name_pattern.as_deref(),
273            Some("^[a-z][a-z0-9_]*$")
274        );
275    }
276
277    #[test]
278    fn rejects_wrong_version() {
279        let yaml = r#"
280version: 2
281default_environment: dev
282environments:
283  dev:
284    api_endpoint: https://rest.fra-02.braze.eu
285    api_key_env: BRAZE_DEV_API_KEY
286"#;
287        let f = write_config(yaml);
288        let err = ConfigFile::load(f.path()).unwrap_err();
289        assert!(matches!(err, Error::Config(_)));
290        assert!(err.to_string().contains("version 2"));
291    }
292
293    #[test]
294    fn rejects_unknown_top_level_field() {
295        let yaml = r#"
296version: 1
297default_environment: dev
298mystery_key: 1
299environments:
300  dev:
301    api_endpoint: https://rest.fra-02.braze.eu
302    api_key_env: BRAZE_DEV_API_KEY
303"#;
304        let f = write_config(yaml);
305        let err = ConfigFile::load(f.path()).unwrap_err();
306        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
307    }
308
309    #[test]
310    fn rejects_legacy_catalog_items_resource_section() {
311        let yaml = r#"
312version: 1
313default_environment: dev
314environments:
315  dev:
316    api_endpoint: https://rest.fra-02.braze.eu
317    api_key_env: BRAZE_DEV_API_KEY
318resources:
319  catalog_items:
320    enabled: true
321"#;
322        let f = write_config(yaml);
323        let err = ConfigFile::load(f.path()).unwrap_err();
324        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
325    }
326
327    #[test]
328    fn rejects_legacy_defaults_rate_limit_per_minute() {
329        // v0.8.0: client-side rate limiter was removed. Leftover
330        // `rate_limit_per_minute` keys must hard-error so users notice
331        // they need to delete them, rather than silently ignoring.
332        let yaml = r#"
333version: 1
334default_environment: dev
335defaults:
336  rate_limit_per_minute: 40
337environments:
338  dev:
339    api_endpoint: https://rest.fra-02.braze.eu
340    api_key_env: BRAZE_DEV_API_KEY
341"#;
342        let f = write_config(yaml);
343        let err = ConfigFile::load(f.path()).unwrap_err();
344        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
345    }
346
347    #[test]
348    fn accepts_exclude_patterns_on_resource_config() {
349        let yaml = r#"
350version: 1
351default_environment: dev
352environments:
353  dev:
354    api_endpoint: https://rest.fra-02.braze.eu
355    api_key_env: BRAZE_DEV_API_KEY
356resources:
357  custom_attribute:
358    path: custom_attributes/registry.yaml
359    exclude_patterns:
360      - "^_"
361      - "^(hoge|hack)$"
362"#;
363        let f = write_config(yaml);
364        let cfg = ConfigFile::load(f.path()).unwrap();
365        assert_eq!(
366            cfg.resources.custom_attribute.exclude_patterns,
367            vec!["^_".to_string(), "^(hoge|hack)$".to_string()]
368        );
369    }
370
371    #[test]
372    fn rejects_invalid_exclude_pattern_at_load_time() {
373        // Unbalanced paren — invalid regex should hard-error at load,
374        // not at first use.
375        let yaml = r#"
376version: 1
377default_environment: dev
378environments:
379  dev:
380    api_endpoint: https://rest.fra-02.braze.eu
381    api_key_env: BRAZE_DEV_API_KEY
382resources:
383  custom_attribute:
384    path: custom_attributes/registry.yaml
385    exclude_patterns:
386      - "("
387"#;
388        let f = write_config(yaml);
389        let err = ConfigFile::load(f.path()).unwrap_err();
390        match err {
391            Error::Config(msg) => {
392                assert!(msg.contains("custom_attribute"), "msg: {msg}");
393                assert!(msg.contains("exclude_patterns[0]"), "msg: {msg}");
394            }
395            other => panic!("expected Config error, got {other:?}"),
396        }
397    }
398
399    #[test]
400    fn is_excluded_matches_any_pattern() {
401        let patterns =
402            compile_exclude_patterns(&["^_".to_string(), "^test_".to_string()], "test").unwrap();
403        assert!(is_excluded("_unset", &patterns));
404        assert!(is_excluded("test_foo", &patterns));
405        assert!(!is_excluded("regular_attr", &patterns));
406    }
407
408    #[test]
409    fn rejects_non_http_endpoint_scheme() {
410        let yaml = r#"
411version: 1
412default_environment: dev
413environments:
414  dev:
415    api_endpoint: ftp://rest.braze.eu
416    api_key_env: BRAZE_DEV_API_KEY
417"#;
418        let f = write_config(yaml);
419        let err = ConfigFile::load(f.path()).unwrap_err();
420        assert!(matches!(err, Error::Config(_)));
421        let msg = err.to_string();
422        assert!(msg.contains("http"), "expected http scheme hint: {msg}");
423        assert!(msg.contains("ftp"), "expected actual scheme: {msg}");
424    }
425
426    #[test]
427    fn rejects_default_environment_not_in_map() {
428        let yaml = r#"
429version: 1
430default_environment: missing
431environments:
432  dev:
433    api_endpoint: https://rest.fra-02.braze.eu
434    api_key_env: BRAZE_DEV_API_KEY
435"#;
436        let f = write_config(yaml);
437        let err = ConfigFile::load(f.path()).unwrap_err();
438        assert!(matches!(err, Error::Config(_)));
439        assert!(err.to_string().contains("missing"));
440    }
441
442    #[test]
443    fn resolve_uses_default_environment_when_no_override() {
444        let f = write_config(MINIMAL);
445        let cfg = ConfigFile::load(f.path()).unwrap();
446        let resolved = cfg
447            .resolve_with(None, |k| {
448                assert_eq!(k, "BRAZE_DEV_API_KEY");
449                Some("token-abc".into())
450            })
451            .unwrap();
452        assert_eq!(resolved.environment_name, "dev");
453        assert_eq!(resolved.api_key.expose_secret(), "token-abc");
454    }
455
456    #[test]
457    fn resolve_uses_override_when_provided() {
458        const TWO_ENVS: &str = r#"
459version: 1
460default_environment: dev
461environments:
462  dev:
463    api_endpoint: https://rest.fra-02.braze.eu
464    api_key_env: BRAZE_DEV_API_KEY
465  prod:
466    api_endpoint: https://rest.fra-02.braze.eu
467    api_key_env: BRAZE_PROD_API_KEY
468"#;
469        let f = write_config(TWO_ENVS);
470        let cfg = ConfigFile::load(f.path()).unwrap();
471        let resolved = cfg
472            .resolve_with(Some("prod"), |k| {
473                assert_eq!(k, "BRAZE_PROD_API_KEY");
474                Some("prod-token".into())
475            })
476            .unwrap();
477        assert_eq!(resolved.environment_name, "prod");
478    }
479
480    #[test]
481    fn resolve_unknown_env_lists_known_envs() {
482        let f = write_config(MINIMAL);
483        let cfg = ConfigFile::load(f.path()).unwrap();
484        let err = cfg
485            .resolve_with(Some("staging"), |_| Some("x".into()))
486            .unwrap_err();
487        let msg = err.to_string();
488        assert!(msg.contains("staging"));
489        assert!(msg.contains("dev"));
490    }
491
492    #[test]
493    fn resolve_missing_env_var_is_typed_error() {
494        let f = write_config(MINIMAL);
495        let cfg = ConfigFile::load(f.path()).unwrap();
496        let err = cfg.resolve_with(None, |_| None).unwrap_err();
497        match err {
498            Error::MissingEnv(name) => assert_eq!(name, "BRAZE_DEV_API_KEY"),
499            other => panic!("expected MissingEnv, got {other:?}"),
500        }
501    }
502
503    #[test]
504    fn resolve_empty_env_var_is_rejected() {
505        let f = write_config(MINIMAL);
506        let cfg = ConfigFile::load(f.path()).unwrap();
507        let err = cfg.resolve_with(None, |_| Some(String::new())).unwrap_err();
508        assert!(matches!(err, Error::Config(_)));
509        assert!(err.to_string().contains("empty"));
510    }
511
512    #[test]
513    fn debug_format_does_not_leak_api_key() {
514        let f = write_config(MINIMAL);
515        let resolved = ConfigFile::load(f.path())
516            .unwrap()
517            .resolve_with(None, |_| Some("super-secret-token-abc-123".into()))
518            .unwrap();
519        let dbg = format!("{resolved:?}");
520        assert!(
521            !dbg.contains("super-secret-token-abc-123"),
522            "Debug output leaked api key: {dbg}"
523        );
524    }
525
526    #[test]
527    fn rejects_empty_api_key_env() {
528        let yaml = r#"
529version: 1
530default_environment: dev
531environments:
532  dev:
533    api_endpoint: https://rest.fra-02.braze.eu
534    api_key_env: ""
535"#;
536        let f = write_config(yaml);
537        let err = ConfigFile::load(f.path()).unwrap_err();
538        assert!(matches!(err, Error::Config(_)), "got: {err:?}");
539        assert!(err.to_string().contains("api_key_env"));
540    }
541
542    #[test]
543    fn load_io_error_for_missing_file() {
544        let err = ConfigFile::load("/nonexistent/braze-sync.config.yaml").unwrap_err();
545        assert!(matches!(err, Error::Io(_)), "got: {err:?}");
546    }
547}