#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct SimilarPackageCandidate {
pub(super) name: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct TyposquatSignal {
pub suspected_name: String,
pub declared_dependent_count: u64,
pub suspected_dependent_count: u64,
}
pub(super) const TYPOSQUAT_RATIO_THRESHOLD: u64 = 50;
pub(super) const TYPOSQUAT_MIN_CANDIDATE_DEPENDENTS: u64 = 50;
pub(super) const TYPOSQUAT_MAX_CANDIDATES_CHECKED: usize = 5;
pub(super) fn evaluate_candidates(
declared_name: &str,
declared_dependent_count: u64,
candidates: &[(String, u64)],
) -> Option<TyposquatSignal> {
candidates
.iter()
.filter(|(name, _)| name != declared_name)
.filter(|(_, count)| *count >= TYPOSQUAT_MIN_CANDIDATE_DEPENDENTS)
.filter(|(_, count)| {
*count >= declared_dependent_count.saturating_mul(TYPOSQUAT_RATIO_THRESHOLD)
})
.max_by_key(|(_, count)| *count)
.map(|(name, count)| TyposquatSignal {
suspected_name: name.clone(),
declared_dependent_count,
suspected_dependent_count: *count,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evaluate_candidates_49x_ratio_does_not_fire() {
let candidates = [("popular".to_string(), 49)];
assert!(evaluate_candidates("tiny", 1, &candidates).is_none());
}
#[test]
fn evaluate_candidates_50x_ratio_fires() {
let candidates = [("popular".to_string(), 50)];
let signal = evaluate_candidates("tiny", 1, &candidates).expect("must fire at exactly 50x");
assert_eq!(signal.suspected_name, "popular");
assert_eq!(signal.declared_dependent_count, 1);
assert_eq!(signal.suspected_dependent_count, 50);
}
#[test]
fn evaluate_candidates_51x_ratio_fires() {
let candidates = [("popular".to_string(), 51)];
assert!(evaluate_candidates("tiny", 1, &candidates).is_some());
}
#[test]
fn evaluate_candidates_below_floor_never_fires_even_at_trivially_high_ratio() {
let candidates = [("obscure".to_string(), 40)];
assert!(evaluate_candidates("tiny", 0, &candidates).is_none());
}
#[test]
fn evaluate_candidates_at_floor_with_trivial_ratio_fires() {
let candidates = [("popular".to_string(), 50)];
assert!(evaluate_candidates("tiny", 0, &candidates).is_some());
}
#[test]
fn evaluate_candidates_excludes_exact_name_match() {
let candidates = [("tiny".to_string(), 10_000)];
assert!(evaluate_candidates("tiny", 1, &candidates).is_none());
}
#[test]
fn evaluate_candidates_empty_list_returns_none() {
assert!(evaluate_candidates("tiny", 1, &[]).is_none());
}
#[test]
fn evaluate_candidates_picks_highest_dependent_count_among_multiple_qualifying() {
let candidates = [("mid".to_string(), 100), ("top".to_string(), 500)];
let signal = evaluate_candidates("tiny", 1, &candidates).expect("must fire");
assert_eq!(signal.suspected_name, "top");
assert_eq!(signal.suspected_dependent_count, 500);
}
}