Skip to main content

spate_core/config/
redact.rs

1//! `Debug` and `Display` adapters that keep credential values out of
2//! formatted output, for hand-written `Debug` impls on config types, and the
3//! same guarantee for config error messages.
4//!
5//! Every adapter redacts all values it prints, whatever their key or name.
6
7use serde_yaml::Value;
8use std::borrow::Cow;
9use std::fmt;
10
11/// Formats as `<redacted>` under both `Debug` and `Display`.
12#[derive(Clone, Copy, Default, PartialEq, Eq)]
13pub struct Redacted;
14
15impl fmt::Debug for Redacted {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        f.write_str("<redacted>")
18    }
19}
20
21impl fmt::Display for Redacted {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        f.write_str("<redacted>")
24    }
25}
26
27/// A map's `Debug` form, from its keys, with every value shown as
28/// `<redacted>`.
29pub fn map<I>(keys: I) -> impl fmt::Debug
30where
31    I: IntoIterator + Clone,
32    I::Item: fmt::Debug,
33{
34    Map(keys)
35}
36
37struct Map<I>(I);
38
39impl<I> fmt::Debug for Map<I>
40where
41    I: IntoIterator + Clone,
42    I::Item: fmt::Debug,
43{
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.debug_map()
46            .entries(self.0.clone().into_iter().map(|k| (k, Redacted)))
47            .finish()
48    }
49}
50
51/// `None`, or `Some(<redacted>)` whatever the value.
52pub fn option<T>(value: &Option<T>) -> impl fmt::Debug + '_ {
53    value.as_ref().map(|_| Redacted)
54}
55
56/// A URL with its userinfo and everything from the first `?` or `#` after
57/// the host replaced by `<redacted>`.
58///
59/// The scheme, host, port and path stay visible. Userinfo runs to the last
60/// `@` after the scheme, so an `@` later in the path redacts the host as well.
61/// When a `?` or `#` comes before that `@`, everything after the scheme is
62/// redacted. A string without `://` is read the same way from its start, so
63/// `user:pass@host:4222` renders as `<redacted>@host:4222`.
64pub fn url(url: &str) -> impl fmt::Debug + fmt::Display + '_ {
65    Url(url)
66}
67
68struct Url<'a>(&'a str);
69
70impl fmt::Display for Url<'_> {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        let rest = match self.0.split_once("://") {
73            Some((scheme, rest)) => {
74                write!(f, "{scheme}://")?;
75                rest
76            }
77            None => self.0,
78        };
79        let at = rest.rfind('@');
80        if let (Some(at), Some(tail)) = (at, rest.find(['?', '#']))
81            && tail < at
82        {
83            return f.write_str("<redacted>");
84        }
85        let rest = match rest.rsplit_once('@') {
86            Some((_, host)) => {
87                f.write_str("<redacted>@")?;
88                host
89            }
90            None => rest,
91        };
92        match rest.find(['?', '#']) {
93            Some(at) => write!(f, "{}{}<redacted>", &rest[..at], &rest[at..=at]),
94            None => f.write_str(rest),
95        }
96    }
97}
98
99impl fmt::Debug for Url<'_> {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        fmt::Debug::fmt(&self.to_string(), f)
102    }
103}
104
105/// A YAML tree with its mapping keys and shape kept and every scalar leaf
106/// shown as `<redacted>`.
107pub(crate) fn yaml(value: &Value) -> impl fmt::Debug + '_ {
108    Yaml(value)
109}
110
111struct Yaml<'a>(&'a Value);
112
113impl fmt::Debug for Yaml<'_> {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self.0 {
116            Value::Null => f.write_str("null"),
117            Value::Bool(_) | Value::Number(_) | Value::String(_) => Redacted.fmt(f),
118            Value::Sequence(items) => f.debug_list().entries(items.iter().map(Yaml)).finish(),
119            Value::Mapping(map) => f
120                .debug_map()
121                .entries(map.iter().map(|(k, v)| (YamlKey(k), Yaml(v))))
122                .finish(),
123            Value::Tagged(tagged) => write!(f, "{} {:?}", tagged.tag, Yaml(&tagged.value)),
124        }
125    }
126}
127
128/// A mapping key: a string key prints as written, any other key as a leaf.
129struct YamlKey<'a>(&'a Value);
130
131impl fmt::Debug for YamlKey<'_> {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self.0 {
134            Value::String(key) => key.fmt(f),
135            other => Yaml(other).fmt(f),
136        }
137    }
138}
139
140/// A serde error message with the value removed from its `invalid type`,
141/// `invalid value` or `unknown variant` clause.
142///
143/// The path, the expected type and any position stay. A message without one
144/// of those clauses is returned unchanged.
145pub(crate) fn error_message(message: &str) -> Cow<'_, str> {
146    const TYPE: &str = "invalid type: ";
147    const VALUE: &str = "invalid value: ";
148    const VARIANT: &str = "unknown variant `";
149
150    // The clause opens before the value, so the earliest marker is the real one.
151    let Some((at, marker)) = [TYPE, VALUE, VARIANT]
152        .into_iter()
153        .filter_map(|marker| message.find(marker).map(|at| (at, marker)))
154        .min()
155    else {
156        return Cow::Borrowed(message);
157    };
158    let start = at + marker.len();
159
160    // The expected half is built from type and variant names, so the last
161    // separator is the one that closes the value.
162    let separators: &[&str] = if marker == VARIANT {
163        &["`, expected ", "`, there are no variants"]
164    } else {
165        &[", expected "]
166    };
167    let Some(end) = separators
168        .iter()
169        .filter_map(|sep| message.rfind(sep))
170        .filter(|&end| end >= start)
171        .max()
172    else {
173        return Cow::Borrowed(message);
174    };
175
176    if marker == VARIANT {
177        return Cow::Owned(format!(
178            "{}unknown variant{}",
179            &message[..at],
180            &message[end + 1..]
181        ));
182    }
183    let unexpected = &message[start..end];
184    let kind = unexpected_kind(unexpected);
185    if kind == unexpected {
186        return Cow::Borrowed(message);
187    }
188    Cow::Owned(format!("{}{kind}{}", &message[..start], &message[end..]))
189}
190
191/// The kind named by a rendered `serde::de::Unexpected`, without its value.
192fn unexpected_kind(unexpected: &str) -> &str {
193    const VALUE_FREE: [&str; 11] = [
194        "unit value",
195        "Option value",
196        "byte array",
197        "newtype struct",
198        "sequence",
199        "map",
200        "enum",
201        "unit variant",
202        "newtype variant",
203        "tuple variant",
204        "struct variant",
205    ];
206    if VALUE_FREE.contains(&unexpected) {
207        return unexpected;
208    }
209    [
210        "boolean",
211        "integer",
212        "floating point",
213        "character",
214        "string",
215    ]
216    .into_iter()
217    .find(|kind| {
218        unexpected
219            .strip_prefix(kind)
220            .is_some_and(|rest| rest.starts_with(' '))
221    })
222    .unwrap_or("value")
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn shown(value: impl fmt::Debug) -> String {
230        format!("{value:?}")
231    }
232
233    #[test]
234    fn url_redacts_userinfo_query_and_fragment() {
235        let cases = [
236            ("https://sr:8081/x", "https://sr:8081/x"),
237            (
238                "https://svc:hunter2@sr:8081/x",
239                "https://<redacted>@sr:8081/x",
240            ),
241            ("https://hunter2@sr", "https://<redacted>@sr"),
242            (
243                "http://ch:8123/?password=hunter2",
244                "http://ch:8123/?<redacted>",
245            ),
246            ("http://ch:8123#hunter2", "http://ch:8123#<redacted>"),
247            // A raw `/` inside the password still ends at the last `@`.
248            ("https://u:hun/ter2@sr/x", "https://<redacted>@sr/x"),
249            // A `?` or `#` before the last `@` cannot be placed, so all of it goes.
250            ("https://u:hun?ter2@sr/x", "https://<redacted>"),
251            (
252                "http://ch:8123/?user=svc@corp&password=hunter2",
253                "http://<redacted>",
254            ),
255            ("http://ch:8123/#a@hunter2", "http://<redacted>"),
256            (
257                "http://ch:8123/p@x?password=hunter2",
258                "http://<redacted>@x?<redacted>",
259            ),
260            ("nats://a:hunter2@n1:4222", "nats://<redacted>@n1:4222"),
261        ];
262        for (raw, want) in cases {
263            assert_eq!(url(raw).to_string(), want, "{raw}");
264            assert_eq!(shown(url(raw)), format!("{want:?}"), "{raw}");
265        }
266    }
267
268    #[test]
269    fn url_without_a_scheme_redacts_the_same_parts() {
270        let cases = [
271            ("n1:4222", "n1:4222"),
272            ("svc:hunter2@n1:4222", "<redacted>@n1:4222"),
273            ("n1:4222?token=hunter2", "n1:4222?<redacted>"),
274        ];
275        for (raw, want) in cases {
276            assert_eq!(url(raw).to_string(), want, "{raw}");
277        }
278    }
279
280    #[test]
281    fn map_keeps_keys() {
282        let pairs =
283            std::collections::BTreeMap::from([("sasl.password", "hunter2"), ("acks", "all")]);
284        assert_eq!(
285            shown(map(pairs.keys())),
286            r#"{"acks": <redacted>, "sasl.password": <redacted>}"#
287        );
288    }
289
290    #[test]
291    fn option_shows_presence() {
292        assert_eq!(shown(option(&Some("hunter2"))), "Some(<redacted>)");
293        assert_eq!(shown(option::<String>(&None)), "None");
294    }
295
296    #[test]
297    fn yaml_keeps_keys_and_shape() {
298        let value: Value = serde_yaml::from_str(
299            "{url: https://sr, password: hunter2, port: 8081, tls: ~, list: [a, true], 7: x}",
300        )
301        .unwrap();
302        assert_eq!(
303            shown(yaml(&value)),
304            "{\"url\": <redacted>, \"password\": <redacted>, \"port\": <redacted>, \
305             \"tls\": null, \"list\": [<redacted>, <redacted>], <redacted>: <redacted>}"
306        );
307        let tagged: Value = serde_yaml::from_str("!secret hunter2").unwrap();
308        assert_eq!(shown(yaml(&tagged)), "!secret <redacted>");
309    }
310
311    #[test]
312    fn error_message_drops_the_value_and_keeps_the_rest() {
313        let cases = [
314            (
315                "invalid type: integer `918273645`, expected a string",
316                "invalid type: integer, expected a string",
317            ),
318            (
319                "invalid type: boolean `true`, expected a string",
320                "invalid type: boolean, expected a string",
321            ),
322            (
323                "invalid type: floating point `1500.0`, expected u8",
324                "invalid type: floating point, expected u8",
325            ),
326            (
327                "invalid type: character `h`, expected u8",
328                "invalid type: character, expected u8",
329            ),
330            (
331                "invalid value: string \"hunter2\", expected a duration",
332                "invalid value: string, expected a duration",
333            ),
334            (
335                "invalid value: integer `-5` as i128, expected u16",
336                "invalid value: integer, expected u16",
337            ),
338            (
339                "invalid type: i128, expected a string",
340                "invalid type: value, expected a string",
341            ),
342            (
343                "invalid value: string \"a, expected b\", expected u16",
344                "invalid value: string, expected u16",
345            ),
346            (
347                "unknown variant `hunter2`, expected `plain` or `scram`",
348                "unknown variant, expected `plain` or `scram`",
349            ),
350            (
351                "unknown variant `a`, expected b`, expected `plain`",
352                "unknown variant, expected `plain`",
353            ),
354            (
355                "unknown variant `hunter2`, there are no variants",
356                "unknown variant, there are no variants",
357            ),
358            (
359                "a.port: invalid type: string \"invalid value: x\", expected u16 at line 1 column 7",
360                "a.port: invalid type: string, expected u16 at line 1 column 7",
361            ),
362        ];
363        for (raw, want) in cases {
364            assert_eq!(error_message(raw), want, "{raw}");
365        }
366    }
367
368    #[test]
369    fn error_message_leaves_value_free_messages_unchanged() {
370        for raw in [
371            "invalid type: map, expected a string",
372            "invalid type: unit value, expected a string",
373            "invalid length 2, expected a tuple of size 3",
374            "missing field `topic`",
375            "unknown field `bogus`, expected `brokers` or `topic`",
376            "mapping values are not allowed in this context at line 2 column 6",
377        ] {
378            assert!(matches!(error_message(raw), Cow::Borrowed(_)), "{raw}");
379        }
380    }
381}