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    macro_rules! serial_test {
310        () => {
311            let _guard = TEST_LOCK.lock().unwrap();
312            clear();
313        };
314    }
315
316    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
317    struct TestConfig {
318        enabled: bool,
319        threshold: f64,
320        #[serde(skip_serializing)]
321        secret_token: String,
322    }
323
324    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
325    struct SensitiveConfig {
326        host: String,
327        password: String,
328        api_token: String,
329        encryption_key: String,
330        normal_field: u32,
331        nested: NestedSensitive,
332    }
333
334    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
335    struct NestedSensitive {
336        db_password: String,
337        port: u16,
338    }
339
340    #[test]
341    fn register_and_retrieve() {
342        serial_test!();
343
344        let config = TestConfig {
345            enabled: true,
346            threshold: 0.75,
347            secret_token: "hunter2".into(),
348        };
349        register::<TestConfig>("test_module", &config);
350
351        assert!(is_registered("test_module"));
352        assert!(!is_registered("nonexistent"));
353
354        let section = get_section("test_module").unwrap();
355        assert_eq!(section.key, "test_module");
356        assert!(section.type_name.contains("TestConfig"));
357
358        assert_eq!(section.effective["enabled"], true);
359        assert_eq!(section.effective["threshold"], 0.75);
360        // skip_serializing excludes it entirely
361        assert!(section.effective.get("secret_token").is_none());
362
363        assert_eq!(section.defaults["enabled"], false);
364        assert_eq!(section.defaults["threshold"], 0.0);
365    }
366
367    #[test]
368    fn sections_returns_sorted() {
369        serial_test!();
370
371        register::<TestConfig>("zebra", &TestConfig::default());
372        register::<TestConfig>("alpha", &TestConfig::default());
373        register::<TestConfig>("middle", &TestConfig::default());
374
375        let keys: Vec<String> = sections().iter().map(|s| s.key.clone()).collect();
376        assert_eq!(keys, vec!["alpha", "middle", "zebra"]);
377    }
378
379    #[test]
380    fn dump_effective_redacts_sensitive_fields() {
381        serial_test!();
382
383        let config = SensitiveConfig {
384            host: "db.example.com".into(),
385            password: "super_secret".into(),
386            api_token: "tok_abc123".into(),
387            encryption_key: "aes256key".into(),
388            normal_field: 42,
389            nested: NestedSensitive {
390                db_password: "nested_secret".into(),
391                port: 5432,
392            },
393        };
394        register::<SensitiveConfig>("db", &config);
395
396        let dump = dump_effective();
397        // Non-sensitive preserved
398        assert_eq!(dump["db"]["host"], "db.example.com");
399        assert_eq!(dump["db"]["normal_field"], 42);
400        assert_eq!(dump["db"]["nested"]["port"], 5432);
401
402        // Sensitive fields redacted
403        assert_eq!(dump["db"]["password"], REDACTED);
404        assert_eq!(dump["db"]["api_token"], REDACTED);
405        assert_eq!(dump["db"]["encryption_key"], REDACTED);
406        assert_eq!(dump["db"]["nested"]["db_password"], REDACTED);
407    }
408
409    #[cfg(feature = "dangerous-diagnostics")]
410    #[test]
411    fn dump_unredacted_preserves_all_fields() {
412        serial_test!();
413
414        let config = SensitiveConfig {
415            password: "visible".into(),
416            ..Default::default()
417        };
418        register::<SensitiveConfig>("db", &config);
419
420        let dump = dump_effective_unredacted();
421        assert_eq!(dump["db"]["password"], "visible");
422    }
423
424    #[test]
425    fn dump_defaults_returns_default_values() {
426        serial_test!();
427
428        register::<TestConfig>(
429            "my_module",
430            &TestConfig {
431                enabled: true,
432                threshold: 0.9,
433                secret_token: String::new(),
434            },
435        );
436
437        let dump = dump_defaults();
438        assert_eq!(dump["my_module"]["enabled"], false);
439        assert_eq!(dump["my_module"]["threshold"], 0.0);
440    }
441
442    #[test]
443    fn re_register_overwrites() {
444        serial_test!();
445
446        let v1 = TestConfig {
447            threshold: 0.5,
448            ..Default::default()
449        };
450        register::<TestConfig>("module", &v1);
451        assert_eq!(get_section("module").unwrap().effective["threshold"], 0.5);
452
453        let v2 = TestConfig {
454            threshold: 0.9,
455            ..Default::default()
456        };
457        register::<TestConfig>("module", &v2);
458        assert_eq!(get_section("module").unwrap().effective["threshold"], 0.9);
459    }
460
461    #[test]
462    fn empty_registry() {
463        serial_test!();
464
465        assert!(sections().is_empty());
466        assert_eq!(dump_effective(), JsonValue::Object(serde_json::Map::new()));
467        assert_eq!(dump_defaults(), JsonValue::Object(serde_json::Map::new()));
468        assert!(!is_registered("anything"));
469        assert!(get_section("anything").is_none());
470    }
471
472    // ── Change notification ─────────────────────────────────────
473
474    #[test]
475    fn on_change_fires_on_update() {
476        serial_test!();
477
478        let counter = Arc::new(AtomicU32::new(0));
479        let counter_clone = counter.clone();
480
481        on_change("my_key", move |_value| {
482            counter_clone.fetch_add(1, Ordering::Relaxed);
483        });
484
485        let config = TestConfig {
486            enabled: true,
487            ..Default::default()
488        };
489        update::<TestConfig>("my_key", &config);
490
491        assert_eq!(counter.load(Ordering::Relaxed), 1);
492
493        // Second update fires again
494        update::<TestConfig>("my_key", &config);
495        assert_eq!(counter.load(Ordering::Relaxed), 2);
496    }
497
498    #[test]
499    fn on_change_receives_new_value() {
500        serial_test!();
501
502        let captured = Arc::new(Mutex::new(JsonValue::Null));
503        let captured_clone = captured.clone();
504
505        on_change("watched", move |value| {
506            if let Ok(mut guard) = captured_clone.lock() {
507                *guard = value.clone();
508            }
509        });
510
511        let config = TestConfig {
512            enabled: true,
513            threshold: 0.99,
514            ..Default::default()
515        };
516        update::<TestConfig>("watched", &config);
517
518        let val = captured.lock().unwrap().clone();
519        assert_eq!(val["enabled"], true);
520        assert_eq!(val["threshold"], 0.99);
521    }
522
523    #[test]
524    fn on_change_only_fires_for_subscribed_key() {
525        serial_test!();
526
527        let counter = Arc::new(AtomicU32::new(0));
528        let counter_clone = counter.clone();
529
530        on_change("key_a", move |_| {
531            counter_clone.fetch_add(1, Ordering::Relaxed);
532        });
533
534        // Update a different key -- listener should NOT fire
535        update::<TestConfig>("key_b", &TestConfig::default());
536        assert_eq!(counter.load(Ordering::Relaxed), 0);
537
538        // Update the subscribed key -- listener fires
539        update::<TestConfig>("key_a", &TestConfig::default());
540        assert_eq!(counter.load(Ordering::Relaxed), 1);
541    }
542
543    #[test]
544    fn update_also_registers() {
545        serial_test!();
546
547        assert!(!is_registered("fresh"));
548        update::<TestConfig>(
549            "fresh",
550            &TestConfig {
551                enabled: true,
552                ..Default::default()
553            },
554        );
555        assert!(is_registered("fresh"));
556        assert_eq!(get_section("fresh").unwrap().effective["enabled"], true);
557    }
558
559    // ── Redaction test structs (module-level to avoid items_after_statements) ──
560
561    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
562    struct MixedCase {
563        #[serde(rename = "Password")]
564        password_upper: String,
565        #[serde(rename = "API_TOKEN")]
566        token_upper: String,
567        #[serde(rename = "mySecret")]
568        secret_camel: String,
569    }
570
571    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
572    struct DeepNested {
573        level1: Level1,
574    }
575    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
576    struct Level1 {
577        level2: Level2,
578        name: String,
579    }
580    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
581    struct Level2 {
582        api_token: String,
583        db_password: String,
584        port: u16,
585    }
586
587    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
588    struct WithArray {
589        items: Vec<ArrayItem>,
590    }
591    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
592    struct ArrayItem {
593        name: String,
594        secret_key: String,
595    }
596
597    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
598    struct WithDefaultSecret {
599        api_token: String,
600        host: String,
601    }
602    impl Default for WithDefaultSecret {
603        fn default() -> Self {
604            Self {
605                api_token: "default-placeholder-token".into(),
606                host: "localhost".into(),
607            }
608        }
609    }
610
611    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
612    struct DoubleProtected {
613        #[serde(skip_serializing)]
614        #[allow(dead_code)]
615        hidden_secret: String,
616        visible_token: String,
617        normal: String,
618    }
619
620    // ── Redaction guarantee tests ──────────────────────────────
621
622    /// Config struct that exercises ALL sensitive field name patterns.
623    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
624    struct AllSensitivePatterns {
625        // Each SENSITIVE_PATTERNS entry must be covered
626        my_password: String,
627        db_secret: String,
628        api_token: String,
629        encryption_key: String,
630        aws_credential: String,
631        oauth_auth_code: String,
632        private_data: String,
633        tls_cert_path: String,
634        // Non-sensitive controls (must NOT be redacted)
635        hostname: String,
636        port: u16,
637        enabled: bool,
638        timeout_ms: u64,
639    }
640
641    #[test]
642    fn redaction_covers_all_sensitive_patterns() {
643        serial_test!();
644
645        let config = AllSensitivePatterns {
646            my_password: "pass123".into(),
647            db_secret: "sec456".into(),
648            api_token: "tok789".into(),
649            encryption_key: "key012".into(),
650            aws_credential: "cred345".into(),
651            oauth_auth_code: "auth678".into(),
652            private_data: "priv901".into(),
653            tls_cert_path: "/etc/tls/cert.pem".into(),
654            hostname: "db.prod.internal".into(),
655            port: 5432,
656            enabled: true,
657            timeout_ms: 30000,
658        };
659        register::<AllSensitivePatterns>("all_patterns", &config);
660
661        let dump = dump_effective();
662        let section = &dump["all_patterns"];
663
664        // Every sensitive field MUST be redacted
665        assert_eq!(section["my_password"], REDACTED, "password pattern missed");
666        assert_eq!(section["db_secret"], REDACTED, "secret pattern missed");
667        assert_eq!(section["api_token"], REDACTED, "token pattern missed");
668        assert_eq!(section["encryption_key"], REDACTED, "key pattern missed");
669        assert_eq!(
670            section["aws_credential"], REDACTED,
671            "credential pattern missed"
672        );
673        assert_eq!(section["oauth_auth_code"], REDACTED, "auth pattern missed");
674        assert_eq!(section["private_data"], REDACTED, "private pattern missed");
675        assert_eq!(section["tls_cert_path"], REDACTED, "cert pattern missed");
676
677        // Non-sensitive fields MUST be preserved
678        assert_eq!(section["hostname"], "db.prod.internal");
679        assert_eq!(section["port"], 5432);
680        assert_eq!(section["enabled"], true);
681        assert_eq!(section["timeout_ms"], 30000);
682    }
683
684    #[test]
685    fn redaction_is_case_insensitive() {
686        serial_test!();
687
688        let config = MixedCase {
689            password_upper: "visible_if_broken".into(),
690            token_upper: "visible_if_broken".into(),
691            secret_camel: "visible_if_broken".into(),
692        };
693        register::<MixedCase>("case_test", &config);
694
695        let dump = dump_effective();
696        let section = &dump["case_test"];
697
698        assert_eq!(section["Password"], REDACTED);
699        assert_eq!(section["API_TOKEN"], REDACTED);
700        assert_eq!(section["mySecret"], REDACTED);
701    }
702
703    #[test]
704    fn redaction_handles_deeply_nested_secrets() {
705        serial_test!();
706
707        let config = DeepNested {
708            level1: Level1 {
709                level2: Level2 {
710                    api_token: "deep_secret_1".into(),
711                    db_password: "deep_secret_2".into(),
712                    port: 3306,
713                },
714                name: "safe_value".into(),
715            },
716        };
717        register::<DeepNested>("deep", &config);
718
719        let dump = dump_effective();
720        assert_eq!(dump["deep"]["level1"]["level2"]["api_token"], REDACTED);
721        assert_eq!(dump["deep"]["level1"]["level2"]["db_password"], REDACTED);
722        assert_eq!(dump["deep"]["level1"]["level2"]["port"], 3306);
723        assert_eq!(dump["deep"]["level1"]["name"], "safe_value");
724    }
725
726    #[test]
727    fn redaction_handles_arrays_with_sensitive_objects() {
728        serial_test!();
729
730        let config = WithArray {
731            items: vec![
732                ArrayItem {
733                    name: "item1".into(),
734                    secret_key: "sk_1".into(),
735                },
736                ArrayItem {
737                    name: "item2".into(),
738                    secret_key: "sk_2".into(),
739                },
740            ],
741        };
742        register::<WithArray>("array_test", &config);
743
744        let dump = dump_effective();
745        let items = dump["array_test"]["items"].as_array().unwrap();
746        for item in items {
747            assert_eq!(item["secret_key"], REDACTED);
748            assert_ne!(item["name"], REDACTED); // name should be preserved
749        }
750    }
751
752    #[test]
753    fn no_secret_values_in_redacted_dump_string() {
754        serial_test!();
755
756        let secrets = [
757            "hunter2",
758            "sk_live_abc123",
759            "super_s3cret!",
760            "my-private-key-data",
761        ];
762
763        let config = AllSensitivePatterns {
764            my_password: secrets[0].into(),
765            db_secret: secrets[1].into(),
766            api_token: secrets[2].into(),
767            encryption_key: secrets[3].into(),
768            ..Default::default()
769        };
770        register::<AllSensitivePatterns>("leak_check", &config);
771
772        // Serialise the full dump to a string and scan for ANY secret value
773        let dump = dump_effective();
774        let dump_str = serde_json::to_string(&dump).unwrap();
775
776        for secret in &secrets {
777            assert!(
778                !dump_str.contains(secret),
779                "SECRET LEAKED in dump_effective(): '{secret}' found in output"
780            );
781        }
782    }
783
784    #[test]
785    fn defaults_dump_also_redacted() {
786        serial_test!();
787
788        register::<WithDefaultSecret>("default_secrets", &WithDefaultSecret::default());
789
790        let dump = dump_defaults();
791        assert_eq!(dump["default_secrets"]["api_token"], REDACTED);
792        assert_eq!(dump["default_secrets"]["host"], "localhost");
793    }
794
795    #[test]
796    fn skip_serializing_plus_heuristic_double_protection() {
797        serial_test!();
798
799        let config = DoubleProtected {
800            hidden_secret: "should_not_appear".into(),
801            visible_token: "should_be_redacted".into(),
802            normal: "visible".into(),
803        };
804        register::<DoubleProtected>("double", &config);
805
806        let dump = dump_effective();
807        let section = &dump["double"];
808
809        // skip_serializing: field absent entirely
810        assert!(section.get("hidden_secret").is_none());
811        // heuristic: field present but redacted
812        assert_eq!(section["visible_token"], REDACTED);
813        // normal: preserved
814        assert_eq!(section["normal"], "visible");
815
816        // String scan: neither secret should appear
817        let dump_str = serde_json::to_string(&dump).unwrap();
818        assert!(!dump_str.contains("should_not_appear"));
819        assert!(!dump_str.contains("should_be_redacted"));
820    }
821
822    // ── Change notification ─────────────────────────────────────
823
824    #[test]
825    fn multiple_listeners_on_same_key() {
826        serial_test!();
827
828        let c1 = Arc::new(AtomicU32::new(0));
829        let c2 = Arc::new(AtomicU32::new(0));
830        let c1c = c1.clone();
831        let c2c = c2.clone();
832
833        on_change("shared", move |_| {
834            c1c.fetch_add(1, Ordering::Relaxed);
835        });
836        on_change("shared", move |_| {
837            c2c.fetch_add(1, Ordering::Relaxed);
838        });
839
840        update::<TestConfig>("shared", &TestConfig::default());
841
842        assert_eq!(c1.load(Ordering::Relaxed), 1);
843        assert_eq!(c2.load(Ordering::Relaxed), 1);
844    }
845}