Skip to main content

hyperi_rustlib/
sensitive.rs

1// Project:   hyperi-rustlib
2// File:      src/sensitive.rs
3// Purpose:   Compile-time safe sensitive string type that never serialises its value
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Sensitive string type for fields that must never be exposed.
10//!
11//! [`SensitiveString`] wraps a `String` but always serialises as
12//! `"***REDACTED***"`. This provides compile-time guarantees that the
13//! value cannot leak through serialisation -- not in the config registry,
14//! not in logs, not in debug output, not in API responses.
15//!
16//! This module is always available (no feature gate) so that any module
17//! can use `SensitiveString` regardless of which features are enabled.
18//!
19//! # Three layers of secret protection
20//!
21//! | Layer | Mechanism | Catches |
22//! |-------|-----------|---------|
23//! | `#[serde(skip_serializing)]` | Field absent from output | Fields that should never appear |
24//! | Heuristic auto-redaction | Field name pattern matching | Common names: password, secret, token, key |
25//! | `SensitiveString` type | Value always serialises as redacted | Non-obvious fields: connection_string, dsn |
26//!
27//! # Usage
28//!
29//! ```rust
30//! use hyperi_rustlib::SensitiveString;
31//! use serde::{Serialize, Deserialize};
32//!
33//! #[derive(Serialize, Deserialize)]
34//! struct DbConfig {
35//!     host: String,
36//!     port: u16,
37//!     connection_string: SensitiveString,  // Always redacted
38//! }
39//! ```
40
41use std::cell::Cell;
42use std::fmt;
43
44use serde::de::Deserializer;
45use serde::ser::Serializer;
46
47const REDACTED: &str = "***REDACTED***";
48
49thread_local! {
50    /// Per-thread serde-exposure flag. When set (via [`expose_during`])
51    /// [`SensitiveString::serialize`] writes the inner value verbatim
52    /// instead of `***REDACTED***`. Default: `false` -- every other call
53    /// site continues to redact.
54    static EXPOSE: Cell<bool> = const { Cell::new(false) };
55}
56
57/// Drop-guard for the thread-local exposure flag.
58///
59/// Using a guard (rather than a try/finally pair) ensures the flag is
60/// restored even if the closure passed to [`expose_during`] panics. Held
61/// for the duration of the `expose_during` body, dropped at scope-exit.
62struct ExposeGuard {
63    prev: bool,
64}
65
66impl ExposeGuard {
67    fn enter() -> Self {
68        EXPOSE.with(|e| {
69            let prev = e.get();
70            e.set(true);
71            Self { prev }
72        })
73    }
74}
75
76impl Drop for ExposeGuard {
77    fn drop(&mut self) {
78        let prev = self.prev;
79        EXPOSE.with(|e| e.set(prev));
80    }
81}
82
83/// Run `f` with [`SensitiveString`]'s `Serialize` impl exposing inner values.
84///
85/// Use this around code paths that need to serialise-and-deserialise a
86/// config struct without destroying its secrets -- typically the
87/// `figment::Figment::from(Serialized::defaults(&config))` + `.extract()`
88/// round-trip in a consumer's config loader.
89///
90/// # Scope and reentrancy
91///
92/// The flag is thread-local. Calls from inside the closure on the same
93/// thread observe exposure; calls from other threads do not. Nested
94/// calls compose correctly (inner guards restore the outer state on
95/// drop). Async callers should be aware that the flag does NOT cross
96/// `.await` boundaries to other threads -- keep the round-trip on one
97/// thread, or wrap each thread's section in its own
98/// `expose_during`.
99///
100/// # Panic safety
101///
102/// If `f` panics, the previous flag value is restored via an RAII
103/// drop guard before the panic unwinds further.
104///
105/// # Examples
106///
107/// ```rust
108/// use hyperi_rustlib::{SensitiveString, expose_during};
109/// use serde::{Serialize, Deserialize};
110///
111/// #[derive(Serialize, Deserialize)]
112/// struct Cfg {
113///     password: SensitiveString,
114/// }
115///
116/// let cfg = Cfg { password: SensitiveString::new("hunter2") };
117///
118/// // Default: serialise redacts.
119/// let json = serde_json::to_string(&cfg).unwrap();
120/// assert!(json.contains("***REDACTED***"));
121///
122/// // Inside expose_during: serialise reveals so a round-trip preserves the value.
123/// let round_tripped: Cfg = expose_during(|| {
124///     let v = serde_json::to_value(&cfg).unwrap();
125///     serde_json::from_value(v).unwrap()
126/// });
127/// assert_eq!(round_tripped.password.expose(), "hunter2");
128///
129/// // After the call, default redaction resumes.
130/// let json = serde_json::to_string(&cfg).unwrap();
131/// assert!(json.contains("***REDACTED***"));
132/// ```
133pub fn expose_during<F, R>(f: F) -> R
134where
135    F: FnOnce() -> R,
136{
137    let _guard = ExposeGuard::enter();
138    f()
139}
140
141/// A string value that is always redacted when serialised.
142///
143/// Use this for config fields that contain secrets but don't have
144/// obviously-sensitive names (e.g., `connection_string`, `dsn`, `uri`).
145///
146/// - `Serialize` always outputs `"***REDACTED***"`
147/// - `Deserialize` reads the actual value normally
148/// - `Display` shows `***REDACTED***`
149/// - `Debug` shows `SensitiveString(***REDACTED***)`
150/// - Inner value accessible via `.expose()` for application logic
151#[derive(Clone, Default, PartialEq, Eq)]
152pub struct SensitiveString(String);
153
154impl SensitiveString {
155    /// Create a new sensitive string.
156    #[must_use]
157    pub fn new(value: impl Into<String>) -> Self {
158        Self(value.into())
159    }
160
161    /// Expose the inner value for application logic.
162    ///
163    /// This is the only way to access the actual value. The name is
164    /// intentionally explicit to make usage grep-able in code review.
165    #[must_use]
166    pub fn expose(&self) -> &str {
167        &self.0
168    }
169
170    /// Check if the inner value is empty.
171    #[must_use]
172    pub fn is_empty(&self) -> bool {
173        self.0.is_empty()
174    }
175}
176
177impl serde::Serialize for SensitiveString {
178    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
179        // Honour the thread-local exposure flag set by `expose_during`.
180        // Without exposure (the default), every serialise path --
181        // serde_json::to_string, config-registry dump, logger output --
182        // emits the redacted constant. Inside `expose_during`, the
183        // serializer emits the inner value verbatim, which is what
184        // figment / serde round-trips need to avoid destroying secrets
185        // (see hyperi-rustlib#41).
186        if EXPOSE.with(Cell::get) {
187            serializer.serialize_str(&self.0)
188        } else {
189            serializer.serialize_str(REDACTED)
190        }
191    }
192}
193
194impl<'de> serde::Deserialize<'de> for SensitiveString {
195    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
196        String::deserialize(deserializer).map(SensitiveString)
197    }
198}
199
200impl fmt::Display for SensitiveString {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(f, "{REDACTED}")
203    }
204}
205
206impl fmt::Debug for SensitiveString {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        write!(f, "SensitiveString({REDACTED})")
209    }
210}
211
212impl From<String> for SensitiveString {
213    fn from(s: String) -> Self {
214        Self(s)
215    }
216}
217
218impl From<&str> for SensitiveString {
219    fn from(s: &str) -> Self {
220        Self(s.to_string())
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn serialize_always_redacted() {
230        let s = SensitiveString::new("my_actual_secret");
231        let json = serde_json::to_string(&s).unwrap();
232        assert_eq!(json, format!("\"{REDACTED}\""));
233        assert!(!json.contains("my_actual_secret"));
234    }
235
236    #[test]
237    fn deserialize_reads_actual_value() {
238        let json = "\"my_actual_secret\"";
239        let s: SensitiveString = serde_json::from_str(json).unwrap();
240        assert_eq!(s.expose(), "my_actual_secret");
241    }
242
243    #[test]
244    fn display_is_redacted() {
245        let s = SensitiveString::new("secret123");
246        assert_eq!(format!("{s}"), REDACTED);
247        assert!(!format!("{s}").contains("secret123"));
248    }
249
250    #[test]
251    fn debug_is_redacted() {
252        let s = SensitiveString::new("secret123");
253        let debug = format!("{s:?}");
254        assert!(debug.contains(REDACTED));
255        assert!(!debug.contains("secret123"));
256    }
257
258    #[test]
259    fn expose_returns_actual_value() {
260        let s = SensitiveString::new("the_real_value");
261        assert_eq!(s.expose(), "the_real_value");
262    }
263
264    #[test]
265    fn default_is_empty() {
266        let s = SensitiveString::default();
267        assert!(s.is_empty());
268        assert_eq!(s.expose(), "");
269    }
270
271    #[test]
272    fn from_string() {
273        let s: SensitiveString = "hello".into();
274        assert_eq!(s.expose(), "hello");
275
276        let s: SensitiveString = String::from("world").into();
277        assert_eq!(s.expose(), "world");
278    }
279
280    #[test]
281    fn struct_with_sensitive_field_serialises_safely() {
282        #[derive(serde::Serialize, serde::Deserialize)]
283        struct Config {
284            host: String,
285            connection_string: SensitiveString,
286        }
287
288        let config = Config {
289            host: "db.example.com".into(),
290            connection_string: SensitiveString::new("postgres://user:pass@host/db"),
291        };
292
293        let json = serde_json::to_string(&config).unwrap();
294        assert!(json.contains("db.example.com"));
295        assert!(json.contains(REDACTED));
296        assert!(!json.contains("postgres://"));
297        assert!(!json.contains("user:pass"));
298    }
299
300    #[test]
301    fn struct_with_sensitive_field_deserialises_correctly() {
302        #[derive(serde::Serialize, serde::Deserialize)]
303        struct Config {
304            host: String,
305            connection_string: SensitiveString,
306        }
307
308        let json =
309            r#"{"host":"db.example.com","connection_string":"postgres://user:pass@host/db"}"#;
310        let config: Config = serde_json::from_str(json).unwrap();
311        assert_eq!(config.host, "db.example.com");
312        assert_eq!(
313            config.connection_string.expose(),
314            "postgres://user:pass@host/db"
315        );
316    }
317
318    #[test]
319    fn no_leak_through_any_serialisation_path() {
320        let secret = "super_secret_value_12345";
321        let s = SensitiveString::new(secret);
322
323        // serde_json
324        assert!(!serde_json::to_string(&s).unwrap().contains(secret));
325        // Display
326        assert!(!format!("{s}").contains(secret));
327        // Debug
328        assert!(!format!("{s:?}").contains(secret));
329        // Only expose() reveals it
330        assert_eq!(s.expose(), secret);
331    }
332
333    // ----- Round-trip preservation (hyperi-rustlib#41) -----
334
335    /// The motivating case from hyperi-rustlib#41: serialise to a serde
336    /// `Value`, then deserialise back. Without `expose_during` the
337    /// inner string is destroyed (replaced by `***REDACTED***`); inside
338    /// the helper, the value survives.
339    #[test]
340    fn round_trip_inside_expose_during_preserves_value() {
341        let s = SensitiveString::new("hunter2");
342        let v = expose_during(|| serde_json::to_value(&s).unwrap());
343        let round_tripped: SensitiveString = serde_json::from_value(v).unwrap();
344        assert_eq!(round_tripped.expose(), "hunter2");
345    }
346
347    #[test]
348    fn round_trip_outside_expose_during_redacts() {
349        let s = SensitiveString::new("hunter2");
350        // Default path -- no `expose_during` wrap.
351        let v = serde_json::to_value(&s).unwrap();
352        let round_tripped: SensitiveString = serde_json::from_value(v).unwrap();
353        // The serialised form was the literal "***REDACTED***", so the
354        // deserialised value is that literal. This is the bug being
355        // fixed for the consumer who wraps their round-trip -- but the
356        // default behaviour is preserved verbatim.
357        assert_eq!(round_tripped.expose(), REDACTED);
358    }
359
360    #[test]
361    fn expose_during_restores_after_body() {
362        let s = SensitiveString::new("secret");
363        // Before: redacted
364        assert!(serde_json::to_string(&s).unwrap().contains(REDACTED));
365        // Inside: exposed
366        expose_during(|| {
367            assert!(serde_json::to_string(&s).unwrap().contains("secret"));
368        });
369        // After: redacted again -- guard restored the flag
370        assert!(serde_json::to_string(&s).unwrap().contains(REDACTED));
371        assert!(!serde_json::to_string(&s).unwrap().contains("secret"));
372    }
373
374    #[test]
375    fn expose_during_restores_after_panic() {
376        let s = SensitiveString::new("secret");
377        let result = std::panic::catch_unwind(|| {
378            expose_during(|| {
379                // Confirm we're exposed inside the closure.
380                assert!(serde_json::to_string(&s).unwrap().contains("secret"));
381                panic!("simulated panic");
382            })
383        });
384        assert!(result.is_err(), "panic should have propagated");
385        // The drop guard must have restored the flag despite the panic.
386        assert!(serde_json::to_string(&s).unwrap().contains(REDACTED));
387        assert!(!serde_json::to_string(&s).unwrap().contains("secret"));
388    }
389
390    #[test]
391    fn expose_during_nests_correctly() {
392        let s = SensitiveString::new("secret");
393        expose_during(|| {
394            assert!(serde_json::to_string(&s).unwrap().contains("secret"));
395            expose_during(|| {
396                assert!(serde_json::to_string(&s).unwrap().contains("secret"));
397            });
398            // Inner guard restored OUTER state (which was also exposed).
399            assert!(serde_json::to_string(&s).unwrap().contains("secret"));
400        });
401        // Outer guard restored the original (redacted) state.
402        assert!(serde_json::to_string(&s).unwrap().contains(REDACTED));
403    }
404
405    #[test]
406    fn struct_round_trip_inside_expose_during_preserves_values() {
407        // Mirrors the dfe-loader bug: serialise a Config containing a
408        // SensitiveString password, merge env overrides via figment,
409        // deserialise back. Without expose_during, password becomes
410        // "***REDACTED***".
411        #[derive(serde::Serialize, serde::Deserialize)]
412        struct Config {
413            host: String,
414            password: SensitiveString,
415        }
416        let original = Config {
417            host: "db.example.com".into(),
418            password: SensitiveString::new("env-resolved-secret"),
419        };
420        let round_tripped: Config = expose_during(|| {
421            let v = serde_json::to_value(&original).unwrap();
422            serde_json::from_value(v).unwrap()
423        });
424        assert_eq!(round_tripped.host, "db.example.com");
425        assert_eq!(round_tripped.password.expose(), "env-resolved-secret");
426    }
427
428    /// Cross-thread isolation: thread A's `expose_during` does NOT
429    /// affect thread B's serialisation.
430    #[test]
431    fn expose_flag_is_thread_local() {
432        use std::sync::{Arc, Mutex};
433        let s = Arc::new(SensitiveString::new("secret"));
434        let observed = Arc::new(Mutex::new(String::new()));
435
436        let s2 = Arc::clone(&s);
437        let observed2 = Arc::clone(&observed);
438        let handle = std::thread::spawn(move || {
439            // Thread B: no expose_during. Must observe REDACTED.
440            let out = serde_json::to_string(&*s2).unwrap();
441            *observed2.lock().unwrap() = out;
442        });
443
444        // Thread A: inside expose_during. Spawn happened above; let it
445        // race the closure.
446        expose_during(|| {
447            std::thread::yield_now();
448        });
449        handle.join().unwrap();
450        let b_output = observed.lock().unwrap().clone();
451        assert!(
452            b_output.contains(REDACTED),
453            "thread B should have observed REDACTED, got: {b_output}"
454        );
455    }
456}