use crate::pick::pick_from_rng;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Canary {
pub token: String,
}
impl Canary {
#[must_use]
pub fn generate() -> Self {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
let token: String = (0..16)
.map(|_| pick_from_rng(CHARSET, b'A', &mut rng) as char)
.collect();
Self { token }
}
}
impl Default for Canary {
fn default() -> Self {
Self::generate()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn generate_produces_16_chars() {
let c = Canary::generate();
assert_eq!(c.token.len(), 16, "canary must be exactly 16 chars");
}
#[test]
fn generate_alphanumeric_only() {
let c = Canary::generate();
for ch in c.token.chars() {
assert!(
ch.is_ascii_alphanumeric(),
"canary contained non-alphanumeric char: {ch:?} (token: {:?})",
c.token
);
}
}
#[test]
fn generate_distinct_across_many_calls() {
let mut seen: HashSet<String> = HashSet::new();
for _ in 0..1000 {
seen.insert(Canary::generate().token);
}
assert!(
seen.len() >= 999,
"1000 canaries collapsed to {} unique — RNG seeded constant?",
seen.len()
);
}
#[test]
fn default_equivalent_to_generate() {
let a = Canary::default();
let b = Canary::generate();
assert_eq!(a.token.len(), b.token.len());
for ch in a.token.chars().chain(b.token.chars()) {
assert!(ch.is_ascii_alphanumeric());
}
}
}