use serde_json::Value;
#[derive(Debug, Clone)]
pub struct Sheet {
salt: String,
secrets: Vec<(String, &'static str)>,
home: String,
}
impl Sheet {
pub fn new(salt: impl Into<String>, home: impl Into<String>) -> Sheet {
Sheet {
salt: salt.into(),
secrets: Vec::new(),
home: home.into(),
}
}
pub fn hide(mut self, secret: impl Into<String>, kind: &'static str) -> Sheet {
let secret = secret.into();
if secret.len() >= 4 {
self.secrets.push((secret, kind));
self.secrets
.sort_by_key(|(secret, _)| std::cmp::Reverse(secret.len()));
}
self
}
fn digest(&self, kind: &str, secret: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(self.salt.as_bytes());
hasher.update(b"\0");
hasher.update(secret.as_bytes());
format!("<{kind} {}>", hex::encode(&hasher.finalize()[..4]))
}
pub fn over(&self, text: &str) -> String {
let mut out = text.to_string();
if self.home.len() >= 4 {
out = out.replace(&self.home, "~");
}
for (secret, kind) in &self.secrets {
if out.contains(secret.as_str()) {
out = out.replace(secret.as_str(), &self.digest(kind, secret));
}
}
out
}
pub fn over_json(&self, value: &Value) -> Value {
match value {
Value::String(text) => Value::String(self.over(text)),
Value::Array(items) => Value::Array(items.iter().map(|v| self.over_json(v)).collect()),
Value::Object(fields) => Value::Object(
fields
.iter()
.map(|(k, v)| (k.clone(), self.over_json(v)))
.collect(),
),
other => other.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sheet() -> Sheet {
Sheet::new("this report", "/Users/someone")
.hide("me@example.com", "email")
.hide("7c6b5a49-3827-4165-a4b3-c2d1e0f9a8b7", "account")
.hide("someone", "user")
}
#[test]
fn an_identifier_becomes_the_same_digest_everywhere_in_one_report() {
let s = sheet();
let once = s.over("me@example.com");
assert_ne!(once, "me@example.com");
assert!(once.starts_with("<email "));
assert_eq!(
s.over("signed in as me@example.com, parked for me@example.com"),
format!("signed in as {once}, parked for {once}"),
"two mentions of one account still line up"
);
}
#[test]
fn two_reports_from_one_machine_do_not_line_up_with_each_other() {
let first = Sheet::new("one moment", "/Users/someone").hide("me@example.com", "email");
let later = Sheet::new("another", "/Users/someone").hide("me@example.com", "email");
assert_ne!(first.over("me@example.com"), later.over("me@example.com"));
}
#[test]
fn a_path_is_kept_and_the_name_in_it_is_not() {
assert_eq!(
sheet().over("/Users/someone/.claude.json"),
"~/.claude.json",
"where a file is matters; whose it is does not"
);
}
#[test]
fn the_longest_secret_is_hidden_first() {
let s = Sheet::new("salt", "/nowhere")
.hide("someone", "user")
.hide("someone@example.com", "email");
let hidden = s.over("someone@example.com");
assert!(hidden.starts_with("<email "), "got {hidden}");
assert!(!hidden.contains("@example.com"));
}
#[test]
fn nothing_worth_keeping_is_lost() {
let s = sheet();
assert_eq!(s.over("the keychain is locked"), "the keychain is locked");
assert_eq!(s.over("credential_store"), "credential_store");
}
#[test]
fn it_reaches_every_string_in_a_report() {
let hidden = sheet().over_json(&serde_json::json!({
"environment": {"home": "/Users/someone/.pitboard"},
"checks": [
{"code": "identity", "detail": "me@example.com", "level": "ok"},
{"code": "accounts", "detail": ["me@example.com", 3]},
],
}));
let printed = hidden.to_string();
assert!(!printed.contains("me@example.com"), "{printed}");
assert!(!printed.contains("someone"), "{printed}");
assert!(
printed.contains("identity"),
"codes are the point of the report"
);
assert!(printed.contains("~/.pitboard"));
}
#[test]
fn something_too_short_to_hide_safely_is_not_hidden() {
let s = Sheet::new("salt", "/nowhere").hide("ab", "user");
assert_eq!(
s.over("a table of absolutes"),
"a table of absolutes",
"a two-character name would rewrite half the report"
);
}
}