use std::fmt;
use std::str::FromStr;
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Clone, PartialEq, Eq, Hash, Default)]
pub struct Redacted<T>(T);
impl<T> Redacted<T> {
pub const fn new(v: T) -> Self {
Redacted(v)
}
pub fn expose(&self) -> &T {
&self.0
}
pub fn into_inner(self) -> T {
self.0
}
}
impl Redacted<SecretBytes> {
pub fn new_zeroize(v: Vec<u8>) -> Self {
Redacted(SecretBytes(v))
}
pub fn expose_bytes(&self) -> &[u8] {
&self.0 .0
}
}
#[derive(Clone, Default, PartialEq, Eq, Hash)]
pub struct SecretString(String);
impl SecretString {
pub fn new(value: String) -> Self {
Self(value)
}
pub fn expose_secret(&self) -> &str {
&self.0
}
pub fn expose(&self) -> &String {
&self.0
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl AsRef<str> for SecretString {
fn as_ref(&self) -> &str {
self.expose_secret()
}
}
impl std::ops::Deref for SecretString {
type Target = str;
fn deref(&self) -> &Self::Target {
self.expose_secret()
}
}
impl Drop for SecretString {
fn drop(&mut self) {
self.0.zeroize();
}
}
impl ZeroizeOnDrop for SecretString {}
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("***")
}
}
impl fmt::Display for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("***")
}
}
impl From<String> for SecretString {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for SecretString {
fn from(value: &str) -> Self {
Self::new(value.to_owned())
}
}
impl FromStr for SecretString {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if let Some(key) = value.strip_prefix("env:") {
if key.is_empty()
|| !key
.bytes()
.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric())
|| key.as_bytes()[0].is_ascii_digit()
{
return Err("invalid environment-variable reference in credential argument".into());
}
return std::env::var(key)
.map(Self::new)
.map_err(|_| format!("credential environment variable {key} is not set"));
}
if let Some(path) = value.strip_prefix("@file:") {
if path.is_empty() {
return Err("credential file reference has an empty path".into());
}
return std::fs::read_to_string(path)
.map(|raw| Self::new(raw.trim_end_matches(['\n', '\r']).to_owned()))
.map_err(|error| format!("read credential file {path}: {error}"));
}
Ok(Self::from(value))
}
}
impl serde::Serialize for SecretString {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for SecretString {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer).map(Self::new)
}
}
#[derive(Clone, Default, PartialEq, Eq, Hash)]
pub struct SecretBytes(Vec<u8>);
impl Zeroize for SecretBytes {
fn zeroize(&mut self) {
self.0.zeroize();
}
}
impl Drop for SecretBytes {
fn drop(&mut self) {
self.zeroize();
}
}
impl ZeroizeOnDrop for SecretBytes {}
impl SecretBytes {
pub fn from_vec(v: Vec<u8>) -> Self {
SecretBytes(v)
}
pub fn as_slice(&self) -> &[u8] {
&self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl std::fmt::Debug for SecretBytes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("***")
}
}
impl serde::Serialize for SecretBytes {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'de> serde::Deserialize<'de> for SecretBytes {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Vec::<u8>::deserialize(d).map(SecretBytes)
}
}
impl<T> fmt::Debug for Redacted<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("***")
}
}
impl<T> fmt::Display for Redacted<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("***")
}
}
impl<T> From<T> for Redacted<T> {
fn from(v: T) -> Self {
Redacted(v)
}
}
impl<T: serde::Serialize> serde::Serialize for Redacted<T> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Redacted<T> {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
T::deserialize(d).map(Redacted)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_bytes_zeroize_on_drop_reference_check() {
use zeroize::{Zeroize, ZeroizeOnDrop};
fn assert_zeroize<T: Zeroize + ZeroizeOnDrop>() {}
assert_zeroize::<SecretBytes>();
let mut s = SecretBytes::from_vec(vec![0xAB, 0xCD]);
assert_eq!(s.as_slice(), &[0xAB, 0xCD]);
s.zeroize();
assert!(s.as_slice().is_empty(), "vec cleared after zeroize");
}
#[test]
fn secret_bytes_hides_via_redacted() {
let key = Redacted::<SecretBytes>::new_zeroize(vec![0xAA; 32]);
assert_eq!(format!("{key:?}"), "***");
assert_eq!(key.expose_bytes().len(), 32);
}
#[test]
fn secret_string_redacts_and_implements_zeroize_on_drop() {
fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
assert_zeroize_on_drop::<SecretString>();
let secret = SecretString::from("correct horse battery staple");
assert_eq!(secret.expose_secret(), "correct horse battery staple");
assert_eq!(format!("{secret:?}"), "***");
assert_eq!(format!("{secret}"), "***");
}
#[test]
fn debug_and_display_print_stars_not_the_value() {
let pw = Redacted::new(String::from("hunter2"));
assert_eq!(format!("{pw}"), "***");
assert_eq!(format!("{pw:?}"), "***");
#[derive(Debug)]
#[allow(dead_code)]
struct Cfg {
user: String,
password: Redacted<String>,
}
let c = Cfg {
user: "alice".into(),
password: Redacted::new("supersecret".into()),
};
let dbg = format!("{c:?}");
assert!(dbg.contains("alice"));
assert!(dbg.contains("***"));
assert!(
!dbg.contains("supersecret"),
"Debug of a struct containing Redacted must not leak the secret"
);
}
#[test]
fn expose_returns_the_real_value() {
let key = Redacted::new(vec![0xAAu8; 32]);
assert_eq!(key.expose().len(), 32);
assert_eq!(key.expose()[0], 0xAA);
let raw = key.into_inner();
assert_eq!(raw.len(), 32);
}
#[test]
fn from_impl_and_equality() {
let a: Redacted<u32> = 42.into();
let b = Redacted::new(42u32);
assert_eq!(a, b);
}
#[test]
fn hidden_in_option_and_result_debug() {
let opt: Option<Redacted<&str>> = Some(Redacted::new("secret-token"));
let dbg = format!("{opt:?}");
assert!(dbg.contains("***"));
assert!(!dbg.contains("secret-token"));
}
#[test]
fn serde_is_transparent() {
let raw: String = "hunter2".into();
let wrapped = Redacted::new(raw.clone());
assert_eq!(
serde_json::to_string(&wrapped).unwrap(),
serde_json::to_string(&raw).unwrap(),
);
let s = "\"round-trip\"";
let back: Redacted<String> = serde_json::from_str(s).unwrap();
assert_eq!(back.expose(), "round-trip");
}
}