cellstate_core/
secret.rs

1// Copyright 2024-2026 CELLSTATE Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Secret wrappers for sensitive string material.
5//!
6//! Use `SecretString` instead of raw `String` for API keys, tokens, and
7//! client secrets so accidental debug/display output cannot leak values.
8
9use serde::{Deserialize, Serialize};
10
11/// Sensitive string wrapper with redacted debug/display output.
12#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
13#[serde(transparent)]
14pub struct SecretString(String);
15
16impl SecretString {
17    /// Wrap a raw secret value.
18    pub fn new(value: impl Into<String>) -> Self {
19        Self(value.into())
20    }
21
22    /// Access the raw secret value.
23    #[must_use]
24    pub fn expose_secret(&self) -> &str {
25        &self.0
26    }
27
28    /// Consume this wrapper and return the raw secret.
29    pub fn into_inner(self) -> String {
30        self.0
31    }
32
33    /// Secret length in bytes.
34    pub fn len(&self) -> usize {
35        self.0.len()
36    }
37
38    /// Whether the secret is empty.
39    pub fn is_empty(&self) -> bool {
40        self.0.is_empty()
41    }
42}
43
44impl From<String> for SecretString {
45    fn from(value: String) -> Self {
46        Self(value)
47    }
48}
49
50impl From<&str> for SecretString {
51    fn from(value: &str) -> Self {
52        Self(value.to_string())
53    }
54}
55
56impl std::fmt::Debug for SecretString {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(f, "[REDACTED, {} chars]", self.0.len())
59    }
60}
61
62impl std::fmt::Display for SecretString {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "[REDACTED]")
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::SecretString;
71
72    #[test]
73    fn debug_and_display_do_not_leak_value() {
74        let secret = SecretString::new("sk_test_123456789");
75        let debug = format!("{secret:?}");
76        let display = format!("{secret}");
77        assert!(debug.contains("[REDACTED"));
78        assert_eq!(display, "[REDACTED]");
79        assert!(!debug.contains("sk_test_123456789"));
80        assert!(!display.contains("sk_test_123456789"));
81    }
82
83    #[test]
84    fn serde_round_trip_preserves_value() {
85        let secret = SecretString::new("super-secret-value");
86        let json = serde_json::to_string(&secret).expect("serialize secret");
87        let parsed: SecretString = serde_json::from_str(&json).expect("deserialize secret");
88        assert_eq!(parsed.expose_secret(), "super-secret-value");
89    }
90}