#![allow(unsafe_code)]
use core::fmt;
pub struct SecretString {
bytes: Vec<u8>,
}
impl SecretString {
#[must_use]
pub fn new(s: &str) -> Self {
Self {
bytes: s.as_bytes().to_vec(),
}
}
#[must_use]
pub fn from_string(s: String) -> Self {
Self {
bytes: s.into_bytes(),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
core::str::from_utf8(&self.bytes)
.expect("SecretString invariant: bytes are valid UTF-8 by constructor")
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.bytes.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.bytes.len()
}
pub fn explicit_zeroize(&mut self) {
self.zeroize_bytes();
self.bytes.clear();
self.bytes.shrink_to_fit();
}
fn zeroize_bytes(&mut self) {
for byte in &mut self.bytes {
unsafe {
core::ptr::write_volatile(byte, 0);
}
}
core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst);
}
}
impl Drop for SecretString {
fn drop(&mut self) {
self.zeroize_bytes();
}
}
impl Clone for SecretString {
fn clone(&self) -> Self {
Self {
bytes: self.bytes.clone(),
}
}
}
impl PartialEq for SecretString {
fn eq(&self, other: &Self) -> bool {
let mut acc = self.bytes.len() ^ other.bytes.len();
let max_len = self.bytes.len().max(other.bytes.len());
for index in 0..max_len {
let a = self.bytes.get(index).copied().unwrap_or(0);
let b = other.bytes.get(index).copied().unwrap_or(0);
acc |= usize::from(a ^ b);
}
core::hint::black_box(acc) == 0
}
}
impl Eq for SecretString {}
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretString(<redacted>)")
}
}
impl From<&str> for SecretString {
fn from(s: &str) -> Self {
Self::new(s)
}
}
impl From<String> for SecretString {
fn from(s: String) -> Self {
Self::from_string(s)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery)]
use super::*;
use std::mem::ManuallyDrop;
#[test]
fn new_preserves_bytes() {
let s = SecretString::new("hunter2");
assert_eq!(s.as_str(), "hunter2");
assert_eq!(s.as_bytes(), b"hunter2");
assert_eq!(s.len(), 7);
assert!(!s.is_empty());
}
#[test]
fn from_string_preserves_bytes() {
let owned = String::from("correct horse battery staple");
let s = SecretString::from_string(owned);
assert_eq!(s.as_str(), "correct horse battery staple");
}
#[test]
fn from_str_via_into() {
let s: SecretString = "p@ssw0rd".into();
assert_eq!(s.as_str(), "p@ssw0rd");
}
#[test]
fn from_string_via_into() {
let s: SecretString = String::from("alpha").into();
assert_eq!(s.as_str(), "alpha");
}
#[test]
fn empty_secret() {
let s = SecretString::new("");
assert!(s.is_empty());
assert_eq!(s.len(), 0);
assert_eq!(s.as_str(), "");
}
#[test]
fn debug_always_redacts() {
let s = SecretString::new("topsecret");
let dbg = format!("{s:?}");
assert_eq!(dbg, "SecretString(<redacted>)");
assert!(!dbg.contains("topsecret"));
}
#[test]
fn debug_redacts_even_for_empty() {
let s = SecretString::new("");
assert_eq!(format!("{s:?}"), "SecretString(<redacted>)");
}
#[test]
fn clone_is_independent() {
let a = SecretString::new("shared");
let b = a.clone();
assert_eq!(a, b);
drop(a);
assert_eq!(b.as_str(), "shared");
}
#[test]
fn eq_constant_time_correctness() {
assert_eq!(SecretString::new("abc"), SecretString::new("abc"));
assert_ne!(SecretString::new("abc"), SecretString::new("abd"));
assert_ne!(SecretString::new("abc"), SecretString::new("abcd"));
assert_ne!(SecretString::new("abc"), SecretString::new(""));
assert_eq!(SecretString::new(""), SecretString::new(""));
}
#[test]
fn drop_zeroizes_secret_bytes() {
let mut s = ManuallyDrop::new(SecretString::new("plaintext"));
let ptr: *const u8 = s.bytes.as_ptr();
let len = s.bytes.len();
assert!(len > 0);
let pre = unsafe { core::slice::from_raw_parts(ptr, len) };
assert_eq!(pre, b"plaintext");
unsafe {
ManuallyDrop::drop(&mut s);
}
let post = unsafe { core::slice::from_raw_parts(ptr, len) };
assert!(
post.iter().all(|&b| b == 0),
"Drop must zeroize every byte; observed: {post:02x?}"
);
}
#[test]
fn from_string_zeroizes_on_drop() {
let mut s = ManuallyDrop::new(SecretString::from_string(String::from("from_string")));
let ptr: *const u8 = s.bytes.as_ptr();
let len = s.bytes.len();
assert_eq!(
unsafe { core::slice::from_raw_parts(ptr, len) },
b"from_string"
);
unsafe {
ManuallyDrop::drop(&mut s);
}
let post = unsafe { core::slice::from_raw_parts(ptr, len) };
assert!(post.iter().all(|&b| b == 0));
}
#[test]
fn explicit_zeroize_clears_bytes_in_place() {
let mut s = SecretString::new("ephemeral");
assert_eq!(s.as_str(), "ephemeral");
s.explicit_zeroize();
assert!(s.is_empty());
assert_eq!(s.as_str(), "");
assert_eq!(s.as_bytes(), b"");
}
#[test]
fn explicit_zeroize_is_idempotent() {
let mut s = SecretString::new("twice");
s.explicit_zeroize();
s.explicit_zeroize();
assert!(s.is_empty());
}
#[test]
fn utf8_multibyte_preserved() {
let s = SecretString::new("пароль🔒");
assert_eq!(s.as_str(), "пароль🔒");
assert_eq!(s.as_bytes(), "пароль🔒".as_bytes());
}
}