Skip to main content

okbot_core/
secret.rs

1//! Secrets and redaction.
2
3use std::borrow::Cow;
4use std::fmt;
5
6use serde::{Deserialize, Deserializer};
7
8/// A secret value that never shows up in `Debug` output or logs.
9///
10/// It deliberately implements neither `Display` nor `Serialize`: reading the value takes an explicit
11/// [`Secret::expose`] call, which is easy to find in review.
12#[derive(Clone, PartialEq, Eq)]
13pub struct Secret<T>(T);
14
15impl<T> Secret<T> {
16    /// Wraps a value.
17    pub fn new(value: T) -> Self {
18        Self(value)
19    }
20
21    /// The secret value. Do not log or format it.
22    pub fn expose(&self) -> &T {
23        &self.0
24    }
25}
26
27impl<T> fmt::Debug for Secret<T> {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.write_str("Secret([REDACTED])")
30    }
31}
32
33impl<T> From<T> for Secret<T> {
34    fn from(value: T) -> Self {
35        Self(value)
36    }
37}
38
39impl<'de, T: Deserialize<'de>> Deserialize<'de> for Secret<T> {
40    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
41        T::deserialize(deserializer).map(Secret)
42    }
43}
44
45/// Replaces known secret values in text (log lines, error messages) with `[REDACTED]`.
46///
47/// Values shorter than 6 characters are ignored, because replacing them would mangle ordinary text.
48#[derive(Debug, Clone, Default)]
49pub struct Redactor {
50    secrets: Vec<String>,
51}
52
53impl Redactor {
54    /// Shortest value that is redacted.
55    pub const MIN_LEN: usize = 6;
56
57    /// An empty redactor.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Adds a secret to redact.
63    pub fn add(&mut self, secret: &Secret<String>) {
64        let value = secret.expose();
65        if value.len() >= Self::MIN_LEN && !self.secrets.contains(value) {
66            self.secrets.push(value.clone());
67            // Longer secrets first, so a secret containing another is replaced whole.
68            self.secrets.sort_by_key(|s| std::cmp::Reverse(s.len()));
69        }
70    }
71
72    /// The text with every known secret replaced.
73    pub fn redact<'a>(&self, text: &'a str) -> Cow<'a, str> {
74        let mut out = Cow::Borrowed(text);
75        for secret in &self.secrets {
76            if out.contains(secret.as_str()) {
77                out = Cow::Owned(out.replace(secret.as_str(), "[REDACTED]"));
78            }
79        }
80        out
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn debug_hides_value() {
90        let s = Secret::new("hunter22".to_string());
91        assert_eq!(format!("{s:?}"), "Secret([REDACTED])");
92        assert_eq!(s.expose(), "hunter22");
93    }
94
95    #[test]
96    fn redactor_replaces_known_secrets() {
97        let mut r = Redactor::new();
98        r.add(&Secret::new("123456:abc-xyz".to_string()));
99        r.add(&Secret::new("short".to_string()));
100        assert_eq!(
101            r.redact("GET https://bot-api/bot123456:abc-xyz/getMe short"),
102            "GET https://bot-api/bot[REDACTED]/getMe short"
103        );
104        assert!(matches!(r.redact("nothing here"), Cow::Borrowed(_)));
105    }
106}