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;