Skip to main content

config_kit/
sensitive.rs

1//! Redaction wrapper for secret configuration values.
2
3use core::fmt;
4
5/// Wraps a secret so `Debug` and `Display` always render `"[REDACTED]"`
6/// while the value stays usable through [`Sensitive::expose`].
7///
8/// Deliberately attribute-free: no derive macros, so nothing can
9/// accidentally drift into leaking. The redacting impls carry no `T:
10/// Debug`/`T: Display` bound, which means a container's derived `Debug`
11/// also renders `"[REDACTED]"` for the field — the value cannot escape
12/// through any formatting path.
13///
14/// Deserialization is always supported (loading configuration is the
15/// point). Serialization is opt-in behind the `serde` feature, because
16/// serializing secrets invites accidental disclosure — enable it only when
17/// your pipeline genuinely needs to write the value back out. Every
18/// intentional read goes through [`expose`](Sensitive::expose), which makes
19/// secret usage grep-friendly: `grep -rn '\.expose()'` audits every
20/// touchpoint.
21///
22/// # Example
23///
24/// ```
25/// use config_kit::Sensitive;
26///
27/// let token = Sensitive::new("hunter2".to_owned());
28/// assert_eq!(format!("{token:?}"), "[REDACTED]");
29/// assert_eq!(format!("{token}"), "[REDACTED]");
30/// assert_eq!(token.expose(), "hunter2");
31/// ```
32pub struct Sensitive<T> {
33    value: T,
34}
35
36impl<T> Sensitive<T> {
37    /// Wraps `value` for redacted transport.
38    #[must_use]
39    pub fn new(value: T) -> Self {
40        Self { value }
41    }
42
43    /// Explicit, grep-friendly access to the wrapped secret.
44    #[must_use]
45    pub fn expose(&self) -> &T {
46        &self.value
47    }
48
49    /// Consumes the wrapper, returning the secret. Like [`expose`](Self::expose),
50    /// this is an explicit declassification point.
51    #[must_use]
52    pub fn into_inner(self) -> T {
53        self.value
54    }
55}
56
57impl<T> From<T> for Sensitive<T> {
58    fn from(value: T) -> Self {
59        Self::new(value)
60    }
61}
62
63impl<T> fmt::Debug for Sensitive<T> {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str("[REDACTED]")
66    }
67}
68
69impl<T> fmt::Display for Sensitive<T> {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str("[REDACTED]")
72    }
73}
74
75impl<'de, T> serde::Deserialize<'de> for Sensitive<T>
76where
77    T: serde::Deserialize<'de>,
78{
79    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80    where
81        D: serde::Deserializer<'de>,
82    {
83        T::deserialize(deserializer).map(Sensitive::new)
84    }
85}
86
87/// Serialization is opt-in: see the type-level documentation for why.
88#[cfg(feature = "serde")]
89impl<T> serde::Serialize for Sensitive<T>
90where
91    T: serde::Serialize,
92{
93    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
94    where
95        S: serde::Serializer,
96    {
97        self.value.serialize(serializer)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    #![allow(clippy::unwrap_used, clippy::expect_used)]
104    use super::*;
105
106    #[test]
107    fn debug_and_display_redact() {
108        let secret = Sensitive::new("hunter2".to_owned());
109        assert_eq!(format!("{secret:?}"), "[REDACTED]");
110        assert_eq!(format!("{secret}"), "[REDACTED]");
111        assert!(!format!("{secret:?}").contains("hunter2"));
112    }
113
114    #[test]
115    fn access_is_explicit() {
116        let secret = Sensitive::from(42_u32);
117        assert_eq!(*secret.expose(), 42);
118        assert_eq!(secret.into_inner(), 42);
119    }
120
121    #[test]
122    fn deserializes_and_redacts_through_containers() {
123        #[derive(serde::Deserialize, Debug)]
124        struct Container {
125            token: Sensitive<String>,
126        }
127
128        let container: Container =
129            toml::from_str("token = \"top-secret\"").expect("deserialize container");
130        assert_eq!(container.token.expose(), "top-secret");
131        let rendered = format!("{container:?}");
132        assert!(rendered.contains("[REDACTED]"), "{rendered}");
133        assert!(!rendered.contains("top-secret"), "{rendered}");
134    }
135
136    #[cfg(feature = "serde")]
137    #[test]
138    fn serde_roundtrip_preserves_value() {
139        #[derive(serde::Serialize, serde::Deserialize)]
140        struct Wrapper {
141            token: Sensitive<String>,
142        }
143
144        let original = Wrapper {
145            token: Sensitive::new("rotate-me".to_owned()),
146        };
147        let encoded = toml::to_string(&original).expect("serialize");
148        let decoded: Wrapper = toml::from_str(&encoded).expect("deserialize");
149        assert_eq!(decoded.token.expose(), "rotate-me");
150        assert_eq!(format!("{:?}", decoded.token), "[REDACTED]");
151    }
152}