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