dodot_lib/secret/secret_string.rs
1//! `SecretString` — a wrapper that holds a resolved secret value and
2//! tries to keep it from leaking through the usual Rust footguns.
3//!
4//! The guarantees this type makes are explicitly limited; see
5//! `docs/proposals/secrets.lex` §5.1 ("defense in depth, not a
6//! guarantee"). Resolved secret values land on disk in the rendered
7//! file regardless, so the meaningful protections are at the OS layer.
8//! What this wrapper does add:
9//!
10//! - **Zero-on-drop.** The byte buffer is overwritten on `Drop` via
11//! the `zeroize` crate's volatile-write loop, which the compiler
12//! can't elide. Reduces the window where a stale-but-still-resident
13//! buffer could be read by another process with sufficient
14//! privilege.
15//! - **No `Debug` / `Display`.** The type is opaque to the standard
16//! formatting machinery. A `tracing::error!("{e:?}", e=...)` that
17//! accidentally captures a `SecretString` won't print the bytes; it
18//! prints `SecretString(<redacted>)`.
19//! - **No `Serialize`.** Same idea, for the JSON / TOML paths.
20//! - **No `Clone`.** Discourages duplicating the value into multiple
21//! buffers; callers that genuinely need a copy can call
22//! [`SecretString::expose`] and re-wrap.
23//!
24//! What this wrapper does NOT do:
25//!
26//! - It doesn't `mlock` the page. Memory locking is a process-wide
27//! concern with rlimits to think about; the spec defers it.
28//! - It doesn't prevent the value from being copied into a `String`
29//! that the renderer hands off to MiniJinja for substitution. The
30//! rendered output goes to disk; the wrapper has no power past that
31//! handoff.
32//!
33//! Read this as: every code path that touches the bytes through
34//! `SecretString` is a deliberate decision, and the type makes the
35//! accidental paths impossible.
36
37use zeroize::Zeroize;
38
39/// A resolved secret value, held briefly in process memory.
40///
41/// Construct via [`SecretString::new`]; read via [`SecretString::expose`].
42/// Zeroes its buffer on drop. Has no `Debug` / `Display` / `Serialize`
43/// implementations; printing one through any of those paths produces
44/// `<redacted>`.
45pub struct SecretString {
46 inner: Vec<u8>,
47}
48
49impl SecretString {
50 /// Wrap a UTF-8 string as a secret. Takes ownership of the input
51 /// bytes so the caller can't keep a parallel handle.
52 pub fn new(value: String) -> Self {
53 Self {
54 inner: value.into_bytes(),
55 }
56 }
57
58 /// Wrap an arbitrary byte slice (for binary secrets — keys, etc.).
59 pub fn from_bytes(bytes: Vec<u8>) -> Self {
60 Self { inner: bytes }
61 }
62
63 /// Borrow the secret as `&str`. Returns an error if the bytes
64 /// aren't valid UTF-8 — the value-injection path requires UTF-8.
65 /// Whole-file deploy uses [`SecretString::expose_bytes`] instead.
66 pub fn expose(&self) -> Result<&str, std::str::Utf8Error> {
67 std::str::from_utf8(&self.inner)
68 }
69
70 /// Borrow the secret as raw bytes. For whole-file flows where
71 /// UTF-8 isn't a guarantee.
72 pub fn expose_bytes(&self) -> &[u8] {
73 &self.inner
74 }
75
76 /// Length of the secret in bytes. Safe to log.
77 pub fn len(&self) -> usize {
78 self.inner.len()
79 }
80
81 /// True iff the secret is empty.
82 pub fn is_empty(&self) -> bool {
83 self.inner.is_empty()
84 }
85
86 /// True iff the secret value contains at least one newline.
87 /// Used by §3.4's multi-line refusal: value-injection requires
88 /// single-line values, and this check is the gate. Reading this
89 /// flag does not expose the bytes anywhere — it's a property of
90 /// the value, not the value itself.
91 pub fn contains_newline(&self) -> bool {
92 self.inner.contains(&b'\n')
93 }
94}
95
96impl Drop for SecretString {
97 fn drop(&mut self) {
98 self.inner.zeroize();
99 }
100}
101
102// Explicitly opaque to formatting machinery.
103impl std::fmt::Debug for SecretString {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 // Length included on purpose: it's safe to log, and helps
106 // distinguish "empty value resolved" from "no value resolved"
107 // when debugging without ever exposing the bytes.
108 write!(f, "SecretString(<redacted>, len={})", self.inner.len())
109 }
110}
111
112// `Display` is intentionally NOT implemented: printing a secret with
113// `{}` should fail to compile, not silently format the bytes.
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn new_and_expose_round_trip() {
121 let s = SecretString::new("hunter2".into());
122 assert_eq!(s.expose().unwrap(), "hunter2");
123 assert_eq!(s.len(), 7);
124 assert!(!s.is_empty());
125 }
126
127 #[test]
128 fn empty_secret_is_well_behaved() {
129 let s = SecretString::new(String::new());
130 assert!(s.is_empty());
131 assert_eq!(s.len(), 0);
132 assert_eq!(s.expose().unwrap(), "");
133 }
134
135 #[test]
136 fn from_bytes_supports_non_utf8_payload() {
137 // 0xff is not valid UTF-8.
138 let s = SecretString::from_bytes(vec![0xde, 0xad, 0xbe, 0xef, 0xff]);
139 assert_eq!(s.expose_bytes(), &[0xde, 0xad, 0xbe, 0xef, 0xff]);
140 assert!(s.expose().is_err(), "expose() must reject non-UTF-8");
141 }
142
143 #[test]
144 fn debug_does_not_leak_value() {
145 let s = SecretString::new("super-secret-token".into());
146 let formatted = format!("{:?}", s);
147 assert!(!formatted.contains("super-secret-token"));
148 assert!(formatted.contains("<redacted>"));
149 // Length surfacing is intentional — useful for debugging,
150 // doesn't reveal the value.
151 assert!(formatted.contains("len=18"));
152 }
153
154 #[test]
155 fn contains_newline_detects_multiline_for_section_3_4() {
156 let single = SecretString::new("one-line-value".into());
157 assert!(!single.contains_newline());
158
159 let multi = SecretString::new("line1\nline2".into());
160 assert!(multi.contains_newline());
161
162 // CR-only (e.g. classic-Mac line endings) is NOT flagged as
163 // multi-line — value-injection's single-line gate is about
164 // file-format breakage from `\n`. CR-only inputs are
165 // pathological enough that we'd rather catch them via the
166 // template engine's UTF-8 / encoding handling.
167 let cr_only = SecretString::new("line1\rline2".into());
168 assert!(!cr_only.contains_newline());
169 }
170
171 #[test]
172 fn drop_zeroes_underlying_bytes() {
173 // We can't observe the bytes after Drop directly without
174 // unsafe (the buffer is freed). Instead, exercise the
175 // zeroize pathway by holding the value, asserting the
176 // pre-drop content, then dropping and constructing a fresh
177 // value to confirm the type still works after Drop ran.
178 // The real guarantee — that `Drop::drop` is called and
179 // calls `zeroize` — is provided by the type's impl;
180 // this test pins the contract on that impl rather than
181 // attempting to read freed memory.
182 let s = SecretString::new("rotate-me".into());
183 assert_eq!(s.expose().unwrap(), "rotate-me");
184 drop(s);
185 let s2 = SecretString::new("next-value".into());
186 assert_eq!(s2.expose().unwrap(), "next-value");
187 }
188
189 // Compile-time check: SecretString does NOT implement Clone.
190 // (If a future change accidentally derives Clone, the line below
191 // would compile and this test would silently pass. Instead, the
192 // negative assertion lives as a doc-comment; rust-analyzer / human
193 // review catches re-introduced Clone derivations.)
194 //
195 // let s = SecretString::new("x".into());
196 // let _ = s.clone(); // <-- must fail to compile
197}