1use serde_json::Value;
21
22#[derive(Debug, Clone)]
24pub struct Sheet {
25 salt: String,
26 secrets: Vec<(String, &'static str)>,
28 home: String,
29}
30
31impl Sheet {
32 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 pub fn hide(mut self, secret: impl Into<String>, kind: &'static str) -> Sheet {
46 let secret = secret.into();
47 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 pub fn over(&self, text: &str) -> String {
72 let mut out = text.to_string();
73 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 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 #[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}