use sha2::{Digest, Sha256};
use unicode_normalization::UnicodeNormalization;
pub const LIVE_SERVICE: &str = "Claude Code-credentials";
pub const CRED_FILE: &str = ".credentials.json";
const FALLBACK_ACCOUNT: &str = "claude-code-user";
pub fn account_name() -> String {
match std::env::var("USER") {
Ok(u) if is_accepted_account(&u) => u,
_ => FALLBACK_ACCOUNT.to_string(),
}
}
fn is_accepted_account(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
pub fn dir_hash(dir: &str) -> String {
let normalised: String = dir.nfc().collect();
hex::encode(&Sha256::digest(normalised.as_bytes())[..4])
}
pub fn service_for_dir(dir: &str) -> String {
format!("{LIVE_SERVICE}-{}", dir_hash(dir))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_measured_vectors() {
let cases = [
("/Users/ngoquocdat/.claude", "e80beed8"),
(
"/private/tmp/claude-501/-Users-ngoquocdat/32ac806a-a8b6-4258-8ba1-548995f8d827/scratchpad/m0/s1",
"a4bc5512",
),
(
"/private/tmp/claude-501/-Users-ngoquocdat/32ac806a-a8b6-4258-8ba1-548995f8d827/scratchpad/m0/s2",
"79d45fb9",
),
];
for (path, expected) in cases {
assert_eq!(dir_hash(path), expected, "derivation drifted for {path}");
}
}
#[test]
fn builds_the_full_service_name() {
assert_eq!(
service_for_dir("/Users/ngoquocdat/.claude"),
"Claude Code-credentials-e80beed8"
);
}
#[test]
fn a_trailing_slash_is_a_different_slot() {
assert_ne!(dir_hash("/Users/x/.claude"), dir_hash("/Users/x/.claude/"));
}
#[test]
fn account_names_are_screened_like_claude_code_screens_them() {
for good in ["ngoquocdat", "a.b_c-1", "X"] {
assert!(is_accepted_account(good), "{good} should be accepted");
}
for bad in [
"",
"has space",
"quote\"",
"sl/ash",
"semi;colon",
"dollar$",
] {
assert!(!is_accepted_account(bad), "{bad:?} should be rejected");
}
}
}