Skip to main content

apollo_redaction/
lib.rs

1//! Redaction utility for sensitive data in error messages, debug logs, etc.
2//!
3//! # Usage
4//! Wrap values in [`Redacted`] to prevent them from being printed directly.
5//!
6//! ```rust
7//! use apollo_redaction::Redacted;
8//! use apollo_redaction::strategy::SimpleRedactor;
9//!
10//! let secret = Redacted::<_, SimpleRedactor>::new("hunter2");
11//! assert_eq!(secret.to_string(), "[REDACTED]");
12//! assert_eq!(secret.unredact().to_string(), "hunter2");
13//! ```
14//!
15//! ## Deserialization
16//!
17//! Often, you won't create [`Redacted`] instances manually. Instead, you might deserialize them
18//! from a configuration structure:
19//!
20//! ```rust
21//! use apollo_redaction::Redacted;
22//!
23//! #[derive(Debug, serde::Deserialize)]
24//! struct RedisConfig {
25//!     username: Redacted<String>,
26//!     password: Redacted<String>,
27//! }
28//!
29//! let redis_config: RedisConfig = serde_json::from_str(r#"
30//!     {
31//!         "username": "admin",
32//!         "password": "topsecret"
33//!     }
34//! "#).unwrap();
35//!
36//! // If you did eg. `dbg!(redis_config)`...
37//! assert_eq!(
38//!     format!("{redis_config:?}"),
39//!     r#"RedisConfig { username: [REDACTED], password: [REDACTED] }"#,
40//! );
41//! ```
42//!
43//! ## Serialization
44//!
45//! Values wrapped in a [`Redacted`] cannot be serialized with [serde]. This is to prevent
46//! accidentally serializing either the redacted or the unredacted form when you don't expect it.
47//! You can use the `#[serde(serialize_with)]` attribute to serialize a [`Redacted`] field.
48//!
49//! apollo-redaction comes with serialization helpers:
50//! - [`ser::unredacted`] - serializes the unredacted representation
51//! - [`ser::display_redacted`] - serializes the redacted [`Display`] representation
52//!
53//! # Strategies
54//!
55//! A strategy must be provided as a type parameter. For example:
56//!
57//! ```rust
58//! use apollo_redaction::Redacted;
59//! use apollo_redaction::strategy::UrlHostRedactor;
60//! use apollo_redaction::strategy::EmailRedactor;
61//!
62//! // when deserializing a field...
63//! struct TelemetryConfig {
64//!     url: Redacted<String, UrlHostRedactor>,
65//! }
66//! // when constructing a value manually...
67//! let email = Redacted::<_, EmailRedactor>::new("myemail@address.com");
68//! ```
69//!
70//! | Strategy | Sample input | Sample output |
71//! |----------|--------------|---------------|
72//! | [`SimpleRedactor`] | any | `[REDACTED]` |
73//! | [`FullRedactor`] | `secret` | `******` |
74//! | [`PasswordRedactor`] | any | `********` |
75//! | [`IpRedactor`] | `192.168.1.100` | `192.168.*.*` |
76//! | [`UrlHostRedactor`] | `https://username:passwd@mytelemetryhost.com/v1/traces` | `****/v1/traces` |
77//! | [`EmailRedactor`] | `janedoe@gmail.com` | `j***e@gmail.com` |
78//! | [`First4Redactor`] | `sk_live_abc123` | `sk_l****` |
79//! | [`Last4Redactor`] | `4242424242424242` | `************4242` |
80//! | [`CardRedactor`] | `4242-4242-4242-4242` | `****-****-****-4242` |
81//! | [`FixedRedactor<N>`] | `password123` (N=4) | `pass*******` |
82//!
83//! [`IpRedactor`]: crate::strategy::IpRedactor
84//! [`UrlHostRedactor`]: crate::strategy::UrlHostRedactor
85//! [`EmailRedactor`]: crate::strategy::EmailRedactor
86//! [`FullRedactor`]: crate::strategy::FullRedactor
87//! [`PasswordRedactor`]: crate::strategy::PasswordRedactor
88//! [`First4Redactor`]: crate::strategy::First4Redactor
89//! [`Last4Redactor`]: crate::strategy::Last4Redactor
90//! [`CardRedactor`]: crate::strategy::CardRedactor
91//! [`FixedRedactor<N>`]: crate::strategy::FixedRedactor
92//!
93//! # Feature flags
94//! - `schemars` enables a transparent [`schemars::JsonSchema`] implementation for `Redacted<T>` when `T`
95//!   implements `schemars::JsonSchema`.
96
97use crate::strategy::SimpleRedactor;
98use std::fmt;
99use std::fmt::Debug;
100use std::fmt::Display;
101use std::str::FromStr;
102
103pub mod ser;
104pub mod strategy;
105
106/// A redaction strategy tells apollo-redaction how to redact values for `Display` and `Debug`.
107///
108/// The trait is generic over the value being redacted. If you know your redaction strategy is just
109/// going to be used for a single type, you can write:
110///
111/// ```rust,ignore
112/// use url::Url;
113/// use apollo_redaction::RedactionStrategy;
114///
115/// #[derive(Default, Clone)]
116/// struct UrlRedactor;
117/// impl RedactionStrategy<Url> for UrlRedactor {
118///     // ...
119/// }
120/// ```
121///
122/// ## Bounds and deserialization
123/// It's strongly recommended that all your redaction strategies derive [`Default`] and [`Clone`].
124/// Without [`Default`], a redacted value cannot be deserialized with [serde], making it
125/// significantly less useful.
126pub trait RedactionStrategy<T> {
127    /// How to redact the value for the [`Debug`] (`{:?}`) implementation. By default, this
128    /// delegates to [`RedactionStrategy::display`].
129    fn debug(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        self.display(value, f)
131    }
132
133    /// How to redact the value for the [`Display`] (`{}`) implementation.
134    fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result;
135}
136
137/// A wrapper type for redacting values, so the actual values are not accidentally printed out.
138///
139/// A redacted value comes with a redaction strategy. The strategy provides the [`Display`] and
140/// [`Debug`] implementations for the inner type.
141///
142/// To access the redacted value proper, you must use the [`Redacted::unredact`] method.
143#[derive(Default, Clone)]
144pub struct Redacted<T, R = SimpleRedactor> {
145    value: T,
146    strategy: R,
147}
148
149impl<T, R: Default> Redacted<T, R> {
150    /// Create a new redacted value with a [`Default`] redaction strategy.
151    ///
152    /// ```rust
153    /// use apollo_redaction::Redacted;
154    /// use apollo_redaction::strategy::SimpleRedactor;
155    ///
156    /// let redacted = Redacted::<_, SimpleRedactor>::new("password for all the company's secrets");
157    /// ```
158    pub fn new(value: T) -> Self {
159        Self::from(value)
160    }
161}
162
163impl<T, R> Redacted<T, R> {
164    /// Create a new redacted value with a redaction strategy.
165    pub fn with_strategy(value: T, strategy: R) -> Self {
166        Self { value, strategy }
167    }
168}
169
170impl<T, R: Default> From<T> for Redacted<T, R> {
171    /// Wrap a value in [`Redacted`] with a [`Default`] redaction strategy.
172    ///
173    /// ```rust
174    /// use apollo_redaction::Redacted;
175    /// use apollo_redaction::strategy::SimpleRedactor;
176    ///
177    /// let redacted = Redacted::<_, SimpleRedactor>::from("password for all the company's secrets");
178    /// ```
179    fn from(value: T) -> Self {
180        Self::with_strategy(value, Default::default())
181    }
182}
183
184impl<T: FromStr, R: Default> FromStr for Redacted<T, R> {
185    type Err = T::Err;
186
187    /// Parse a value from a string with a [`Default`] redaction strategy.
188    ///
189    /// ```rust
190    /// use apollo_redaction::Redacted;
191    /// use apollo_redaction::strategy::SimpleRedactor;
192    /// use url::Url;
193    ///
194    /// let redacted: Redacted<Url, SimpleRedactor> = "https://apollographql.com".parse().expect("it's valid");
195    /// ```
196    fn from_str(s: &str) -> Result<Self, Self::Err> {
197        T::from_str(s).map(Self::from)
198    }
199}
200
201impl<'de, T: serde::Deserialize<'de>, R: Default> serde::Deserialize<'de> for Redacted<T, R> {
202    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
203    where
204        D: serde::Deserializer<'de>,
205    {
206        T::deserialize(deserializer)
207            .map(Self::new)
208            // We don't want to leak the actual value in the error message, so we return a generic error.
209            .map_err(|_| {
210                let type_name = std::any::type_name::<T>();
211                let short_name = type_name.rsplit("::").next().unwrap_or(type_name);
212                serde::de::Error::custom(format!("failed to parse '{short_name}'"))
213            })
214    }
215}
216
217impl<T, R> Redacted<T, R> {
218    /// Access the underlying value.
219    pub fn unredact(&self) -> &T {
220        &self.value
221    }
222
223    /// Access the underlying value.
224    pub fn unredact_mut(&mut self) -> &mut T {
225        &mut self.value
226    }
227}
228
229impl<T, R: RedactionStrategy<T>> Display for Redacted<T, R> {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        self.strategy.display(&self.value, f)
232    }
233}
234
235impl<T, R: RedactionStrategy<T>> Debug for Redacted<T, R> {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        self.strategy.debug(&self.value, f)
238    }
239}
240
241#[cfg(feature = "schemars")]
242impl<T, R> schemars::JsonSchema for Redacted<T, R>
243where
244    T: schemars::JsonSchema,
245{
246    fn schema_id() -> std::borrow::Cow<'static, str> {
247        T::schema_id()
248    }
249
250    fn schema_name() -> std::borrow::Cow<'static, str> {
251        T::schema_name()
252    }
253
254    fn inline_schema() -> bool {
255        T::inline_schema()
256    }
257
258    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
259        T::json_schema(generator)
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn unredact() {
269        let redacted = Redacted::<_, SimpleRedactor>::new(1234);
270        assert_eq!(*redacted.unredact(), 1234);
271        let mut redacted = redacted;
272        assert_eq!(*redacted.unredact_mut(), 1234);
273
274        *redacted.unredact_mut() = 1235;
275        assert_eq!(*redacted.unredact(), 1235);
276    }
277
278    #[test]
279    fn deserialize_error_does_not_leak_value() {
280        // URLs often contain credentials (usernames/passwords). When deserializing fails,
281        // the url crate's error message would normally include the malformed URL, potentially
282        // leaking these credentials. Redacted<Url> prevents this.
283
284        // Without redaction this would leak an error "relative URL without a base: \"not-a-url-with-secret-api-key-12345\" at line 1 column 37"
285        let redacted_err =
286            serde_json::from_str::<Redacted<url::Url>>(r#""not-a-url-with-secret-api-key-12345""#)
287                .unwrap_err()
288                .to_string();
289        assert_eq!(redacted_err, "failed to parse 'Url'");
290        assert!(
291            !redacted_err.contains("secret-api-key-12345"),
292            "Redacted<Url> error must not contain the secret"
293        );
294    }
295}