use zeroize::Zeroize;
pub struct SecretString {
inner: Vec<u8>,
}
impl SecretString {
pub fn new(value: String) -> Self {
Self {
inner: value.into_bytes(),
}
}
pub fn from_bytes(bytes: Vec<u8>) -> Self {
Self { inner: bytes }
}
pub fn expose(&self) -> Result<&str, std::str::Utf8Error> {
std::str::from_utf8(&self.inner)
}
pub fn expose_bytes(&self) -> &[u8] {
&self.inner
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
pub fn contains_newline(&self) -> bool {
self.inner.contains(&b'\n')
}
}
impl Drop for SecretString {
fn drop(&mut self) {
self.inner.zeroize();
}
}
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SecretString(<redacted>, len={})", self.inner.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_and_expose_round_trip() {
let s = SecretString::new("hunter2".into());
assert_eq!(s.expose().unwrap(), "hunter2");
assert_eq!(s.len(), 7);
assert!(!s.is_empty());
}
#[test]
fn empty_secret_is_well_behaved() {
let s = SecretString::new(String::new());
assert!(s.is_empty());
assert_eq!(s.len(), 0);
assert_eq!(s.expose().unwrap(), "");
}
#[test]
fn from_bytes_supports_non_utf8_payload() {
let s = SecretString::from_bytes(vec![0xde, 0xad, 0xbe, 0xef, 0xff]);
assert_eq!(s.expose_bytes(), &[0xde, 0xad, 0xbe, 0xef, 0xff]);
assert!(s.expose().is_err(), "expose() must reject non-UTF-8");
}
#[test]
fn debug_does_not_leak_value() {
let s = SecretString::new("super-secret-token".into());
let formatted = format!("{:?}", s);
assert!(!formatted.contains("super-secret-token"));
assert!(formatted.contains("<redacted>"));
assert!(formatted.contains("len=18"));
}
#[test]
fn contains_newline_detects_multiline_for_section_3_4() {
let single = SecretString::new("one-line-value".into());
assert!(!single.contains_newline());
let multi = SecretString::new("line1\nline2".into());
assert!(multi.contains_newline());
let cr_only = SecretString::new("line1\rline2".into());
assert!(!cr_only.contains_newline());
}
#[test]
fn drop_zeroes_underlying_bytes() {
let s = SecretString::new("rotate-me".into());
assert_eq!(s.expose().unwrap(), "rotate-me");
drop(s);
let s2 = SecretString::new("next-value".into());
assert_eq!(s2.expose().unwrap(), "next-value");
}
}