Skip to main content

keyhog_core/
finding.rs

1//! Scanner findings: the output type for detected secrets with location,
2//! confidence, detector metadata, and optional verification status.
3
4// Debt bucket: 16 public items predating the crate floor raising `missing_docs`
5// to `warn`. Public output schema; remove once each carries a doc line.
6#![allow(missing_docs)]
7
8use serde::ser::SerializeStruct;
9use serde::{Deserialize, Serialize, Serializer};
10use std::borrow::Cow;
11use std::collections::{BTreeMap, HashMap};
12use std::sync::Arc;
13
14use crate::{SensitiveString, Severity};
15
16/// SHA-256 digest of a credential.
17///
18/// This is intentionally distinct from other 32-byte digests in the system
19/// (Merkle content hashes, detector-set hashes, verifier cache internals). A
20/// credential hash can suppress findings, correlate reports, and cross detector
21/// boundaries; keeping it named prevents those contracts from blending into
22/// arbitrary `[u8; 32]` arrays.
23#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
24#[repr(transparent)]
25#[serde(transparent)]
26pub struct CredentialHash(#[serde(with = "serde_hash_hex")] [u8; 32]);
27
28impl CredentialHash {
29    /// All-zero hash used only as a compatibility sentinel for historical test
30    /// constructors and old serialized fixtures that omitted real hashes.
31    pub const ZERO: Self = Self([0; 32]);
32
33    /// Construct from raw SHA-256 bytes.
34    #[inline]
35    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
36        Self(bytes)
37    }
38
39    /// Borrow the raw SHA-256 bytes.
40    #[inline]
41    pub const fn as_bytes(&self) -> &[u8; 32] {
42        &self.0
43    }
44
45    /// Return the raw SHA-256 bytes.
46    #[inline]
47    pub const fn into_bytes(self) -> [u8; 32] {
48        self.0
49    }
50
51    /// True when this value is the historical all-zero compatibility sentinel.
52    #[inline]
53    pub const fn is_zero(self) -> bool {
54        let mut idx = 0;
55        while idx < self.0.len() {
56            if self.0[idx] != 0 {
57                return false;
58            }
59            idx += 1;
60        }
61        true
62    }
63}
64
65impl From<[u8; 32]> for CredentialHash {
66    #[inline]
67    fn from(bytes: [u8; 32]) -> Self {
68        Self::from_bytes(bytes)
69    }
70}
71
72impl From<CredentialHash> for [u8; 32] {
73    #[inline]
74    fn from(hash: CredentialHash) -> Self {
75        hash.into_bytes()
76    }
77}
78
79impl AsRef<[u8; 32]> for CredentialHash {
80    #[inline]
81    fn as_ref(&self) -> &[u8; 32] {
82        self.as_bytes()
83    }
84}
85
86impl AsRef<[u8]> for CredentialHash {
87    #[inline]
88    fn as_ref(&self) -> &[u8] {
89        self.as_bytes()
90    }
91}
92
93impl std::fmt::Debug for CredentialHash {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.write_str(&hex_encode(self))
96    }
97}
98
99/// Borrowed raw-match identity used before report-scope deduplication.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub struct RawMatchDedupKey<'a> {
102    pub detector_id: &'a str,
103    pub credential: &'a str,
104}
105
106/// Companion values keyed by scanner-compiled, reference-counted names.
107///
108/// Names are immutable detector metadata. Sharing them keeps repeated findings
109/// from allocating and copying the same key while preserving the map's string
110/// keys at serialization and reporting boundaries.
111pub type CompanionMap = HashMap<Arc<str>, String>;
112
113mod serde_companion_map {
114    use super::CompanionMap;
115    use serde::ser::SerializeMap;
116    use serde::{Deserialize, Serializer};
117    use std::collections::HashMap;
118    use std::sync::Arc;
119
120    pub(super) fn serialize<S>(companions: &CompanionMap, serializer: S) -> Result<S::Ok, S::Error>
121    where
122        S: Serializer,
123    {
124        let mut map = serializer.serialize_map(Some(companions.len()))?;
125        for (name, value) in companions {
126            map.serialize_entry(name.as_ref(), value)?;
127        }
128        map.end()
129    }
130
131    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<CompanionMap, D::Error>
132    where
133        D: serde::Deserializer<'de>,
134    {
135        HashMap::<String, String>::deserialize(deserializer).map(|companions| {
136            companions
137                .into_iter()
138                .map(|(name, value)| (Arc::from(name), value))
139                .collect()
140        })
141    }
142}
143
144/// A raw pattern match before verification or deduplication.
145///
146/// `entropy` and `confidence` are stored as `f64` but are guaranteed never to
147/// be `NaN` (sanitized at construction time). This keeps the manual `Eq` impl
148/// reflexive, which downstream code relies on for `HashMap`/`BTreeMap` keys.
149///
150/// Serde is deliberately asymmetric: `Deserialize` accepts historical
151/// protected-wire input for compatibility, while implicit `Serialize` fails
152/// closed through `SensitiveString` and cannot emit plaintext. Manual `Debug`
153/// also redacts the credential. Use [`Self::to_redacted`] before any disk,
154/// network, log, or report output boundary.
155#[derive(Clone, Serialize, Deserialize)]
156pub struct RawMatch {
157    /// Stable detector identifier.
158    #[serde(with = "serde_arc_str")]
159    pub detector_id: Arc<str>,
160    /// Human-readable detector name.
161    #[serde(with = "serde_arc_str")]
162    pub detector_name: Arc<str>,
163    /// Service namespace associated with the detector.
164    #[serde(with = "serde_arc_str")]
165    pub service: Arc<str>,
166    /// Detector severity level.
167    pub severity: Severity,
168    /// Matched credential bytes before redaction.
169    pub credential: SensitiveString,
170    /// SHA-256 digest of the credential for allowlisting and deduplication.
171    ///
172    /// Stored as the raw 32 inline bytes (matching the verifier `CacheKey`),
173    /// never the 64-char hex `String`: zero heap, half the per-finding
174    /// footprint, no per-match allocation on the pre-dedup hot path. Hex
175    /// encoding happens lazily at the redacted/report boundary only.
176    pub credential_hash: CredentialHash,
177    /// Companion credential or context value extracted nearby.
178    #[serde(with = "serde_companion_map")]
179    pub companions: CompanionMap,
180    /// Source location for the match.
181    pub location: MatchLocation,
182    /// Shannon entropy of the matched credential (0.0 - 8.0). NaN-sanitized.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub entropy: Option<f64>,
185    /// Confidence score (0.0 - 1.0). NaN-sanitized at construction.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub confidence: Option<f64>,
188}
189
190impl RawMatch {
191    /// Replace NaN floats with `None` so the manual `Eq` impl stays reflexive
192    /// and `HashMap`/`BTreeMap` lookups don't trap. Call this on any externally
193    /// constructed `RawMatch` (deserialized findings, scanner outputs).
194    pub(crate) fn sanitize_floats(mut self) -> Self {
195        if self.entropy.is_some_and(f64::is_nan) {
196            self.entropy = None;
197        }
198        if self.confidence.is_some_and(f64::is_nan) {
199            self.confidence = None;
200        }
201        self
202    }
203}
204
205impl PartialEq for RawMatch {
206    fn eq(&self, other: &Self) -> bool {
207        // Compare every field; for the f64 options use `total_cmp` semantics so
208        // NaN-vs-NaN compares equal. We additionally normalize NaN→None on
209        // construction (`sanitize_floats`), but the total-ordering comparison
210        // here keeps the impl sound even if a NaN slips through.
211        self.detector_id == other.detector_id
212            && self.detector_name == other.detector_name
213            && self.service == other.service
214            && self.severity == other.severity
215            && self.credential == other.credential
216            && self.credential_hash == other.credential_hash
217            && self.companions == other.companions
218            && self.location == other.location
219            && opt_f64_total_eq(self.entropy, other.entropy)
220            && opt_f64_total_eq(self.confidence, other.confidence)
221    }
222}
223
224impl Eq for RawMatch {}
225
226impl std::fmt::Debug for RawMatch {
227    /// Redacted Debug. Replaces `derive(Debug)` which would print the raw
228    /// credential plaintext. See kimi-wave1 audit finding 1.1.
229    /// `credential_hash` is preserved because it's already a one-way SHA-256.
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        f.debug_struct("RawMatch")
232            .field("detector_id", &self.detector_id)
233            .field("detector_name", &self.detector_name)
234            .field("service", &self.service)
235            .field("severity", &self.severity)
236            .field(
237                "credential",
238                &format_args!("<redacted {} bytes>", self.credential.len()),
239            )
240            .field(
241                "credential_hash",
242                &format_args!("{}", hex_encode(self.credential_hash)),
243            )
244            .field(
245                "companions",
246                &format_args!("<{} redacted companions>", self.companions.len()),
247            )
248            .field("location", &self.location)
249            .field("entropy", &self.entropy)
250            .field("confidence", &self.confidence)
251            .finish()
252    }
253}
254
255#[inline]
256fn opt_f64_total_eq(a: Option<f64>, b: Option<f64>) -> bool {
257    match (a, b) {
258        (None, None) => true,
259        (Some(x), Some(y)) => x.total_cmp(&y) == std::cmp::Ordering::Equal,
260        _ => false,
261    }
262}
263
264#[inline]
265fn opt_f64_total_cmp(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
266    match (a, b) {
267        (None, None) => std::cmp::Ordering::Equal,
268        (None, Some(_)) => std::cmp::Ordering::Less,
269        (Some(_), None) => std::cmp::Ordering::Greater,
270        (Some(x), Some(y)) => x.total_cmp(&y),
271    }
272}
273
274fn companion_map_cmp(a: &CompanionMap, b: &CompanionMap) -> std::cmp::Ordering {
275    if a == b {
276        return std::cmp::Ordering::Equal;
277    }
278    match a.len().cmp(&b.len()) {
279        std::cmp::Ordering::Equal => {}
280        ordering => return ordering,
281    }
282
283    // Companion sets are tiny and this path runs only after every priority key
284    // ties. Walk each map in lexical-key order without allocating comparator
285    // scratch; the O(n²) selection cost is bounded by companion count and avoids
286    // heap traffic inside BinaryHeap/sort comparators.
287    let mut a_after: Option<&str> = None;
288    let mut b_after: Option<&str> = None;
289    for _ in 0..a.len() {
290        let Some(a_entry) = a
291            .iter()
292            .filter(|(key, _)| a_after.is_none_or(|after| key.as_ref() > after))
293            .min_by(|left, right| left.0.cmp(right.0))
294        else {
295            return std::cmp::Ordering::Equal;
296        };
297        let Some(b_entry) = b
298            .iter()
299            .filter(|(key, _)| b_after.is_none_or(|after| key.as_ref() > after))
300            .min_by(|left, right| left.0.cmp(right.0))
301        else {
302            return std::cmp::Ordering::Equal;
303        };
304        match a_entry.cmp(&b_entry) {
305            std::cmp::Ordering::Equal => {
306                a_after = Some(a_entry.0.as_ref());
307                b_after = Some(b_entry.0.as_ref());
308            }
309            ordering => return ordering,
310        }
311    }
312    std::cmp::Ordering::Equal
313}
314
315impl PartialOrd for RawMatch {
316    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
317        Some(self.cmp(other))
318    }
319}
320
321impl Ord for RawMatch {
322    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
323        // Higher confidence first
324        // LAW10 (both): recall-safe, a `None` confidence sorts as 0.0 (lowest)
325        // for stable display ordering ONLY. Both findings remain in the result
326        // set; sort position never drops a finding.
327        let self_conf = self.confidence.unwrap_or(0.0); // LAW10: absent confidence => 0.0 for sort/partition ordering only; recall-safe
328        let other_conf = other.confidence.unwrap_or(0.0); // LAW10: absent confidence => 0.0 for sort/partition ordering only; recall-safe
329
330        match other_conf.total_cmp(&self_conf) {
331            std::cmp::Ordering::Equal => {}
332            ord => return ord,
333        }
334
335        // Then higher severity first (Critical > High > Medium > Low > Info)
336        match other.severity.cmp(&self.severity) {
337            std::cmp::Ordering::Equal => {}
338            ord => return ord,
339        }
340
341        // Then by detector and credential.
342        match self.detector_id.cmp(&other.detector_id) {
343            std::cmp::Ordering::Equal => {}
344            ord => return ord,
345        }
346        match self.credential.cmp(&other.credential) {
347            std::cmp::Ordering::Equal => {}
348            ord => return ord,
349        }
350
351        // Next by location (offset, then line) so the priority prefix is total
352        // with respect to the dedup identity (detector, credential, offset).
353        // Without this key, two matches of the same secret at different offsets
354        // compare Equal, so when the capped per-chunk match heap
355        // (`ScanState::push_match`) evicts among them at `max_matches_per_chunk`,
356        // the survivor is chosen by insertion order, which is HashMap-iteration
357        // and rayon-thread nondeterministic. A dense, repetitive chunk (e.g. the
358        // concat-source throughput corpus) overflows the cap with many such
359        // ties, so the finding set flickered run-to-run. Including the location
360        // makes eviction content-determined: the kept set is reproducible
361        // regardless of marking volume or thread interleaving.
362        match self.location.offset.cmp(&other.location.offset) {
363            std::cmp::Ordering::Equal => {}
364            ord => return ord,
365        }
366        match self.location.line.cmp(&other.location.line) {
367            std::cmp::Ordering::Equal => {}
368            ord => return ord,
369        }
370
371        // The priority keys above intentionally determine user-visible order.
372        // The remaining fields are deterministic identity tiebreakers only:
373        // `Ord` must compare Equal exactly when field-wise `Eq` does, otherwise
374        // BTree collections and unstable sorts can silently merge or reorder
375        // distinct findings.
376        self.detector_name
377            .cmp(&other.detector_name)
378            .then_with(|| self.service.cmp(&other.service))
379            .then_with(|| self.credential_hash.cmp(&other.credential_hash))
380            .then_with(|| companion_map_cmp(&self.companions, &other.companions))
381            .then_with(|| self.location.source.cmp(&other.location.source))
382            .then_with(|| self.location.file_path.cmp(&other.location.file_path))
383            .then_with(|| self.location.commit.cmp(&other.location.commit))
384            .then_with(|| self.location.author.cmp(&other.location.author))
385            .then_with(|| self.location.date.cmp(&other.location.date))
386            .then_with(|| opt_f64_total_cmp(self.entropy, other.entropy))
387            .then_with(|| opt_f64_total_cmp(self.confidence, other.confidence))
388    }
389}
390
391/// Where a credential was found: file path, line number, commit, and author.
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
393pub struct MatchLocation {
394    /// Logical source backend, such as `filesystem` or `git`.
395    #[serde(with = "serde_arc_str")]
396    pub source: Arc<str>,
397    /// File path, object key, or logical path when available.
398    ///
399    /// Paths stored here must be valid UTF-8. Source implementations that see
400    /// non-UTF-8 paths should encode them into a reversible escaped string
401    /// before constructing a [`MatchLocation`].
402    #[serde(with = "serde_arc_str_opt")]
403    pub file_path: Option<Arc<str>>,
404    /// One-based line number when known.
405    pub line: Option<usize>,
406    /// Byte offset from the start of the source chunk.
407    pub offset: usize,
408    /// Commit identifier for history-derived matches.
409    #[serde(with = "serde_arc_str_opt")]
410    pub commit: Option<Arc<str>>,
411    /// Commit author when available.
412    #[serde(with = "serde_arc_str_opt")]
413    pub author: Option<Arc<str>>,
414    /// Commit timestamp when available.
415    #[serde(with = "serde_arc_str_opt")]
416    pub date: Option<Arc<str>>,
417}
418
419/// A finding after verification - the final output.
420#[derive(Debug, Clone, Deserialize)]
421pub struct VerifiedFinding {
422    /// Stable detector identifier.
423    #[serde(with = "serde_arc_str")]
424    pub detector_id: Arc<str>,
425    /// Human-readable detector name.
426    #[serde(with = "serde_arc_str")]
427    pub detector_name: Arc<str>,
428    /// Service namespace associated with the detector.
429    #[serde(with = "serde_arc_str")]
430    pub service: Arc<str>,
431    /// Detector severity level.
432    pub severity: Severity,
433    /// Redacted version of the credential for reporting.
434    pub credential_redacted: Cow<'static, str>,
435    /// SHA-256 digest of the original credential for internal correlation.
436    /// Raw 32 inline bytes; hex-encoded lazily at the serde/reporter boundary.
437    pub credential_hash: CredentialHash,
438    /// Redacted companion credentials or context values extracted nearby.
439    ///
440    /// Companion values follow the same boundary rule as the primary
441    /// credential: reports may expose a safe preview, never plaintext.
442    #[serde(default)]
443    pub companions_redacted: HashMap<String, String>,
444    /// Source location for the match.
445    pub location: MatchLocation,
446    /// Verification result.
447    pub verification: VerificationResult,
448    /// Additional provider-specific metadata (e.g. account ID, scope).
449    pub metadata: HashMap<String, String>,
450    /// Additional duplicate locations found for this credential.
451    pub additional_locations: Vec<MatchLocation>,
452    /// Shannon entropy measured by the detection path, when available.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub entropy: Option<f64>,
455    /// Confidence score (0.0 - 1.0) combining entropy, keyword proximity, file type, etc.
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub confidence: Option<f64>,
458}
459
460impl VerifiedFinding {
461    /// Construct the report-safe view of a deduplicated match.
462    ///
463    /// This is the single conversion boundary for verifier and skipped paths:
464    /// every new report field must be initialized here, while callers retain
465    /// ownership of policy-specific severity and verification decisions.
466    pub fn from_deduped(
467        group: crate::DedupedMatch,
468        severity: Severity,
469        verification: VerificationResult,
470        metadata: HashMap<String, String>,
471    ) -> Self {
472        Self {
473            detector_id: group.detector_id,
474            detector_name: group.detector_name,
475            service: group.service,
476            severity,
477            credential_redacted: crate::redact(&group.credential),
478            credential_hash: group.credential_hash,
479            companions_redacted: redact_companions(&group.companions),
480            location: group.primary_location,
481            verification,
482            metadata,
483            additional_locations: group.additional_locations,
484            entropy: group.entropy,
485            confidence: group.confidence,
486        }
487    }
488}
489
490impl Serialize for VerifiedFinding {
491    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
492    where
493        S: Serializer,
494    {
495        let remediation =
496            crate::auto_fix::remediation_for(&self.detector_id, &self.service, self.severity);
497        let mut field_count = 12;
498        if self.entropy.is_some() {
499            field_count += 1;
500        }
501        if self.confidence.is_some() {
502            field_count += 1;
503        }
504        let mut state = serializer.serialize_struct("VerifiedFinding", field_count)?;
505        state.serialize_field("detector_id", self.detector_id.as_ref())?;
506        state.serialize_field("detector_name", self.detector_name.as_ref())?;
507        state.serialize_field("service", self.service.as_ref())?;
508        state.serialize_field("severity", &self.severity)?;
509        state.serialize_field("credential_redacted", self.credential_redacted.as_ref())?;
510        state.serialize_field("credential_hash", &hex_encode(self.credential_hash))?;
511        let sorted_companions: BTreeMap<&str, &str> = self
512            .companions_redacted
513            .iter()
514            .map(|(key, value)| (key.as_str(), value.as_str()))
515            .collect();
516        state.serialize_field("companions_redacted", &sorted_companions)?;
517        state.serialize_field("location", &self.location)?;
518        state.serialize_field("verification", &self.verification)?;
519        let sorted_metadata: BTreeMap<&str, &str> = self
520            .metadata
521            .iter()
522            .map(|(key, value)| (key.as_str(), value.as_str()))
523            .collect();
524        state.serialize_field("metadata", &sorted_metadata)?;
525        state.serialize_field("additional_locations", &self.additional_locations)?;
526        if let Some(entropy) = self.entropy {
527            state.serialize_field("entropy", &entropy)?;
528        }
529        if let Some(confidence) = self.confidence {
530            state.serialize_field("confidence", &confidence)?;
531        }
532        state.serialize_field("remediation", &remediation)?;
533        state.end()
534    }
535}
536
537/// Result of live verification: whether the credential is active, revoked, or untested.
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
539#[serde(rename_all = "snake_case")]
540pub enum VerificationResult {
541    /// Credential is active and verified by the provider.
542    Live,
543    /// Credential is valid but has been explicitly revoked or disabled.
544    Revoked,
545    /// Credential was rejected by the provider (invalid password/token).
546    Dead,
547    /// Provider returned a rate-limit error (e.g. 429).
548    RateLimited,
549    /// Verification failed due to network error or timeout.
550    Error(String),
551    /// Detector does not support live verification.
552    Unverifiable,
553    /// Verification was not attempted (e.g. disabled via flag).
554    Skipped,
555}
556
557impl RawMatch {
558    /// Get the raw-match correlation key used before report scope is applied.
559    ///
560    /// This intentionally excludes location. Window/raw-span dedup uses
561    /// `(detector, credential, offset)` in the scanner, while report grouping
562    /// applies `DedupScope` in `core::dedup`.
563    pub(crate) fn deduplication_key(&self) -> RawMatchDedupKey<'_> {
564        RawMatchDedupKey {
565            detector_id: &self.detector_id,
566            credential: &self.credential,
567        }
568    }
569
570    /// Convert into a serialization-safe DTO that never carries the plaintext
571    /// credential. Use this anywhere a `RawMatch` would otherwise be written
572    /// to disk, sent over the network, or rendered into a user-visible
573    /// report. See kimi-wave1 audit finding 2.1 (`scan_system.rs` JSON exfil).
574    pub fn to_redacted(&self) -> RedactedFinding {
575        RedactedFinding {
576            detector_id: self.detector_id.clone(),
577            detector_name: self.detector_name.clone(),
578            service: self.service.clone(),
579            severity: self.severity,
580            credential_redacted: crate::redact(&self.credential),
581            credential_hash: self.credential_hash,
582            companions_redacted: redact_companions(&self.companions),
583            location: self.location.clone(),
584            entropy: self.entropy,
585            confidence: self.confidence,
586        }
587    }
588}
589
590/// Redact every companion value at the process boundary.
591///
592/// Keeping this transformation centralized prevents verifier, offline scan,
593/// and serialization paths from accidentally diverging on companion safety.
594pub fn redact_companions<K>(companions: &HashMap<K, String>) -> HashMap<String, String>
595where
596    K: AsRef<str> + Eq + std::hash::Hash,
597{
598    companions
599        .iter()
600        .map(|(key, value)| (key.as_ref().to_string(), crate::redact(value).into_owned()))
601        .collect()
602}
603
604/// Redacted, disk-safe view of a `RawMatch`. Carries only the SHA-256 hash
605/// and a "first4...last4" preview, never the plaintext credential. Use this
606/// before verification; [`VerifiedFinding`] is the final report-safe shape.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct RedactedFinding {
609    #[serde(with = "serde_arc_str")]
610    pub detector_id: Arc<str>,
611    #[serde(with = "serde_arc_str")]
612    pub detector_name: Arc<str>,
613    #[serde(with = "serde_arc_str")]
614    pub service: Arc<str>,
615    pub severity: Severity,
616    pub credential_redacted: Cow<'static, str>,
617    /// SHA-256 digest as raw 32 inline bytes; hex-encoded at the serde boundary.
618    pub credential_hash: CredentialHash,
619    pub companions_redacted: HashMap<String, String>,
620    pub location: MatchLocation,
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub entropy: Option<f64>,
623    #[serde(skip_serializing_if = "Option::is_none")]
624    pub confidence: Option<f64>,
625}
626
627/// Lower-case hex of digest bytes. The only place the hex string is materialized
628/// for `CredentialHash` values (reporters, Debug).
629#[inline]
630pub fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
631    hex::encode(bytes.as_ref())
632}
633
634/// SHA-256 of a string as the `CredentialHash` domain type. This is the single
635/// source for credential hashing across the workspace (scanner, dedup,
636/// telemetry); hex encoding is a separate step at the serde/reporter boundary
637/// via [`hex_encode`], keeping the pre-dedup hot path zero-heap.
638#[inline]
639pub fn sha256_hash(s: &str) -> CredentialHash {
640    use sha2::{Digest, Sha256};
641    let mut hasher = Sha256::new();
642    hasher.update(s.as_bytes());
643    CredentialHash::from_bytes(hasher.finalize().into())
644}
645
646/// Serde adapter keeping the on-wire shape of `credential_hash` a 64-char
647/// lower-case hex string while the in-memory field is raw `[u8; 32]`. This
648/// preserves the documented JSON/JSONL/baseline/SARIF format (`.credential_hash`
649/// consumers, `keyhogignore` `hash:` entries) with zero heap on the hot path.
650pub(crate) mod serde_hash_hex {
651    use std::borrow::Cow;
652
653    use serde::{Deserialize, Deserializer, Serializer};
654
655    pub(crate) fn serialize<S>(val: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
656    where
657        S: Serializer,
658    {
659        serializer.serialize_str(&super::hex_encode(val))
660    }
661
662    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
663    where
664        D: Deserializer<'de>,
665    {
666        let s = Cow::<'de, str>::deserialize(deserializer)?;
667        if s.len() != crate::git_lfs::SHA256_HEX_LEN {
668            return Err(serde::de::Error::invalid_length(
669                s.len(),
670                &"64-char hex SHA-256 digest",
671            ));
672        }
673        let mut bytes = [0_u8; 32];
674        hex::decode_to_slice(s.as_bytes(), &mut bytes).map_err(serde::de::Error::custom)?;
675        Ok(bytes)
676    }
677}
678
679/// Convert a borrowed-or-owned string into an `Arc<str>` without an extra copy
680/// when the value is already owned. Single owner shared by `serde_arc_str` and
681/// `serde_arc_str_opt` deserialization.
682#[inline]
683fn arc_from_cow(value: Cow<'_, str>) -> Arc<str> {
684    match value {
685        Cow::Borrowed(value) => Arc::from(value),
686        Cow::Owned(value) => Arc::from(value),
687    }
688}
689
690pub(crate) mod serde_arc_str {
691    use serde::{Deserialize, Deserializer, Serialize, Serializer};
692    use std::borrow::Cow;
693    use std::sync::Arc;
694
695    pub(crate) fn serialize<S>(val: &Arc<str>, serializer: S) -> Result<S::Ok, S::Error>
696    where
697        S: Serializer,
698    {
699        val.as_ref().serialize(serializer)
700    }
701
702    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>
703    where
704        D: Deserializer<'de>,
705    {
706        Cow::<'de, str>::deserialize(deserializer).map(super::arc_from_cow)
707    }
708}
709
710pub(crate) mod serde_arc_str_opt {
711    use serde::{Deserialize, Deserializer, Serialize, Serializer};
712    use std::borrow::Cow;
713    use std::sync::Arc;
714
715    pub(crate) fn serialize<S>(val: &Option<Arc<str>>, serializer: S) -> Result<S::Ok, S::Error>
716    where
717        S: Serializer,
718    {
719        val.as_ref().map(|s| s.as_ref()).serialize(serializer)
720    }
721
722    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Arc<str>>, D::Error>
723    where
724        D: Deserializer<'de>,
725    {
726        Option::<Cow<'de, str>>::deserialize(deserializer).map(|opt| opt.map(super::arc_from_cow))
727    }
728}
729
730// Tests live in `tests/unit/finding_arc_str_serde_roundtrip.rs` (KH-GAP-004: no
731// inline test modules in `src/`). The `arc_from_cow` deserialize helper is
732// exercised end-to-end through the public `RawMatch` serde round-trip (its
733// `Arc<str>` fields use `serde_arc_str`, which calls it).