Skip to main content

hyperi_rustlib/config/
registry.rs

1// Project:   hyperi-rustlib
2// File:      src/config/registry.rs
3// Purpose:   Auto-registering config registry for reflection and admin endpoints
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Reflectable configuration registry.
10//!
11//! Modules that read config via [`Config::unmarshal_key_registered`](crate::config::Config::unmarshal_key_registered) are
12//! automatically recorded in a global registry. This enables:
13//!
14//! - Listing all config sections an application uses
15//! - Dumping the effective config (with redaction) for debugging
16//! - Dumping defaults for documentation
17//! - Future: admin `/config` endpoint, change notifications
18//!
19//! # Auto-registration
20//!
21//! ```rust,ignore
22//! use hyperi_rustlib::config;
23//!
24//! // This automatically registers "expression" in the registry:
25//! let cfg = config::get();
26//! let profile: MyConfig = cfg.unmarshal_key_registered("expression").unwrap_or_default();
27//!
28//! // Later, reflect on all registered sections:
29//! for section in config::registry::sections() {
30//!     println!("{}: {}", section.key, section.type_name);
31//! }
32//! ```
33
34use std::collections::BTreeMap;
35use std::sync::{Arc, Mutex};
36
37use serde_json::Value as JsonValue;
38
39/// Global config registry singleton.
40static REGISTRY: Mutex<Option<Registry>> = Mutex::new(None);
41
42/// A change listener callback.
43///
44/// Stored as `Arc<dyn ...>` rather than `Box<dyn ...>` so [`register_section`]
45/// can snapshot the callbacks into a local Vec under the listener lock and
46/// then drop the lock BEFORE invoking them. Holding the listener mutex
47/// during callback invocation deadlocked re-entrant callers (e.g. a
48/// callback that registered a new listener) and forced any callback work
49/// into the critical section.
50type ChangeCallback = Arc<dyn Fn(&JsonValue) + Send + Sync>;
51type ListenerCallback = ChangeCallback;
52
53/// Change listener storage.
54static LISTENERS: Mutex<Option<BTreeMap<String, Vec<ChangeCallback>>>> = Mutex::new(None);
55
56/// A registered config section.
57#[derive(Debug, Clone)]
58pub struct ConfigSection {
59    /// The config key (e.g., "expression", "memory", "version_check").
60    pub key: String,
61    /// The Rust type name (e.g., "ProfileConfig").
62    pub type_name: String,
63    /// Default values as JSON (from `T::default()`).
64    pub defaults: JsonValue,
65    /// Effective values as JSON (from the cascade).
66    pub effective: JsonValue,
67}
68
69/// The config registry -- stores all registered sections.
70#[derive(Debug, Clone, Default)]
71struct Registry {
72    sections: BTreeMap<String, ConfigSection>,
73}
74
75/// Register a config section in the global registry.
76///
77/// Called automatically by [`Config::unmarshal_key_registered`](crate::config::Config::unmarshal_key_registered). Can also
78/// be called manually for sections that don't go through the cascade.
79///
80/// Requires `T: Serialize + Default` so we can capture both the default
81/// and effective values as JSON for reflection.
82pub fn register<T>(key: &str, effective: &T)
83where
84    T: serde::Serialize + Default + 'static,
85{
86    let section = ConfigSection {
87        key: key.to_string(),
88        type_name: std::any::type_name::<T>().to_string(),
89        defaults: serde_json::to_value(T::default()).unwrap_or(JsonValue::Null),
90        effective: serde_json::to_value(effective).unwrap_or(JsonValue::Null),
91    };
92
93    if let Ok(mut guard) = REGISTRY.lock() {
94        let registry = guard.get_or_insert_with(Registry::default);
95        registry.sections.insert(key.to_string(), section);
96    }
97}
98
99/// List all registered config sections, sorted by key.
100#[must_use]
101pub fn sections() -> Vec<ConfigSection> {
102    REGISTRY
103        .lock()
104        .ok()
105        .and_then(|guard| {
106            guard
107                .as_ref()
108                .map(|r| r.sections.values().cloned().collect())
109        })
110        .unwrap_or_default()
111}
112
113/// Dump all effective config values as a JSON object (redacted).
114///
115/// Applies heuristic redaction to fields whose names contain sensitive
116/// patterns (password, secret, token, key, credential, auth, private,
117/// cert, encryption). Fields with `#[serde(skip_serializing)]` are
118/// already excluded at serialisation time -- this is the safety net for
119/// fields that weren't annotated.
120#[must_use]
121pub fn dump_effective() -> JsonValue {
122    let mut map: serde_json::Map<String, JsonValue> = sections()
123        .into_iter()
124        .map(|s| (s.key, s.effective))
125        .collect();
126    for value in map.values_mut() {
127        if let JsonValue::Object(obj) = value {
128            redact_sensitive_fields(obj);
129        }
130    }
131    JsonValue::Object(map)
132}
133
134/// Dump effective config WITHOUT redaction. **Compile-time gated**
135/// behind the `dangerous-diagnostics` feature; not in `full`. Off
136/// in every shipping build. One-off operator-driven diagnostics
137/// only -- never wire to a network endpoint.
138#[cfg(feature = "dangerous-diagnostics")]
139#[cfg_attr(docsrs, doc(cfg(feature = "dangerous-diagnostics")))]
140#[must_use]
141pub fn dump_effective_unredacted() -> JsonValue {
142    let map: serde_json::Map<String, JsonValue> = sections()
143        .into_iter()
144        .map(|s| (s.key, s.effective))
145        .collect();
146    JsonValue::Object(map)
147}
148
149/// Dump all default config values as a JSON object (redacted).
150#[must_use]
151pub fn dump_defaults() -> JsonValue {
152    let mut map: serde_json::Map<String, JsonValue> = sections()
153        .into_iter()
154        .map(|s| (s.key, s.defaults))
155        .collect();
156    for value in map.values_mut() {
157        if let JsonValue::Object(obj) = value {
158            redact_sensitive_fields(obj);
159        }
160    }
161    JsonValue::Object(map)
162}
163
164/// Field name patterns that trigger automatic redaction.
165///
166/// Any JSON field whose name (lowercased) contains one of these
167/// substrings will have its value replaced with `"***REDACTED***"`.
168///
169/// Safety net only -- the primary protection is [`SensitiveString`]
170/// on the field type (compile-time safe). This heuristic catches fields
171/// that developers forgot to mark as sensitive.
172const SENSITIVE_PATTERNS: &[&str] = &[
173    "password",
174    "secret",
175    "token",
176    "key",
177    "credential",
178    "auth",
179    "private",
180    "cert",
181    "encryption",
182    "connection_string",
183    "dsn",
184];
185
186const REDACTED: &str = "***REDACTED***";
187
188/// Recursively redact fields with sensitive names.
189fn redact_sensitive_fields(obj: &mut serde_json::Map<String, JsonValue>) {
190    for (key, value) in obj.iter_mut() {
191        let lower = key.to_lowercase();
192        if SENSITIVE_PATTERNS.iter().any(|p| lower.contains(p)) {
193            *value = JsonValue::String(REDACTED.into());
194            continue;
195        }
196        match value {
197            JsonValue::Object(nested) => redact_sensitive_fields(nested),
198            JsonValue::Array(arr) => {
199                for item in arr.iter_mut() {
200                    if let JsonValue::Object(nested) = item {
201                        redact_sensitive_fields(nested);
202                    }
203                }
204            }
205            _ => {}
206        }
207    }
208}
209
210/// Check if a specific key is registered.
211#[must_use]
212pub fn is_registered(key: &str) -> bool {
213    REGISTRY
214        .lock()
215        .ok()
216        .and_then(|guard| guard.as_ref().map(|r| r.sections.contains_key(key)))
217        .unwrap_or(false)
218}
219
220/// Get a single registered section by key.
221#[must_use]
222pub fn get_section(key: &str) -> Option<ConfigSection> {
223    REGISTRY
224        .lock()
225        .ok()
226        .and_then(|guard| guard.as_ref().and_then(|r| r.sections.get(key).cloned()))
227}
228
229/// Subscribe to changes for a specific config key (opt-in).
230///
231/// The callback fires when [`update`] is called for the given key.
232/// Modules that need hot-reload subscribe at init; modules that don't
233/// simply use the `OnceLock` pattern and ignore change events.
234///
235/// The callback receives the new effective value as JSON.
236pub fn on_change(key: &str, callback: impl Fn(&JsonValue) + Send + Sync + 'static) {
237    if let Ok(mut guard) = LISTENERS.lock() {
238        let listeners = guard.get_or_insert_with(BTreeMap::new);
239        listeners
240            .entry(key.to_string())
241            .or_default()
242            .push(Arc::new(callback));
243    }
244}
245
246/// Re-register a config section and notify listeners.
247///
248/// Call this when config is reloaded (e.g., from `ConfigReloader`).
249/// Listeners registered via [`on_change`] are notified with the new
250/// effective value.
251pub fn update<T>(key: &str, effective: &T)
252where
253    T: serde::Serialize + Default + 'static,
254{
255    let effective_json = serde_json::to_value(effective).unwrap_or(JsonValue::Null);
256
257    // Update the registry entry
258    let section = ConfigSection {
259        key: key.to_string(),
260        type_name: std::any::type_name::<T>().to_string(),
261        defaults: serde_json::to_value(T::default()).unwrap_or(JsonValue::Null),
262        effective: effective_json.clone(),
263    };
264
265    if let Ok(mut guard) = REGISTRY.lock() {
266        let registry = guard.get_or_insert_with(Registry::default);
267        registry.sections.insert(key.to_string(), section);
268    }
269
270    // Notify listeners. Snapshot the callbacks into a local Vec under the
271    // lock, then drop the guard BEFORE invoking them. A callback that
272    // re-enters listener registration (e.g. registers a new listener
273    // while running) would otherwise deadlock against the same mutex.
274    // Callbacks may also do non-trivial work, allocation, or I/O -- none
275    // of which belong under a global mutex.
276    let snapshot: Option<Vec<ListenerCallback>> = LISTENERS.lock().ok().and_then(|guard| {
277        guard
278            .as_ref()
279            .and_then(|listeners| listeners.get(key).map(|cbs| cbs.iter().cloned().collect()))
280    });
281    if let Some(callbacks) = snapshot {
282        for cb in callbacks {
283            cb(&effective_json);
284        }
285    }
286}
287
288/// Clear the registry (for testing only).
289#[cfg(test)]
290pub(crate) fn clear() {
291    if let Ok(mut guard) = REGISTRY.lock() {
292        *guard = None;
293    }
294    if let Ok(mut guard) = LISTENERS.lock() {
295        *guard = None;
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use std::sync::Arc;
302    use std::sync::atomic::{AtomicU32, Ordering};
303
304    use super::*;
305
306    /// Tests share global statics -- serialise them.
307    static TEST_LOCK: Mutex<()> = Mutex::new(());
308
309    /// Acquire the shared test lock and reset global registry state.
310    /// Returned guard holds the lock for the caller's test body.
311    fn serial_test_guard() -> std::sync::MutexGuard<'static, ()> {
312        let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
313        clear();
314        guard
315    }
316
317    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
318    struct TestConfig {
319        enabled: bool,
320        threshold: f64,
321        #[serde(skip_serializing)]
322        secret_token: String,
323    }
324
325    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
326    struct SensitiveConfig {
327        host: String,
328        password: String,
329        api_token: String,
330        encryption_key: String,
331        normal_field: u32,
332        nested: NestedSensitive,
333    }
334
335    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
336    struct NestedSensitive {
337        db_password: String,
338        port: u16,
339    }
340
341    #[test]
342    fn register_and_retrieve() {
343        let _guard = serial_test_guard();
344
345        let config = TestConfig {
346            enabled: true,
347            threshold: 0.75,
348            secret_token: "hunter2".into(),
349        };
350        register::<TestConfig>("test_module", &config);
351
352        assert!(is_registered("test_module"));
353        assert!(!is_registered("nonexistent"));
354
355        let section = get_section("test_module").unwrap();
356        assert_eq!(section.key, "test_module");
357        assert!(section.type_name.contains("TestConfig"));
358
359        assert_eq!(section.effective["enabled"], true);
360        assert_eq!(section.effective["threshold"], 0.75);
361        // skip_serializing excludes it entirely
362        assert!(section.effective.get("secret_token").is_none());
363
364        assert_eq!(section.defaults["enabled"], false);
365        assert_eq!(section.defaults["threshold"], 0.0);
366    }
367
368    #[test]
369    fn sections_returns_sorted() {
370        let _guard = serial_test_guard();
371
372        register::<TestConfig>("zebra", &TestConfig::default());
373        register::<TestConfig>("alpha", &TestConfig::default());
374        register::<TestConfig>("middle", &TestConfig::default());
375
376        let keys: Vec<String> = sections().iter().map(|s| s.key.clone()).collect();
377        assert_eq!(keys, vec!["alpha", "middle", "zebra"]);
378    }
379
380    #[test]
381    fn dump_effective_redacts_sensitive_fields() {
382        let _guard = serial_test_guard();
383
384        let config = SensitiveConfig {
385            host: "db.example.com".into(),
386            password: "super_secret".into(),
387            api_token: "tok_abc123".into(),
388            encryption_key: "aes256key".into(),
389            normal_field: 42,
390            nested: NestedSensitive {
391                db_password: "nested_secret".into(),
392                port: 5432,
393            },
394        };
395        register::<SensitiveConfig>("db", &config);
396
397        let dump = dump_effective();
398        // Non-sensitive preserved
399        assert_eq!(dump["db"]["host"], "db.example.com");
400        assert_eq!(dump["db"]["normal_field"], 42);
401        assert_eq!(dump["db"]["nested"]["port"], 5432);
402
403        // Sensitive fields redacted
404        assert_eq!(dump["db"]["password"], REDACTED);
405        assert_eq!(dump["db"]["api_token"], REDACTED);
406        assert_eq!(dump["db"]["encryption_key"], REDACTED);
407        assert_eq!(dump["db"]["nested"]["db_password"], REDACTED);
408    }
409
410    #[cfg(feature = "dangerous-diagnostics")]
411    #[test]
412    fn dump_unredacted_preserves_all_fields() {
413        let _guard = serial_test_guard();
414
415        let config = SensitiveConfig {
416            password: "visible".into(),
417            ..Default::default()
418        };
419        register::<SensitiveConfig>("db", &config);
420
421        let dump = dump_effective_unredacted();
422        assert_eq!(dump["db"]["password"], "visible");
423    }
424
425    #[test]
426    fn dump_defaults_returns_default_values() {
427        let _guard = serial_test_guard();
428
429        register::<TestConfig>(
430            "my_module",
431            &TestConfig {
432                enabled: true,
433                threshold: 0.9,
434                secret_token: String::new(),
435            },
436        );
437
438        let dump = dump_defaults();
439        assert_eq!(dump["my_module"]["enabled"], false);
440        assert_eq!(dump["my_module"]["threshold"], 0.0);
441    }
442
443    #[test]
444    fn re_register_overwrites() {
445        let _guard = serial_test_guard();
446
447        let v1 = TestConfig {
448            threshold: 0.5,
449            ..Default::default()
450        };
451        register::<TestConfig>("module", &v1);
452        assert_eq!(get_section("module").unwrap().effective["threshold"], 0.5);
453
454        let v2 = TestConfig {
455            threshold: 0.9,
456            ..Default::default()
457        };
458        register::<TestConfig>("module", &v2);
459        assert_eq!(get_section("module").unwrap().effective["threshold"], 0.9);
460    }
461
462    #[test]
463    fn empty_registry() {
464        let _guard = serial_test_guard();
465
466        assert!(sections().is_empty());
467        assert_eq!(dump_effective(), JsonValue::Object(serde_json::Map::new()));
468        assert_eq!(dump_defaults(), JsonValue::Object(serde_json::Map::new()));
469        assert!(!is_registered("anything"));
470        assert!(get_section("anything").is_none());
471    }
472
473    // ── Change notification ─────────────────────────────────────
474
475    #[test]
476    fn on_change_fires_on_update() {
477        let _guard = serial_test_guard();
478
479        let counter = Arc::new(AtomicU32::new(0));
480        let counter_clone = counter.clone();
481
482        on_change("my_key", move |_value| {
483            counter_clone.fetch_add(1, Ordering::Relaxed);
484        });
485
486        let config = TestConfig {
487            enabled: true,
488            ..Default::default()
489        };
490        update::<TestConfig>("my_key", &config);
491
492        assert_eq!(counter.load(Ordering::Relaxed), 1);
493
494        // Second update fires again
495        update::<TestConfig>("my_key", &config);
496        assert_eq!(counter.load(Ordering::Relaxed), 2);
497    }
498
499    #[test]
500    fn on_change_receives_new_value() {
501        let _guard = serial_test_guard();
502
503        let captured = Arc::new(Mutex::new(JsonValue::Null));
504        let captured_clone = captured.clone();
505
506        on_change("watched", move |value| {
507            if let Ok(mut guard) = captured_clone.lock() {
508                *guard = value.clone();
509            }
510        });
511
512        let config = TestConfig {
513            enabled: true,
514            threshold: 0.99,
515            ..Default::default()
516        };
517        update::<TestConfig>("watched", &config);
518
519        let val = captured.lock().unwrap().clone();
520        assert_eq!(val["enabled"], true);
521        assert_eq!(val["threshold"], 0.99);
522    }
523
524    #[test]
525    fn on_change_only_fires_for_subscribed_key() {
526        let _guard = serial_test_guard();
527
528        let counter = Arc::new(AtomicU32::new(0));
529        let counter_clone = counter.clone();
530
531        on_change("key_a", move |_| {
532            counter_clone.fetch_add(1, Ordering::Relaxed);
533        });
534
535        // Update a different key -- listener should NOT fire
536        update::<TestConfig>("key_b", &TestConfig::default());
537        assert_eq!(counter.load(Ordering::Relaxed), 0);
538
539        // Update the subscribed key -- listener fires
540        update::<TestConfig>("key_a", &TestConfig::default());
541        assert_eq!(counter.load(Ordering::Relaxed), 1);
542    }
543
544    #[test]
545    fn update_also_registers() {
546        let _guard = serial_test_guard();
547
548        assert!(!is_registered("fresh"));
549        update::<TestConfig>(
550            "fresh",
551            &TestConfig {
552                enabled: true,
553                ..Default::default()
554            },
555        );
556        assert!(is_registered("fresh"));
557        assert_eq!(get_section("fresh").unwrap().effective["enabled"], true);
558    }
559
560    // ── Redaction test structs (module-level to avoid items_after_statements) ──
561
562    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
563    struct MixedCase {
564        #[serde(rename = "Password")]
565        password_upper: String,
566        #[serde(rename = "API_TOKEN")]
567        token_upper: String,
568        #[serde(rename = "mySecret")]
569        secret_camel: String,
570    }
571
572    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
573    struct DeepNested {
574        level1: Level1,
575    }
576    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
577    struct Level1 {
578        level2: Level2,
579        name: String,
580    }
581    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
582    struct Level2 {
583        api_token: String,
584        db_password: String,
585        port: u16,
586    }
587
588    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
589    struct WithArray {
590        items: Vec<ArrayItem>,
591    }
592    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
593    struct ArrayItem {
594        name: String,
595        secret_key: String,
596    }
597
598    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
599    struct WithDefaultSecret {
600        api_token: String,
601        host: String,
602    }
603    impl Default for WithDefaultSecret {
604        fn default() -> Self {
605            Self {
606                api_token: "default-placeholder-token".into(),
607                host: "localhost".into(),
608            }
609        }
610    }
611
612    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
613    struct DoubleProtected {
614        #[serde(skip_serializing)]
615        #[allow(dead_code)]
616        hidden_secret: String,
617        visible_token: String,
618        normal: String,
619    }
620
621    // ── Redaction guarantee tests ──────────────────────────────
622
623    /// Config struct that exercises ALL sensitive field name patterns.
624    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
625    struct AllSensitivePatterns {
626        // Each SENSITIVE_PATTERNS entry must be covered
627        my_password: String,
628        db_secret: String,
629        api_token: String,
630        encryption_key: String,
631        aws_credential: String,
632        oauth_auth_code: String,
633        private_data: String,
634        tls_cert_path: String,
635        // Non-sensitive controls (must NOT be redacted)
636        hostname: String,
637        port: u16,
638        enabled: bool,
639        timeout_ms: u64,
640    }
641
642    #[test]
643    fn redaction_covers_all_sensitive_patterns() {
644        let _guard = serial_test_guard();
645
646        let config = AllSensitivePatterns {
647            my_password: "pass123".into(),
648            db_secret: "sec456".into(),
649            api_token: "tok789".into(),
650            encryption_key: "key012".into(),
651            aws_credential: "cred345".into(),
652            oauth_auth_code: "auth678".into(),
653            private_data: "priv901".into(),
654            tls_cert_path: "/etc/tls/cert.pem".into(),
655            hostname: "db.prod.internal".into(),
656            port: 5432,
657            enabled: true,
658            timeout_ms: 30000,
659        };
660        register::<AllSensitivePatterns>("all_patterns", &config);
661
662        let dump = dump_effective();
663        let section = &dump["all_patterns"];
664
665        // Every sensitive field MUST be redacted
666        assert_eq!(section["my_password"], REDACTED, "password pattern missed");
667        assert_eq!(section["db_secret"], REDACTED, "secret pattern missed");
668        assert_eq!(section["api_token"], REDACTED, "token pattern missed");
669        assert_eq!(section["encryption_key"], REDACTED, "key pattern missed");
670        assert_eq!(
671            section["aws_credential"], REDACTED,
672            "credential pattern missed"
673        );
674        assert_eq!(section["oauth_auth_code"], REDACTED, "auth pattern missed");
675        assert_eq!(section["private_data"], REDACTED, "private pattern missed");
676        assert_eq!(section["tls_cert_path"], REDACTED, "cert pattern missed");
677
678        // Non-sensitive fields MUST be preserved
679        assert_eq!(section["hostname"], "db.prod.internal");
680        assert_eq!(section["port"], 5432);
681        assert_eq!(section["enabled"], true);
682        assert_eq!(section["timeout_ms"], 30000);
683    }
684
685    #[test]
686    fn redaction_is_case_insensitive() {
687        let _guard = serial_test_guard();
688
689        let config = MixedCase {
690            password_upper: "visible_if_broken".into(),
691            token_upper: "visible_if_broken".into(),
692            secret_camel: "visible_if_broken".into(),
693        };
694        register::<MixedCase>("case_test", &config);
695
696        let dump = dump_effective();
697        let section = &dump["case_test"];
698
699        assert_eq!(section["Password"], REDACTED);
700        assert_eq!(section["API_TOKEN"], REDACTED);
701        assert_eq!(section["mySecret"], REDACTED);
702    }
703
704    #[test]
705    fn redaction_handles_deeply_nested_secrets() {
706        let _guard = serial_test_guard();
707
708        let config = DeepNested {
709            level1: Level1 {
710                level2: Level2 {
711                    api_token: "deep_secret_1".into(),
712                    db_password: "deep_secret_2".into(),
713                    port: 3306,
714                },
715                name: "safe_value".into(),
716            },
717        };
718        register::<DeepNested>("deep", &config);
719
720        let dump = dump_effective();
721        assert_eq!(dump["deep"]["level1"]["level2"]["api_token"], REDACTED);
722        assert_eq!(dump["deep"]["level1"]["level2"]["db_password"], REDACTED);
723        assert_eq!(dump["deep"]["level1"]["level2"]["port"], 3306);
724        assert_eq!(dump["deep"]["level1"]["name"], "safe_value");
725    }
726
727    #[test]
728    fn redaction_handles_arrays_with_sensitive_objects() {
729        let _guard = serial_test_guard();
730
731        let config = WithArray {
732            items: vec![
733                ArrayItem {
734                    name: "item1".into(),
735                    secret_key: "sk_1".into(),
736                },
737                ArrayItem {
738                    name: "item2".into(),
739                    secret_key: "sk_2".into(),
740                },
741            ],
742        };
743        register::<WithArray>("array_test", &config);
744
745        let dump = dump_effective();
746        let items = dump["array_test"]["items"].as_array().unwrap();
747        for item in items {
748            assert_eq!(item["secret_key"], REDACTED);
749            assert_ne!(item["name"], REDACTED); // name should be preserved
750        }
751    }
752
753    #[test]
754    fn no_secret_values_in_redacted_dump_string() {
755        let _guard = serial_test_guard();
756
757        let secrets = [
758            "hunter2",
759            "sk_live_abc123",
760            "super_s3cret!",
761            "my-private-key-data",
762        ];
763
764        let config = AllSensitivePatterns {
765            my_password: secrets[0].into(),
766            db_secret: secrets[1].into(),
767            api_token: secrets[2].into(),
768            encryption_key: secrets[3].into(),
769            ..Default::default()
770        };
771        register::<AllSensitivePatterns>("leak_check", &config);
772
773        // Serialise the full dump to a string and scan for ANY secret value
774        let dump = dump_effective();
775        let dump_str = serde_json::to_string(&dump).unwrap();
776
777        for secret in &secrets {
778            assert!(
779                !dump_str.contains(secret),
780                "SECRET LEAKED in dump_effective(): '{secret}' found in output"
781            );
782        }
783    }
784
785    #[test]
786    fn defaults_dump_also_redacted() {
787        let _guard = serial_test_guard();
788
789        register::<WithDefaultSecret>("default_secrets", &WithDefaultSecret::default());
790
791        let dump = dump_defaults();
792        assert_eq!(dump["default_secrets"]["api_token"], REDACTED);
793        assert_eq!(dump["default_secrets"]["host"], "localhost");
794    }
795
796    #[test]
797    fn skip_serializing_plus_heuristic_double_protection() {
798        let _guard = serial_test_guard();
799
800        let config = DoubleProtected {
801            hidden_secret: "should_not_appear".into(),
802            visible_token: "should_be_redacted".into(),
803            normal: "visible".into(),
804        };
805        register::<DoubleProtected>("double", &config);
806
807        let dump = dump_effective();
808        let section = &dump["double"];
809
810        // skip_serializing: field absent entirely
811        assert!(section.get("hidden_secret").is_none());
812        // heuristic: field present but redacted
813        assert_eq!(section["visible_token"], REDACTED);
814        // normal: preserved
815        assert_eq!(section["normal"], "visible");
816
817        // String scan: neither secret should appear
818        let dump_str = serde_json::to_string(&dump).unwrap();
819        assert!(!dump_str.contains("should_not_appear"));
820        assert!(!dump_str.contains("should_be_redacted"));
821    }
822
823    // ── Change notification ─────────────────────────────────────
824
825    #[test]
826    fn multiple_listeners_on_same_key() {
827        let _guard = serial_test_guard();
828
829        let c1 = Arc::new(AtomicU32::new(0));
830        let c2 = Arc::new(AtomicU32::new(0));
831        let c1c = c1.clone();
832        let c2c = c2.clone();
833
834        on_change("shared", move |_| {
835            c1c.fetch_add(1, Ordering::Relaxed);
836        });
837        on_change("shared", move |_| {
838            c2c.fetch_add(1, Ordering::Relaxed);
839        });
840
841        update::<TestConfig>("shared", &TestConfig::default());
842
843        assert_eq!(c1.load(Ordering::Relaxed), 1);
844        assert_eq!(c2.load(Ordering::Relaxed), 1);
845    }
846}