1use serde_yaml::Value;
8use std::borrow::Cow;
9use std::fmt;
10
11#[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
27pub 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
51pub fn option<T>(value: &Option<T>) -> impl fmt::Debug + '_ {
53 value.as_ref().map(|_| Redacted)
54}
55
56pub 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
105pub(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
128struct 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
140pub(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 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 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
191fn 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 ("https://u:hun/ter2@sr/x", "https://<redacted>@sr/x"),
249 ("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}