Archived
180 lines
5.2 KiB
Rust
180 lines
5.2 KiB
Rust
/// Secret<T> — a typed value that never leaks via Display/Debug.
|
|
///
|
|
/// The wrapper ensures that accidental logging or serialization of a secret
|
|
/// never reveals the actual value. You must explicitly call `.expose()` to
|
|
/// read it, which creates a visible opt-in point in the code.
|
|
|
|
use serde::{Deserialize, Serialize, Serializer};
|
|
|
|
/// A secret value. Never prints the inner value via Display or Debug.
|
|
///
|
|
/// Always displays as `[REDACTED]`. To access the inner value:
|
|
/// ```
|
|
/// use el_secrets::Secret;
|
|
/// let key = Secret::new("my-secret-key".to_string());
|
|
/// let actual: &str = key.expose(); // explicit opt-in
|
|
/// ```
|
|
#[derive(Clone)]
|
|
pub struct Secret<T: Clone>(T);
|
|
|
|
impl<T: Clone> Secret<T> {
|
|
/// Wrap a value in a Secret.
|
|
pub fn new(value: T) -> Self {
|
|
Self(value)
|
|
}
|
|
|
|
/// Access the inner value.
|
|
///
|
|
/// This is the ONLY way to get the actual secret value out.
|
|
/// Name it `expose` so it's searchable in code review.
|
|
pub fn expose(&self) -> &T {
|
|
&self.0
|
|
}
|
|
|
|
/// Consume the Secret and return the inner value.
|
|
pub fn into_inner(self) -> T {
|
|
self.0
|
|
}
|
|
|
|
/// Map the inner value to a new type, wrapping in a new Secret.
|
|
pub fn map<U: Clone, F: FnOnce(T) -> U>(self, f: F) -> Secret<U> {
|
|
Secret(f(self.0))
|
|
}
|
|
}
|
|
|
|
/// Debug never reveals the secret value.
|
|
impl<T: Clone> std::fmt::Debug for Secret<T> {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "Secret([REDACTED])")
|
|
}
|
|
}
|
|
|
|
/// Display never reveals the secret value.
|
|
impl<T: Clone> std::fmt::Display for Secret<T> {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "[REDACTED]")
|
|
}
|
|
}
|
|
|
|
/// Serialize writes [REDACTED], never the actual value.
|
|
/// This prevents secrets from appearing in JSON logs, API responses, etc.
|
|
impl<T: Clone> Serialize for Secret<T> {
|
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
serializer.serialize_str("[REDACTED]")
|
|
}
|
|
}
|
|
|
|
/// Deserialize from a string — used when loading secrets from files/env.
|
|
/// Only implemented for Secret<String> since we always load as strings.
|
|
impl<'de> Deserialize<'de> for Secret<String> {
|
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
let s = String::deserialize(deserializer)?;
|
|
Ok(Secret::new(s))
|
|
}
|
|
}
|
|
|
|
/// A guard that prevents a value from being accidentally exposed.
|
|
///
|
|
/// Use this on struct fields that should never be serialized or logged.
|
|
#[derive(Clone)]
|
|
pub struct SecretGuard<T: Clone> {
|
|
inner: Secret<T>,
|
|
/// A hint shown in Debug output (not the value itself).
|
|
label: &'static str,
|
|
}
|
|
|
|
impl<T: Clone> SecretGuard<T> {
|
|
pub fn new(value: T, label: &'static str) -> Self {
|
|
Self {
|
|
inner: Secret::new(value),
|
|
label,
|
|
}
|
|
}
|
|
|
|
pub fn expose(&self) -> &T {
|
|
self.inner.expose()
|
|
}
|
|
}
|
|
|
|
impl<T: Clone> std::fmt::Debug for SecretGuard<T> {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "SecretGuard({}: [REDACTED])", self.label)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn secret_expose() {
|
|
let s = Secret::new("my-api-key".to_string());
|
|
assert_eq!(s.expose(), "my-api-key");
|
|
}
|
|
|
|
#[test]
|
|
fn secret_debug_redacted() {
|
|
let s = Secret::new("super-secret".to_string());
|
|
let debug = format!("{:?}", s);
|
|
assert_eq!(debug, "Secret([REDACTED])");
|
|
assert!(!debug.contains("super-secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn secret_display_redacted() {
|
|
let s = Secret::new(12345u32);
|
|
let display = format!("{}", s);
|
|
assert_eq!(display, "[REDACTED]");
|
|
assert!(!display.contains("12345"));
|
|
}
|
|
|
|
#[test]
|
|
fn secret_serialize_redacted() {
|
|
let s = Secret::new("should-not-appear".to_string());
|
|
let json = serde_json::to_string(&s).unwrap();
|
|
assert_eq!(json, r#""[REDACTED]""#);
|
|
assert!(!json.contains("should-not-appear"));
|
|
}
|
|
|
|
#[test]
|
|
fn secret_deserialize() {
|
|
let s: Secret<String> = serde_json::from_str(r#""my-secret""#).unwrap();
|
|
assert_eq!(s.expose(), "my-secret");
|
|
}
|
|
|
|
#[test]
|
|
fn secret_map() {
|
|
let s = Secret::new("42".to_string());
|
|
let n: Secret<u32> = s.map(|v| v.parse().unwrap());
|
|
assert_eq!(*n.expose(), 42u32);
|
|
}
|
|
|
|
#[test]
|
|
fn secret_into_inner() {
|
|
let s = Secret::new("value".to_string());
|
|
assert_eq!(s.into_inner(), "value");
|
|
}
|
|
|
|
#[test]
|
|
fn secret_guard_debug() {
|
|
let g = SecretGuard::new("token".to_string(), "jwt_token");
|
|
let debug = format!("{:?}", g);
|
|
assert!(debug.contains("jwt_token"));
|
|
assert!(!debug.contains("token\""));
|
|
}
|
|
|
|
#[test]
|
|
fn secret_guard_expose() {
|
|
let g = SecretGuard::new("secret-value".to_string(), "api_key");
|
|
assert_eq!(g.expose(), "secret-value");
|
|
}
|
|
|
|
#[test]
|
|
fn secret_clone_does_not_expose() {
|
|
let s1 = Secret::new("clone-me".to_string());
|
|
let s2 = s1.clone();
|
|
assert_eq!(s2.expose(), "clone-me");
|
|
assert!(!format!("{:?}", s2).contains("clone-me"));
|
|
}
|
|
}
|