use std::collections::BTreeMap;
pub const MIN_LEN: usize = 20;
pub const MAX_LEN: usize = 128;
pub const ENTROPY_MARGIN: f64 = 0.30;
pub const MIN_BITS: f64 = 80.0;
pub const CONFIDENCE_FLOOR: f32 = 0.60;
pub const CONFIDENCE_CEILING: f32 = 0.85;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CharsetClass {
Hex,
Base64,
Alphanumeric,
}
impl CharsetClass {
pub fn alphabet_size(self) -> usize {
match self {
CharsetClass::Hex => 16,
CharsetClass::Base64 => 64,
CharsetClass::Alphanumeric => 62,
}
}
}
pub fn shannon_entropy(value: &str) -> f64 {
let n = value.chars().count();
if n == 0 {
return 0.0;
}
let mut counts: BTreeMap<char, usize> = BTreeMap::new();
for c in value.chars() {
*counts.entry(c).or_insert(0) += 1;
}
let n_f = n as f64;
counts.values().fold(0.0, |acc, &count| {
let p = count as f64 / n_f;
acc - p * p.log2()
})
}
pub fn expected_uniform_entropy(k: usize, n: usize) -> f64 {
if n == 0 || k == 0 {
return 0.0;
}
let k_f = k as f64;
let n_f = n as f64;
k_f.log2() - (k_f - 1.0) / (2.0 * n_f * std::f64::consts::LN_2)
}
pub fn classify_charset(value: &str) -> Option<CharsetClass> {
if value.is_empty() {
return None;
}
if is_screaming_snake(value) || is_path_shaped(value) {
return None;
}
let all_hex = value.chars().all(|c| c.is_ascii_hexdigit());
let has_hex_letter = value.chars().any(|c| matches!(c, 'a'..='f' | 'A'..='F'));
if all_hex && has_hex_letter {
return Some(CharsetClass::Hex);
}
let has_plus_slash_eq = value.chars().any(|c| matches!(c, '+' | '/' | '='));
let has_minus = value.contains('-');
let has_underscore = value.contains('_');
let base64_alphabet = value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '-' | '_'));
if base64_alphabet && (has_plus_slash_eq || (has_minus && has_underscore)) {
return Some(CharsetClass::Base64);
}
if value.chars().all(|c| c.is_ascii_alphanumeric()) {
return Some(CharsetClass::Alphanumeric);
}
None
}
fn is_screaming_snake(value: &str) -> bool {
value.contains('_')
&& value
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
&& value.chars().any(|c| c.is_ascii_uppercase())
}
fn is_path_shaped(value: &str) -> bool {
value.contains('/') && value.contains('.')
|| value.starts_with('/')
|| value.contains("/build/")
|| value.contains("/time/")
}
pub fn score_entropy(value: &str) -> Option<f32> {
let n = value.chars().count();
if !(MIN_LEN..=MAX_LEN).contains(&n) {
return None;
}
let class = classify_charset(value)?;
if class != CharsetClass::Hex {
let has_digit = value.chars().any(|c| c.is_ascii_digit());
let has_letter = value.chars().any(|c| c.is_ascii_alphabetic());
if !has_digit || !has_letter {
return None;
}
}
let h = shannon_entropy(value);
let k = class.alphabet_size();
let floor = expected_uniform_entropy(k, n) - ENTROPY_MARGIN;
if h < floor {
return None;
}
if (n as f64) * h < MIN_BITS {
return None;
}
let max = (k as f64).log2();
let span = (max - floor).max(1e-6);
let t = ((h - floor) / span).clamp(0.0, 1.0);
let confidence = CONFIDENCE_FLOOR as f64 + t * (CONFIDENCE_CEILING - CONFIDENCE_FLOOR) as f64;
Some(confidence as f32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markerless_alnum_is_not_base64() {
let value = "AbCdEfGhIjKlMnOpQrStUvWx0123";
assert_eq!(classify_charset(value), Some(CharsetClass::Alphanumeric));
}
#[test]
fn hex_requires_a_to_f() {
assert_eq!(
classify_charset("abcdef0123456789abcdef0123456789"),
Some(CharsetClass::Hex)
);
assert_eq!(
classify_charset("01234567890123456789012345678901"),
Some(CharsetClass::Alphanumeric)
);
}
#[test]
fn base64_needs_structural_evidence() {
assert_eq!(
classify_charset("dXNlcm5hbWU6cGFzc3dvcmQ="),
Some(CharsetClass::Base64)
);
assert_eq!(
classify_charset("abc-def_ghi-jkl_mnopqrstuvwx"),
Some(CharsetClass::Base64)
);
}
#[test]
fn screaming_snake_is_not_base64() {
assert_eq!(classify_charset("ANTHROPIC_API_KEY_PLACEHOLDER_XX"), None);
assert_eq!(classify_charset("A1B2C3D4E5F6G7H8I9A_"), None);
assert!(score_entropy("A1B2C3D4E5F6G7H8I9A_").is_none());
}
#[test]
fn empty_entropy_is_zero() {
assert_eq!(shannon_entropy(""), 0.0);
}
#[test]
fn uniform_hex_passes_length_aware_floor() {
let value = "a1b2c3d4e5f60718293a4b5c6d7e8f90";
assert!(score_entropy(value).is_some());
}
#[test]
fn short_values_are_rejected() {
assert!(score_entropy("abcdef0123456789").is_none());
}
#[test]
fn confidence_stays_in_band() {
let value = "a1b2c3d4e5f60718293a4b5c6d7e8f90";
let score = score_entropy(value).unwrap();
assert!((CONFIDENCE_FLOOR..=CONFIDENCE_CEILING).contains(&score));
}
}