Skip to main content

aurum_core/
secret.rs

1//! Redacting secret wrapper (JOE-1779 / JOE-1914).
2//!
3//! Secrets must never appear via Debug, Display, or accidental Serde serialization.
4//! Loading from config still deserializes the real value; serializing always emits
5//! a redacted placeholder so dumps cannot leak credentials.
6
7use serde::de::{self, Visitor};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt;
10
11/// Opaque secret string.
12///
13/// - [`Debug`] / [`Display`] always print a redacted form.
14/// - [`Serialize`] always emits `"***"` (never the raw value).
15/// - [`Deserialize`] accepts a string and stores it (config/env load path).
16/// - Raw access is only via [`SecretString::expose`] / [`SecretString::into_inner`].
17#[derive(Clone, PartialEq, Eq)]
18pub struct SecretString(String);
19
20impl SecretString {
21    pub fn new(s: impl Into<String>) -> Self {
22        Self(s.into())
23    }
24
25    /// Access the raw secret for Authorization-header construction only.
26    /// Prefer not logging the return value.
27    pub fn expose(&self) -> &str {
28        &self.0
29    }
30
31    pub fn into_inner(self) -> String {
32        self.0
33    }
34
35    pub fn is_empty(&self) -> bool {
36        self.0.is_empty()
37    }
38
39    /// Minimum length before a value is treated as a secret for redaction helpers.
40    pub const MIN_REDACT_LEN: usize = 8;
41}
42
43impl fmt::Debug for SecretString {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str("SecretString(***)")
46    }
47}
48
49impl fmt::Display for SecretString {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str("***")
52    }
53}
54
55impl Serialize for SecretString {
56    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
57    where
58        S: Serializer,
59    {
60        // Never emit plaintext. Diagnostics and accidental serde dumps stay clean.
61        serializer.serialize_str("***")
62    }
63}
64
65impl<'de> Deserialize<'de> for SecretString {
66    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
67    where
68        D: Deserializer<'de>,
69    {
70        struct V;
71        impl Visitor<'_> for V {
72            type Value = SecretString;
73
74            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
75                f.write_str("a secret string")
76            }
77
78            fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
79                Ok(SecretString::new(v))
80            }
81
82            fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
83                Ok(SecretString::new(v))
84            }
85        }
86        deserializer.deserialize_string(V)
87    }
88}
89
90impl From<String> for SecretString {
91    fn from(s: String) -> Self {
92        Self(s)
93    }
94}
95
96impl From<&str> for SecretString {
97    fn from(s: &str) -> Self {
98        Self(s.to_string())
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn secret_never_in_debug_or_display() {
108        let s = SecretString::new("sk-super-secret");
109        let d = format!("{s:?}");
110        let disp = format!("{s}");
111        assert!(!d.contains("sk-super-secret"));
112        assert!(!disp.contains("sk-super-secret"));
113        assert!(d.contains("***"));
114        assert_eq!(s.expose(), "sk-super-secret");
115    }
116
117    #[test]
118    fn serialize_never_emits_plaintext() {
119        let s = SecretString::new("sk-or-v1-canary-plaintext-value-xyz");
120        let json = serde_json::to_string(&s).expect("serialize");
121        assert!(!json.contains("canary"));
122        assert!(!json.contains("plaintext"));
123        assert_eq!(json, "\"***\"");
124    }
125
126    #[test]
127    fn deserialize_loads_value() {
128        let s: SecretString = serde_json::from_str("\"sk-loaded-from-config\"").unwrap();
129        assert_eq!(s.expose(), "sk-loaded-from-config");
130        assert!(!format!("{s:?}").contains("sk-loaded"));
131    }
132
133    #[test]
134    fn option_serialize_redacts() {
135        let v: Option<SecretString> = Some(SecretString::new("sk-nested-secret-value"));
136        let json = serde_json::to_string(&v).unwrap();
137        assert!(!json.contains("nested-secret"));
138        assert!(json.contains("***"));
139    }
140}