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//! See IMPLEMENTATION.md §10. The api key is wrapped in
14//! [`secrecy::SecretString`] from the moment it leaves the OS so that
15//! `Debug`, `tracing`, and panic messages cannot leak it.
16
17pub mod schema;
18
19pub use schema::{
20    ConfigFile, Defaults, EnvironmentConfig, NamingConfig, ResourceConfig, ResourcesConfig,
21};
22
23use crate::error::{Error, Result};
24use crate::resource::ResourceKind;
25use regex_lite::Regex;
26use secrecy::SecretString;
27use std::collections::HashMap;
28use std::path::Path;
29use url::Url;
30
31/// Fully-resolved config: an environment has been picked and the API key
32/// has been pulled out of the OS environment.
33#[derive(Debug)]
34pub struct ResolvedConfig {
35    pub environment_name: String,
36    pub api_endpoint: Url,
37    /// API key, secrecy-wrapped. Use [`secrecy::ExposeSecret`] at the call
38    /// site that needs the plaintext (typically only the BrazeClient
39    /// constructor).
40    pub api_key: SecretString,
41    pub resources: ResourcesConfig,
42    pub naming: NamingConfig,
43    /// Compiled `exclude_patterns` per resource kind. Populated by
44    /// [`ConfigFile::resolve_with`] so callers can look up a `&[Regex]`
45    /// without recompiling on every invocation.
46    pub excludes: HashMap<ResourceKind, Vec<Regex>>,
47}
48
49impl ResolvedConfig {
50    /// Compiled exclude patterns for `kind`. Returns an empty slice when
51    /// no patterns are configured.
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 IMPLEMENTATION.md §2.5 for 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/// Compile a list of raw regex patterns from a resource's
172/// `exclude_patterns` into `Regex` values. The `context` label is used
173/// in error messages (e.g. `"custom_attribute"`).
174pub fn compile_exclude_patterns(patterns: &[String], context: &str) -> Result<Vec<Regex>> {
175    patterns
176        .iter()
177        .enumerate()
178        .map(|(i, p)| {
179            Regex::new(p).map_err(|e| {
180                Error::Config(format!(
181                    "{context}.exclude_patterns[{i}]: invalid regex {p:?}: {e}"
182                ))
183            })
184        })
185        .collect()
186}
187
188/// Return `true` if `name` matches any of the compiled patterns.
189pub fn is_excluded(name: &str, patterns: &[Regex]) -> bool {
190    patterns.iter().any(|r| r.is_match(name))
191}
192
193/// Load `.env` from the current working directory only — no parent
194/// traversal — to populate `std::env` before config resolution. A missing
195/// file is the common dev case and is not an error.
196///
197/// IMPLEMENTATION.md §10: via dotenvy, CWD only, no parent traversal.
198pub fn load_dotenv() -> Result<()> {
199    match dotenvy::from_path(".env") {
200        Ok(()) => Ok(()),
201        Err(e) if e.not_found() => Ok(()),
202        Err(e) => Err(Error::Config(format!(".env load error: {e}"))),
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use secrecy::ExposeSecret;
210    use std::io::Write;
211
212    fn write_config(content: &str) -> tempfile::NamedTempFile {
213        let mut f = tempfile::NamedTempFile::new().unwrap();
214        f.write_all(content.as_bytes()).unwrap();
215        f
216    }
217
218    const MINIMAL: &str = r#"
219version: 1
220default_environment: dev
221environments:
222  dev:
223    api_endpoint: https://rest.fra-02.braze.eu
224    api_key_env: BRAZE_DEV_API_KEY
225"#;
226
227    #[test]
228    fn loads_minimal_config_with_all_defaults() {
229        let f = write_config(MINIMAL);
230        let cfg = ConfigFile::load(f.path()).unwrap();
231        assert_eq!(cfg.version, 1);
232        assert_eq!(cfg.default_environment, "dev");
233        assert_eq!(cfg.environments.len(), 1);
234        // resources defaulted in full
235        assert!(cfg.resources.catalog_schema.enabled);
236        assert_eq!(
237            cfg.resources.catalog_schema.path,
238            std::path::PathBuf::from("catalogs/")
239        );
240        assert_eq!(
241            cfg.resources.custom_attribute.path,
242            std::path::PathBuf::from("custom_attributes/registry.yaml")
243        );
244    }
245
246    #[test]
247    fn loads_full_config_from_section_10() {
248        const FULL: &str = r#"
249version: 1
250default_environment: dev
251environments:
252  dev:
253    api_endpoint: https://rest.fra-02.braze.eu
254    api_key_env: BRAZE_DEV_API_KEY
255  prod:
256    api_endpoint: https://rest.fra-02.braze.eu
257    api_key_env: BRAZE_PROD_API_KEY
258resources:
259  catalog_schema:
260    enabled: true
261    path: catalogs/
262  content_block:
263    enabled: true
264    path: content_blocks/
265  email_template:
266    enabled: false
267    path: email_templates/
268  custom_attribute:
269    enabled: true
270    path: custom_attributes/registry.yaml
271naming:
272  catalog_name_pattern: "^[a-z][a-z0-9_]*$"
273"#;
274        let f = write_config(FULL);
275        let cfg = ConfigFile::load(f.path()).unwrap();
276        assert_eq!(cfg.environments.len(), 2);
277        assert!(!cfg.resources.email_template.enabled);
278        assert_eq!(
279            cfg.naming.catalog_name_pattern.as_deref(),
280            Some("^[a-z][a-z0-9_]*$")
281        );
282    }
283
284    #[test]
285    fn rejects_wrong_version() {
286        let yaml = r#"
287version: 2
288default_environment: dev
289environments:
290  dev:
291    api_endpoint: https://rest.fra-02.braze.eu
292    api_key_env: BRAZE_DEV_API_KEY
293"#;
294        let f = write_config(yaml);
295        let err = ConfigFile::load(f.path()).unwrap_err();
296        assert!(matches!(err, Error::Config(_)));
297        assert!(err.to_string().contains("version 2"));
298    }
299
300    #[test]
301    fn rejects_unknown_top_level_field() {
302        let yaml = r#"
303version: 1
304default_environment: dev
305mystery_key: 1
306environments:
307  dev:
308    api_endpoint: https://rest.fra-02.braze.eu
309    api_key_env: BRAZE_DEV_API_KEY
310"#;
311        let f = write_config(yaml);
312        let err = ConfigFile::load(f.path()).unwrap_err();
313        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
314    }
315
316    #[test]
317    fn rejects_legacy_catalog_items_resource_section() {
318        let yaml = r#"
319version: 1
320default_environment: dev
321environments:
322  dev:
323    api_endpoint: https://rest.fra-02.braze.eu
324    api_key_env: BRAZE_DEV_API_KEY
325resources:
326  catalog_items:
327    enabled: true
328"#;
329        let f = write_config(yaml);
330        let err = ConfigFile::load(f.path()).unwrap_err();
331        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
332    }
333
334    #[test]
335    fn rejects_legacy_defaults_rate_limit_per_minute() {
336        // v0.8.0: client-side rate limiter was removed. Leftover
337        // `rate_limit_per_minute` keys must hard-error so users notice
338        // they need to delete them, rather than silently ignoring.
339        let yaml = r#"
340version: 1
341default_environment: dev
342defaults:
343  rate_limit_per_minute: 40
344environments:
345  dev:
346    api_endpoint: https://rest.fra-02.braze.eu
347    api_key_env: BRAZE_DEV_API_KEY
348"#;
349        let f = write_config(yaml);
350        let err = ConfigFile::load(f.path()).unwrap_err();
351        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
352    }
353
354    #[test]
355    fn rejects_legacy_environment_rate_limit_per_minute() {
356        let yaml = r#"
357version: 1
358default_environment: dev
359environments:
360  dev:
361    api_endpoint: https://rest.fra-02.braze.eu
362    api_key_env: BRAZE_DEV_API_KEY
363    rate_limit_per_minute: 30
364"#;
365        let f = write_config(yaml);
366        let err = ConfigFile::load(f.path()).unwrap_err();
367        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
368    }
369
370    #[test]
371    fn accepts_exclude_patterns_on_resource_config() {
372        let yaml = r#"
373version: 1
374default_environment: dev
375environments:
376  dev:
377    api_endpoint: https://rest.fra-02.braze.eu
378    api_key_env: BRAZE_DEV_API_KEY
379resources:
380  custom_attribute:
381    path: custom_attributes/registry.yaml
382    exclude_patterns:
383      - "^_"
384      - "^(hoge|hack)$"
385"#;
386        let f = write_config(yaml);
387        let cfg = ConfigFile::load(f.path()).unwrap();
388        assert_eq!(
389            cfg.resources.custom_attribute.exclude_patterns,
390            vec!["^_".to_string(), "^(hoge|hack)$".to_string()]
391        );
392    }
393
394    #[test]
395    fn rejects_invalid_exclude_pattern_at_load_time() {
396        // Unbalanced paren — invalid regex should hard-error at load,
397        // not at first use.
398        let yaml = r#"
399version: 1
400default_environment: dev
401environments:
402  dev:
403    api_endpoint: https://rest.fra-02.braze.eu
404    api_key_env: BRAZE_DEV_API_KEY
405resources:
406  custom_attribute:
407    path: custom_attributes/registry.yaml
408    exclude_patterns:
409      - "("
410"#;
411        let f = write_config(yaml);
412        let err = ConfigFile::load(f.path()).unwrap_err();
413        match err {
414            Error::Config(msg) => {
415                assert!(msg.contains("custom_attribute"), "msg: {msg}");
416                assert!(msg.contains("exclude_patterns[0]"), "msg: {msg}");
417            }
418            other => panic!("expected Config error, got {other:?}"),
419        }
420    }
421
422    #[test]
423    fn is_excluded_matches_any_pattern() {
424        let patterns =
425            compile_exclude_patterns(&["^_".to_string(), "^test_".to_string()], "test").unwrap();
426        assert!(is_excluded("_unset", &patterns));
427        assert!(is_excluded("test_foo", &patterns));
428        assert!(!is_excluded("regular_attr", &patterns));
429    }
430
431    #[test]
432    fn rejects_non_http_endpoint_scheme() {
433        let yaml = r#"
434version: 1
435default_environment: dev
436environments:
437  dev:
438    api_endpoint: ftp://rest.braze.eu
439    api_key_env: BRAZE_DEV_API_KEY
440"#;
441        let f = write_config(yaml);
442        let err = ConfigFile::load(f.path()).unwrap_err();
443        assert!(matches!(err, Error::Config(_)));
444        let msg = err.to_string();
445        assert!(msg.contains("http"), "expected http scheme hint: {msg}");
446        assert!(msg.contains("ftp"), "expected actual scheme: {msg}");
447    }
448
449    #[test]
450    fn rejects_default_environment_not_in_map() {
451        let yaml = r#"
452version: 1
453default_environment: missing
454environments:
455  dev:
456    api_endpoint: https://rest.fra-02.braze.eu
457    api_key_env: BRAZE_DEV_API_KEY
458"#;
459        let f = write_config(yaml);
460        let err = ConfigFile::load(f.path()).unwrap_err();
461        assert!(matches!(err, Error::Config(_)));
462        assert!(err.to_string().contains("missing"));
463    }
464
465    #[test]
466    fn resolve_uses_default_environment_when_no_override() {
467        let f = write_config(MINIMAL);
468        let cfg = ConfigFile::load(f.path()).unwrap();
469        let resolved = cfg
470            .resolve_with(None, |k| {
471                assert_eq!(k, "BRAZE_DEV_API_KEY");
472                Some("token-abc".into())
473            })
474            .unwrap();
475        assert_eq!(resolved.environment_name, "dev");
476        assert_eq!(resolved.api_key.expose_secret(), "token-abc");
477    }
478
479    #[test]
480    fn resolve_uses_override_when_provided() {
481        const TWO_ENVS: &str = r#"
482version: 1
483default_environment: dev
484environments:
485  dev:
486    api_endpoint: https://rest.fra-02.braze.eu
487    api_key_env: BRAZE_DEV_API_KEY
488  prod:
489    api_endpoint: https://rest.fra-02.braze.eu
490    api_key_env: BRAZE_PROD_API_KEY
491"#;
492        let f = write_config(TWO_ENVS);
493        let cfg = ConfigFile::load(f.path()).unwrap();
494        let resolved = cfg
495            .resolve_with(Some("prod"), |k| {
496                assert_eq!(k, "BRAZE_PROD_API_KEY");
497                Some("prod-token".into())
498            })
499            .unwrap();
500        assert_eq!(resolved.environment_name, "prod");
501    }
502
503    #[test]
504    fn resolve_unknown_env_lists_known_envs() {
505        let f = write_config(MINIMAL);
506        let cfg = ConfigFile::load(f.path()).unwrap();
507        let err = cfg
508            .resolve_with(Some("staging"), |_| Some("x".into()))
509            .unwrap_err();
510        let msg = err.to_string();
511        assert!(msg.contains("staging"));
512        assert!(msg.contains("dev"));
513    }
514
515    #[test]
516    fn resolve_missing_env_var_is_typed_error() {
517        let f = write_config(MINIMAL);
518        let cfg = ConfigFile::load(f.path()).unwrap();
519        let err = cfg.resolve_with(None, |_| None).unwrap_err();
520        match err {
521            Error::MissingEnv(name) => assert_eq!(name, "BRAZE_DEV_API_KEY"),
522            other => panic!("expected MissingEnv, got {other:?}"),
523        }
524    }
525
526    #[test]
527    fn resolve_empty_env_var_is_rejected() {
528        let f = write_config(MINIMAL);
529        let cfg = ConfigFile::load(f.path()).unwrap();
530        let err = cfg.resolve_with(None, |_| Some(String::new())).unwrap_err();
531        assert!(matches!(err, Error::Config(_)));
532        assert!(err.to_string().contains("empty"));
533    }
534
535    #[test]
536    fn debug_format_does_not_leak_api_key() {
537        let f = write_config(MINIMAL);
538        let resolved = ConfigFile::load(f.path())
539            .unwrap()
540            .resolve_with(None, |_| Some("super-secret-token-abc-123".into()))
541            .unwrap();
542        let dbg = format!("{resolved:?}");
543        assert!(
544            !dbg.contains("super-secret-token-abc-123"),
545            "Debug output leaked api key: {dbg}"
546        );
547    }
548
549    #[test]
550    fn rejects_empty_api_key_env() {
551        let yaml = r#"
552version: 1
553default_environment: dev
554environments:
555  dev:
556    api_endpoint: https://rest.fra-02.braze.eu
557    api_key_env: ""
558"#;
559        let f = write_config(yaml);
560        let err = ConfigFile::load(f.path()).unwrap_err();
561        assert!(matches!(err, Error::Config(_)), "got: {err:?}");
562        assert!(err.to_string().contains("api_key_env"));
563    }
564
565    #[test]
566    fn load_io_error_for_missing_file() {
567        let err = ConfigFile::load("/nonexistent/braze-sync.config.yaml").unwrap_err();
568        assert!(matches!(err, Error::Io(_)), "got: {err:?}");
569    }
570}