use crate::context::Context;
use crate::host::Os;
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct Sheet {
salt: String,
secrets: Vec<Hidden>,
folders: Vec<Hidden>,
}
#[derive(Debug, Clone)]
struct Hidden {
text: String,
under: &'static str,
any_case: bool,
}
impl Sheet {
pub(crate) fn new(salt: impl Into<String>, home: impl Into<String>) -> Sheet {
let home = Hidden {
text: home.into(),
under: "~",
any_case: false,
};
Sheet {
salt: salt.into(),
secrets: Vec::new(),
folders: vec![home],
}
}
pub(crate) fn of(ctx: &Context, salt: impl Into<String>) -> Sheet {
let sheet = Sheet::new(salt, ctx.home().to_string_lossy());
match ctx.os {
Os::MacOs | Os::Linux => sheet,
Os::Windows => {
let homes = ctx.profile.iter().chain([&ctx.home]);
let sheet = homes.fold(sheet, |sheet, folder| {
sheet.folder(&windows_spellings(folder), "~")
});
let sheet = match &ctx.local_app_data {
Some(local) => sheet.folder(&windows_spellings(local), "%LOCALAPPDATA%"),
None => sheet,
};
sheet
.hide_in_any_case(ctx.user.clone().unwrap_or_default(), "user")
.hide_in_any_case(ctx.sid.clone().unwrap_or_default(), "sid")
}
}
}
pub(crate) fn folder(mut self, spellings: &[String], under: &'static str) -> Sheet {
self.folders.extend(spellings.iter().map(|text| Hidden {
text: text.clone(),
under,
any_case: true,
}));
self.folders
.sort_by_key(|folder| std::cmp::Reverse(folder.text.chars().count()));
self
}
pub(crate) fn hide(self, secret: impl Into<String>, kind: &'static str) -> Sheet {
self.secret(secret.into(), kind, false)
}
pub(crate) fn hide_in_any_case(self, secret: impl Into<String>, kind: &'static str) -> Sheet {
self.secret(secret.into(), kind, true)
}
fn secret(mut self, secret: String, kind: &'static str, any_case: bool) -> Sheet {
if secret.len() >= 4 {
self.secrets.push(Hidden {
text: secret,
under: kind,
any_case,
});
self.secrets
.sort_by_key(|secret| std::cmp::Reverse(secret.text.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(crate) fn over(&self, text: &str) -> String {
let mut out = text.to_string();
for folder in self.folders.iter().filter(|folder| folder.text.len() >= 4) {
out = folder.replace(&out, folder.under);
}
for secret in &self.secrets {
out = secret.replace(&out, &self.digest(secret.under, &secret.text));
}
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(),
}
}
}
impl Hidden {
fn replace(&self, text: &str, with: &str) -> String {
if !self.any_case {
return text.replace(&self.text, with);
}
let upper = crate::host::upper_one_for_one;
let wanted: Vec<char> = self.text.chars().map(upper).collect();
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut at = 0;
while at < chars.len() {
let here = chars.get(at..at + wanted.len());
if here.is_some_and(|here| here.iter().copied().map(upper).eq(wanted.iter().copied())) {
out.push_str(with);
at += wanted.len();
} else {
out.push(chars[at]);
at += 1;
}
}
out
}
}
fn windows_spellings(folder: &std::path::Path) -> Vec<String> {
let long = std::fs::canonicalize(folder)
.ok()
.map(crate::host::without_verbatim_prefix);
let short = crate::host::fs::short_path(folder);
let names: Vec<String> = [Some(folder.to_path_buf()), long, short]
.into_iter()
.flatten()
.map(|named| named.to_string_lossy().into_owned())
.collect();
spellings(&names.iter().map(String::as_str).collect::<Vec<_>>())
}
fn spellings(names: &[&str]) -> Vec<String> {
names
.iter()
.flat_map(|named| {
[
(*named).to_owned(),
format!(r"\\?\{named}"),
named.replace('\\', "/"),
]
})
.collect()
}
#[cfg(test)]
#[allow(
clippy::disallowed_methods,
reason = "a test sets up its scratch files as a person or a tool would"
)]
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 a_windows_folder_is_hidden_in_every_spelling_and_case() {
let sheet = Sheet::new("salt", "")
.folder(&spellings(&[r"C:\Users\Đạt Ng", r"C:\Users\DTNG~1"]), "~")
.folder(&spellings(&[r"D:\Local"]), "%LOCALAPPDATA%")
.hide_in_any_case("Đạt", "user");
for (text, shown) in [
(r"C:\Users\Đạt Ng\.claude", r"~\.claude"),
(r"c:\users\đẠT NG\.claude.json", r"~\.claude.json"),
(r"\\?\C:\Users\Đạt Ng\.codex", r"~\.codex"),
("C:/Users/Đạt Ng/.codex", "~/.codex"),
(
r"C:\Users\dtng~1\AppData\Local\Pitboard",
r"~\AppData\Local\Pitboard",
),
(r"\\?\d:\LOCAL\Pitboard", r"%LOCALAPPDATA%\Pitboard"),
] {
assert_eq!(sheet.over(text), shown, "{text}");
}
let user = sheet.over("Đạt");
assert!(user.starts_with("<user "), "{user}");
assert_eq!(
sheet.over("signed in as đẠT"),
format!("signed in as {user}")
);
}
#[test]
fn a_report_from_windows_hides_its_profile_local_app_data_and_user() {
use crate::context::Environment;
let profile = std::env::temp_dir().join(format!("pitboard redact {}", std::process::id()));
let local = std::env::temp_dir().join("pitboard-redact-local");
std::fs::create_dir_all(&profile).expect("a scratch profile");
let named = |path: &std::path::Path| path.to_string_lossy().into_owned();
let env = Environment::on(
Os::Windows,
[
("USERPROFILE", named(&profile)),
("LOCALAPPDATA", named(&local)),
("USERNAME", "Dana Test".into()),
],
);
let sheet = Sheet::of(&Context::read(&env, "cli"), "salt");
let shown = |path: &std::path::Path| sheet.over(&named(&path.join("x")));
assert_eq!(shown(&profile), named(&std::path::Path::new("~").join("x")));
assert_eq!(
shown(&local),
named(&std::path::Path::new("%LOCALAPPDATA%").join("x"))
);
assert_eq!(sheet.over(&named(&profile).to_uppercase()), "~");
if let Some(short) = crate::host::fs::short_path(&profile) {
assert_eq!(sheet.over(&named(&short)), "~", "{}", short.display());
}
assert!(sheet.over("dana test").starts_with("<user "));
let _ = std::fs::remove_dir_all(&profile);
}
#[test]
fn a_report_from_windows_hides_the_persons_sid_in_every_form() {
use crate::context::Environment;
let profile =
std::env::temp_dir().join(format!("pitboard-redact-sid-{}", std::process::id()));
let env = Environment::on(
Os::Windows,
[("USERPROFILE", profile.to_string_lossy().into_owned())],
);
let mut ctx = Context::read(&env, "cli");
if cfg!(windows) {
assert!(ctx.sid.is_some(), "the token's own");
assert_eq!(ctx.sid, crate::host::user::sid());
}
let sid = ctx
.sid
.get_or_insert_with(|| "S-1-5-21-1004336348-1177238915-682003330-1001".into())
.clone();
let sheet = Sheet::of(&ctx, "salt");
let hidden = sheet.over(&sid);
assert!(hidden.starts_with("<sid "), "{hidden}");
for form in [
format!(r#""*{sid}:F""#),
format!(r#""*{sid}:(OI)(CI)F""#),
sid.to_lowercase(),
] {
let shown = sheet.over(&form);
assert!(shown.contains(&hidden), "{form}: {shown}");
assert!(!shown.to_uppercase().contains("S-1-5-21-"), "{shown}");
}
assert_eq!(
sheet.over("*S-1-5-18:F"),
"*S-1-5-18:F",
"SYSTEM is nobody's"
);
}
#[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"
);
}
}