use std::cmp::Ordering;
use std::collections::HashMap;
use std::sync::Arc;
use keyhog_core::{CredentialHash, MatchLocation, RawMatch, SensitiveString, Severity};
fn sha256(s: &str) -> CredentialHash {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(s.as_bytes());
CredentialHash::from_bytes(h.finalize().into())
}
fn loc(offset: usize, line: usize) -> MatchLocation {
MatchLocation {
source: Arc::from("filesystem"),
file_path: Some(Arc::from("creds.env")),
line: Some(line),
offset,
commit: None,
author: None,
date: None,
}
}
#[allow(clippy::too_many_arguments)]
fn rm(
det_id: &str,
tag: &str,
sev: Severity,
cred: &str,
conf: Option<f64>,
offset: usize,
line: usize,
) -> RawMatch {
RawMatch {
detector_id: Arc::from(det_id),
detector_name: Arc::from(tag),
service: Arc::from("svc"),
severity: sev,
credential: SensitiveString::from(cred),
credential_hash: sha256(cred),
companions: HashMap::new(),
location: loc(offset, line),
entropy: None,
confidence: conf,
}
}
fn tags(v: &[RawMatch]) -> Vec<&str> {
v.iter().map(|m| &*m.detector_name).collect()
}
#[test]
fn higher_confidence_sorts_before_higher_severity() {
let mut v = vec![
rm(
"d",
"critical_lowconf",
Severity::Critical,
"cb",
Some(0.5),
0,
1,
),
rm("d", "low_highconf", Severity::Low, "ca", Some(0.9), 0, 1),
];
v.sort();
assert_eq!(tags(&v), vec!["low_highconf", "critical_lowconf"]);
}
#[test]
fn equal_confidence_orders_by_severity_descending() {
let mut v = vec![
rm("d", "info", Severity::Info, "cb", Some(0.7), 0, 1),
rm("d", "critical", Severity::Critical, "ca", Some(0.7), 0, 1),
rm("d", "medium", Severity::Medium, "cc", Some(0.7), 0, 1),
];
v.sort();
assert_eq!(tags(&v), vec!["critical", "medium", "info"]);
}
#[test]
fn rawmatch_severity_direction_is_flipped_vs_native_severity_ord() {
let crit = rm("d", "c", Severity::Critical, "x", Some(0.5), 0, 1);
let info = rm("d", "i", Severity::Info, "y", Some(0.5), 0, 1);
assert_eq!(Severity::Critical.cmp(&Severity::Info), Ordering::Greater);
assert_eq!(crit.cmp(&info), Ordering::Less);
assert_eq!(info.cmp(&crit), Ordering::Greater);
}
#[test]
fn equal_conf_and_severity_orders_by_detector_id_ascending() {
let mut v = vec![
rm("zzz-det", "z", Severity::High, "cb", Some(0.4), 0, 1),
rm("aaa-det", "a", Severity::High, "ca", Some(0.4), 0, 1),
rm("mmm-det", "m", Severity::High, "cc", Some(0.4), 0, 1),
];
v.sort();
assert_eq!(tags(&v), vec!["a", "m", "z"]);
}
#[test]
fn equal_conf_severity_detector_orders_by_credential_ascending() {
let mut v = vec![
rm(
"det",
"beta",
Severity::Medium,
"beta-secret",
Some(0.5),
0,
1,
),
rm(
"det",
"alpha",
Severity::Medium,
"alpha-secret",
Some(0.5),
0,
1,
),
];
v.sort();
assert_eq!(tags(&v), vec!["alpha", "beta"]);
}
#[test]
fn final_tiebreak_is_offset_then_line_ascending() {
let mut v = vec![
rm("det", "off100", Severity::High, "c", Some(0.5), 100, 1),
rm("det", "off5", Severity::High, "c", Some(0.5), 5, 9),
rm("det", "off5_line2", Severity::High, "c", Some(0.5), 5, 2),
];
v.sort();
assert_eq!(tags(&v), vec!["off5_line2", "off5", "off100"]);
}
#[test]
fn none_confidence_sorts_last_but_is_retained() {
let mut v = vec![
rm("d", "none", Severity::Critical, "cb", None, 0, 1),
rm("d", "tiny", Severity::Info, "ca", Some(0.01), 0, 1),
];
v.sort();
assert_eq!(tags(&v), vec!["tiny", "none"]);
assert_eq!(v.len(), 2);
}
#[test]
fn none_confidence_equals_explicit_zero_for_the_confidence_key() {
let some_zero_low = rm("d", "some0_low", Severity::Low, "ca", Some(0.0), 0, 1);
let none_crit = rm("d", "none_crit", Severity::Critical, "cb", None, 0, 1);
assert_eq!(none_crit.cmp(&some_zero_low), Ordering::Less);
let mut v = vec![some_zero_low, none_crit];
v.sort();
assert_eq!(tags(&v), vec!["none_crit", "some0_low"]);
}
#[test]
fn full_mixed_batch_sorts_to_exact_order() {
let f1 = rm("d1", "crit_hi", Severity::Critical, "c1", Some(0.95), 0, 1);
let f2 = rm("d2", "low_hi", Severity::Low, "c2", Some(0.95), 0, 1);
let f3 = rm("d3", "crit_mid", Severity::Critical, "c3", Some(0.60), 0, 1);
let f4 = rm("d4", "info_mid", Severity::Info, "c4", Some(0.60), 0, 1);
let f5 = rm("d5", "none_crit", Severity::Critical, "c5", None, 0, 1);
let f6 = rm("d6", "high_lo", Severity::High, "c6", Some(0.10), 0, 1);
let mut v = vec![f1, f2, f3, f4, f5, f6];
v.sort();
assert_eq!(
tags(&v),
vec![
"crit_hi",
"low_hi",
"crit_mid",
"info_mid",
"high_lo",
"none_crit"
]
);
assert_eq!(&*v[0].detector_name, "crit_hi");
assert_eq!(&*v[5].detector_name, "none_crit");
}
#[test]
fn two_input_permutations_sort_identically() {
let build = || {
vec![
rm("d1", "crit_hi", Severity::Critical, "c1", Some(0.95), 0, 1),
rm("d2", "low_hi", Severity::Low, "c2", Some(0.95), 0, 1),
rm("d3", "crit_mid", Severity::Critical, "c3", Some(0.60), 0, 1),
rm("d6", "high_lo", Severity::High, "c6", Some(0.10), 0, 1),
]
};
let mut forward = build();
forward.sort();
let mut reversed = build();
reversed.reverse();
reversed.sort();
let expected = vec!["crit_hi", "low_hi", "crit_mid", "high_lo"];
assert_eq!(tags(&forward), expected);
assert_eq!(tags(&reversed), expected);
}
#[test]
fn cmp_is_reflexive_and_partial_cmp_agrees() {
let a = rm("d", "a", Severity::High, "cred", Some(0.5), 3, 7);
assert_eq!(a.cmp(&a), Ordering::Equal);
assert_eq!(a.partial_cmp(&a), Some(Ordering::Equal));
}
#[test]
fn cmp_is_antisymmetric_for_ordered_pair() {
let hi = rm("d", "hi", Severity::Low, "ca", Some(0.9), 0, 1);
let lo = rm("d", "lo", Severity::Critical, "cb", Some(0.2), 0, 1);
assert_eq!(hi.cmp(&lo), Ordering::Less);
assert_eq!(lo.cmp(&hi), Ordering::Greater);
assert_eq!(hi.partial_cmp(&lo), Some(Ordering::Less));
}
#[test]
fn iter_min_selects_highest_priority_finding() {
let v = vec![
rm("d5", "none_crit", Severity::Critical, "c5", None, 0, 1),
rm("d1", "crit_hi", Severity::Critical, "c1", Some(0.95), 0, 1),
rm("d6", "high_lo", Severity::High, "c6", Some(0.10), 0, 1),
];
let best = v.iter().min().expect("non-empty");
assert_eq!(&*best.detector_name, "crit_hi");
let worst = v.iter().max().expect("non-empty");
assert_eq!(&*worst.detector_name, "none_crit");
}
#[test]
fn detector_name_breaks_a_complete_priority_tie() {
let make = |tag: &str| rm("det", tag, Severity::High, "same-cred", Some(0.5), 4, 2);
let x = make("X");
let y = make("Y");
assert_ne!(x.cmp(&y), Ordering::Equal);
assert_ne!(x, y);
let mut xy = vec![make("X"), make("Y")];
xy.sort();
assert_eq!(tags(&xy), vec!["X", "Y"]);
let mut yx = vec![make("Y"), make("X")];
yx.sort();
assert_eq!(tags(&yx), vec!["X", "Y"]);
let set = std::collections::BTreeSet::from([make("X"), make("Y")]);
assert_eq!(
set.len(),
2,
"distinct findings must remain distinct BTree keys"
);
}
#[test]
fn every_fieldwise_identity_difference_breaks_cmp_equality() {
let base = rm("det", "name", Severity::High, "same-cred", Some(0.0), 4, 2);
let mut variants = Vec::new();
let mut changed = base.clone();
changed.service = Arc::from("other-service");
variants.push(("service", changed));
let mut changed = base.clone();
changed.credential_hash = sha256("other-hash-input");
variants.push(("credential_hash", changed));
let mut changed = base.clone();
changed
.companions
.insert("client_id".into(), "other".into());
variants.push(("companions", changed));
let mut changed = base.clone();
changed.location.source = Arc::from("git");
variants.push(("location.source", changed));
let mut changed = base.clone();
changed.location.file_path = Some(Arc::from("other.env"));
variants.push(("location.file_path", changed));
let mut changed = base.clone();
changed.location.commit = Some(Arc::from("deadbeef"));
variants.push(("location.commit", changed));
let mut changed = base.clone();
changed.location.author = Some(Arc::from("operator"));
variants.push(("location.author", changed));
let mut changed = base.clone();
changed.location.date = Some(Arc::from("2026-07-10"));
variants.push(("location.date", changed));
let mut changed = base.clone();
changed.entropy = Some(3.75);
variants.push(("entropy", changed));
let mut changed = base.clone();
changed.confidence = None;
variants.push(("confidence identity", changed));
for (field, changed) in variants {
assert_ne!(base, changed, "fixture must differ in {field}");
assert_ne!(
base.cmp(&changed),
Ordering::Equal,
"fieldwise difference in {field} must break cmp equality"
);
}
}
#[test]
fn companion_ordering_is_insertion_order_independent() {
let mut forward = rm("det", "name", Severity::High, "same-cred", Some(0.5), 4, 2);
forward.companions.insert("alpha".into(), "one".into());
forward.companions.insert("beta".into(), "two".into());
let mut reverse = rm("det", "name", Severity::High, "same-cred", Some(0.5), 4, 2);
reverse.companions.insert("beta".into(), "two".into());
reverse.companions.insert("alpha".into(), "one".into());
assert_eq!(forward, reverse);
assert_eq!(forward.cmp(&reverse), Ordering::Equal);
reverse.companions.insert("beta".into(), "three".into());
assert_eq!(
forward.cmp(&reverse),
reverse.cmp(&forward).reverse(),
"different companion maps must retain an antisymmetric lexical order"
);
assert_ne!(forward.cmp(&reverse), Ordering::Equal);
}