Skip to main content

keyhog_core/
dedup.rs

1//! Match deduplication: group raw matches by detector, credential, and optional
2//! scan scope.
3//!
4//! This module provides the canonical [`DedupedMatch`] type and
5//! [`dedup_matches`] function. The full finding-identity taxonomy is documented
6//! in `docs/src/architecture.md` under "Finding identity and dedup"; keep this
7//! module focused on operator-visible report grouping, not window-overlap raw
8//! hit dedup.
9
10use indexmap::{Equivalent, IndexMap, IndexSet};
11use serde::ser::SerializeMap;
12use serde::{Deserialize, Serialize};
13use std::hash::{Hash, Hasher};
14use std::sync::atomic::AtomicU64;
15use std::sync::Arc;
16
17use crate::{
18    sha256_hash, CompanionMap, CredentialHash, EvidenceVerdict, MatchLocation, RawMatch,
19    SensitiveString, Severity,
20};
21
22/// Count of times [`dedup_cross_detector`] reached the (guard-impossible) empty
23/// singleton-group branch, where a finding would otherwise vanish from the
24/// report. Stays 0 in all correct runs; a non-zero value is a recall bug to
25/// investigate.
26pub(crate) static DEDUP_LOST_SINGLETON: AtomicU64 = AtomicU64::new(0);
27
28/// Deduplication scope for grouping findings.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum DedupScope {
31    /// No deduplication: every raw match is reported as a unique finding.
32    None,
33    /// Deduplicate within each file: same secret in same file is one finding.
34    File,
35    /// Deduplicate across entire scan: same secret across all files is one finding.
36    Credential,
37}
38
39/// A group of related raw matches representing a single distinct secret finding.
40///
41/// Manual `Debug` impl redacts the `credential` field - the previous
42/// derive-`Debug` was a CRITICAL leak vector (kimi-wave1 audit finding 1.2).
43#[derive(Clone, Serialize)]
44pub struct DedupedMatch {
45    /// Stable detector identifier.
46    #[serde(with = "crate::finding::serde_arc_str")]
47    pub detector_id: Arc<str>,
48    /// Human-readable detector name.
49    #[serde(with = "crate::finding::serde_arc_str")]
50    pub detector_name: Arc<str>,
51    /// Service namespace associated with the detector.
52    #[serde(with = "crate::finding::serde_arc_str")]
53    pub service: Arc<str>,
54    /// Severity preserved from the original match.
55    pub severity: Severity,
56    /// Unredacted credential for verification.
57    pub credential: SensitiveString,
58    /// SHA-256 hash of the original credential for internal correlation.
59    /// Named credential digest for suppression, correlation, and reporting.
60    pub credential_hash: CredentialHash,
61    /// Optional companion credentials extracted nearby.
62    #[serde(serialize_with = "serialize_companions_sorted")]
63    pub companions: CompanionMap,
64    /// Primary source location.
65    pub primary_location: MatchLocation,
66    /// Additional duplicate locations.
67    pub additional_locations: Vec<MatchLocation>,
68    /// Confidence score (0.0 - 1.0) combining entropy, keyword proximity, file type, etc.
69    pub confidence: Option<f64>,
70    /// Strongest deterministic evidence verdict among grouped matches.
71    pub evidence: EvidenceVerdict,
72    /// Shannon entropy measured for the credential, when the detection path
73    /// computed it. `None` means entropy was not part of that path.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub entropy: Option<f64>,
76}
77
78impl std::fmt::Debug for DedupedMatch {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("DedupedMatch")
81            .field("detector_id", &self.detector_id)
82            .field("detector_name", &self.detector_name)
83            .field("service", &self.service)
84            .field("severity", &self.severity)
85            .field(
86                "credential",
87                &format_args!("<redacted {} bytes>", self.credential.len()),
88            )
89            .field(
90                "credential_hash",
91                &crate::finding::hex_encode(self.credential_hash),
92            )
93            .field(
94                "companions",
95                &format_args!("<{} redacted companions>", self.companions.len()),
96            )
97            .field("primary_location", &self.primary_location)
98            .field("additional_locations", &self.additional_locations)
99            .field("confidence", &self.confidence)
100            .field("evidence", &self.evidence)
101            .field("entropy", &self.entropy)
102            .finish()
103    }
104}
105
106/// Deduplicate raw matches according to the given [`DedupScope`].
107pub fn dedup_matches(matches: Vec<RawMatch>, scope: &DedupScope) -> Vec<DedupedMatch> {
108    if *scope == DedupScope::None {
109        return matches
110            .into_iter()
111            .map(|m| {
112                let credential_hash =
113                    effective_credential_hash(m.credential.as_ref(), m.credential_hash);
114                DedupedMatch {
115                    detector_id: m.detector_id,
116                    detector_name: m.detector_name,
117                    service: m.service,
118                    severity: m.severity,
119                    credential: m.credential,
120                    credential_hash,
121                    companions: m.companions,
122                    primary_location: m.location,
123                    additional_locations: Vec::new(),
124                    confidence: m.confidence,
125                    evidence: m.evidence,
126                    entropy: m.entropy,
127                }
128            })
129            .collect();
130    }
131
132    // IndexMap (not HashMap or BTreeMap) for the best of both worlds: O(1)
133    // amortized insert like HashMap PLUS deterministic iteration order
134    // (insertion order, which we sort post-pass for cross-run stability).
135    // BTreeMap was O(log N) per insert and dominated dedup time on 1M+
136    // matches - see the internal design notes.
137    type DedupKey = (Arc<str>, SensitiveString, Option<FileScopeIdentity>);
138
139    // O(1) per-match membership for additional_locations. The duplicate arm
140    // used to run `existing.additional_locations.iter().any(is_same_location)`
141    // once per duplicate, so a single (detector, credential, file) group of K
142    // matches on K distinct lines (a generated credentials dump, an exported
143    // .env, a .tfvars, a config with one token repeated per stanza) cost
144    // 0+1+...+(K-1) = K(K-1)/2 = O(K^2) location comparisons, unbounded by the
145    // per-chunk recall budget. Each group instead carries a HashSet of the
146    // location-identity tuples (source, file_path, line, commit) it has already
147    // recorded - the SAME identity `is_same_location` compares - keyed by the
148    // group's slot in `groups`. Insert-returns-false is the exact negation of
149    // the prior `.any()` scan, so output is byte-identical: a location is added
150    // to additional_locations iff it differs from the primary AND from every
151    // already-recorded additional, now in O(1) instead of O(K). Turns a
152    // K-repeat group from O(K^2) to O(K).
153    // Sort by offset ascending so that for any group of (detector, credential,
154    // file) matches the LOWEST offset becomes the primary_location and any
155    // higher-offset duplicates land in additional_locations (or get
156    // suppressed by the same-(file, line) guard below). Without this the
157    // structured-preprocessor synthetic-line alias of a match arrives in
158    // raw-vec order: parallel rayon scans can produce that alias FIRST,
159    // making "primary at offset 80 in a 51-byte file" the report. Sorting
160    // by offset is O(N log N) instead of O(N) but N is bounded by the
161    // detector recall budget (max_matches_per_chunk) so the cost is small
162    // compared to extract_matches and ML scoring. Cross-file scope keeps
163    // the same group key so per-file primary selection picks the smallest
164    // offset per file independently. #16 regression: a GitHub PAT
165    // primary at offset 79 in a 64-byte file.
166    let mut matches = matches;
167    let match_count = matches.len();
168    let mut groups: IndexMap<DedupKey, DedupedMatch> = IndexMap::with_capacity(match_count);
169    let mut seen_locations: Vec<IndexSet<LocationIdentity>> = Vec::with_capacity(match_count);
170    matches.sort_by(|a, b| {
171        a.location
172            .file_path
173            .cmp(&b.location.file_path)
174            .then_with(|| a.location.offset.cmp(&b.location.offset))
175            .then_with(|| a.location.line.cmp(&b.location.line))
176            .then_with(|| a.location.source.cmp(&b.location.source))
177            .then_with(|| a.location.commit.cmp(&b.location.commit))
178            .then_with(|| a.detector_id.cmp(&b.detector_id))
179            .then_with(|| a.credential.cmp(&b.credential))
180    });
181
182    for matched in matches {
183        let key_ref = DedupKeyRef {
184            detector_id: matched.detector_id.as_ref(),
185            credential: matched.credential.as_str(),
186            file_scope: match scope {
187                DedupScope::Credential => None,
188                DedupScope::File => Some(FileScopeIdentityRef {
189                    source: matched.location.source.as_ref(),
190                    file_path: matched.location.file_path.as_deref(),
191                    commit: matched.location.commit.as_deref(),
192                }),
193                DedupScope::None => continue,
194            },
195        };
196
197        match groups.get_full_mut(&key_ref) {
198            Some((idx, _, existing)) => {
199                if is_decoder_alias_pair(&existing.primary_location, &matched.location) {
200                    if is_decoder_location(&existing.primary_location)
201                        && !is_decoder_location(&matched.location)
202                    {
203                        // The primary's identity changes; keep the seen-set in
204                        // sync so a later true duplicate of the new primary is
205                        // still recognized as same-as-primary (the
206                        // is_same_location(primary, ...) guard below handles it,
207                        // but recording it keeps the set a faithful mirror).
208                        let seen = &mut seen_locations[idx];
209                        seen.shift_remove(&location_identity_ref(&existing.primary_location));
210                        seen.insert(location_identity(&matched.location));
211                        existing.primary_location = matched.location;
212                    }
213                    merge_companions(&mut existing.companions, matched.companions);
214                    existing.confidence = max_confidence(existing.confidence, matched.confidence);
215                    existing.entropy = max_entropy(existing.entropy, matched.entropy);
216                    existing.evidence = existing.evidence.stronger(matched.evidence);
217                    continue;
218                }
219                // Drop locations that are the same (file_path, line) as the
220                // primary OR any already-recorded additional. They are the
221                // structured-preprocessor synthetic alias of an original
222                // match: build_preprocessed_text appends a `"key: value"`
223                // line after the original chunk text so detectors that
224                // need keyword context still see the value. The regex
225                // then fires twice on the same value - once at the real
226                // offset, once at original_end+offset_within_synthetic
227                // (past EOF on a single-line .env file). #16 regression:
228                // single-secret .env reported `+1 more locations` at
229                // offset 80 in a 51-byte file. Same (file, line) implies
230                // same finding; the synthetic match adds no signal.
231                //
232                // Membership is O(1) via the per-group seen-locations set
233                // (initialized with the primary's identity), so a K-repeat
234                // group is O(K) instead of the old O(K^2) `.any()` sweep. The
235                // set insert returns false exactly when the identity already
236                // exists (primary OR a prior additional), reproducing the old
237                // two-part guard with identical output.
238                if insert_new_location_identity(&mut seen_locations[idx], &matched.location) {
239                    existing.additional_locations.push(matched.location);
240                }
241                merge_companions(&mut existing.companions, matched.companions);
242                existing.confidence = max_confidence(existing.confidence, matched.confidence);
243                existing.entropy = max_entropy(existing.entropy, matched.entropy);
244                existing.evidence = existing.evidence.stronger(matched.evidence);
245            }
246            None => {
247                let mut seen = IndexSet::with_capacity(1);
248                seen.insert(location_identity(&matched.location));
249                let credential_hash =
250                    effective_credential_hash(matched.credential.as_ref(), matched.credential_hash);
251                let file_scope = match scope {
252                    DedupScope::File => Some(file_scope_identity(&matched.location)),
253                    DedupScope::Credential | DedupScope::None => None,
254                };
255                let key = (
256                    Arc::clone(&matched.detector_id),
257                    matched.credential.clone(),
258                    file_scope,
259                );
260                groups.insert(
261                    key,
262                    DedupedMatch {
263                        detector_id: matched.detector_id,
264                        detector_name: matched.detector_name,
265                        service: matched.service,
266                        severity: matched.severity,
267                        credential: matched.credential,
268                        credential_hash,
269                        companions: matched.companions,
270                        primary_location: matched.location,
271                        additional_locations: Vec::new(),
272                        confidence: matched.confidence,
273                        evidence: matched.evidence,
274                        entropy: matched.entropy,
275                    },
276                );
277                // groups.insert on a fresh key appends at the tail, so the new
278                // group's slot index is the prior length - keep seen_locations
279                // index-aligned with the IndexMap.
280                debug_assert_eq!(seen_locations.len(), groups.len() - 1);
281                seen_locations.push(seen);
282            }
283        }
284    }
285
286    // Sort the map in place, then move only its values into the output. This
287    // preserves the canonical key order without materializing an intermediate
288    // Vec of duplicate key/value graph owners.
289    groups.sort_keys();
290    groups.into_values().collect()
291}
292
293/// A decoded-source match and its raw twin count as the same finding when their
294/// known lines are within this many lines of each other. The splice preprocessor
295/// can shift the decoded credential onto an adjacent line, so exact-line equality
296/// is too strict; more than one line apart is treated as a distinct location.
297const DECODER_ALIAS_MAX_LINE_DELTA: usize = 1;
298
299/// When neither location carries a line number, fall back to byte offset: the
300/// decoded chunk is spliced in at (or a few bytes from) the original blob, so an
301/// offset gap within this many bytes marks the pair as a decoder alias.
302const DECODER_ALIAS_MAX_OFFSET_DELTA: usize = 16;
303
304fn is_decoder_alias_pair(a: &MatchLocation, b: &MatchLocation) -> bool {
305    if a.file_path != b.file_path || a.commit != b.commit {
306        return false;
307    }
308    if is_decoder_location(a) == is_decoder_location(b) {
309        return false;
310    }
311    match (a.line, b.line) {
312        (Some(left), Some(right)) if left.abs_diff(right) <= DECODER_ALIAS_MAX_LINE_DELTA => {
313            return true
314        }
315        (Some(_), Some(_)) => return false,
316        _ => {}
317    }
318    a.offset.abs_diff(b.offset) <= DECODER_ALIAS_MAX_OFFSET_DELTA
319}
320
321fn serialize_companions_sorted<S>(
322    companions: &CompanionMap,
323    serializer: S,
324) -> Result<S::Ok, S::Error>
325where
326    S: serde::Serializer,
327{
328    let mut entries: Vec<_> = companions.iter().collect();
329    entries.sort_by(|left, right| left.0.cmp(right.0));
330    let mut map = serializer.serialize_map(Some(entries.len()))?;
331    for (key, value) in entries {
332        map.serialize_entry(key.as_ref(), value)?;
333    }
334    map.end()
335}
336
337fn is_decoder_location(location: &MatchLocation) -> bool {
338    crate::embedded::DECODER_SOURCE_SUFFIXES
339        .iter()
340        .any(|suffix| location.source.ends_with(*suffix))
341}
342
343fn effective_credential_hash(credential: &str, credential_hash: CredentialHash) -> CredentialHash {
344    if credential_hash.is_zero() {
345        sha256_hash(credential)
346    } else {
347        credential_hash
348    }
349}
350
351/// Cross-detector dedup at emit time.
352///
353/// One credential value commonly matches multiple detectors - `AIza...` keys
354/// fire google-api, google-maps, google-places, google-translate; opaque
355/// 32-hex strings fire entropy + several service-specific generic detectors.
356/// The first-pass `dedup_matches` keeps each `(detector, credential)` pair
357/// separate. This second pass groups the deduped Vec by `credential_hash`
358/// and folds related detectors into the WINNING DedupedMatch's companions
359/// map under a `cross_detector` namespace, so a reporter sees ONE finding
360/// per credential with the alternate service guesses listed as evidence -
361/// the internal design notes innovation #5, "Cuts noise ~30%".
362///
363/// The winning detector is chosen by:
364///   1. Highest confidence (Some(f64)::total_cmp).
365///   2. Highest severity.
366///   3. Lexicographic detector_id (deterministic tiebreak).
367///
368/// Loser entries' detector_id, detector_name, and service are folded into
369/// the winner's `companions` under keys like `cross_detector.0`,
370/// `cross_detector.1`, ... in confidence-descending order.
371pub fn dedup_cross_detector(deduped: Vec<DedupedMatch>) -> Vec<DedupedMatch> {
372    if deduped.len() < 2 {
373        return deduped;
374    }
375
376    // Group by (credential_hash, primary_location.file_path) - splitting by
377    // file keeps file-scope dedup intact when the caller used DedupScope::File.
378    type GroupKey = (CredentialHash, Option<Arc<str>>);
379    let mut groups: IndexMap<GroupKey, Vec<DedupedMatch>> = IndexMap::with_capacity(deduped.len());
380    for m in deduped {
381        let key_ref = CrossDetectorGroupKeyRef {
382            credential_hash: m.credential_hash,
383            file_path: m.primary_location.file_path.as_deref(),
384        };
385        match groups.get_full_mut(&key_ref) {
386            Some((_, _, group)) => group.push(m),
387            None => {
388                let key = (m.credential_hash, m.primary_location.file_path.clone());
389                groups.insert(key, vec![m]);
390            }
391        }
392    }
393
394    let mut out: Vec<DedupedMatch> = Vec::with_capacity(groups.len());
395    for (_, mut group) in groups {
396        if group.len() == 1 {
397            // Law 10: the `len() == 1` guard proves `pop()` is `Some`, so this is
398            // not a silent drop today. But to be recall-safe against a future
399            // guard refactor, the impossible `None` arm is made LOUD: a lost
400            // dedup group would otherwise silently disappear a finding from the
401            // report. We surface it (eprintln + counter) instead of skipping.
402            match group.pop() {
403                Some(only) => out.push(only),
404                None => {
405                    DEDUP_LOST_SINGLETON.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
406                    eprintln!(
407                        "keyhog: BUG, dedup_cross_detector hit an empty group under \
408                         a len()==1 guard; a finding may have been dropped. Please \
409                         report this with the scanned input shape."
410                    );
411                }
412            }
413            continue;
414        }
415        // Sort: highest-confidence first, then severity desc, then detector_id
416        // asc, then credential / credential_hash / offset so the order is TOTAL.
417        // The winner is `group.remove(0)`; without the trailing keys, two
418        // matches sharing (confidence, severity, detector_id), e.g. the same
419        // detector firing on two credentials at one (file, line, commit) scope
420        // compare Equal, so which becomes the primary (vs. a `cross_detector.*`
421        // companion) is decided by input order, which is HashMap-iteration /
422        // thread nondeterministic. A total key fixes the primary credential.
423        group.sort_by(|a, b| {
424            // A `None` confidence sorts as 0.0 (lowest) for winner selection, a
425            // deterministic ordering choice, not a swallowed value; the
426            // credential/hash/offset tiebreaks below keep the order TOTAL so no
427            // finding is dropped or nondeterministically reordered.
428            let ac = a.confidence.unwrap_or(0.0); // LAW10: sort default, see note above
429            let bc = b.confidence.unwrap_or(0.0); // LAW10: sort default, see note above
430            bc.total_cmp(&ac)
431                .then_with(|| b.severity.cmp(&a.severity))
432                .then_with(|| a.detector_id.cmp(&b.detector_id))
433                .then_with(|| a.credential.cmp(&b.credential))
434                .then_with(|| a.credential_hash.cmp(&b.credential_hash))
435                .then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
436        });
437        let mut winner = group.remove(0);
438        let mut seen_locations = IndexSet::new();
439        insert_new_location_identity(&mut seen_locations, &winner.primary_location);
440        for loc in &winner.additional_locations {
441            insert_new_location_identity(&mut seen_locations, loc);
442        }
443        for (idx, loser) in group.into_iter().enumerate() {
444            let key = format!("cross_detector.{idx}");
445            let value = format!(
446                "{} ({}) [{}]",
447                loser.service,
448                loser.detector_name,
449                loser
450                    .confidence
451                    .map(|c| format!("{c:.2}"))
452                    .unwrap_or_else(|| "n/a".to_string()) // LAW10: display-only label for absent confidence in cross_detector evidence, no recall impact
453            );
454            winner.companions.entry(Arc::from(key)).or_insert(value);
455            winner.entropy = max_entropy(winner.entropy, loser.entropy);
456            let strongest_reason = winner.evidence.stronger(loser.evidence).reason_code();
457            winner.evidence = winner.evidence.with_reason(strongest_reason);
458            merge_cross_detector_locations(&mut winner, &mut seen_locations, loser);
459        }
460        out.push(winner);
461    }
462
463    // Re-sort for cross-run determinism (insertion order is input-dependent).
464    // Tiebreak on file_path then offset so the order is TOTAL: two winners that
465    // share (detector_id, credential_hash) across different files would otherwise
466    // compare Equal and fall back to IndexMap insertion order, reintroducing the
467    // input-order dependence the rest of this module eliminates for stable
468    // SARIF/baseline diffs.
469    out.sort_by(|a, b| {
470        a.detector_id
471            .cmp(&b.detector_id)
472            .then_with(|| a.credential_hash.cmp(&b.credential_hash))
473            .then_with(|| {
474                a.primary_location
475                    .file_path
476                    .cmp(&b.primary_location.file_path)
477            })
478            .then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
479    });
480    out
481}
482
483fn merge_cross_detector_locations(
484    winner: &mut DedupedMatch,
485    seen_locations: &mut IndexSet<LocationIdentity>,
486    loser: DedupedMatch,
487) {
488    if insert_new_location_identity(seen_locations, &loser.primary_location) {
489        winner.additional_locations.push(loser.primary_location);
490    }
491    for loc in loser.additional_locations {
492        if insert_new_location_identity(seen_locations, &loc) {
493            winner.additional_locations.push(loc);
494        }
495    }
496}
497
498#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
499struct FileScopeIdentity {
500    source: Arc<str>,
501    file_path: Option<Arc<str>>,
502    commit: Option<Arc<str>>,
503}
504
505struct FileScopeIdentityRef<'a> {
506    source: &'a str,
507    file_path: Option<&'a str>,
508    commit: Option<&'a str>,
509}
510
511impl Hash for FileScopeIdentityRef<'_> {
512    fn hash<H: Hasher>(&self, state: &mut H) {
513        self.source.hash(state);
514        self.file_path.hash(state);
515        self.commit.hash(state);
516    }
517}
518
519impl Equivalent<FileScopeIdentity> for FileScopeIdentityRef<'_> {
520    fn equivalent(&self, key: &FileScopeIdentity) -> bool {
521        self.source == key.source.as_ref()
522            && self.file_path == key.file_path.as_deref()
523            && self.commit == key.commit.as_deref()
524    }
525}
526
527struct DedupKeyRef<'a> {
528    detector_id: &'a str,
529    credential: &'a str,
530    file_scope: Option<FileScopeIdentityRef<'a>>,
531}
532
533impl Hash for DedupKeyRef<'_> {
534    fn hash<H: Hasher>(&self, state: &mut H) {
535        self.detector_id.hash(state);
536        self.credential.hash(state);
537        self.file_scope.hash(state);
538    }
539}
540
541impl Equivalent<(Arc<str>, SensitiveString, Option<FileScopeIdentity>)> for DedupKeyRef<'_> {
542    fn equivalent(&self, key: &(Arc<str>, SensitiveString, Option<FileScopeIdentity>)) -> bool {
543        self.detector_id == key.0.as_ref()
544            && self.credential == key.1.as_str()
545            && match (&self.file_scope, key.2.as_ref()) {
546                (None, None) => true,
547                (Some(scope_ref), Some(scope)) => scope_ref.equivalent(scope),
548                _ => false,
549            }
550    }
551}
552
553struct CrossDetectorGroupKeyRef<'a> {
554    credential_hash: CredentialHash,
555    file_path: Option<&'a str>,
556}
557
558impl Hash for CrossDetectorGroupKeyRef<'_> {
559    fn hash<H: Hasher>(&self, state: &mut H) {
560        self.credential_hash.hash(state);
561        self.file_path.hash(state);
562    }
563}
564
565impl Equivalent<(CredentialHash, Option<Arc<str>>)> for CrossDetectorGroupKeyRef<'_> {
566    fn equivalent(&self, key: &(CredentialHash, Option<Arc<str>>)) -> bool {
567        self.credential_hash == key.0 && self.file_path == key.1.as_deref()
568    }
569}
570
571fn file_scope_identity(location: &MatchLocation) -> FileScopeIdentity {
572    FileScopeIdentity {
573        source: Arc::clone(&location.source),
574        file_path: location.file_path.clone(),
575        commit: location.commit.clone(),
576    }
577}
578
579/// The hashable identity `(source, file_path, line, commit)` that defines
580/// when two locations are "the same finding" and must collapse. Offset is
581/// intentionally excluded: the structured preprocessor's synthetic-line append
582/// produces matches whose offset lies past the source file's EOF (the offset is
583/// into final_text, not the original chunk text), but whose `line` field is
584/// correctly remapped via LineMapping to the original source line. So
585/// same-(file, line) means the dedupe SHOULD collapse them: emitting both as
586/// "primary at line 1 offset 27" + "additional at line 1 offset 80 (past EOF)"
587/// is a confusing duplicate, not two findings. Used as the per-group seen-set
588/// element so additional_locations membership is O(1) instead of an O(K)
589/// linear scan.
590///
591/// Offset is intentionally excluded (KH-1438): synthetic multiline/decode
592/// windows re-emit the same secret at different byte offsets on the same
593/// logical line. Including offset would re-inflate those as additional
594/// locations. Distinct commits still keep separate identities so history
595/// scans do not collapse cross-revision hits.
596#[derive(Clone, Debug, PartialEq, Eq, Hash)]
597struct LocationIdentity {
598    source: Arc<str>,
599    file_path: Option<Arc<str>>,
600    line: Option<usize>,
601    commit: Option<Arc<str>>,
602}
603
604struct LocationIdentityRef<'a> {
605    source: &'a str,
606    file_path: Option<&'a str>,
607    line: Option<usize>,
608    commit: Option<&'a str>,
609}
610
611impl Hash for LocationIdentityRef<'_> {
612    fn hash<H: Hasher>(&self, state: &mut H) {
613        self.source.hash(state);
614        self.file_path.hash(state);
615        self.line.hash(state);
616        self.commit.hash(state);
617    }
618}
619
620impl Equivalent<LocationIdentity> for LocationIdentityRef<'_> {
621    fn equivalent(&self, key: &LocationIdentity) -> bool {
622        self.source == key.source.as_ref()
623            && self.file_path == key.file_path.as_deref()
624            && self.line == key.line
625            && self.commit == key.commit.as_deref()
626    }
627}
628
629fn location_identity(loc: &MatchLocation) -> LocationIdentity {
630    LocationIdentity {
631        source: Arc::clone(&loc.source),
632        file_path: loc.file_path.clone(),
633        line: loc.line,
634        commit: loc.commit.clone(),
635    }
636}
637
638fn location_identity_ref(loc: &MatchLocation) -> LocationIdentityRef<'_> {
639    LocationIdentityRef {
640        source: loc.source.as_ref(),
641        file_path: loc.file_path.as_deref(),
642        line: loc.line,
643        commit: loc.commit.as_deref(),
644    }
645}
646
647fn insert_new_location_identity(
648    seen: &mut IndexSet<LocationIdentity>,
649    location: &MatchLocation,
650) -> bool {
651    let identity = location_identity_ref(location);
652    if seen.contains(&identity) {
653        return false;
654    }
655    seen.insert(location_identity(location));
656    true
657}
658
659fn merge_companions(existing: &mut CompanionMap, incoming: CompanionMap) {
660    // Most duplicate matches carry no companions; skip the Vec alloc + sort in
661    // that hot-loop-common case. (dedup calls this once per merged duplicate.)
662    if incoming.is_empty() {
663        return;
664    }
665    // Sort incoming by key so the merged " | "-delimited string is stable
666    // across runs even though the existing field is a HashMap. Without this,
667    // rerunning the same scan can produce different companion orderings.
668    let mut sorted: Vec<(Arc<str>, String)> = incoming.into_iter().collect();
669    sorted.sort_by(|a, b| a.0.cmp(&b.0));
670    for (name, value) in sorted {
671        match existing.get_mut(&name) {
672            Some(current) if current != &value => {
673                let already_present = current
674                    .split(" | ")
675                    .any(|candidate| candidate == value.as_str());
676                if !already_present {
677                    current.push_str(" | ");
678                    current.push_str(&value);
679                }
680            }
681            Some(_) => {}
682            None => {
683                existing.insert(name, value);
684            }
685        }
686    }
687}
688
689fn max_confidence(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
690    match (lhs, rhs) {
691        (Some(a), Some(b)) => Some(a.max(b)),
692        (Some(a), None) => Some(a),
693        (None, Some(b)) => Some(b),
694        (None, None) => None,
695    }
696}
697
698fn max_entropy(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
699    match (lhs, rhs) {
700        (Some(a), Some(b)) => Some(if a.total_cmp(&b).is_ge() { a } else { b }),
701        (Some(a), None) => Some(a),
702        (None, Some(b)) => Some(b),
703        (None, None) => None,
704    }
705}