Skip to main content

memf_linux/
oom_events.rs

1//! OOM (Out-of-Memory) kill event recovery from the kernel log buffer.
2//!
3//! Scans the `__log_buf` printk ring buffer for OOM kill messages
4//! ("Out of memory: Killed process") and extracts structured event info.
5//! Events that killed security/monitoring processes are flagged as suspicious.
6
7use memf_core::object_reader::ObjectReader;
8use memf_format::PhysicalMemoryProvider;
9
10use crate::Result;
11
12/// Information about an OOM kill event recovered from kernel logs.
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct OomEventInfo {
15    /// PID of the process that was OOM-killed.
16    pub victim_pid: u32,
17    /// Command name of the killed process.
18    pub victim_comm: String,
19    /// OOM score adjustment at time of kill.
20    pub oom_score_adj: i16,
21    /// Total virtual memory in kilobytes.
22    pub total_vm_kb: u64,
23    /// Resident set size in kilobytes.
24    pub rss_kb: u64,
25    /// Timestamp in nanoseconds from the printk record.
26    pub timestamp_ns: u64,
27    /// Source of the OOM event (e.g. "oom_kill_process", "mem_cgroup_oom").
28    pub reason: String,
29    /// True when the victim is a security/monitoring process or has PID < 100.
30    pub is_suspicious: bool,
31}
32
33/// Classify whether an OOM kill event is suspicious.
34///
35/// Suspicious when the victim command matches a security/monitoring daemon
36/// name, or the victim PID is below 100 (likely a critical system process).
37pub use crate::heuristics::classify_oom_victim;
38
39/// Parse a single OOM kill log line and return `(pid, comm, oom_score_adj, total_vm_kb, rss_kb)`.
40///
41/// Expected format (kernel 4.x+):
42/// `Out of memory: Killed process 1234 (comm) score 567 total-vm:89012kB, anon-rss:12345kB, ...`
43fn parse_oom_line(line: &str) -> Option<(u32, String, i16, u64, u64)> {
44    if !line.contains("Out of memory: Kill") {
45        return None;
46    }
47
48    // Extract PID: "process <pid> "
49    let pid = {
50        let marker = "process ";
51        let start = line.find(marker)? + marker.len();
52        let end = line[start..].find(' ')? + start;
53        line[start..end].trim().parse::<u32>().ok()?
54    };
55
56    // Extract comm: parenthesised token after the PID.
57    let comm = {
58        let needle = format!("{pid} (");
59        let after_pid = line.find(&needle)?;
60        let paren_start = after_pid + needle.len();
61        let paren_end = paren_start + line[paren_start..].find(')')?;
62        line[paren_start..paren_end].to_string()
63    };
64
65    // Extract oom_score_adj proxy from "score <n>".
66    let score_adj: i16 = if let Some(pos) = line.find("score ") {
67        let s = pos + "score ".len();
68        let e = s + line[s..]
69            .find(|c: char| !c.is_ascii_digit() && c != '-')
70            .unwrap_or(0);
71        line[s..e].trim().parse::<i16>().unwrap_or(0)
72    } else {
73        0
74    };
75
76    let total_vm_kb = extract_kb(line, "total-vm:");
77    let rss_kb = extract_kb(line, "anon-rss:");
78
79    Some((pid, comm, score_adj, total_vm_kb, rss_kb))
80}
81
82/// Extract a `<label><value>kB` numeric value from a log line.
83fn extract_kb(line: &str, label: &str) -> u64 {
84    let pos = match line.find(label) {
85        Some(p) => p + label.len(),
86        None => return 0,
87    };
88    let end = line[pos..]
89        .find(|c: char| !c.is_ascii_digit())
90        .map_or(line.len(), |e| pos + e);
91    line[pos..end].trim().parse::<u64>().unwrap_or(0)
92}
93
94/// Maximum number of kmsg records to scan (runaway protection).
95const MAX_RECORDS: usize = 8192;
96/// Maximum kmsg ring buffer size to read.
97const MAX_BUF_LEN: usize = 1 << 18; // 256 KiB
98
99/// Walk the kernel log ring buffer for OOM kill events.
100///
101/// Returns `Ok(Vec::new())` when the `__log_buf` symbol is absent (graceful
102/// degradation — mirrors the pattern used in `kmsg.rs`).
103pub fn walk_oom_events<P: PhysicalMemoryProvider>(
104    reader: &ObjectReader<P>,
105) -> Result<Vec<OomEventInfo>> {
106    let buf_addr = match reader.symbols().symbol_address("__log_buf") {
107        Some(addr) => addr,
108        None => return Ok(Vec::new()),
109    };
110
111    // Read log_buf_len if available; default to 4096.
112    let buf_len: usize = reader
113        .symbols()
114        .symbol_address("log_buf_len")
115        .and_then(|a| {
116            reader
117                .read_bytes(a, 4)
118                .ok()
119                .and_then(|b| b.try_into().ok())
120                .map(u32::from_le_bytes)
121                .map(|v| v as usize)
122        })
123        .unwrap_or(4096)
124        .min(MAX_BUF_LEN);
125
126    let raw = match reader.read_bytes(buf_addr, buf_len) {
127        Ok(b) => b,
128        Err(_) => return Ok(Vec::new()),
129    };
130
131    let mut results = Vec::new();
132    let mut offset = 0usize;
133    let mut record_count = 0usize;
134
135    while offset + 16 <= raw.len() && record_count < MAX_RECORDS {
136        // printk_log header (kernel 3.x+):
137        //  u64 ts_nsec     @ 0
138        //  u16 len         @ 8
139        //  u16 text_len    @ 10
140        //  u16 dict_len    @ 12
141        //  u8  facility    @ 14
142        //  u8  flags_level @ 15
143        let ts_nsec = raw[offset..offset + 8]
144            .try_into()
145            .map_or(0, u64::from_le_bytes);
146        let len = raw[offset + 8..offset + 10]
147            .try_into()
148            .map_or(0, u16::from_le_bytes) as usize;
149        let text_len = raw[offset + 10..offset + 12]
150            .try_into()
151            .map_or(0, u16::from_le_bytes) as usize;
152
153        if len == 0 || offset + len > raw.len() {
154            break;
155        }
156
157        let text_start = offset + 16;
158        if text_start + text_len <= raw.len() {
159            let text = std::str::from_utf8(&raw[text_start..text_start + text_len])
160                .unwrap_or("")
161                .trim_end_matches('\0');
162
163            if let Some((pid, comm, score_adj, total_vm_kb, rss_kb)) = parse_oom_line(text) {
164                let is_suspicious = classify_oom_victim(&comm, pid);
165                let reason = if text.contains("memory cgroup") || text.contains("mem_cgroup") {
166                    "mem_cgroup_oom".to_string()
167                } else {
168                    "oom_kill_process".to_string()
169                };
170                results.push(OomEventInfo {
171                    victim_pid: pid,
172                    victim_comm: comm,
173                    oom_score_adj: score_adj,
174                    total_vm_kb,
175                    rss_kb,
176                    timestamp_ns: ts_nsec,
177                    reason,
178                    is_suspicious,
179                });
180            }
181        }
182
183        offset += len;
184        record_count += 1;
185    }
186
187    Ok(results)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use memf_core::object_reader::ObjectReader;
194    use memf_core::test_builders::{PageTableBuilder, SyntheticPhysMem};
195    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
196    use memf_symbols::isf::IsfResolver;
197    use memf_symbols::test_builders::IsfBuilder;
198
199    fn make_no_symbol_reader() -> ObjectReader<SyntheticPhysMem> {
200        let isf = IsfBuilder::new().build_json();
201        let resolver = IsfResolver::from_value(&isf).unwrap();
202        let (cr3, mem) = PageTableBuilder::new().build();
203        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
204        ObjectReader::new(vas, Box::new(resolver))
205    }
206
207    #[test]
208    fn classify_oom_kill_of_auditd_suspicious() {
209        assert!(
210            classify_oom_victim("auditd", 1234),
211            "OOM kill of auditd must be suspicious"
212        );
213    }
214
215    #[test]
216    fn classify_oom_kill_of_sshd_suspicious() {
217        assert!(
218            classify_oom_victim("sshd", 500),
219            "OOM kill of sshd must be suspicious"
220        );
221    }
222
223    #[test]
224    fn classify_oom_kill_of_low_pid_suspicious() {
225        assert!(
226            classify_oom_victim("kworker", 42),
227            "OOM kill of PID < 100 must be suspicious"
228        );
229    }
230
231    #[test]
232    fn classify_oom_kill_of_user_process_benign() {
233        assert!(
234            !classify_oom_victim("chrome", 9999),
235            "OOM kill of a regular user process must not be suspicious"
236        );
237    }
238
239    #[test]
240    fn classify_oom_kill_of_containerd_suspicious() {
241        assert!(
242            classify_oom_victim("containerd", 2000),
243            "OOM kill of containerd must be suspicious"
244        );
245    }
246
247    #[test]
248    fn parse_oom_line_extracts_pid_and_comm() {
249        let line = "Out of memory: Killed process 4321 (myapp) score 100 total-vm:204800kB, anon-rss:102400kB, file-rss:0kB";
250        let (pid, comm, _score, total_vm, rss) = parse_oom_line(line).unwrap();
251        assert_eq!(pid, 4321);
252        assert_eq!(comm, "myapp");
253        assert_eq!(total_vm, 204800);
254        assert_eq!(rss, 102400);
255    }
256
257    #[test]
258    fn parse_oom_line_returns_none_for_non_oom() {
259        assert!(parse_oom_line("normal kernel log message").is_none());
260    }
261
262    #[test]
263    fn walk_oom_events_no_symbol_returns_empty() {
264        let reader = make_no_symbol_reader();
265        let result = walk_oom_events(&reader).unwrap();
266        assert!(
267            result.is_empty(),
268            "no __log_buf symbol → empty vec expected"
269        );
270    }
271
272    // -------------------------------------------------------------------
273    // parse_oom_line edge-case tests
274    // -------------------------------------------------------------------
275
276    #[test]
277    fn parse_oom_line_with_mem_cgroup_prefix() {
278        // Line uses "Kill" variant (also matches "Killed")
279        let line =
280            "Out of memory: Kill process 100 (victim) score 0 total-vm:1024kB, anon-rss:512kB";
281        let result = parse_oom_line(line);
282        assert!(result.is_some(), "Kill (without -ed) should also match");
283        let (pid, comm, _, total_vm, rss) = result.unwrap();
284        assert_eq!(pid, 100);
285        assert_eq!(comm, "victim");
286        assert_eq!(total_vm, 1024);
287        assert_eq!(rss, 512);
288    }
289
290    #[test]
291    fn parse_oom_line_no_score_field() {
292        // Line without "score" → score_adj defaults to 0
293        let line = "Out of memory: Killed process 5678 (noscore) total-vm:2048kB, anon-rss:1024kB";
294        let (pid, comm, score, total_vm, rss) = parse_oom_line(line).unwrap();
295        assert_eq!(pid, 5678);
296        assert_eq!(comm, "noscore");
297        assert_eq!(score, 0);
298        assert_eq!(total_vm, 2048);
299        assert_eq!(rss, 1024);
300    }
301
302    #[test]
303    fn parse_oom_line_no_total_vm() {
304        // total-vm missing → 0
305        let line = "Out of memory: Killed process 42 (partial) score 10 anon-rss:256kB";
306        let (pid, _comm, _score, total_vm, rss) = parse_oom_line(line).unwrap();
307        assert_eq!(pid, 42);
308        assert_eq!(total_vm, 0);
309        assert_eq!(rss, 256);
310    }
311
312    #[test]
313    fn parse_oom_line_no_anon_rss() {
314        // anon-rss missing → 0
315        let line = "Out of memory: Killed process 99 (norss) score 5 total-vm:512kB";
316        let (_pid, _comm, _score, total_vm, rss) = parse_oom_line(line).unwrap();
317        assert_eq!(total_vm, 512);
318        assert_eq!(rss, 0);
319    }
320
321    #[test]
322    fn parse_oom_line_pid_parse_failure_returns_none() {
323        // "process" marker is present but PID is not a number
324        let line = "Out of memory: Killed process NOTAPID (comm) score 0";
325        assert!(parse_oom_line(line).is_none());
326    }
327
328    // -------------------------------------------------------------------
329    // extract_kb unit tests
330    // -------------------------------------------------------------------
331
332    #[test]
333    fn extract_kb_missing_label_returns_zero() {
334        assert_eq!(extract_kb("no labels here", "total-vm:"), 0);
335    }
336
337    #[test]
338    fn extract_kb_label_present_parses_value() {
339        assert_eq!(
340            extract_kb("total-vm:8192kB, anon-rss:4096kB", "total-vm:"),
341            8192
342        );
343    }
344
345    #[test]
346    fn extract_kb_at_end_of_string() {
347        // No non-digit character after the value → uses line.len() as end
348        assert_eq!(extract_kb("anon-rss:1024", "anon-rss:"), 1024);
349    }
350
351    // -------------------------------------------------------------------
352    // classify_oom_victim — additional names
353    // -------------------------------------------------------------------
354
355    #[test]
356    fn classify_oom_victim_journald_suspicious() {
357        assert!(classify_oom_victim("systemd-journald", 5000));
358    }
359
360    #[test]
361    fn classify_oom_victim_rsyslogd_suspicious() {
362        assert!(classify_oom_victim("rsyslogd", 300));
363    }
364
365    #[test]
366    fn classify_oom_victim_dockerd_suspicious() {
367        assert!(classify_oom_victim("dockerd", 1000));
368    }
369
370    #[test]
371    fn classify_oom_victim_systemd_suspicious() {
372        assert!(classify_oom_victim("systemd", 1));
373    }
374
375    #[test]
376    fn classify_oom_victim_pid_exactly_100_not_suspicious() {
377        // Boundary: pid == 100 is NOT < 100
378        assert!(!classify_oom_victim("someproc", 100));
379    }
380
381    #[test]
382    fn classify_oom_victim_pid_99_suspicious() {
383        assert!(classify_oom_victim("someproc", 99));
384    }
385
386    // -------------------------------------------------------------------
387    // walk_oom_events with a synthetic log buffer
388    // -------------------------------------------------------------------
389
390    fn build_printk_record(ts_nsec: u64, text: &[u8]) -> Vec<u8> {
391        // printk_log header layout (kernel 3.x+):
392        //   u64 ts_nsec   @ 0
393        //   u16 len       @ 8   (total record size including header)
394        //   u16 text_len  @ 10
395        //   u16 dict_len  @ 12
396        //   u8  facility  @ 14
397        //   u8  flags     @ 15
398        let header_size = 16usize;
399        let text_len = text.len();
400        // total len must be aligned to 8 bytes
401        let raw_len = header_size + text_len;
402        let len = (raw_len + 7) & !7;
403
404        let mut rec = vec![0u8; len];
405        rec[0..8].copy_from_slice(&ts_nsec.to_le_bytes());
406        rec[8..10].copy_from_slice(&(len as u16).to_le_bytes());
407        rec[10..12].copy_from_slice(&(text_len as u16).to_le_bytes());
408        // dict_len, facility, flags_level all zero
409        rec[header_size..header_size + text_len].copy_from_slice(text);
410        rec
411    }
412
413    #[test]
414    fn walk_oom_events_with_synthetic_oom_record() {
415        use memf_core::test_builders::flags as ptf;
416
417        let log_text = b"Out of memory: Killed process 1234 (auditd) score 200 total-vm:65536kB, anon-rss:32768kB, file-rss:0kB";
418        let record = build_printk_record(123_456_789, log_text);
419
420        let buf_vaddr: u64 = 0xFFFF_8800_0000_0000;
421        let buf_paddr: u64 = 0x0010_0000; // 1 MB — within 16 MB SyntheticPhysMem limit
422
423        let isf = IsfBuilder::new()
424            .add_symbol("__log_buf", buf_vaddr)
425            .build_json();
426
427        let resolver = IsfResolver::from_value(&isf).unwrap();
428
429        // Pad record to at least 4096 bytes so the default buf_len read works
430        let mut buf = record.clone();
431        buf.resize(4096, 0);
432
433        let (cr3, mem) = PageTableBuilder::new()
434            .map_4k(buf_vaddr, buf_paddr, ptf::WRITABLE)
435            .write_phys(buf_paddr, &buf)
436            .build();
437
438        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
439        let reader = ObjectReader::new(vas, Box::new(resolver));
440
441        let result = walk_oom_events(&reader).expect("should not error");
442        assert_eq!(result.len(), 1, "expected exactly one OOM event");
443        let ev = &result[0];
444        assert_eq!(ev.victim_pid, 1234);
445        assert_eq!(ev.victim_comm, "auditd");
446        assert_eq!(ev.total_vm_kb, 65536);
447        assert_eq!(ev.rss_kb, 32768);
448        assert!(ev.is_suspicious, "auditd kill must be suspicious");
449        assert_eq!(ev.timestamp_ns, 123_456_789);
450        assert_eq!(ev.reason, "oom_kill_process");
451    }
452
453    #[test]
454    fn walk_oom_events_mem_cgroup_reason() {
455        use memf_core::test_builders::flags as ptf;
456
457        let log_text = b"Out of memory: Kill process 200 (victim) due to memory cgroup score 0 total-vm:1024kB, anon-rss:512kB";
458        let record = build_printk_record(999, log_text);
459
460        let buf_vaddr: u64 = 0xFFFF_8800_0001_0000;
461        let buf_paddr: u64 = 0x0020_0000; // 2 MB — within 16 MB SyntheticPhysMem limit
462
463        let isf = IsfBuilder::new()
464            .add_symbol("__log_buf", buf_vaddr)
465            .build_json();
466
467        let resolver = IsfResolver::from_value(&isf).unwrap();
468
469        let mut buf = record.clone();
470        buf.resize(4096, 0);
471
472        let (cr3, mem) = PageTableBuilder::new()
473            .map_4k(buf_vaddr, buf_paddr, ptf::WRITABLE)
474            .write_phys(buf_paddr, &buf)
475            .build();
476
477        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
478        let reader = ObjectReader::new(vas, Box::new(resolver));
479
480        let result = walk_oom_events(&reader).expect("should not error");
481        assert_eq!(result.len(), 1);
482        assert_eq!(result[0].reason, "mem_cgroup_oom");
483    }
484
485    #[test]
486    fn walk_oom_events_log_buf_unreadable_returns_empty() {
487        // __log_buf symbol exists but the address is not mapped → read_bytes fails → empty
488        let isf = IsfBuilder::new()
489            .add_symbol("__log_buf", 0xDEAD_BEEF_0000_0000)
490            .build_json();
491
492        let resolver = IsfResolver::from_value(&isf).unwrap();
493        let (cr3, mem) = PageTableBuilder::new().build();
494        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
495        let reader = ObjectReader::new(vas, Box::new(resolver));
496
497        let result = walk_oom_events(&reader).expect("should not error");
498        assert!(
499            result.is_empty(),
500            "unreadable log buffer must yield empty result"
501        );
502    }
503
504    #[test]
505    fn oom_event_info_serializes() {
506        let ev = OomEventInfo {
507            victim_pid: 42,
508            victim_comm: "auditd".to_string(),
509            oom_score_adj: 0,
510            total_vm_kb: 1024,
511            rss_kb: 512,
512            timestamp_ns: 1_000_000,
513            reason: "oom_kill_process".to_string(),
514            is_suspicious: true,
515        };
516        let json = serde_json::to_string(&ev).unwrap();
517        assert!(json.contains("\"victim_pid\":42"));
518        assert!(json.contains("\"is_suspicious\":true"));
519    }
520
521    // --- OomEventInfo: Clone + Debug coverage ---
522    #[test]
523    fn oom_event_info_clone_debug() {
524        let ev = OomEventInfo {
525            victim_pid: 7,
526            victim_comm: "sshd".to_string(),
527            oom_score_adj: -1000,
528            total_vm_kb: 2048,
529            rss_kb: 1024,
530            timestamp_ns: 42,
531            reason: "oom_kill_process".to_string(),
532            is_suspicious: true,
533        };
534        let cloned = ev.clone();
535        assert_eq!(cloned.victim_pid, 7);
536        let dbg = format!("{cloned:?}");
537        assert!(dbg.contains("sshd"));
538    }
539
540    // --- walk_oom_events: log_buf_len symbol present → uses that size ---
541    // Exercises lines 131-138 (log_buf_len reading branch).
542    #[test]
543    fn walk_oom_events_with_log_buf_len_symbol() {
544        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
545        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
546        use memf_symbols::isf::IsfResolver;
547        use memf_symbols::test_builders::IsfBuilder;
548
549        let log_text =
550            b"Out of memory: Killed process 5000 (bash) score 300 total-vm:8192kB, anon-rss:4096kB";
551        let record = build_printk_record(777, log_text);
552
553        let buf_vaddr: u64 = 0xFFFF_8800_0003_0000;
554        let buf_paddr: u64 = 0x0030_0000;
555        let buflen_vaddr: u64 = 0xFFFF_8800_0003_1000;
556        let buflen_paddr: u64 = 0x0031_0000;
557
558        // log_buf_len = 4096 (as a u32 LE at buflen_vaddr)
559        let buf_len_val: u32 = 4096;
560
561        let isf = IsfBuilder::new()
562            .add_symbol("__log_buf", buf_vaddr)
563            .add_symbol("log_buf_len", buflen_vaddr)
564            .build_json();
565        let resolver = IsfResolver::from_value(&isf).unwrap();
566
567        let mut buf = record.clone();
568        buf.resize(4096, 0);
569
570        let (cr3, mem) = PageTableBuilder::new()
571            .map_4k(buf_vaddr, buf_paddr, ptf::WRITABLE)
572            .write_phys(buf_paddr, &buf)
573            .map_4k(buflen_vaddr, buflen_paddr, ptf::WRITABLE)
574            .write_phys(buflen_paddr, &buf_len_val.to_le_bytes())
575            .build();
576
577        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
578        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
579
580        let result = walk_oom_events(&reader).expect("should not error");
581        assert_eq!(
582            result.len(),
583            1,
584            "should parse OOM event from buf with explicit log_buf_len"
585        );
586        assert_eq!(result[0].victim_pid, 5000);
587        assert_eq!(result[0].victim_comm, "bash");
588        // bash PID=5000 ≥ 100 and not in suspicious list → benign
589        assert!(!result[0].is_suspicious);
590    }
591
592    // --- walk_oom_events: record with len==0 → loop breaks immediately ---
593    #[test]
594    fn walk_oom_events_zero_len_record_stops_parsing() {
595        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
596        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
597        use memf_symbols::isf::IsfResolver;
598        use memf_symbols::test_builders::IsfBuilder;
599
600        // A buffer whose first record header has len=0 → loop breaks immediately.
601        let buf_vaddr: u64 = 0xFFFF_8800_0004_0000;
602        let buf_paddr: u64 = 0x0040_0000;
603
604        let isf = IsfBuilder::new()
605            .add_symbol("__log_buf", buf_vaddr)
606            .build_json();
607        let resolver = IsfResolver::from_value(&isf).unwrap();
608
609        // Build a 4096-byte buffer: header with ts_nsec=1, len=0 → break on first record.
610        let mut buf = vec![0u8; 4096];
611        buf[0..8].copy_from_slice(&1u64.to_le_bytes()); // ts_nsec
612                                                        // len at [8..10] = 0 → loop should break
613
614        let (cr3, mem) = PageTableBuilder::new()
615            .map_4k(buf_vaddr, buf_paddr, ptf::WRITABLE)
616            .write_phys(buf_paddr, &buf)
617            .build();
618
619        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
620        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
621
622        let result = walk_oom_events(&reader).expect("should not error");
623        assert!(
624            result.is_empty(),
625            "zero-len record should stop parsing → empty result"
626        );
627    }
628
629    // --- walk_oom_events: record with len > remaining buffer → break ---
630    #[test]
631    fn walk_oom_events_len_exceeds_buffer_stops_parsing() {
632        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
633        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
634        use memf_symbols::isf::IsfResolver;
635        use memf_symbols::test_builders::IsfBuilder;
636
637        let buf_vaddr: u64 = 0xFFFF_8800_0005_0000;
638        let buf_paddr: u64 = 0x0050_0000;
639
640        let isf = IsfBuilder::new()
641            .add_symbol("__log_buf", buf_vaddr)
642            .build_json();
643        let resolver = IsfResolver::from_value(&isf).unwrap();
644
645        // Header with len=60000 which exceeds the buffer → break.
646        let mut buf = vec![0u8; 4096];
647        buf[0..8].copy_from_slice(&1u64.to_le_bytes()); // ts_nsec
648        buf[8..10].copy_from_slice(&60000u16.to_le_bytes()); // len > 4096 → break
649
650        let (cr3, mem) = PageTableBuilder::new()
651            .map_4k(buf_vaddr, buf_paddr, ptf::WRITABLE)
652            .write_phys(buf_paddr, &buf)
653            .build();
654
655        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
656        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
657
658        let result = walk_oom_events(&reader).expect("should not error");
659        assert!(
660            result.is_empty(),
661            "over-length record must stop parsing → empty result"
662        );
663    }
664
665    // --- classify_oom_victim: case-insensitive containerd detection ---
666    #[test]
667    fn classify_oom_victim_case_insensitive() {
668        // The check uses to_ascii_lowercase() + contains()
669        assert!(
670            classify_oom_victim("DOCKERD", 5000),
671            "DOCKERD in uppercase must be detected"
672        );
673        assert!(
674            classify_oom_victim("Containerd-shim", 9999),
675            "containerd substring case-insensitive"
676        );
677    }
678}