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