Skip to main content

keyhog_core/
detector_corpus.rs

1//! Explicit detector-corpus composition policy.
2
3use crate::DetectorSpec;
4use std::collections::BTreeSet;
5
6/// How a custom detector corpus participates in the effective corpus.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum DetectorCorpusMode {
9    /// The custom corpus is the complete effective corpus.
10    Replace,
11    /// The custom corpus is appended to the embedded corpus after collision checks.
12    Overlay,
13}
14
15/// Failure to compose an effective detector corpus.
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
17pub enum DetectorCorpusError {
18    /// An overlay tried to reuse an embedded detector identifier.
19    #[error(
20        "detector overlay collides with embedded detector id(s): {ids}. \
21         Overlay mode never shadows shipped detectors; rename the custom detector id(s), \
22         or select replace mode for a fully custom corpus"
23    )]
24    IdCollision {
25        /// Sorted, comma-separated detector identifiers present in both corpora.
26        ids: String,
27    },
28}
29
30/// Compose an effective detector corpus without implicit merging.
31///
32/// Replace mode returns the custom vector directly and does not clone either
33/// corpus. Overlay mode preserves each input's order, rejects every identifier
34/// shared by the embedded and custom sets, and then moves the custom specs onto
35/// the end of the embedded vector.
36pub fn compose_detector_corpus(
37    mut embedded: Vec<DetectorSpec>,
38    custom: Vec<DetectorSpec>,
39    mode: DetectorCorpusMode,
40) -> Result<Vec<DetectorSpec>, DetectorCorpusError> {
41    if mode == DetectorCorpusMode::Replace {
42        return Ok(custom);
43    }
44
45    let embedded_ids: BTreeSet<&str> = embedded
46        .iter()
47        .map(|detector| detector.id.as_str())
48        .collect();
49    let collisions: BTreeSet<&str> = custom
50        .iter()
51        .map(|detector| detector.id.as_str())
52        .filter(|id| embedded_ids.contains(id))
53        .collect();
54    if !collisions.is_empty() {
55        return Err(DetectorCorpusError::IdCollision {
56            ids: collisions.into_iter().collect::<Vec<_>>().join(", "),
57        });
58    }
59
60    embedded.reserve(custom.len());
61    embedded.extend(custom);
62    Ok(embedded)
63}
64/// Compute a deterministic digest of current-schema detector specs.
65///
66/// Embedded callers that cannot select another schema use this convenience
67/// entry point. Directory loaders should retain [`crate::LoadedDetectorCorpus`]
68/// and call its schema-aware `compute_digest` method instead.
69pub fn compute_detector_corpus_digest(
70    detectors: &[DetectorSpec],
71) -> Result<[u8; 32], serde_json::Error> {
72    compute_detector_corpus_digest_for_schema(detectors, crate::DETECTOR_CORPUS_SCHEMA_VERSION)
73}
74
75/// Compute a deterministic, schema-bound digest of a complete effective
76/// detector corpus.
77///
78/// Unlike [`crate::compute_spec_hash`], whose contract intentionally includes
79/// only fields that can change scan finding sets, this identity serializes every
80/// declared detector field. It also binds the canonical corpus-manifest path and
81/// the schema version that normalized the specs, so a legacy schema-1 corpus
82/// cannot share cache, handshake, or autoroute evidence with an otherwise equal
83/// current-schema corpus.
84pub fn compute_detector_corpus_digest_for_schema(
85    detectors: &[DetectorSpec],
86    schema_version: u32,
87) -> Result<[u8; 32], serde_json::Error> {
88    let mut canonical: Vec<&DetectorSpec> = detectors.iter().collect();
89    canonical.sort_by(|left, right| left.id.cmp(&right.id));
90    let encoded = serde_json::to_vec(&canonical)?;
91    let mut hasher = blake3::Hasher::new();
92    hasher.update(b"keyhog-effective-detector-corpus-v2\0");
93    hasher.update(crate::DETECTOR_CORPUS_MANIFEST_FILE.as_bytes());
94    hasher.update(&[0]);
95    hasher.update(&schema_version.to_le_bytes());
96    hasher.update(&encoded);
97    Ok(*hasher.finalize().as_bytes())
98}