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, MatchLocation, RawMatch, SensitiveString, Severity,
19};
20
21/// Count of times [`dedup_cross_detector`] reached the (guard-impossible) empty
22/// singleton-group branch, where a finding would otherwise vanish from the
23/// report. Stays 0 in all correct runs; a non-zero value is a recall bug to
24/// investigate.
25pub(crate) static DEDUP_LOST_SINGLETON: AtomicU64 = AtomicU64::new(0);
26
27/// Deduplication scope for grouping findings.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub enum DedupScope {
30    /// No deduplication: every raw match is reported as a unique finding.
31    None,
32    /// Deduplicate within each file: same secret in same file is one finding.
33    File,
34    /// Deduplicate across entire scan: same secret across all files is one finding.
35    Credential,
36}
37
38/// A group of related raw matches representing a single distinct secret finding.
39///
40/// Manual `Debug` impl redacts the `credential` field - the previous
41/// derive-`Debug` was a CRITICAL leak vector (kimi-wave1 audit finding 1.2).
42#[derive(Clone, Serialize)]
43pub struct DedupedMatch {
44    /// Stable detector identifier.
45    #[serde(with = "crate::finding::serde_arc_str")]
46    pub detector_id: Arc<str>,
47    /// Human-readable detector name.
48    #[serde(with = "crate::finding::serde_arc_str")]
49    pub detector_name: Arc<str>,
50    /// Service namespace associated with the detector.
51    #[serde(with = "crate::finding::serde_arc_str")]
52    pub service: Arc<str>,
53    /// Severity preserved from the original match.
54    pub severity: Severity,
55    /// Unredacted credential for verification.
56    pub credential: SensitiveString,
57    /// SHA-256 hash of the original credential for internal correlation.
58    /// Named credential digest for suppression, correlation, and reporting.
59    pub credential_hash: CredentialHash,
60    /// Optional companion credentials extracted nearby.
61    #[serde(serialize_with = "serialize_companions_sorted")]
62    pub companions: CompanionMap,
63    /// Primary source location.
64    pub primary_location: MatchLocation,
65    /// Additional duplicate locations.
66    pub additional_locations: Vec<MatchLocation>,
67    /// Confidence score (0.0 - 1.0) combining entropy, keyword proximity, file type, etc.
68    pub confidence: Option<f64>,
69    /// Shannon entropy measured for the credential, when the detection path
70    /// computed it. `None` means entropy was not part of that path.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub entropy: Option<f64>,
73}
74
75impl std::fmt::Debug for DedupedMatch {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("DedupedMatch")
78            .field("detector_id", &self.detector_id)
79            .field("detector_name", &self.detector_name)
80            .field("service", &self.service)
81            .field("severity", &self.severity)
82            .field(
83                "credential",
84                &format_args!("<redacted {} bytes>", self.credential.len()),
85            )
86            .field(
87                "credential_hash",
88                &crate::finding::hex_encode(self.credential_hash),
89            )
90            .field(
91                "companions",
92                &format_args!("<{} redacted companions>", self.companions.len()),
93            )
94            .field("primary_location", &self.primary_location)
95            .field("additional_locations", &self.additional_locations)
96            .field("confidence", &self.confidence)
97            .field("entropy", &self.entropy)
98            .finish()
99    }
100}
101
102/// Deduplicate raw matches according to the given [`DedupScope`].
103pub fn dedup_matches(matches: Vec<RawMatch>, scope: &DedupScope) -> Vec<DedupedMatch> {
104    if *scope == DedupScope::None {
105        return matches
106            .into_iter()
107            .map(|m| {
108                let credential_hash =
109                    effective_credential_hash(m.credential.as_ref(), m.credential_hash);
110                DedupedMatch {
111                    detector_id: m.detector_id,
112                    detector_name: m.detector_name,
113                    service: m.service,
114                    severity: m.severity,
115                    credential: m.credential,
116                    credential_hash,
117                    companions: m.companions,
118                    primary_location: m.location,
119                    additional_locations: Vec::new(),
120                    confidence: m.confidence,
121                    entropy: m.entropy,
122                }
123            })
124            .collect();
125    }
126
127    // IndexMap (not HashMap or BTreeMap) for the best of both worlds: O(1)
128    // amortized insert like HashMap PLUS deterministic iteration order
129    // (insertion order, which we sort post-pass for cross-run stability).
130    // BTreeMap was O(log N) per insert and dominated dedup time on 1M+
131    // matches - see the internal design notes.
132    type DedupKey = (Arc<str>, SensitiveString, Option<FileScopeIdentity>);
133
134    // O(1) per-match membership for additional_locations. The duplicate arm
135    // used to run `existing.additional_locations.iter().any(is_same_location)`
136    // once per duplicate, so a single (detector, credential, file) group of K
137    // matches on K distinct lines (a generated credentials dump, an exported
138    // .env, a .tfvars, a config with one token repeated per stanza) cost
139    // 0+1+...+(K-1) = K(K-1)/2 = O(K^2) location comparisons, unbounded by the
140    // per-chunk recall budget. Each group instead carries a HashSet of the
141    // location-identity tuples (source, file_path, line, commit) it has already
142    // recorded - the SAME identity `is_same_location` compares - keyed by the
143    // group's slot in `groups`. Insert-returns-false is the exact negation of
144    // the prior `.any()` scan, so output is byte-identical: a location is added
145    // to additional_locations iff it differs from the primary AND from every
146    // already-recorded additional, now in O(1) instead of O(K). Turns a
147    // K-repeat group from O(K^2) to O(K).
148    // Sort by offset ascending so that for any group of (detector, credential,
149    // file) matches the LOWEST offset becomes the primary_location and any
150    // higher-offset duplicates land in additional_locations (or get
151    // suppressed by the same-(file, line) guard below). Without this the
152    // structured-preprocessor synthetic-line alias of a match arrives in
153    // raw-vec order: parallel rayon scans can produce that alias FIRST,
154    // making "primary at offset 80 in a 51-byte file" the report. Sorting
155    // by offset is O(N log N) instead of O(N) but N is bounded by the
156    // detector recall budget (max_matches_per_chunk) so the cost is small
157    // compared to extract_matches and ML scoring. Cross-file scope keeps
158    // the same group key so per-file primary selection picks the smallest
159    // offset per file independently. #16 regression: a GitHub PAT
160    // primary at offset 79 in a 64-byte file.
161    let mut matches = matches;
162    let match_count = matches.len();
163    let mut groups: IndexMap<DedupKey, DedupedMatch> = IndexMap::with_capacity(match_count);
164    let mut seen_locations: Vec<IndexSet<LocationIdentity>> = Vec::with_capacity(match_count);
165    matches.sort_by(|a, b| {
166        a.location
167            .file_path
168            .cmp(&b.location.file_path)
169            .then_with(|| a.location.offset.cmp(&b.location.offset))
170            .then_with(|| a.location.line.cmp(&b.location.line))
171            .then_with(|| a.location.source.cmp(&b.location.source))
172            .then_with(|| a.location.commit.cmp(&b.location.commit))
173            .then_with(|| a.detector_id.cmp(&b.detector_id))
174            .then_with(|| a.credential.cmp(&b.credential))
175    });
176
177    for matched in matches {
178        let key_ref = DedupKeyRef {
179            detector_id: matched.detector_id.as_ref(),
180            credential: matched.credential.as_str(),
181            file_scope: match scope {
182                DedupScope::Credential => None,
183                DedupScope::File => Some(FileScopeIdentityRef {
184                    source: matched.location.source.as_ref(),
185                    file_path: matched.location.file_path.as_deref(),
186                    commit: matched.location.commit.as_deref(),
187                }),
188                DedupScope::None => continue,
189            },
190        };
191
192        match groups.get_full_mut(&key_ref) {
193            Some((idx, _, existing)) => {
194                if is_decoder_alias_pair(&existing.primary_location, &matched.location) {
195                    if is_decoder_location(&existing.primary_location)
196                        && !is_decoder_location(&matched.location)
197                    {
198                        // The primary's identity changes; keep the seen-set in
199                        // sync so a later true duplicate of the new primary is
200                        // still recognized as same-as-primary (the
201                        // is_same_location(primary, ...) guard below handles it,
202                        // but recording it keeps the set a faithful mirror).
203                        let seen = &mut seen_locations[idx];
204                        seen.shift_remove(&location_identity_ref(&existing.primary_location));
205                        seen.insert(location_identity(&matched.location));
206                        existing.primary_location = matched.location;
207                    }
208                    merge_companions(&mut existing.companions, matched.companions);
209                    existing.confidence = max_confidence(existing.confidence, matched.confidence);
210                    existing.entropy = max_entropy(existing.entropy, matched.entropy);
211                    continue;
212                }
213                // Drop locations that are the same (file_path, line) as the
214                // primary OR any already-recorded additional. They are the
215                // structured-preprocessor synthetic alias of an original
216                // match: build_preprocessed_text appends a `"key: value"`
217                // line after the original chunk text so detectors that
218                // need keyword context still see the value. The regex
219                // then fires twice on the same value - once at the real
220                // offset, once at original_end+offset_within_synthetic
221                // (past EOF on a single-line .env file). #16 regression:
222                // single-secret .env reported `+1 more locations` at
223                // offset 80 in a 51-byte file. Same (file, line) implies
224                // same finding; the synthetic match adds no signal.
225                //
226                // Membership is O(1) via the per-group seen-locations set
227                // (initialized with the primary's identity), so a K-repeat
228                // group is O(K) instead of the old O(K^2) `.any()` sweep. The
229                // set insert returns false exactly when the identity already
230                // exists (primary OR a prior additional), reproducing the old
231                // two-part guard with identical output.
232                if insert_new_location_identity(&mut seen_locations[idx], &matched.location) {
233                    existing.additional_locations.push(matched.location);
234                }
235                merge_companions(&mut existing.companions, matched.companions);
236                existing.confidence = max_confidence(existing.confidence, matched.confidence);
237                existing.entropy = max_entropy(existing.entropy, matched.entropy);
238            }
239            None => {
240                let mut seen = IndexSet::with_capacity(1);
241                seen.insert(location_identity(&matched.location));
242                let credential_hash =
243                    effective_credential_hash(matched.credential.as_ref(), matched.credential_hash);
244                let file_scope = match scope {
245                    DedupScope::File => Some(file_scope_identity(&matched.location)),
246                    DedupScope::Credential | DedupScope::None => None,
247                };
248                let key = (
249                    Arc::clone(&matched.detector_id),
250                    matched.credential.clone(),
251                    file_scope,
252                );
253                groups.insert(
254                    key,
255                    DedupedMatch {
256                        detector_id: matched.detector_id,
257                        detector_name: matched.detector_name,
258                        service: matched.service,
259                        severity: matched.severity,
260                        credential: matched.credential,
261                        credential_hash,
262                        companions: matched.companions,
263                        primary_location: matched.location,
264                        additional_locations: Vec::new(),
265                        confidence: matched.confidence,
266                        entropy: matched.entropy,
267                    },
268                );
269                // groups.insert on a fresh key appends at the tail, so the new
270                // group's slot index is the prior length - keep seen_locations
271                // index-aligned with the IndexMap.
272                debug_assert_eq!(seen_locations.len(), groups.len() - 1);
273                seen_locations.push(seen);
274            }
275        }
276    }
277
278    // Sort the map in place, then move only its values into the output. This
279    // preserves the canonical key order without materializing an intermediate
280    // Vec of duplicate key/value graph owners.
281    groups.sort_keys();
282    groups.into_values().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: &CompanionMap,
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.as_ref(), 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(Arc::from(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 CompanionMap, incoming: CompanionMap) {
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<(Arc<str>, 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}