1use memf_core::object_reader::ObjectReader;
8use memf_format::PhysicalMemoryProvider;
9
10use crate::Result;
11
12#[derive(Debug, Clone, serde::Serialize)]
14pub struct OomEventInfo {
15 pub victim_pid: u32,
17 pub victim_comm: String,
19 pub oom_score_adj: i16,
21 pub total_vm_kb: u64,
23 pub rss_kb: u64,
25 pub timestamp_ns: u64,
27 pub reason: String,
29 pub is_suspicious: bool,
31}
32
33pub use crate::heuristics::classify_oom_victim;
38
39fn 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 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 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 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
82fn 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
94const MAX_RECORDS: usize = 8192;
96const MAX_BUF_LEN: usize = 1 << 18; pub 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 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 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 #[test]
277 fn parse_oom_line_with_mem_cgroup_prefix() {
278 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 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 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 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 let line = "Out of memory: Killed process NOTAPID (comm) score 0";
325 assert!(parse_oom_line(line).is_none());
326 }
327
328 #[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 assert_eq!(extract_kb("anon-rss:1024", "anon-rss:"), 1024);
349 }
350
351 #[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 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 fn build_printk_record(ts_nsec: u64, text: &[u8]) -> Vec<u8> {
391 let header_size = 16usize;
399 let text_len = text.len();
400 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 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; 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 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; 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 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 #[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 #[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 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 assert!(!result[0].is_suspicious);
590 }
591
592 #[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 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 let mut buf = vec![0u8; 4096];
611 buf[0..8].copy_from_slice(&1u64.to_le_bytes()); 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 #[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 let mut buf = vec![0u8; 4096];
647 buf[0..8].copy_from_slice(&1u64.to_le_bytes()); buf[8..10].copy_from_slice(&60000u16.to_le_bytes()); 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 #[test]
667 fn classify_oom_victim_case_insensitive() {
668 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}