Skip to main content

deps_core/
secret.rs

1//! Generic "never surfaced via `Debug`/`Display`" wrapper for in-memory secrets.
2//!
3//! `deps_core::github::AuthToken`, `deps_cargo::config::AuthToken`,
4//! `deps_nuget::config::NuGetAuth`, and `deps_nuget::config::RedactedSecret` each
5//! hand-rolled the same single-field tuple struct: a private/crate-visible constructor, an
6//! `as_str()` accessor documented "never logged, printed, or otherwise surfaced", and
7//! hand-written `Debug`/`Display` impls that print `***` (#573). [`Redacted<T>`] is the one
8//! place that pattern is implemented, so the four call sites cannot silently diverge on it
9//! and a fifth ecosystem crate needing the same guarantee does not reinvent it a fifth time.
10//!
11//! Placed beside [`crate::net_policy::redact_userinfo`], which owns the adjacent "a
12//! credential must not leak via a log line" concern for URLs specifically, while this module
13//! owns it for an owned secret value held in memory.
14//!
15//! Beyond redacting `Debug`/`Display`, [`Redacted<T>`] zeroizes its backing memory on drop
16//! (issue #574) — after the value goes out of scope, a core dump or a read of freed/swapped
17//! memory cannot recover the plaintext credential.
18
19use zeroize::{Zeroize, Zeroizing};
20
21/// A secret value whose `Debug`/`Display` output is always `***`, and whose backing memory
22/// is zeroized when it is dropped.
23///
24/// Wrap any credential that must never reach a log line, a panic message, or a future
25/// `#[derive(Debug)]` added to a struct embedding it. `T` defaults to `String`, the shape
26/// every current call site needs; a caller that needs a different backing type must supply
27/// one that implements [`Zeroize`] (e.g. secret bytes as `Vec<u8>`).
28///
29/// # Examples
30///
31/// ```
32/// use deps_core::secret::Redacted;
33///
34/// let token = Redacted::new("super-secret-value".to_string());
35/// assert_eq!(token.expose_secret(), "super-secret-value");
36/// assert_eq!(format!("{token:?}"), "Redacted(***)");
37/// assert_eq!(format!("{token}"), "***");
38/// ```
39#[derive(Clone)]
40pub struct Redacted<T: Zeroize = String>(Zeroizing<T>);
41
42impl<T: Zeroize> Redacted<T> {
43    /// Wraps `value`. The only way to recover it is [`Self::expose_secret`] (for `T: AsRef<str>`).
44    pub fn new(value: T) -> Self {
45        Self(Zeroizing::new(value))
46    }
47}
48
49impl<T: Zeroize + AsRef<str>> Redacted<T> {
50    /// The raw secret value. Never pass this to anything but the one call site that needs
51    /// it (e.g. attaching a header value to a request) — never to a log, error message, or
52    /// anything `Debug`/`Display`-formatted downstream.
53    ///
54    /// Named `expose_secret()` rather than `as_str()` deliberately, mirroring the `secrecy`
55    /// crate's `ExposeSecret::expose_secret()` convention: a name shared with hundreds of
56    /// ordinary string-conversion methods across the workspace cannot be grepped for in
57    /// isolation, while a distinctive name lets a reviewer or a future automated lint find
58    /// every place a secret's plaintext crosses its wrapper boundary with a single search.
59    #[must_use]
60    pub fn expose_secret(&self) -> &str {
61        self.0.as_ref()
62    }
63}
64
65impl<T: Zeroize> std::fmt::Debug for Redacted<T> {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.write_str("Redacted(***)")
68    }
69}
70
71impl<T: Zeroize> std::fmt::Display for Redacted<T> {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.write_str("***")
74    }
75}
76
77impl<T: Zeroize + PartialEq> PartialEq for Redacted<T> {
78    fn eq(&self, other: &Self) -> bool {
79        *self.0 == *other.0
80    }
81}
82
83impl<T: Zeroize + Eq> Eq for Redacted<T> {}
84
85/// Hashes the wrapped value, not the redaction wrapper — so `Redacted<T>` can be used as (or
86/// inside) a hash-map/set key exactly when `T` itself could be. Opt-in via `T: Hash`, same
87/// shape as the `PartialEq`/`Eq` impls above: a caller that needs this must ask for it by
88/// bounding on `Hash`, so embedding a secret in a hash key stays a deliberate choice at each
89/// call site rather than something a blanket impl would make automatic.
90impl<T: Zeroize + std::hash::Hash> std::hash::Hash for Redacted<T> {
91    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
92        (*self.0).hash(state);
93    }
94}
95
96/// Marker confirming [`Redacted<T>`] zeroizes its backing memory on drop — the actual
97/// zeroing is performed by the wrapped [`Zeroizing<T>`] field's own [`Drop`] impl.
98impl<T: Zeroize> zeroize::ZeroizeOnDrop for Redacted<T> {}
99
100#[cfg(test)]
101mod tests {
102    use super::Redacted;
103
104    #[test]
105    fn debug_redacts() {
106        let secret = Redacted::new("hunter2".to_string());
107        assert_eq!(format!("{secret:?}"), "Redacted(***)");
108    }
109
110    #[test]
111    fn display_redacts() {
112        let secret = Redacted::new("hunter2".to_string());
113        assert_eq!(format!("{secret}"), "***");
114    }
115
116    #[test]
117    fn expose_secret_recovers_the_value() {
118        let secret = Redacted::new("hunter2".to_string());
119        assert_eq!(secret.expose_secret(), "hunter2");
120    }
121
122    #[test]
123    fn equality_compares_the_wrapped_value() {
124        assert_eq!(
125            Redacted::new("hunter2".to_string()),
126            Redacted::new("hunter2".to_string())
127        );
128        assert_ne!(
129            Redacted::new("hunter2".to_string()),
130            Redacted::new("other".to_string())
131        );
132    }
133
134    #[test]
135    fn embedding_in_a_debug_derive_still_redacts() {
136        #[derive(Debug)]
137        struct Wrapper {
138            token: Redacted,
139        }
140        let wrapper = Wrapper {
141            token: Redacted::new("hunter2".to_string()),
142        };
143        assert_eq!(wrapper.token.expose_secret(), "hunter2");
144        let debug_output = format!("{wrapper:?}");
145        assert!(debug_output.contains("Redacted(***)"), "{debug_output}");
146        assert!(!debug_output.contains("hunter2"), "{debug_output}");
147    }
148
149    /// Compile-time proof `Redacted<T>` zeroizes on drop — inspecting freed memory
150    /// portably isn't practical in a test, so this asserts the trait bound instead (the
151    /// idiomatic pattern for the `zeroize` ecosystem).
152    #[test]
153    fn implements_zeroize_on_drop() {
154        fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
155        assert_zeroize_on_drop::<Redacted<String>>();
156    }
157}