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