Skip to main content

winevt_memory/
lib.rs

1// winevt-memory: types and analysis functions for EVTX/ETW data recovered from memory dumps.
2// No dependency on memory readers — provides types that memf-windows populates.
3
4pub use winevt_core::binary::{EvtxChunkHeader, IntegrityAnomaly, RECORD_MAGIC};
5
6/// A record found by scanning a raw memory buffer for EVTX record magic.
7#[derive(Debug, Clone, serde::Serialize)]
8pub struct MemoryCarvedRecord {
9    /// Byte offset of the record magic within the input buffer.
10    pub offset: usize,
11    /// Raw bytes of the candidate record (from magic through trailing size field).
12    pub raw: Vec<u8>,
13    /// True when the BinXML payload starts with a valid FragmentHeader token (0x0F).
14    pub binxml_valid: bool,
15}
16
17/// Scan an arbitrary byte buffer for EVTX record magic and recover plausible records.
18///
19/// Uses a three-phase approach:
20/// 1. Byte-scan for `0x2A 0x2A 0x00 0x00` (record magic).
21/// 2. Validate `record_length` field: must be in `[28, 65536]` and fit within the buffer.
22/// 3. Check BinXML token at `payload[0]` (must be `0x0F` FragmentHeader).
23///
24/// Records whose size field would extend beyond the buffer are silently skipped.
25pub fn scan_memory_buffer(buf: &[u8]) -> Vec<MemoryCarvedRecord> {
26    const MIN_RECORD: usize = 28;   // 4 magic + 4 size + 8 id + 8 ts + 0 payload + 4 tail
27    const MAX_RECORD: usize = 65536;
28    const FRAGMENT_HEADER: u8 = 0x0F;
29
30    if buf.len() < 8 {
31        return vec![];
32    }
33
34    let mut out = Vec::new();
35    let mut pos = 0usize;
36
37    while pos + 8 <= buf.len() {
38        if buf[pos..pos + 4] != RECORD_MAGIC {
39            pos += 1;
40            continue;
41        }
42        let size = u32::from_le_bytes(
43            buf[pos + 4..pos + 8].try_into().unwrap_or([0; 4]),
44        ) as usize;
45        if size < MIN_RECORD || size > MAX_RECORD {
46            pos += 1;
47            continue;
48        }
49        if pos + size > buf.len() {
50            pos += 1;
51            continue;
52        }
53        let raw = buf[pos..pos + size].to_vec();
54        // BinXML payload starts at offset 24 (after 24-byte record header)
55        let binxml_valid = raw.len() > 24 && raw[24] == FRAGMENT_HEADER;
56        out.push(MemoryCarvedRecord { offset: pos, raw, binxml_valid });
57        pos += size;
58    }
59    out
60}
61
62/// An ETW event recovered from a session buffer in kernel memory.
63#[derive(Debug, Clone, serde::Serialize)]
64pub struct RecoveredEtwEvent {
65    pub timestamp: u64,
66    pub provider_id: String,
67    pub event_id: u16,
68    pub payload: Vec<u8>,
69}
70
71/// A chunk recovered from process memory (Event Log service VAD scan).
72#[derive(Debug, Clone, serde::Serialize)]
73pub struct MemoryRecoveredChunk {
74    pub vaddr: u64,
75    pub header: EvtxChunkHeader,
76    pub record_count: u32,
77    pub first_timestamp: u64,
78    pub last_timestamp: u64,
79    pub channel: String,
80    pub source_process: Option<String>,
81    pub source_pid: Option<u32>,
82    pub anti_forensic: Vec<IntegrityAnomaly>,
83}
84
85/// An ETW session recovered from kernel memory (`_WMI_LOGGER_CONTEXT` walk).
86#[derive(Debug, Clone, serde::Serialize)]
87pub struct RecoveredEtwSession {
88    pub logger_id: u32,
89    pub name: String,
90    pub is_running: bool,
91    pub buffer_count: u32,
92    pub buffer_size: u32,
93    pub events_lost: u32,
94    pub log_mode: u32,
95    pub buffer_events: Vec<RecoveredEtwEvent>,
96}
97
98/// ETW-level tampering indicators.
99#[derive(Debug, Clone, serde::Serialize)]
100pub enum EtwTamperingIndicator {
101    /// Session has abnormally high `events_lost` count.
102    HighEventsLost {
103        session_name: String,
104        events_lost: u32,
105        threshold: u32,
106    },
107    /// Expected Event Log session missing (e.g., "EventLog-Security" not found).
108    MissingEventLogSession { expected_name: String },
109    /// Session exists but is not running (stopped ETW session = blind spot).
110    SessionStopped { session_name: String },
111    /// Buffer count is zero for a running session (buffers deallocated).
112    ZeroBuffers { session_name: String },
113    /// Session has `log_mode = 0` (no output configured).
114    SuspiciousLogMode { session_name: String, log_mode: u32 },
115}
116
117/// Events-lost threshold above which a session is flagged.
118const HIGH_EVENTS_LOST_THRESHOLD: u32 = 1000;
119
120/// Filter sessions whose name starts with `"EventLog-"`.
121pub fn identify_eventlog_sessions(sessions: &[RecoveredEtwSession]) -> Vec<&RecoveredEtwSession> {
122    sessions
123        .iter()
124        .filter(|s| s.name.starts_with("EventLog-"))
125        .collect()
126}
127
128/// Required `EventLog` session names that must be present.
129const REQUIRED_EVENTLOG_SESSIONS: &[&str] = &[
130    "EventLog-Security",
131    "EventLog-System",
132    "EventLog-Application",
133];
134
135/// Detect ETW-level tampering indicators across the given sessions.
136pub fn detect_etw_tampering(sessions: &[RecoveredEtwSession]) -> Vec<EtwTamperingIndicator> {
137    let mut indicators = Vec::new();
138
139    // Feature 7: check required sessions are present
140    for required in REQUIRED_EVENTLOG_SESSIONS {
141        if !sessions.iter().any(|s| s.name == *required) {
142            indicators.push(EtwTamperingIndicator::MissingEventLogSession {
143                expected_name: (*required).to_string(),
144            });
145        }
146    }
147
148    for session in sessions {
149        if session.events_lost > HIGH_EVENTS_LOST_THRESHOLD {
150            indicators.push(EtwTamperingIndicator::HighEventsLost {
151                session_name: session.name.clone(),
152                events_lost: session.events_lost,
153                threshold: HIGH_EVENTS_LOST_THRESHOLD,
154            });
155        }
156        if session.log_mode == 0 {
157            indicators.push(EtwTamperingIndicator::SuspiciousLogMode {
158                session_name: session.name.clone(),
159                log_mode: 0,
160            });
161        }
162        // Feature 7: stopped session
163        if session.name.starts_with("EventLog-") && !session.is_running {
164            indicators.push(EtwTamperingIndicator::SessionStopped {
165                session_name: session.name.clone(),
166            });
167        }
168        // Feature 7: zero buffers on running session
169        if session.is_running && session.buffer_count == 0 {
170            indicators.push(EtwTamperingIndicator::ZeroBuffers {
171                session_name: session.name.clone(),
172            });
173        }
174    }
175    indicators
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn make_chunk_header() -> winevt_core::binary::EvtxChunkHeader {
183        let mut buf = vec![0u8; 0x10000];
184        buf[0..8].copy_from_slice(b"ElfChnk\0");
185        buf[8..16].copy_from_slice(&1u64.to_le_bytes());
186        buf[16..24].copy_from_slice(&10u64.to_le_bytes());
187        buf[24..32].copy_from_slice(&1u64.to_le_bytes());
188        buf[32..40].copy_from_slice(&10u64.to_le_bytes());
189        buf[40..44].copy_from_slice(&0x80u32.to_le_bytes());
190        buf[44..48].copy_from_slice(&0x200u32.to_le_bytes());
191        buf[48..52].copy_from_slice(&0x200u32.to_le_bytes());
192        buf[52..56].copy_from_slice(&0u32.to_le_bytes());
193        let crc = crc32fast::hash(&buf[0..0x78]);
194        buf[0x78..0x7C].copy_from_slice(&crc.to_le_bytes());
195        winevt_core::binary::EvtxChunkHeader::parse(&buf).unwrap()
196    }
197
198    #[test]
199    fn memory_recovered_chunk_can_be_constructed() {
200        let header = make_chunk_header();
201        let chunk = MemoryRecoveredChunk {
202            vaddr: 0xFFFF_C000_0000_0000,
203            header,
204            record_count: 10,
205            first_timestamp: 100,
206            last_timestamp: 200,
207            channel: "Security".to_string(),
208            source_process: Some("EventLog".to_string()),
209            source_pid: Some(1234),
210            anti_forensic: vec![],
211        };
212        assert_eq!(chunk.vaddr, 0xFFFF_C000_0000_0000);
213        assert_eq!(chunk.record_count, 10);
214        assert_eq!(chunk.channel, "Security");
215    }
216
217    #[test]
218    fn recovered_etw_session_can_be_constructed() {
219        let session = RecoveredEtwSession {
220            logger_id: 7,
221            name: "EventLog-Security".to_string(),
222            is_running: true,
223            buffer_count: 4,
224            buffer_size: 64,
225            events_lost: 0,
226            log_mode: 0x00000101,
227            buffer_events: vec![],
228        };
229        assert_eq!(session.logger_id, 7);
230        assert_eq!(session.name, "EventLog-Security");
231        assert!(session.is_running);
232    }
233
234    #[test]
235    fn identify_eventlog_sessions_returns_only_prefixed() {
236        let sessions = vec![
237            RecoveredEtwSession {
238                logger_id: 1,
239                name: "EventLog-Security".to_string(),
240                is_running: true,
241                buffer_count: 4,
242                buffer_size: 64,
243                events_lost: 0,
244                log_mode: 0,
245                buffer_events: vec![],
246            },
247            RecoveredEtwSession {
248                logger_id: 2,
249                name: "NT Kernel Logger".to_string(),
250                is_running: true,
251                buffer_count: 4,
252                buffer_size: 64,
253                events_lost: 0,
254                log_mode: 0,
255                buffer_events: vec![],
256            },
257            RecoveredEtwSession {
258                logger_id: 3,
259                name: "EventLog-System".to_string(),
260                is_running: true,
261                buffer_count: 4,
262                buffer_size: 64,
263                events_lost: 0,
264                log_mode: 0,
265                buffer_events: vec![],
266            },
267        ];
268        let found = identify_eventlog_sessions(&sessions);
269        assert_eq!(found.len(), 2);
270        assert!(found.iter().any(|s| s.name == "EventLog-Security"));
271        assert!(found.iter().any(|s| s.name == "EventLog-System"));
272    }
273
274    #[test]
275    fn identify_eventlog_sessions_returns_empty_when_none_match() {
276        let sessions = vec![RecoveredEtwSession {
277            logger_id: 1,
278            name: "NT Kernel Logger".to_string(),
279            is_running: true,
280            buffer_count: 4,
281            buffer_size: 64,
282            events_lost: 0,
283            log_mode: 0,
284            buffer_events: vec![],
285        }];
286        let found = identify_eventlog_sessions(&sessions);
287        assert!(found.is_empty());
288    }
289
290    #[test]
291    fn detect_etw_tampering_flags_high_events_lost() {
292        let sessions = vec![RecoveredEtwSession {
293            logger_id: 1,
294            name: "EventLog-Security".to_string(),
295            is_running: true,
296            buffer_count: 4,
297            buffer_size: 64,
298            events_lost: 1001,
299            log_mode: 0x00000101,
300            buffer_events: vec![],
301        }];
302        let indicators = detect_etw_tampering(&sessions);
303        let has_high_lost = indicators.iter().any(|ind| {
304            matches!(ind, EtwTamperingIndicator::HighEventsLost { session_name, events_lost, .. }
305                if session_name == "EventLog-Security" && *events_lost == 1001)
306        });
307        assert!(
308            has_high_lost,
309            "expected HighEventsLost indicator, got: {:?}",
310            indicators
311        );
312    }
313
314    #[test]
315    fn detect_etw_tampering_returns_empty_for_low_events_lost() {
316        // Must include all three required sessions to avoid MissingEventLogSession indicators
317        let sessions = vec![
318            RecoveredEtwSession {
319                logger_id: 1,
320                name: "EventLog-Security".to_string(),
321                is_running: true,
322                buffer_count: 4,
323                buffer_size: 64,
324                events_lost: 100,
325                log_mode: 0x00000101,
326                buffer_events: vec![],
327            },
328            RecoveredEtwSession {
329                logger_id: 2,
330                name: "EventLog-System".to_string(),
331                is_running: true,
332                buffer_count: 4,
333                buffer_size: 64,
334                events_lost: 0,
335                log_mode: 0x00000101,
336                buffer_events: vec![],
337            },
338            RecoveredEtwSession {
339                logger_id: 3,
340                name: "EventLog-Application".to_string(),
341                is_running: true,
342                buffer_count: 4,
343                buffer_size: 64,
344                events_lost: 0,
345                log_mode: 0x00000101,
346                buffer_events: vec![],
347            },
348        ];
349        let indicators = detect_etw_tampering(&sessions);
350        assert!(
351            indicators.is_empty(),
352            "expected empty indicators for low events_lost (all sessions present), got: {:?}",
353            indicators
354        );
355    }
356
357    // ---- US-05: extended ETW tampering detection ----
358
359    #[test]
360    fn detect_etw_tampering_flags_suspicious_log_mode_zero() {
361        let sessions = vec![RecoveredEtwSession {
362            logger_id: 1,
363            name: "EventLog-Security".to_string(),
364            is_running: true,
365            buffer_count: 4,
366            buffer_size: 64,
367            events_lost: 0,
368            log_mode: 0, // no output configured — suspicious
369            buffer_events: vec![],
370        }];
371        let indicators = detect_etw_tampering(&sessions);
372        let has_suspicious = indicators.iter().any(|ind| {
373            matches!(ind, EtwTamperingIndicator::SuspiciousLogMode { session_name, .. }
374                if session_name == "EventLog-Security")
375        });
376        assert!(
377            has_suspicious,
378            "expected SuspiciousLogMode indicator for log_mode=0, got: {:?}",
379            indicators
380        );
381    }
382
383    #[test]
384    fn detect_etw_tampering_returns_empty_for_normal_active_session() {
385        // Must include all three required sessions
386        let sessions = vec![
387            RecoveredEtwSession {
388                logger_id: 1,
389                name: "EventLog-Security".to_string(),
390                is_running: true,
391                buffer_count: 4,
392                buffer_size: 64,
393                events_lost: 5,
394                log_mode: 0x00000101,
395                buffer_events: vec![],
396            },
397            RecoveredEtwSession {
398                logger_id: 2,
399                name: "EventLog-System".to_string(),
400                is_running: true,
401                buffer_count: 4,
402                buffer_size: 64,
403                events_lost: 0,
404                log_mode: 0x00000101,
405                buffer_events: vec![],
406            },
407            RecoveredEtwSession {
408                logger_id: 3,
409                name: "EventLog-Application".to_string(),
410                is_running: true,
411                buffer_count: 4,
412                buffer_size: 64,
413                events_lost: 0,
414                log_mode: 0x00000101,
415                buffer_events: vec![],
416            },
417        ];
418        let indicators = detect_etw_tampering(&sessions);
419        assert!(
420            indicators.is_empty(),
421            "expected empty indicators for normal session (all required present), got: {:?}",
422            indicators
423        );
424    }
425
426    #[test]
427    fn identify_eventlog_sessions_finds_security_system_application() {
428        let sessions = vec![
429            RecoveredEtwSession {
430                logger_id: 1,
431                name: "EventLog-Security".to_string(),
432                is_running: true,
433                buffer_count: 4,
434                buffer_size: 64,
435                events_lost: 0,
436                log_mode: 0,
437                buffer_events: vec![],
438            },
439            RecoveredEtwSession {
440                logger_id: 2,
441                name: "EventLog-System".to_string(),
442                is_running: true,
443                buffer_count: 4,
444                buffer_size: 64,
445                events_lost: 0,
446                log_mode: 0,
447                buffer_events: vec![],
448            },
449            RecoveredEtwSession {
450                logger_id: 3,
451                name: "EventLog-Application".to_string(),
452                is_running: true,
453                buffer_count: 4,
454                buffer_size: 64,
455                events_lost: 0,
456                log_mode: 0,
457                buffer_events: vec![],
458            },
459            RecoveredEtwSession {
460                logger_id: 4,
461                name: "NT Kernel Logger".to_string(),
462                is_running: true,
463                buffer_count: 4,
464                buffer_size: 64,
465                events_lost: 0,
466                log_mode: 0,
467                buffer_events: vec![],
468            },
469        ];
470        let found = identify_eventlog_sessions(&sessions);
471        assert_eq!(found.len(), 3);
472        let names: Vec<&str> = found.iter().map(|s| s.name.as_str()).collect();
473        assert!(names.contains(&"EventLog-Security"));
474        assert!(names.contains(&"EventLog-System"));
475        assert!(names.contains(&"EventLog-Application"));
476    }
477
478    #[test]
479    fn memory_recovered_chunk_implements_serialize() {
480        let header = make_chunk_header();
481        let chunk = MemoryRecoveredChunk {
482            vaddr: 0x1000,
483            header,
484            record_count: 1,
485            first_timestamp: 0,
486            last_timestamp: 0,
487            channel: "Security".to_string(),
488            source_process: None,
489            source_pid: None,
490            anti_forensic: vec![],
491        };
492        let json = serde_json::to_string(&chunk).expect("serialize MemoryRecoveredChunk");
493        assert!(json.contains("Security"));
494    }
495
496    #[test]
497    fn recovered_etw_session_implements_serialize() {
498        let session = RecoveredEtwSession {
499            logger_id: 1,
500            name: "EventLog-Security".to_string(),
501            is_running: true,
502            buffer_count: 4,
503            buffer_size: 64,
504            events_lost: 0,
505            log_mode: 0,
506            buffer_events: vec![],
507        };
508        let json = serde_json::to_string(&session).expect("serialize RecoveredEtwSession");
509        assert!(json.contains("EventLog-Security"));
510    }
511
512    // ---- Feature 7: Missing/stopped ETW sessions + zero buffers ----
513
514    fn make_session(
515        name: &str,
516        is_running: bool,
517        buffer_count: u32,
518        log_mode: u32,
519    ) -> RecoveredEtwSession {
520        RecoveredEtwSession {
521            logger_id: 1,
522            name: name.to_string(),
523            is_running,
524            buffer_count,
525            buffer_size: 64,
526            events_lost: 0,
527            log_mode,
528            buffer_events: vec![],
529        }
530    }
531
532    #[test]
533    fn detect_etw_tampering_all_expected_present_no_new_indicators() {
534        let sessions = vec![
535            make_session("EventLog-Security", true, 4, 0x101),
536            make_session("EventLog-System", true, 4, 0x101),
537            make_session("EventLog-Application", true, 4, 0x101),
538        ];
539        let indicators = detect_etw_tampering(&sessions);
540        let has_missing = indicators
541            .iter()
542            .any(|i| matches!(i, EtwTamperingIndicator::MissingEventLogSession { .. }));
543        let has_stopped = indicators
544            .iter()
545            .any(|i| matches!(i, EtwTamperingIndicator::SessionStopped { .. }));
546        let has_zero = indicators
547            .iter()
548            .any(|i| matches!(i, EtwTamperingIndicator::ZeroBuffers { .. }));
549        assert!(
550            !has_missing && !has_stopped && !has_zero,
551            "expected no missing/stopped/zero indicators, got: {:?}",
552            indicators
553        );
554    }
555
556    #[test]
557    fn detect_etw_tampering_missing_security_emits_missing_indicator() {
558        let sessions = vec![
559            make_session("EventLog-System", true, 4, 0x101),
560            make_session("EventLog-Application", true, 4, 0x101),
561        ];
562        let indicators = detect_etw_tampering(&sessions);
563        let has_missing = indicators.iter().any(|i| {
564            matches!(i, EtwTamperingIndicator::MissingEventLogSession { expected_name }
565                if expected_name == "EventLog-Security")
566        });
567        assert!(
568            has_missing,
569            "expected MissingEventLogSession for EventLog-Security, got: {:?}",
570            indicators
571        );
572    }
573
574    #[test]
575    fn detect_etw_tampering_stopped_session_emits_stopped_indicator() {
576        let sessions = vec![
577            make_session("EventLog-Security", false, 4, 0x101),
578            make_session("EventLog-System", true, 4, 0x101),
579            make_session("EventLog-Application", true, 4, 0x101),
580        ];
581        let indicators = detect_etw_tampering(&sessions);
582        let has_stopped = indicators.iter().any(|i| {
583            matches!(i, EtwTamperingIndicator::SessionStopped { session_name }
584                if session_name == "EventLog-Security")
585        });
586        assert!(
587            has_stopped,
588            "expected SessionStopped for EventLog-Security, got: {:?}",
589            indicators
590        );
591    }
592
593    #[test]
594    fn detect_etw_tampering_zero_buffers_running_emits_zero_indicator() {
595        let sessions = vec![
596            make_session("EventLog-Security", true, 0, 0x101),
597            make_session("EventLog-System", true, 4, 0x101),
598            make_session("EventLog-Application", true, 4, 0x101),
599        ];
600        let indicators = detect_etw_tampering(&sessions);
601        let has_zero = indicators.iter().any(|i| {
602            matches!(i, EtwTamperingIndicator::ZeroBuffers { session_name }
603                if session_name == "EventLog-Security")
604        });
605        assert!(
606            has_zero,
607            "expected ZeroBuffers for EventLog-Security (running, 0 buffers), got: {:?}",
608            indicators
609        );
610    }
611
612    #[test]
613    fn detect_etw_tampering_all_three_missing_when_no_sessions() {
614        let sessions: Vec<RecoveredEtwSession> = vec![];
615        let indicators = detect_etw_tampering(&sessions);
616        let missing_names: Vec<&str> = indicators
617            .iter()
618            .filter_map(|i| {
619                if let EtwTamperingIndicator::MissingEventLogSession { expected_name } = i {
620                    Some(expected_name.as_str())
621                } else {
622                    None
623                }
624            })
625            .collect();
626        assert!(missing_names.contains(&"EventLog-Security"));
627        assert!(missing_names.contains(&"EventLog-System"));
628        assert!(missing_names.contains(&"EventLog-Application"));
629    }
630
631    // ── §8: scan_memory_buffer ────────────────────────────────────────────────
632
633    fn make_raw_record(record_id: u64, ts: u64, payload: &[u8]) -> Vec<u8> {
634        let size = (4 + 4 + 8 + 8 + payload.len() + 4) as u32;
635        let mut rec = vec![0u8; size as usize];
636        rec[0..4].copy_from_slice(&[0x2A, 0x2A, 0x00, 0x00]);
637        rec[4..8].copy_from_slice(&size.to_le_bytes());
638        rec[8..16].copy_from_slice(&record_id.to_le_bytes());
639        rec[16..24].copy_from_slice(&ts.to_le_bytes());
640        rec[24..24 + payload.len()].copy_from_slice(payload);
641        let tail = size as usize - 4;
642        rec[tail..].copy_from_slice(&size.to_le_bytes());
643        rec
644    }
645
646    fn valid_binxml_payload() -> Vec<u8> {
647        // Minimal BinXML fragment header: 0x0F 0x01 ...
648        vec![0x0Fu8, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]
649    }
650
651    #[test]
652    fn scan_empty_buffer_returns_empty() {
653        let records = scan_memory_buffer(&[]);
654        assert!(records.is_empty(), "empty buffer must return empty");
655    }
656
657    #[test]
658    fn scan_buffer_with_no_magic_returns_empty() {
659        let buf = vec![0xFFu8; 256];
660        let records = scan_memory_buffer(&buf);
661        assert!(records.is_empty(), "buffer with no record magic must return empty");
662    }
663
664    #[test]
665    fn scan_buffer_with_injected_record_finds_it() {
666        let payload = valid_binxml_payload();
667        let rec = make_raw_record(42, 100_000, &payload);
668        let mut buf = vec![0u8; 64];
669        buf.extend_from_slice(&rec);
670        buf.extend_from_slice(&[0u8; 64]);
671        let records = scan_memory_buffer(&buf);
672        assert_eq!(records.len(), 1, "should find exactly one record; got {}", records.len());
673        assert_eq!(records[0].offset, 64, "record should be at offset 64");
674    }
675
676    #[test]
677    fn scan_ignores_random_bytes_matching_magic_only() {
678        // Magic bytes with invalid record_length (0 bytes)
679        let mut buf = vec![0u8; 128];
680        buf[10] = 0x2A;
681        buf[11] = 0x2A;
682        buf[12] = 0x00;
683        buf[13] = 0x00;
684        // size field at [14..18] = 0 (invalid — below minimum 28)
685        let records = scan_memory_buffer(&buf);
686        assert!(
687            records.is_empty(),
688            "magic with invalid size must be ignored; got {} records", records.len()
689        );
690    }
691
692    #[test]
693    fn scan_multi_record_buffer_finds_all() {
694        let payload = valid_binxml_payload();
695        let rec1 = make_raw_record(1, 100, &payload);
696        let rec2 = make_raw_record(2, 200, &payload);
697        let rec3 = make_raw_record(3, 300, &payload);
698        let mut buf = vec![0u8; 32];
699        buf.extend_from_slice(&rec1);
700        buf.extend_from_slice(&rec2);
701        buf.extend_from_slice(&rec3);
702        let records = scan_memory_buffer(&buf);
703        assert_eq!(records.len(), 3, "should find 3 records; got {}", records.len());
704        assert_eq!(records[0].offset, 32);
705        assert!(records[1].offset > records[0].offset);
706        assert!(records[2].offset > records[1].offset);
707    }
708
709    #[test]
710    fn scan_record_beyond_buffer_end_is_ignored() {
711        // Record whose size field claims more bytes than available
712        let mut buf = vec![0x2A, 0x2A, 0x00, 0x00];
713        buf.extend_from_slice(&65000u32.to_le_bytes()); // very large size
714        buf.extend_from_slice(&[0u8; 20]);
715        let records = scan_memory_buffer(&buf);
716        assert!(
717            records.is_empty(),
718            "record extending beyond buffer must be ignored"
719        );
720    }
721}