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 by key for cross-run determinism (the IndexMap iteration order is
279    // insertion order, which depends on input ordering). SARIF fingerprints,
280    // baselines, and CI diffs all need stable output across reruns.
281    let mut deduped: Vec<(DedupKey, DedupedMatch)> = groups.into_iter().collect();
282    deduped.sort_by(|a, b| a.0.cmp(&b.0));
283    deduped.into_iter().map(|(_, v)| v).collect()
284}
285
286/// A decoded-source match and its raw twin count as the same finding when their
287/// known lines are within this many lines of each other. The splice preprocessor
288/// can shift the decoded credential onto an adjacent line, so exact-line equality
289/// is too strict; more than one line apart is treated as a distinct location.
290const DECODER_ALIAS_MAX_LINE_DELTA: usize = 1;
291
292/// When neither location carries a line number, fall back to byte offset: the
293/// decoded chunk is spliced in at (or a few bytes from) the original blob, so an
294/// offset gap within this many bytes marks the pair as a decoder alias.
295const DECODER_ALIAS_MAX_OFFSET_DELTA: usize = 16;
296
297fn is_decoder_alias_pair(a: &MatchLocation, b: &MatchLocation) -> bool {
298    if a.file_path != b.file_path || a.commit != b.commit {
299        return false;
300    }
301    if is_decoder_location(a) == is_decoder_location(b) {
302        return false;
303    }
304    match (a.line, b.line) {
305        (Some(left), Some(right)) if left.abs_diff(right) <= DECODER_ALIAS_MAX_LINE_DELTA => {
306            return true
307        }
308        (Some(_), Some(_)) => return false,
309        _ => {}
310    }
311    a.offset.abs_diff(b.offset) <= DECODER_ALIAS_MAX_OFFSET_DELTA
312}
313
314fn serialize_companions_sorted<S>(
315    companions: &CompanionMap,
316    serializer: S,
317) -> Result<S::Ok, S::Error>
318where
319    S: serde::Serializer,
320{
321    let mut entries: Vec<_> = companions.iter().collect();
322    entries.sort_by(|left, right| left.0.cmp(right.0));
323    let mut map = serializer.serialize_map(Some(entries.len()))?;
324    for (key, value) in entries {
325        map.serialize_entry(key.as_ref(), value)?;
326    }
327    map.end()
328}
329
330fn is_decoder_location(location: &MatchLocation) -> bool {
331    crate::embedded::DECODER_SOURCE_SUFFIXES
332        .iter()
333        .any(|suffix| location.source.ends_with(*suffix))
334}
335
336fn effective_credential_hash(credential: &str, credential_hash: CredentialHash) -> CredentialHash {
337    if credential_hash.is_zero() {
338        sha256_hash(credential)
339    } else {
340        credential_hash
341    }
342}
343
344/// Cross-detector dedup at emit time.
345///
346/// One credential value commonly matches multiple detectors - `AIza...` keys
347/// fire google-api, google-maps, google-places, google-translate; opaque
348/// 32-hex strings fire entropy + several service-specific generic detectors.
349/// The first-pass `dedup_matches` keeps each `(detector, credential)` pair
350/// separate. This second pass groups the deduped Vec by `credential_hash`
351/// and folds related detectors into the WINNING DedupedMatch's companions
352/// map under a `cross_detector` namespace, so a reporter sees ONE finding
353/// per credential with the alternate service guesses listed as evidence -
354/// the internal design notes innovation #5, "Cuts noise ~30%".
355///
356/// The winning detector is chosen by:
357///   1. Highest confidence (Some(f64)::total_cmp).
358///   2. Highest severity.
359///   3. Lexicographic detector_id (deterministic tiebreak).
360///
361/// Loser entries' detector_id, detector_name, and service are folded into
362/// the winner's `companions` under keys like `cross_detector.0`,
363/// `cross_detector.1`, ... in confidence-descending order.
364pub fn dedup_cross_detector(deduped: Vec<DedupedMatch>) -> Vec<DedupedMatch> {
365    if deduped.len() < 2 {
366        return deduped;
367    }
368
369    // Group by (credential_hash, primary_location.file_path) - splitting by
370    // file keeps file-scope dedup intact when the caller used DedupScope::File.
371    type GroupKey = (CredentialHash, Option<Arc<str>>);
372    let mut groups: IndexMap<GroupKey, Vec<DedupedMatch>> = IndexMap::with_capacity(deduped.len());
373    for m in deduped {
374        let key_ref = CrossDetectorGroupKeyRef {
375            credential_hash: m.credential_hash,
376            file_path: m.primary_location.file_path.as_deref(),
377        };
378        match groups.get_full_mut(&key_ref) {
379            Some((_, _, group)) => group.push(m),
380            None => {
381                let key = (m.credential_hash, m.primary_location.file_path.clone());
382                groups.insert(key, vec![m]);
383            }
384        }
385    }
386
387    let mut out: Vec<DedupedMatch> = Vec::with_capacity(groups.len());
388    for (_, mut group) in groups {
389        if group.len() == 1 {
390            // Law 10: the `len() == 1` guard proves `pop()` is `Some`, so this is
391            // not a silent drop today. But to be recall-safe against a future
392            // guard refactor, the impossible `None` arm is made LOUD: a lost
393            // dedup group would otherwise silently disappear a finding from the
394            // report. We surface it (eprintln + counter) instead of skipping.
395            match group.pop() {
396                Some(only) => out.push(only),
397                None => {
398                    DEDUP_LOST_SINGLETON.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
399                    eprintln!(
400                        "keyhog: BUG, dedup_cross_detector hit an empty group under \
401                         a len()==1 guard; a finding may have been dropped. Please \
402                         report this with the scanned input shape."
403                    );
404                }
405            }
406            continue;
407        }
408        // Sort: highest-confidence first, then severity desc, then detector_id
409        // asc, then credential / credential_hash / offset so the order is TOTAL.
410        // The winner is `group.remove(0)`; without the trailing keys, two
411        // matches sharing (confidence, severity, detector_id), e.g. the same
412        // detector firing on two credentials at one (file, line, commit) scope
413        // compare Equal, so which becomes the primary (vs. a `cross_detector.*`
414        // companion) is decided by input order, which is HashMap-iteration /
415        // thread nondeterministic. A total key fixes the primary credential.
416        group.sort_by(|a, b| {
417            // A `None` confidence sorts as 0.0 (lowest) for winner selection, a
418            // deterministic ordering choice, not a swallowed value; the
419            // credential/hash/offset tiebreaks below keep the order TOTAL so no
420            // finding is dropped or nondeterministically reordered.
421            let ac = a.confidence.unwrap_or(0.0); // LAW10: sort default, see note above
422            let bc = b.confidence.unwrap_or(0.0); // LAW10: sort default, see note above
423            bc.total_cmp(&ac)
424                .then_with(|| b.severity.cmp(&a.severity))
425                .then_with(|| a.detector_id.cmp(&b.detector_id))
426                .then_with(|| a.credential.cmp(&b.credential))
427                .then_with(|| a.credential_hash.cmp(&b.credential_hash))
428                .then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
429        });
430        let mut winner = group.remove(0);
431        let mut seen_locations = IndexSet::new();
432        insert_new_location_identity(&mut seen_locations, &winner.primary_location);
433        for loc in &winner.additional_locations {
434            insert_new_location_identity(&mut seen_locations, loc);
435        }
436        for (idx, loser) in group.into_iter().enumerate() {
437            let key = format!("cross_detector.{idx}");
438            let value = format!(
439                "{} ({}) [{}]",
440                loser.service,
441                loser.detector_name,
442                loser
443                    .confidence
444                    .map(|c| format!("{c:.2}"))
445                    .unwrap_or_else(|| "n/a".to_string()) // LAW10: display-only label for absent confidence in cross_detector evidence, no recall impact
446            );
447            winner.companions.entry(Arc::from(key)).or_insert(value);
448            winner.entropy = max_entropy(winner.entropy, loser.entropy);
449            merge_cross_detector_locations(&mut winner, &mut seen_locations, loser);
450        }
451        out.push(winner);
452    }
453
454    // Re-sort for cross-run determinism (insertion order is input-dependent).
455    // Tiebreak on file_path then offset so the order is TOTAL: two winners that
456    // share (detector_id, credential_hash) across different files would otherwise
457    // compare Equal and fall back to IndexMap insertion order, reintroducing the
458    // input-order dependence the rest of this module eliminates for stable
459    // SARIF/baseline diffs.
460    out.sort_by(|a, b| {
461        a.detector_id
462            .cmp(&b.detector_id)
463            .then_with(|| a.credential_hash.cmp(&b.credential_hash))
464            .then_with(|| {
465                a.primary_location
466                    .file_path
467                    .cmp(&b.primary_location.file_path)
468            })
469            .then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
470    });
471    out
472}
473
474fn merge_cross_detector_locations(
475    winner: &mut DedupedMatch,
476    seen_locations: &mut IndexSet<LocationIdentity>,
477    loser: DedupedMatch,
478) {
479    if insert_new_location_identity(seen_locations, &loser.primary_location) {
480        winner.additional_locations.push(loser.primary_location);
481    }
482    for loc in loser.additional_locations {
483        if insert_new_location_identity(seen_locations, &loc) {
484            winner.additional_locations.push(loc);
485        }
486    }
487}
488
489#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
490struct FileScopeIdentity {
491    source: Arc<str>,
492    file_path: Option<Arc<str>>,
493    commit: Option<Arc<str>>,
494}
495
496struct FileScopeIdentityRef<'a> {
497    source: &'a str,
498    file_path: Option<&'a str>,
499    commit: Option<&'a str>,
500}
501
502impl Hash for FileScopeIdentityRef<'_> {
503    fn hash<H: Hasher>(&self, state: &mut H) {
504        self.source.hash(state);
505        self.file_path.hash(state);
506        self.commit.hash(state);
507    }
508}
509
510impl Equivalent<FileScopeIdentity> for FileScopeIdentityRef<'_> {
511    fn equivalent(&self, key: &FileScopeIdentity) -> bool {
512        self.source == key.source.as_ref()
513            && self.file_path == key.file_path.as_deref()
514            && self.commit == key.commit.as_deref()
515    }
516}
517
518struct DedupKeyRef<'a> {
519    detector_id: &'a str,
520    credential: &'a str,
521    file_scope: Option<FileScopeIdentityRef<'a>>,
522}
523
524impl Hash for DedupKeyRef<'_> {
525    fn hash<H: Hasher>(&self, state: &mut H) {
526        self.detector_id.hash(state);
527        self.credential.hash(state);
528        self.file_scope.hash(state);
529    }
530}
531
532impl Equivalent<(Arc<str>, SensitiveString, Option<FileScopeIdentity>)> for DedupKeyRef<'_> {
533    fn equivalent(&self, key: &(Arc<str>, SensitiveString, Option<FileScopeIdentity>)) -> bool {
534        self.detector_id == key.0.as_ref()
535            && self.credential == key.1.as_str()
536            && match (&self.file_scope, key.2.as_ref()) {
537                (None, None) => true,
538                (Some(scope_ref), Some(scope)) => scope_ref.equivalent(scope),
539                _ => false,
540            }
541    }
542}
543
544struct CrossDetectorGroupKeyRef<'a> {
545    credential_hash: CredentialHash,
546    file_path: Option<&'a str>,
547}
548
549impl Hash for CrossDetectorGroupKeyRef<'_> {
550    fn hash<H: Hasher>(&self, state: &mut H) {
551        self.credential_hash.hash(state);
552        self.file_path.hash(state);
553    }
554}
555
556impl Equivalent<(CredentialHash, Option<Arc<str>>)> for CrossDetectorGroupKeyRef<'_> {
557    fn equivalent(&self, key: &(CredentialHash, Option<Arc<str>>)) -> bool {
558        self.credential_hash == key.0 && self.file_path == key.1.as_deref()
559    }
560}
561
562fn file_scope_identity(location: &MatchLocation) -> FileScopeIdentity {
563    FileScopeIdentity {
564        source: Arc::clone(&location.source),
565        file_path: location.file_path.clone(),
566        commit: location.commit.clone(),
567    }
568}
569
570/// The hashable identity `(source, file_path, line, commit)` that defines
571/// when two locations are "the same finding" and must collapse. Offset is
572/// intentionally excluded: the structured preprocessor's synthetic-line append
573/// produces matches whose offset lies past the source file's EOF (the offset is
574/// into final_text, not the original chunk text), but whose `line` field is
575/// correctly remapped via LineMapping to the original source line. So
576/// same-(file, line) means the dedupe SHOULD collapse them: emitting both as
577/// "primary at line 1 offset 27" + "additional at line 1 offset 80 (past EOF)"
578/// is a confusing duplicate, not two findings. Used as the per-group seen-set
579/// element so additional_locations membership is O(1) instead of an O(K)
580/// linear scan.
581///
582/// Offset is intentionally excluded (KH-1438): synthetic multiline/decode
583/// windows re-emit the same secret at different byte offsets on the same
584/// logical line. Including offset would re-inflate those as additional
585/// locations. Distinct commits still keep separate identities so history
586/// scans do not collapse cross-revision hits.
587#[derive(Clone, Debug, PartialEq, Eq, Hash)]
588struct LocationIdentity {
589    source: Arc<str>,
590    file_path: Option<Arc<str>>,
591    line: Option<usize>,
592    commit: Option<Arc<str>>,
593}
594
595struct LocationIdentityRef<'a> {
596    source: &'a str,
597    file_path: Option<&'a str>,
598    line: Option<usize>,
599    commit: Option<&'a str>,
600}
601
602impl Hash for LocationIdentityRef<'_> {
603    fn hash<H: Hasher>(&self, state: &mut H) {
604        self.source.hash(state);
605        self.file_path.hash(state);
606        self.line.hash(state);
607        self.commit.hash(state);
608    }
609}
610
611impl Equivalent<LocationIdentity> for LocationIdentityRef<'_> {
612    fn equivalent(&self, key: &LocationIdentity) -> bool {
613        self.source == key.source.as_ref()
614            && self.file_path == key.file_path.as_deref()
615            && self.line == key.line
616            && self.commit == key.commit.as_deref()
617    }
618}
619
620fn location_identity(loc: &MatchLocation) -> LocationIdentity {
621    LocationIdentity {
622        source: Arc::clone(&loc.source),
623        file_path: loc.file_path.clone(),
624        line: loc.line,
625        commit: loc.commit.clone(),
626    }
627}
628
629fn location_identity_ref(loc: &MatchLocation) -> LocationIdentityRef<'_> {
630    LocationIdentityRef {
631        source: loc.source.as_ref(),
632        file_path: loc.file_path.as_deref(),
633        line: loc.line,
634        commit: loc.commit.as_deref(),
635    }
636}
637
638fn insert_new_location_identity(
639    seen: &mut IndexSet<LocationIdentity>,
640    location: &MatchLocation,
641) -> bool {
642    let identity = location_identity_ref(location);
643    if seen.contains(&identity) {
644        return false;
645    }
646    seen.insert(location_identity(location));
647    true
648}
649
650fn merge_companions(existing: &mut CompanionMap, incoming: CompanionMap) {
651    // Most duplicate matches carry no companions; skip the Vec alloc + sort in
652    // that hot-loop-common case. (dedup calls this once per merged duplicate.)
653    if incoming.is_empty() {
654        return;
655    }
656    // Sort incoming by key so the merged " | "-delimited string is stable
657    // across runs even though the existing field is a HashMap. Without this,
658    // rerunning the same scan can produce different companion orderings.
659    let mut sorted: Vec<(Arc<str>, String)> = incoming.into_iter().collect();
660    sorted.sort_by(|a, b| a.0.cmp(&b.0));
661    for (name, value) in sorted {
662        match existing.get_mut(&name) {
663            Some(current) if current != &value => {
664                let already_present = current
665                    .split(" | ")
666                    .any(|candidate| candidate == value.as_str());
667                if !already_present {
668                    current.push_str(" | ");
669                    current.push_str(&value);
670                }
671            }
672            Some(_) => {}
673            None => {
674                existing.insert(name, value);
675            }
676        }
677    }
678}
679
680fn max_confidence(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
681    match (lhs, rhs) {
682        (Some(a), Some(b)) => Some(a.max(b)),
683        (Some(a), None) => Some(a),
684        (None, Some(b)) => Some(b),
685        (None, None) => None,
686    }
687}
688
689fn max_entropy(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
690    match (lhs, rhs) {
691        (Some(a), Some(b)) => Some(if a.total_cmp(&b).is_ge() { a } else { b }),
692        (Some(a), None) => Some(a),
693        (None, Some(b)) => Some(b),
694        (None, None) => None,
695    }
696}