omni_dev/utils/secret.rs
1//! Redacting wrapper for secret string values.
2//!
3//! [`Secret`] holds sensitive credential material (API tokens, session
4//! tokens) and guarantees it cannot leak through `Debug` formatting: the
5//! hand-written `Debug` impl prints `<redacted>` instead of the value.
6//! The type deliberately implements neither `Display` nor any serde
7//! traits, so a secret cannot be `{}`-formatted or accidentally
8//! serialized; the only way out is an explicit, greppable
9//! [`expose_secret`](Secret::expose_secret) call.
10
11use std::fmt;
12
13/// A string secret that redacts itself in `Debug` output.
14///
15/// Wrapping a credential field in `Secret` makes a containing struct's
16/// derived `Debug` print `<redacted>` for that field, so a future
17/// `tracing::debug!("{creds:?}")` or `.context(format!("… {creds:?}"))`
18/// cannot leak the value into logs or error chains.
19///
20/// `Secret` implements neither `Display` nor serde traits by design; the
21/// wrapped value is only reachable through
22/// [`expose_secret`](Self::expose_secret).
23///
24/// # Examples
25///
26/// ```
27/// use omni_dev::utils::secret::Secret;
28///
29/// let token = Secret::new("s3cr3t");
30/// assert_eq!(format!("{token:?}"), "<redacted>");
31/// assert_eq!(token.expose_secret(), "s3cr3t");
32/// ```
33// PartialEq is derived (not constant-time): fine for test/config equality,
34// never use it as an authentication check.
35#[derive(Clone, PartialEq, Eq)]
36pub struct Secret(String);
37
38impl Secret {
39 /// Creates a `Secret` wrapping the given value.
40 #[must_use]
41 pub fn new(value: impl Into<String>) -> Self {
42 Self(value.into())
43 }
44
45 /// Returns the wrapped secret value.
46 ///
47 /// The name is deliberately loud: every call site is an auditable
48 /// point where the secret leaves the redacting wrapper.
49 #[must_use]
50 pub fn expose_secret(&self) -> &str {
51 &self.0
52 }
53}
54
55impl fmt::Debug for Secret {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.write_str("<redacted>")
58 }
59}
60
61impl From<String> for Secret {
62 fn from(value: String) -> Self {
63 Self(value)
64 }
65}
66
67impl From<&str> for Secret {
68 fn from(value: &str) -> Self {
69 Self(value.to_string())
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[test]
78 fn debug_output_is_redacted() {
79 let secret = Secret::new("super-sekret-value");
80 let debug = format!("{secret:?}");
81 assert_eq!(debug, "<redacted>");
82 assert!(!debug.contains("super-sekret-value"));
83 }
84
85 #[test]
86 fn debug_redacts_inside_derived_container() {
87 #[derive(Debug)]
88 struct Holder {
89 // Read only through the derived Debug impl, which dead-code
90 // analysis intentionally ignores.
91 #[allow(dead_code)]
92 token: Secret,
93 }
94 let holder = Holder {
95 token: "super-sekret-value".into(),
96 };
97 let debug = format!("{holder:?}");
98 assert!(debug.contains("token: <redacted>"));
99 assert!(!debug.contains("super-sekret-value"));
100 }
101
102 #[test]
103 fn expose_secret_returns_the_wrapped_value() {
104 assert_eq!(Secret::new("v").expose_secret(), "v");
105 }
106
107 #[test]
108 fn from_string_and_str_compare_equal() {
109 let a: Secret = "tok".into();
110 let b: Secret = String::from("tok").into();
111 assert_eq!(a, b);
112 assert_eq!(a.clone(), a);
113 }
114}