Skip to main content

cortex_ingest_core/
normalize.rs

1//! Message normalization for error signature detection.
2//!
3//! `normalize_template` collapses variable log message parts (timestamps,
4//! IPs, IDs, hashes, numbers, JSON values, quoted strings, paths) into fixed
5//! placeholders so that repeated error patterns map to a single canonical
6//! template and, from that, a stable SHA-256 signature hash.
7//!
8//! **Design constraints**:
9//! - No regex pipeline — pure byte/char scanner for hot-path performance.
10//! - Must handle multi-byte UTF-8 without splitting codepoints.
11//! - JSON pre-pass is highest priority (before any other replacements).
12//!
13//! `NORMALIZER_VERSION` must be bumped whenever the output of
14//! `normalize_template` changes so that stale rows in `error_signatures` are
15//! not confused with new ones.
16
17use sha2::{Digest, Sha256};
18
19/// Bump this whenever `normalize_template`'s output changes for any input.
20pub const NORMALIZER_VERSION: i64 = 1;
21
22/// Normalise a message into a template by replacing variable runs with
23/// placeholders.
24///
25/// Priority (highest first):
26/// 1. JSON object/array — replace the whole value with `<json>`
27/// 2. RFC 3164 timestamp prefix — replace leading `Mon DD HH:MM:SS ` with `<ts> `
28/// 3. UUID (8-4-4-4-12 hex) → `<uuid>`
29/// 4. IPv4 / IPv4:port → `<ip>` / `<ip>:<n>`
30/// 5. Long hex run (≥ 8 chars) → `<hex>`
31/// 6. Quoted string (single or double, ≤ 200 chars) → `<str>`
32/// 7. Linux absolute path (`/…/…`) → `<path>`
33/// 8. Numeric run → `<n>`
34/// 9. Non-ASCII codepoints pass through intact.
35pub fn normalize_template(msg: &str) -> String {
36    // --- JSON pre-pass (highest priority) -----------------------------------
37    // If the entire message is a JSON object or array, replace it wholesale.
38    // If a JSON object/array is embedded within the message, replace just that
39    // span. We do a simple brace/bracket counter without a full parser so we
40    // stay allocation-light.
41    let msg = strip_json_spans(msg);
42
43    let bytes = msg.as_bytes();
44    let mut out = String::with_capacity(msg.len());
45    let mut i = 0;
46
47    // RFC 3164 timestamp prefix: "Mon DD HH:MM:SS " at position 0.
48    // e.g. "Jan  1 00:00:00 " or "Jan 12 13:14:15 "
49    if let Some(after_ts) = rfc3164_ts_end(bytes) {
50        out.push_str("<ts> ");
51        i = after_ts;
52    }
53
54    while i < bytes.len() {
55        let b = bytes[i];
56
57        // Non-ASCII: copy the whole UTF-8 codepoint intact.
58        if !b.is_ascii() {
59            let ch = msg[i..].chars().next().expect("char at UTF-8 boundary");
60            out.push(ch);
61            i += ch.len_utf8();
62            continue;
63        }
64
65        // UUID: 8-4-4-4-12 hex separated by dashes
66        if is_hex(b) && looks_like_uuid_at(bytes, i) {
67            out.push_str("<uuid>");
68            i += 36;
69            continue;
70        }
71
72        // IPv4 / IPv4:port
73        if b.is_ascii_digit()
74            && let Some(end) = ipv4_end(bytes, i)
75        {
76            out.push_str("<ip>");
77            i = end;
78            if i < bytes.len() && bytes[i] == b':' {
79                let mut j = i + 1;
80                while j < bytes.len() && bytes[j].is_ascii_digit() {
81                    j += 1;
82                }
83                if j > i + 1 {
84                    out.push_str(":<n>");
85                    i = j;
86                }
87            }
88            continue;
89        }
90
91        // Long hex run (>= 8 chars)
92        if is_hex(b) {
93            let mut j = i;
94            while j < bytes.len() && is_hex(bytes[j]) {
95                j += 1;
96            }
97            if j - i >= 8 {
98                out.push_str("<hex>");
99                i = j;
100                continue;
101            }
102        }
103
104        // Quoted string (double or single, capped at 200 chars to avoid
105        // swallowing multi-sentence content).
106        if b == b'"' || b == b'\'' {
107            let quote = b;
108            let mut j = i + 1;
109            while j < bytes.len() && bytes[j] != quote && j - i <= 201 {
110                if bytes[j] == b'\\' {
111                    j += 1; // skip escaped char
112                }
113                j += 1;
114            }
115            if j < bytes.len() && bytes[j] == quote && j - i <= 201 {
116                out.push_str("<str>");
117                i = j + 1;
118                continue;
119            }
120        }
121
122        // Linux absolute path: starts with / followed by a word-char
123        if b == b'/'
124            && i + 1 < bytes.len()
125            && (bytes[i + 1].is_ascii_alphanumeric() || bytes[i + 1] == b'_')
126        {
127            let mut j = i + 1;
128            while j < bytes.len() {
129                let c = bytes[j];
130                if c.is_ascii_alphanumeric() || matches!(c, b'/' | b'_' | b'-' | b'.' | b'~') {
131                    j += 1;
132                } else {
133                    break;
134                }
135            }
136            if j > i + 1 {
137                out.push_str("<path>");
138                i = j;
139                continue;
140            }
141        }
142
143        // Numeric run
144        if b.is_ascii_digit() {
145            let mut j = i;
146            while j < bytes.len() && bytes[j].is_ascii_digit() {
147                j += 1;
148            }
149            out.push_str("<n>");
150            i = j;
151            continue;
152        }
153
154        out.push(b as char);
155        i += 1;
156    }
157
158    out
159}
160
161/// Compute a stable SHA-256 hex digest of a normalized template.
162pub fn signature_hash(template: &str) -> String {
163    let mut hasher = Sha256::new();
164    hasher.update(template.as_bytes());
165    format!("{:x}", hasher.finalize())
166}
167
168// ---------------------------------------------------------------------------
169// JSON span replacement
170
171/// Walk `msg` and replace any top-level JSON object `{…}` or array `[…]`
172/// spans with `<json>`. Handles nesting via a counter; strings (including
173/// escaped quotes) are handled so brace/bracket characters inside strings are
174/// not counted.
175fn strip_json_spans(msg: &str) -> std::borrow::Cow<'_, str> {
176    let bytes = msg.as_bytes();
177    // Fast check: if there are no `{` or `[` at all, skip the scan.
178    if !bytes.iter().any(|&b| b == b'{' || b == b'[') {
179        return std::borrow::Cow::Borrowed(msg);
180    }
181
182    let mut result = String::new();
183    let mut i = 0;
184    let mut any_replaced = false;
185
186    while i < bytes.len() {
187        let b = bytes[i];
188        if b == b'{' || b == b'[' {
189            let close = if b == b'{' { b'}' } else { b']' };
190            if let Some(end) = find_matching_bracket(bytes, i, b, close) {
191                if !any_replaced {
192                    // Lazy: only materialise `result` on first replacement.
193                    result.push_str(&msg[..i]);
194                    any_replaced = true;
195                }
196                result.push_str("<json>");
197                i = end + 1;
198                continue;
199            }
200        }
201        if any_replaced {
202            if b.is_ascii() {
203                result.push(b as char);
204                i += 1;
205            } else {
206                let ch = msg[i..].chars().next().expect("UTF-8 boundary");
207                result.push(ch);
208                i += ch.len_utf8();
209            }
210        } else {
211            i += if b.is_ascii() {
212                1
213            } else {
214                msg[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1)
215            };
216        }
217    }
218
219    if any_replaced {
220        std::borrow::Cow::Owned(result)
221    } else {
222        std::borrow::Cow::Borrowed(msg)
223    }
224}
225
226/// Find the matching closing bracket for a JSON object/array starting at
227/// `start`. Handles string escaping, nested braces/brackets. Returns the
228/// index of the closing bracket, or `None` if not found.
229fn find_matching_bracket(bytes: &[u8], start: usize, open: u8, close: u8) -> Option<usize> {
230    let mut depth = 0i32;
231    let mut i = start;
232    while i < bytes.len() {
233        let b = bytes[i];
234        if b == b'"' {
235            // Skip over a JSON string
236            i += 1;
237            while i < bytes.len() {
238                if bytes[i] == b'\\' {
239                    i += 2;
240                    continue;
241                }
242                if bytes[i] == b'"' {
243                    i += 1;
244                    break;
245                }
246                i += 1;
247            }
248            continue;
249        }
250        if b == open {
251            depth += 1;
252        } else if b == close {
253            depth -= 1;
254            if depth == 0 {
255                return Some(i);
256            }
257        }
258        i += 1;
259    }
260    None
261}
262
263// ---------------------------------------------------------------------------
264// RFC 3164 timestamp prefix
265
266/// Detect a leading RFC 3164 timestamp: `Mon DD HH:MM:SS ` (16 or 17 bytes).
267/// Month abbreviations: Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec.
268/// Returns the index immediately after the space that follows the timestamp.
269fn rfc3164_ts_end(bytes: &[u8]) -> Option<usize> {
270    // Minimum: "Jan  1 00:00:00 " = 16 bytes (space-padded single digit day)
271    //          "Jan 12 00:00:00 " = 16 bytes
272    if bytes.len() < 16 {
273        return None;
274    }
275    // Month: 3 ASCII uppercase-then-lower letters
276    let month_ok = bytes[0].is_ascii_uppercase()
277        && bytes[1].is_ascii_lowercase()
278        && bytes[2].is_ascii_lowercase()
279        && MONTHS.contains(&(&bytes[0..3] as &[u8]));
280    if !month_ok {
281        return None;
282    }
283    if bytes[3] != b' ' {
284        return None;
285    }
286    // Day: space-padded or zero-padded 1-2 digits
287    let (day_start, day_end) = if bytes[4] == b' ' {
288        // " D"
289        (5, 6)
290    } else if bytes[4].is_ascii_digit() {
291        // "DD"
292        (4, 6)
293    } else {
294        return None;
295    };
296    if day_end > bytes.len() || !bytes[day_start..day_end].iter().all(|b| b.is_ascii_digit()) {
297        return None;
298    }
299    let p = day_end;
300    if p >= bytes.len() || bytes[p] != b' ' {
301        return None;
302    }
303    let p = p + 1;
304    // HH:MM:SS
305    if p + 8 > bytes.len() {
306        return None;
307    }
308    let ts = &bytes[p..p + 8];
309    // HH:MM:SS pattern: D D : D D : D D
310    if !ts[0].is_ascii_digit()
311        || !ts[1].is_ascii_digit()
312        || ts[2] != b':'
313        || !ts[3].is_ascii_digit()
314        || !ts[4].is_ascii_digit()
315        || ts[5] != b':'
316        || !ts[6].is_ascii_digit()
317        || !ts[7].is_ascii_digit()
318    {
319        return None;
320    }
321    let p = p + 8;
322    // Trailing space
323    if p >= bytes.len() || bytes[p] != b' ' {
324        return None;
325    }
326    Some(p + 1)
327}
328
329static MONTHS: &[&[u8]] = &[
330    b"Jan", b"Feb", b"Mar", b"Apr", b"May", b"Jun", b"Jul", b"Aug", b"Sep", b"Oct", b"Nov", b"Dec",
331];
332
333// ---------------------------------------------------------------------------
334// Shared helpers
335
336fn is_hex(b: u8) -> bool {
337    b.is_ascii_hexdigit()
338}
339
340fn looks_like_uuid_at(bytes: &[u8], i: usize) -> bool {
341    if i + 36 > bytes.len() {
342        return false;
343    }
344    const DASH_POSITIONS: [usize; 4] = [8, 13, 18, 23];
345    bytes[i..i + 36].iter().enumerate().all(|(k, &b)| {
346        if DASH_POSITIONS.contains(&k) {
347            b == b'-'
348        } else {
349            b.is_ascii_hexdigit()
350        }
351    })
352}
353
354fn ipv4_end(bytes: &[u8], start: usize) -> Option<usize> {
355    let mut i = start;
356    let mut octets = 0;
357    while octets < 4 {
358        let octet_start = i;
359        while i < bytes.len() && bytes[i].is_ascii_digit() {
360            i += 1;
361        }
362        let len = i - octet_start;
363        if !(1..=3).contains(&len) {
364            return None;
365        }
366        octets += 1;
367        if octets < 4 {
368            if i >= bytes.len() || bytes[i] != b'.' {
369                return None;
370            }
371            i += 1;
372        }
373    }
374    Some(i)
375}
376
377// ---------------------------------------------------------------------------
378// Tests
379
380#[cfg(test)]
381#[path = "normalize_tests.rs"]
382mod tests;