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}