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