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}