Skip to main content

smix_error/
redact.rs

1//! A failure with a secret taken out of everything it says.
2
3use crate::ExpectationFailure;
4
5/// What a secret is shown as.
6pub const MASK: &str = "***";
7
8/// Pieces of a secret shorter than this are left alone where they stand
9/// by themselves: two characters of a password say nothing, and masking
10/// every "ab" in a message would make it unreadable.
11const SHORTEST_PIECE: usize = 3;
12
13impl ExpectationFailure {
14    /// This failure with `secret` masked wherever it says anything: the
15    /// message, the hint, suggestions, device log lines, and the text and
16    /// names of the elements it lists. A runner that quotes what a field
17    /// held ("holds \"un1-first\"") quotes part of what was typed, so a
18    /// quoted piece of the secret is masked too.
19    #[must_use]
20    pub fn without_secret(mut self, secret: &str) -> Self {
21        if secret.is_empty() {
22            return self;
23        }
24        self.message = mask(&self.message, secret);
25        self.hint = self.hint.map(|h| mask(&h, secret));
26        for s in &mut self.suggestions {
27            *s = mask(s, secret);
28        }
29        for line in &mut self.device_log {
30            *line = mask(line, secret);
31        }
32        for e in &mut self.visible_elements {
33            e.text = e.text.as_deref().map(|t| mask_field(t, secret));
34            e.name = e.name.as_deref().map(|n| mask_field(n, secret));
35        }
36        self
37    }
38}
39
40/// `text` with every whole `secret`, and every quoted piece of it, masked.
41pub fn mask(text: &str, secret: &str) -> String {
42    let whole = text.replace(secret, MASK);
43    let mut out = String::with_capacity(whole.len());
44    let mut rest = whole.as_str();
45    while let Some(open) = rest.find('"') {
46        out.push_str(&rest[..=open]);
47        rest = &rest[open + 1..];
48        let Some(close) = rest.find('"') else {
49            break;
50        };
51        let quoted = &rest[..close];
52        if is_piece(quoted, secret) {
53            out.push_str(MASK);
54        } else {
55            out.push_str(quoted);
56        }
57        out.push('"');
58        rest = &rest[close + 1..];
59    }
60    out.push_str(rest);
61    out
62}
63
64/// An element's own text: masked whole when it is the secret or a piece
65/// of it, otherwise with the secret masked inside it.
66fn mask_field(text: &str, secret: &str) -> String {
67    if is_piece(text, secret) {
68        MASK.to_string()
69    } else {
70        text.replace(secret, MASK)
71    }
72}
73
74fn is_piece(s: &str, secret: &str) -> bool {
75    s.chars().count() >= SHORTEST_PIECE.min(secret.chars().count())
76        && !s.is_empty()
77        && secret.contains(s)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::{FailureCode, FailureInit};
84
85    #[test]
86    fn the_secret_and_quoted_pieces_of_it_are_masked() {
87        let said = r#"input-text: typed "run1-first" into f in 1 chunk(s); the field held "" and holds "un1-first"."#;
88        assert_eq!(
89            mask(said, "run1-first"),
90            r#"input-text: typed "***" into f in 1 chunk(s); the field held "" and holds "***"."#
91        );
92        // a quoted word that is not a piece of the secret stays
93        assert_eq!(
94            mask(r#"id "input-password""#, "hunter2"),
95            r#"id "input-password""#
96        );
97        // a piece shorter than three characters stays where it stands alone
98        assert_eq!(mask(r#"held "hu""#, "hunter2"), r#"held "hu""#);
99        // a short secret is masked whole
100        assert_eq!(mask("typed ab here", "ab"), "typed *** here");
101    }
102
103    #[test]
104    fn every_part_of_a_failure_loses_the_secret() {
105        let mut e = ExpectationFailure::new(FailureInit {
106            code: Some(FailureCode::DriverError),
107            message: "fill sent hunter2".into(),
108            hint: Some("the field holds \"unter2\"".into()),
109            ..Default::default()
110        });
111        e.suggestions = vec!["type hunter2 again".into()];
112        e.device_log = vec!["log: hunter2".into()];
113        let e = e.without_secret("hunter2");
114        let all = format!(
115            "{} {:?} {:?} {:?}",
116            e.message, e.hint, e.suggestions, e.device_log
117        );
118        assert!(!all.contains("hunter2") && !all.contains("unter2"), "{all}");
119        assert_eq!(e.message, "fill sent ***");
120    }
121}