Skip to main content

rsigma_ir/
cache.rs

1//! Versioned serialization of lowered rules, the HIR cache.
2//!
3//! A slice of [`IrRule`] serializes to a self-describing blob: a
4//! [`HirCacheHeader`] (schema version + producing `rsigma-ir` version) followed
5//! by the rules. CBOR is the compact binary format for the on-disk cache
6//! (e.g. a daemon restart cache that skips parse, pipeline, and lowering);
7//! [`to_json`] gives a human-readable debug export of the same shape.
8//!
9//! CBOR (not a fixed-layout format like postcard) is used deliberately: the HIR
10//! embeds `rsigma_parser::LogSource`, whose `#[serde(flatten)]` custom-key map
11//! serializes with an unknown length, which fixed-layout encoders reject. CBOR
12//! encodes such maps natively.
13//!
14//! The header is read and version-checked *before* the rules are decoded, so a
15//! blob written by an incompatible schema is rejected cleanly rather than
16//! misparsed. Bump [`HIR_SCHEMA_VERSION`] on any breaking change to the HIR
17//! types or the embedded parser types they reference.
18
19use serde::{Deserialize, Serialize};
20
21use crate::hir::IrRule;
22
23/// Schema version of the serialized HIR. Bump on any breaking change to the
24/// HIR types or the embedded `rsigma-parser` types they contain.
25pub const HIR_SCHEMA_VERSION: u32 = 1;
26
27/// Header prefixed to a serialized HIR blob.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct HirCacheHeader {
30    /// The [`HIR_SCHEMA_VERSION`] the blob was written with.
31    pub ir_schema_version: u32,
32    /// The `rsigma-ir` package version that produced the blob (informational;
33    /// not enforced on load).
34    pub rsigma_version: String,
35}
36
37impl HirCacheHeader {
38    /// The header for the running build.
39    pub fn current() -> Self {
40        Self {
41            ir_schema_version: HIR_SCHEMA_VERSION,
42            rsigma_version: env!("CARGO_PKG_VERSION").to_string(),
43        }
44    }
45}
46
47/// A full HIR cache: header plus the lowered rules. Used for JSON export; the
48/// binary path encodes the same fields in order via [`encode_rules`].
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct HirCache {
51    pub header: HirCacheHeader,
52    pub rules: Vec<IrRule>,
53}
54
55/// Errors from encoding or decoding a HIR cache blob.
56#[derive(Debug, thiserror::Error)]
57pub enum CacheError {
58    #[error("HIR cache encode failed: {0}")]
59    Encode(String),
60    #[error("HIR cache decode failed: {0}")]
61    Decode(String),
62    #[error("HIR cache schema mismatch: blob is v{found}, this build expects v{expected}")]
63    SchemaMismatch { expected: u32, found: u32 },
64}
65
66/// Encode a slice of lowered rules into a versioned CBOR blob: the header CBOR
67/// item followed by the rules CBOR item.
68pub fn encode_rules(rules: &[IrRule]) -> Result<Vec<u8>, CacheError> {
69    let mut buf = Vec::new();
70    ciborium::into_writer(&HirCacheHeader::current(), &mut buf)
71        .map_err(|e| CacheError::Encode(e.to_string()))?;
72    ciborium::into_writer(&rules, &mut buf).map_err(|e| CacheError::Encode(e.to_string()))?;
73    Ok(buf)
74}
75
76/// Decode a versioned CBOR blob, rejecting a schema-version mismatch before
77/// attempting to decode the rules.
78pub fn decode_rules(bytes: &[u8]) -> Result<Vec<IrRule>, CacheError> {
79    let mut cursor = std::io::Cursor::new(bytes);
80    let header: HirCacheHeader =
81        ciborium::from_reader(&mut cursor).map_err(|e| CacheError::Decode(e.to_string()))?;
82    if header.ir_schema_version != HIR_SCHEMA_VERSION {
83        return Err(CacheError::SchemaMismatch {
84            expected: HIR_SCHEMA_VERSION,
85            found: header.ir_schema_version,
86        });
87    }
88    ciborium::from_reader(&mut cursor).map_err(|e| CacheError::Decode(e.to_string()))
89}
90
91/// Human-readable JSON debug export of the cache (header + rules), pretty-printed.
92pub fn to_json(rules: &[IrRule]) -> Result<String, CacheError> {
93    let cache = HirCache {
94        header: HirCacheHeader::current(),
95        rules: rules.to_vec(),
96    };
97    serde_json::to_string_pretty(&cache).map_err(|e| CacheError::Encode(e.to_string()))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::hir::{IrRule, IrRuleMetadata};
104    use rsigma_parser::LogSource;
105
106    fn sample_rule(title: &str) -> IrRule {
107        IrRule {
108            metadata: IrRuleMetadata {
109                title: title.to_string(),
110                ..Default::default()
111            },
112            logsource: LogSource::default(),
113            sigma_version: None,
114            detections: Default::default(),
115            conditions: Vec::new(),
116        }
117    }
118
119    #[test]
120    fn round_trips_through_postcard() {
121        let rules = vec![sample_rule("a"), sample_rule("b")];
122        let blob = encode_rules(&rules).unwrap();
123        let decoded = decode_rules(&blob).unwrap();
124        assert_eq!(decoded, rules);
125    }
126
127    #[test]
128    fn rejects_schema_mismatch() {
129        let rules = vec![sample_rule("a")];
130        // Craft a blob with a future schema version.
131        let mut blob = Vec::new();
132        let header = HirCacheHeader {
133            ir_schema_version: HIR_SCHEMA_VERSION + 1,
134            rsigma_version: "test".to_string(),
135        };
136        ciborium::into_writer(&header, &mut blob).unwrap();
137        ciborium::into_writer(&rules, &mut blob).unwrap();
138
139        match decode_rules(&blob) {
140            Err(CacheError::SchemaMismatch { expected, found }) => {
141                assert_eq!(expected, HIR_SCHEMA_VERSION);
142                assert_eq!(found, HIR_SCHEMA_VERSION + 1);
143            }
144            other => panic!("expected schema mismatch, got {other:?}"),
145        }
146    }
147
148    #[test]
149    fn json_export_is_readable() {
150        let rules = vec![sample_rule("json")];
151        let json = to_json(&rules).unwrap();
152        assert!(json.contains("\"ir_schema_version\""));
153        assert!(json.contains("\"json\""));
154    }
155}