use std::fmt::Debug;
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};
pub const REDACTED: &str = "<redacted>";
#[derive(Clone, Default, Eq, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
#[serde(transparent)]
pub struct SecretString(String);
impl SecretString {
#[must_use]
pub fn expose_secret(&self) -> &str {
self.0.as_str()
}
#[must_use]
pub fn into_inner(mut self) -> String {
std::mem::take(&mut self.0)
}
}
impl From<String> for SecretString {
fn from(value: String) -> Self {
Self(value)
}
}
impl Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(REDACTED)
}
}
impl From<&str> for SecretString {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
#[must_use]
pub const fn redact_option<T: ?Sized>(value: Option<&T>) -> Option<&'static str> {
match value {
Some(_) => Some(REDACTED),
None => None,
}
}
pub fn zeroize_json_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(value) => value.zeroize(),
serde_json::Value::Array(values) => values.iter_mut().for_each(zeroize_json_value),
serde_json::Value::Object(values) => values.values_mut().for_each(zeroize_json_value),
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
}
}
#[must_use]
pub fn mask_api_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
let len = chars.len();
if len <= 8 {
return "*".repeat(len);
}
let first: String = chars[..4].iter().collect();
let last: String = chars[len - 4..].iter().collect();
format!("{first}...{last}")
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use zeroize::ZeroizeOnDrop;
use super::*;
fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
#[rstest]
fn test_secret_string_redacts_debug() {
let value = SecretString::from("session-secret");
assert_eq!(value.expose_secret(), "session-secret");
assert_eq!(format!("{value:?}"), REDACTED);
assert_zeroize_on_drop::<SecretString>();
}
#[rstest]
fn test_secret_string_serde_is_transparent() {
let value = SecretString::from("session-secret");
let serialized = serde_json::to_string(&value).unwrap();
let deserialized: SecretString = serde_json::from_str(&serialized).unwrap();
assert_eq!(serialized, r#""session-secret""#);
assert_eq!(deserialized.expose_secret(), "session-secret");
}
#[rstest]
fn test_zeroize_json_value_clears_nested_strings() {
let mut value = serde_json::json!({
"secret": "top-level",
"nested": ["array-value", {"secret": "nested-value"}],
"number": 42,
});
zeroize_json_value(&mut value);
assert_eq!(value["secret"], "");
assert_eq!(value["nested"][0], "");
assert_eq!(value["nested"][1]["secret"], "");
assert_eq!(value["number"], 42);
}
#[rstest]
#[case("", "")]
#[case("a", "*")]
#[case("abc", "***")]
#[case("abcdefgh", "********")]
#[case("abcdefghi", "abcd...fghi")]
#[case("abcdefghijklmnop", "abcd...mnop")]
#[case("VeryLongAPIKey123456789", "Very...6789")]
fn test_mask_api_key(#[case] input: &str, #[case] expected: &str) {
assert_eq!(mask_api_key(input), expected);
}
#[rstest]
fn test_redact_option_present() {
assert_eq!(redact_option(Some("secret")), Some(REDACTED));
}
#[rstest]
fn test_redact_option_absent() {
assert_eq!(redact_option(None::<&str>), None);
}
}