Skip to main content

pitboard_core/
redact.rs

1//! Making a diagnosis safe to paste into a bug report.
2//!
3//! pitboard's bug template asks for `pitboard doctor --json` and tells people it prints
4//! "labels, codes, paths and times: no tokens, no email addresses, no account identifiers".
5//! It printed the signed-in email address and the organization uuid in the identity check,
6//! the login name in the slot check, a value derived from the refresh token in the
7//! credential check, and home and config paths carrying the username. The promise was
8//! false, and the contract snapshot could not catch it because it redacted the whole checks
9//! array.
10//!
11//! So `--json` is now what the template says it is, and the human-readable report is not
12//! touched: a person looking at their own machine should see their own email, and the thing
13//! they paste somewhere else should not carry it.
14//!
15//! Identifiers become salted digests rather than disappearing, so two mentions of one
16//! account still line up inside a report without naming it. The salt is this machine and
17//! this moment, so two reports from one machine do not correlate. That is the intent and it
18//! is worth saying out loud rather than leaving to be discovered.
19
20use serde_json::Value;
21
22/// Everything a report must not carry out of a machine, and what to put in its place.
23#[derive(Debug, Clone)]
24pub struct Sheet {
25    salt: String,
26    /// Longest first, so a substring never hides the string that contains it.
27    secrets: Vec<(String, &'static str)>,
28    home: String,
29}
30
31impl Sheet {
32    /// What to hide, and what to hide it under.
33    ///
34    /// `salt` makes the digests this report's own. Pass something that changes between
35    /// reports and does not identify the machine on its own.
36    pub fn new(salt: impl Into<String>, home: impl Into<String>) -> Sheet {
37        Sheet {
38            salt: salt.into(),
39            secrets: Vec::new(),
40            home: home.into(),
41        }
42    }
43
44    /// Hide every occurrence of `secret`, under a digest prefixed by `kind`.
45    pub fn hide(mut self, secret: impl Into<String>, kind: &'static str) -> Sheet {
46        let secret = secret.into();
47        // Too short to be worth hiding is also too short to hide safely: a two-character
48        // login name would rewrite half the report.
49        if secret.len() >= 4 {
50            self.secrets.push((secret, kind));
51            self.secrets
52                .sort_by_key(|(secret, _)| std::cmp::Reverse(secret.len()));
53        }
54        self
55    }
56
57    fn digest(&self, kind: &str, secret: &str) -> String {
58        use sha2::{Digest, Sha256};
59        let mut hasher = Sha256::new();
60        hasher.update(self.salt.as_bytes());
61        hasher.update(b"\0");
62        hasher.update(secret.as_bytes());
63        format!("<{kind} {}>", hex::encode(&hasher.finalize()[..4]))
64    }
65
66    /// One string, with everything hidden that should be.
67    ///
68    /// The home comes first, because a login name is usually inside it: digesting the name
69    /// before shortening the path would leave a path nobody can read and a digest where a
70    /// `~` belongs.
71    pub fn over(&self, text: &str) -> String {
72        let mut out = text.to_string();
73        // A path is worth keeping, and whose it is is not.
74        if self.home.len() >= 4 {
75            out = out.replace(&self.home, "~");
76        }
77        for (secret, kind) in &self.secrets {
78            if out.contains(secret.as_str()) {
79                out = out.replace(secret.as_str(), &self.digest(kind, secret));
80            }
81        }
82        out
83    }
84
85    /// The same, over every string anywhere in a JSON value.
86    pub fn over_json(&self, value: &Value) -> Value {
87        match value {
88            Value::String(text) => Value::String(self.over(text)),
89            Value::Array(items) => Value::Array(items.iter().map(|v| self.over_json(v)).collect()),
90            Value::Object(fields) => Value::Object(
91                fields
92                    .iter()
93                    .map(|(k, v)| (k.clone(), self.over_json(v)))
94                    .collect(),
95            ),
96            other => other.clone(),
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn sheet() -> Sheet {
106        Sheet::new("this report", "/Users/someone")
107            .hide("me@example.com", "email")
108            .hide("7c6b5a49-3827-4165-a4b3-c2d1e0f9a8b7", "account")
109            .hide("someone", "user")
110    }
111
112    #[test]
113    fn an_identifier_becomes_the_same_digest_everywhere_in_one_report() {
114        let s = sheet();
115        let once = s.over("me@example.com");
116        assert_ne!(once, "me@example.com");
117        assert!(once.starts_with("<email "));
118        assert_eq!(
119            s.over("signed in as me@example.com, parked for me@example.com"),
120            format!("signed in as {once}, parked for {once}"),
121            "two mentions of one account still line up"
122        );
123    }
124
125    #[test]
126    fn two_reports_from_one_machine_do_not_line_up_with_each_other() {
127        let first = Sheet::new("one moment", "/Users/someone").hide("me@example.com", "email");
128        let later = Sheet::new("another", "/Users/someone").hide("me@example.com", "email");
129        assert_ne!(first.over("me@example.com"), later.over("me@example.com"));
130    }
131
132    #[test]
133    fn a_path_is_kept_and_the_name_in_it_is_not() {
134        assert_eq!(
135            sheet().over("/Users/someone/.claude.json"),
136            "~/.claude.json",
137            "where a file is matters; whose it is does not"
138        );
139    }
140
141    /// A login name that is a substring of an email address must not rewrite half of it.
142    #[test]
143    fn the_longest_secret_is_hidden_first() {
144        let s = Sheet::new("salt", "/nowhere")
145            .hide("someone", "user")
146            .hide("someone@example.com", "email");
147        let hidden = s.over("someone@example.com");
148        assert!(hidden.starts_with("<email "), "got {hidden}");
149        assert!(!hidden.contains("@example.com"));
150    }
151
152    #[test]
153    fn nothing_worth_keeping_is_lost() {
154        let s = sheet();
155        assert_eq!(s.over("the keychain is locked"), "the keychain is locked");
156        assert_eq!(s.over("credential_store"), "credential_store");
157    }
158
159    #[test]
160    fn it_reaches_every_string_in_a_report() {
161        let hidden = sheet().over_json(&serde_json::json!({
162            "environment": {"home": "/Users/someone/.pitboard"},
163            "checks": [
164                {"code": "identity", "detail": "me@example.com", "level": "ok"},
165                {"code": "accounts", "detail": ["me@example.com", 3]},
166            ],
167        }));
168        let printed = hidden.to_string();
169        assert!(!printed.contains("me@example.com"), "{printed}");
170        assert!(!printed.contains("someone"), "{printed}");
171        assert!(
172            printed.contains("identity"),
173            "codes are the point of the report"
174        );
175        assert!(printed.contains("~/.pitboard"));
176    }
177
178    #[test]
179    fn something_too_short_to_hide_safely_is_not_hidden() {
180        let s = Sheet::new("salt", "/nowhere").hide("ab", "user");
181        assert_eq!(
182            s.over("a table of absolutes"),
183            "a table of absolutes",
184            "a two-character name would rewrite half the report"
185        );
186    }
187}