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:   FSL-1.1-ALv2
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::fmt;
42
43use serde::de::Deserializer;
44use serde::ser::Serializer;
45
46const REDACTED: &str = "***REDACTED***";
47
48/// A string value that is always redacted when serialised.
49///
50/// Use this for config fields that contain secrets but don't have
51/// obviously-sensitive names (e.g., `connection_string`, `dsn`, `uri`).
52///
53/// - `Serialize` always outputs `"***REDACTED***"`
54/// - `Deserialize` reads the actual value normally
55/// - `Display` shows `***REDACTED***`
56/// - `Debug` shows `SensitiveString(***REDACTED***)`
57/// - Inner value accessible via `.expose()` for application logic
58#[derive(Clone, Default, PartialEq, Eq)]
59pub struct SensitiveString(String);
60
61impl SensitiveString {
62    /// Create a new sensitive string.
63    #[must_use]
64    pub fn new(value: impl Into<String>) -> Self {
65        Self(value.into())
66    }
67
68    /// Expose the inner value for application logic.
69    ///
70    /// This is the only way to access the actual value. The name is
71    /// intentionally explicit to make usage grep-able in code review.
72    #[must_use]
73    pub fn expose(&self) -> &str {
74        &self.0
75    }
76
77    /// Check if the inner value is empty.
78    #[must_use]
79    pub fn is_empty(&self) -> bool {
80        self.0.is_empty()
81    }
82}
83
84impl serde::Serialize for SensitiveString {
85    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
86        serializer.serialize_str(REDACTED)
87    }
88}
89
90impl<'de> serde::Deserialize<'de> for SensitiveString {
91    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
92        String::deserialize(deserializer).map(SensitiveString)
93    }
94}
95
96impl fmt::Display for SensitiveString {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(f, "{REDACTED}")
99    }
100}
101
102impl fmt::Debug for SensitiveString {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        write!(f, "SensitiveString({REDACTED})")
105    }
106}
107
108impl From<String> for SensitiveString {
109    fn from(s: String) -> Self {
110        Self(s)
111    }
112}
113
114impl From<&str> for SensitiveString {
115    fn from(s: &str) -> Self {
116        Self(s.to_string())
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn serialize_always_redacted() {
126        let s = SensitiveString::new("my_actual_secret");
127        let json = serde_json::to_string(&s).unwrap();
128        assert_eq!(json, format!("\"{REDACTED}\""));
129        assert!(!json.contains("my_actual_secret"));
130    }
131
132    #[test]
133    fn deserialize_reads_actual_value() {
134        let json = "\"my_actual_secret\"";
135        let s: SensitiveString = serde_json::from_str(json).unwrap();
136        assert_eq!(s.expose(), "my_actual_secret");
137    }
138
139    #[test]
140    fn display_is_redacted() {
141        let s = SensitiveString::new("secret123");
142        assert_eq!(format!("{s}"), REDACTED);
143        assert!(!format!("{s}").contains("secret123"));
144    }
145
146    #[test]
147    fn debug_is_redacted() {
148        let s = SensitiveString::new("secret123");
149        let debug = format!("{s:?}");
150        assert!(debug.contains(REDACTED));
151        assert!(!debug.contains("secret123"));
152    }
153
154    #[test]
155    fn expose_returns_actual_value() {
156        let s = SensitiveString::new("the_real_value");
157        assert_eq!(s.expose(), "the_real_value");
158    }
159
160    #[test]
161    fn default_is_empty() {
162        let s = SensitiveString::default();
163        assert!(s.is_empty());
164        assert_eq!(s.expose(), "");
165    }
166
167    #[test]
168    fn from_string() {
169        let s: SensitiveString = "hello".into();
170        assert_eq!(s.expose(), "hello");
171
172        let s: SensitiveString = String::from("world").into();
173        assert_eq!(s.expose(), "world");
174    }
175
176    #[test]
177    fn struct_with_sensitive_field_serialises_safely() {
178        #[derive(serde::Serialize, serde::Deserialize)]
179        struct Config {
180            host: String,
181            connection_string: SensitiveString,
182        }
183
184        let config = Config {
185            host: "db.example.com".into(),
186            connection_string: SensitiveString::new("postgres://user:pass@host/db"),
187        };
188
189        let json = serde_json::to_string(&config).unwrap();
190        assert!(json.contains("db.example.com"));
191        assert!(json.contains(REDACTED));
192        assert!(!json.contains("postgres://"));
193        assert!(!json.contains("user:pass"));
194    }
195
196    #[test]
197    fn struct_with_sensitive_field_deserialises_correctly() {
198        #[derive(serde::Serialize, serde::Deserialize)]
199        struct Config {
200            host: String,
201            connection_string: SensitiveString,
202        }
203
204        let json =
205            r#"{"host":"db.example.com","connection_string":"postgres://user:pass@host/db"}"#;
206        let config: Config = serde_json::from_str(json).unwrap();
207        assert_eq!(config.host, "db.example.com");
208        assert_eq!(
209            config.connection_string.expose(),
210            "postgres://user:pass@host/db"
211        );
212    }
213
214    #[test]
215    fn no_leak_through_any_serialisation_path() {
216        let secret = "super_secret_value_12345";
217        let s = SensitiveString::new(secret);
218
219        // serde_json
220        assert!(!serde_json::to_string(&s).unwrap().contains(secret));
221        // Display
222        assert!(!format!("{s}").contains(secret));
223        // Debug
224        assert!(!format!("{s:?}").contains(secret));
225        // Only expose() reveals it
226        assert_eq!(s.expose(), secret);
227    }
228}