use std::collections::BTreeSet;
#[cfg(feature = "rng")]
use rand::{Rng, rng};
use sha2::{Digest, Sha256};
pub type Hash32 = [u8; 32];
#[derive(Clone)]
pub struct Salt([u8; 16]);
impl Salt {
#[cfg(feature = "rng")]
pub fn random() -> Self {
let mut bytes = [0u8; 16];
rng().fill_bytes(&mut bytes);
Self(bytes)
}
pub fn from_secret(secret: &[u8]) -> Self {
let digest = Sha256::digest(secret);
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
Self(bytes)
}
pub fn hash(&self, value: &str) -> Hash32 {
let mut hasher = Sha256::new();
hasher.update(self.0);
hasher.update(value.as_bytes());
hasher.finalize().into()
}
pub fn hash_set<I, S>(&self, elements: I) -> BTreeSet<Hash32>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
elements
.into_iter()
.map(|e| self.hash(e.as_ref()))
.collect()
}
}
impl std::fmt::Debug for Salt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Salt").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Stored {
Category(Hash32),
Set(BTreeSet<Hash32>),
Numeric(i64),
}
#[allow(clippy::cast_precision_loss)] pub fn jaccard(a: &BTreeSet<Hash32>, b: &BTreeSet<Hash32>) -> f64 {
if a.is_empty() && b.is_empty() {
return 1.0;
}
let intersection = a.intersection(b).count();
let union = a.len() + b.len() - intersection;
intersection as f64 / union as f64
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stability {
High,
Medium,
Low,
}
impl Stability {
pub fn m_prior(self) -> f64 {
match self {
Stability::High => 0.95,
Stability::Medium => 0.80,
Stability::Low => 0.50,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Category,
Set,
Numeric,
}
#[derive(Debug, Clone, Copy)]
pub struct FieldSpec {
pub stability: Stability,
pub kind: Kind,
}
pub fn classify(name: &str) -> FieldSpec {
let (stability, kind) = match name {
"webgl" | "platform" | "timezone" | "audio" | "languages" => {
(Stability::High, Kind::Category)
}
"cpu_cores" | "device_memory" => (Stability::High, Kind::Numeric),
"fonts" | "plugins" => (Stability::Medium, Kind::Set),
"screen" => (Stability::Medium, Kind::Numeric),
"user_agent" => (Stability::Low, Kind::Category),
_ => (Stability::Medium, Kind::Category),
};
FieldSpec { stability, kind }
}
#[cfg(test)]
mod tests {
use super::{Salt, Stability, jaccard};
#[test]
fn same_value_hashes_stably_under_one_salt() {
let salt = Salt::random();
assert_eq!(salt.hash("Chrome/120"), salt.hash("Chrome/120"));
assert_ne!(salt.hash("Chrome/120"), salt.hash("Chrome/121"));
}
#[test]
fn different_salts_diverge() {
assert_ne!(Salt::random().hash("UTC"), Salt::random().hash("UTC"));
}
#[test]
fn per_element_hashing_preserves_jaccard() {
let salt = Salt::random();
let a = salt.hash_set(["Arial", "Helvetica", "Courier", "Times"]);
let b = salt.hash_set(["Arial", "Helvetica", "Courier", "Verdana"]);
assert!((jaccard(&a, &b) - 0.6).abs() < 1e-9);
assert!((jaccard(&a, &a) - 1.0).abs() < 1e-9);
}
#[test]
fn empty_sets_are_identical() {
let empty = Salt::random().hash_set(Vec::<String>::new());
assert!((jaccard(&empty, &empty) - 1.0).abs() < 1e-9);
}
#[test]
fn priors_follow_stability_tier() {
assert!(Stability::High.m_prior() > Stability::Medium.m_prior());
assert!(Stability::Medium.m_prior() > Stability::Low.m_prior());
}
#[test]
fn schema_maps_known_components() {
use super::{Kind, classify};
use crate::fuzzy::component::Stability;
assert_eq!(classify("user_agent").stability, Stability::Low);
assert_eq!(classify("webgl").stability, Stability::High);
assert_eq!(classify("fonts").kind, Kind::Set);
assert_eq!(classify("cpu_cores").kind, Kind::Numeric);
let unknown = classify("something_new");
assert_eq!(unknown.stability, Stability::Medium);
assert_eq!(unknown.kind, Kind::Category);
}
}