memf-format 0.2.1

Physical memory dump format parsers for the memf forensics framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
//! Kdump (makedumpfile / diskdump) format provider.
//!
//! Parses kdump files with `KDUMP   ` or `DISKDUMP` header signatures.
//! Uses lazy page decompression with an LRU cache for random-access reads.
//! Supports zlib (flate2), snappy (snap), zstd (ruzstd), and uncompressed pages.
//! LZO decompression is deferred with a clear error message.

use std::num::NonZeroUsize;
use std::path::Path;
use std::sync::Mutex;

use crate::{DumpMetadata, Error, FormatPlugin, PhysicalMemoryProvider, PhysicalRange, Result};

/// KDUMP signature: "KDUMP   " (8 bytes with 3 trailing spaces).
const KDUMP_SIG: &[u8; 8] = b"KDUMP   ";
/// DISKDUMP signature: "DISKDUMP" (8 bytes).
const DISKDUMP_SIG: &[u8; 8] = b"DISKDUMP";

/// Compression flag: zlib.
const COMPRESS_ZLIB: u32 = 0x01;
/// Compression flag: LZO.
const COMPRESS_LZO: u32 = 0x02;
/// Compression flag: snappy.
const COMPRESS_SNAPPY: u32 = 0x04;
/// Compression flag: zstd.
const COMPRESS_ZSTD: u32 = 0x20;

/// Size of a single `page_desc` entry in bytes.
const PAGE_DESC_SIZE: usize = 24;

/// LRU cache capacity (number of decompressed pages).
const CACHE_CAPACITY: usize = 1024;

/// A parsed page descriptor from the kdump file.
#[derive(Debug, Clone)]
struct PageDesc {
    /// File offset of the compressed page data.
    offset: i64,
    /// Size of the compressed data in bytes.
    size: u32,
    /// Compression flags.
    flags: u32,
}

/// Kdump format provider with lazy decompression and LRU cache.
pub struct KdumpProvider {
    /// Raw file data.
    data: Vec<u8>,
    /// Block size in bytes (typically 4096).
    block_size: u32,
    /// Maximum page frame number.
    max_mapnr: u32,
    /// 2nd bitmap (dumped PFNs): byte offset and length in `data`.
    bitmap2_offset: usize,
    bitmap2_len: usize,
    /// File offset where page descriptors start.
    desc_offset: usize,
    /// Total number of page descriptors.
    num_descs: usize,
    /// Pre-computed physical ranges from the bitmap.
    ranges: Vec<PhysicalRange>,
    /// LRU cache: PFN -> decompressed page data.
    cache: Mutex<lru::LruCache<u64, Vec<u8>>>,
}

/// Read a little-endian i32 from `data` at `offset`.
fn read_i32(data: &[u8], offset: usize) -> Result<i32> {
    data.get(offset..offset + 4)
        .and_then(|s| s.try_into().ok())
        .map(i32::from_le_bytes)
        .ok_or_else(|| Error::Corrupt(format!("read_i32 out of bounds at offset {offset}")))
}

/// Read a little-endian u32 from `data` at `offset`.
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
    data.get(offset..offset + 4)
        .and_then(|s| s.try_into().ok())
        .map(u32::from_le_bytes)
        .ok_or_else(|| Error::Corrupt(format!("read_u32 out of bounds at offset {offset}")))
}

/// Read a little-endian i64 from `data` at `offset`.
fn read_i64(data: &[u8], offset: usize) -> Result<i64> {
    data.get(offset..offset + 8)
        .and_then(|s| s.try_into().ok())
        .map(i64::from_le_bytes)
        .ok_or_else(|| Error::Corrupt(format!("read_i64 out of bounds at offset {offset}")))
}

/// Check whether the first 8 bytes match a known kdump/diskdump signature.
fn is_kdump_signature(header: &[u8]) -> bool {
    if header.len() < 8 {
        return false;
    }
    &header[0..8] == KDUMP_SIG || &header[0..8] == DISKDUMP_SIG
}

/// Parse a page descriptor from the raw data at the given offset.
fn parse_page_desc(data: &[u8], offset: usize) -> Result<PageDesc> {
    Ok(PageDesc {
        offset: read_i64(data, offset)?,
        size: read_u32(data, offset + 8)?,
        flags: read_u32(data, offset + 12)?,
    })
}

/// Test whether a specific bit is set in a bitmap.
fn bitmap_test(bitmap: &[u8], bit: usize) -> bool {
    let byte_idx = bit / 8;
    let bit_idx = bit % 8;
    if byte_idx >= bitmap.len() {
        return false;
    }
    (bitmap[byte_idx] >> bit_idx) & 1 != 0
}

/// Count the number of set bits in a bitmap before the given bit position.
fn bitmap_popcount_before(bitmap: &[u8], bit: usize) -> usize {
    let full_bytes = bit / 8;
    let remaining_bits = bit % 8;
    let mut count = 0usize;
    for &b in &bitmap[..full_bytes.min(bitmap.len())] {
        count += b.count_ones() as usize;
    }
    if remaining_bits > 0 && full_bytes < bitmap.len() {
        // Count only the bits below the target bit position in the partial byte.
        let mask = (1u8 << remaining_bits) - 1;
        count += (bitmap[full_bytes] & mask).count_ones() as usize;
    }
    count
}

/// Build physical ranges from a bitmap: contiguous runs of set bits.
fn ranges_from_bitmap(bitmap: &[u8], max_pfn: u32, block_size: u32) -> Vec<PhysicalRange> {
    let mut ranges = Vec::new();
    let mut run_start: Option<u64> = None;
    let bs = u64::from(block_size);

    for pfn in 0..max_pfn as usize {
        if bitmap_test(bitmap, pfn) {
            if run_start.is_none() {
                run_start = Some(pfn as u64 * bs);
            }
        } else if let Some(start) = run_start.take() {
            ranges.push(PhysicalRange {
                start,
                end: pfn as u64 * bs,
            });
        }
    }
    // Close any trailing run.
    if let Some(start) = run_start {
        ranges.push(PhysicalRange {
            start,
            end: u64::from(max_pfn) * bs,
        });
    }
    ranges
}

/// Decompress page data based on the compression flags.
fn decompress_page(compressed: &[u8], flags: u32, block_size: u32) -> Result<Vec<u8>> {
    let bs = block_size as usize;
    match flags {
        0 => {
            // Uncompressed: size must equal block_size.
            if compressed.len() == bs {
                Ok(compressed.to_vec())
            } else {
                Err(Error::Corrupt(format!(
                    "uncompressed page size {} != block_size {bs}",
                    compressed.len()
                )))
            }
        }
        COMPRESS_ZLIB => {
            use std::io::Read as _;
            let mut decoder = flate2::read::ZlibDecoder::new(compressed);
            let mut out = vec![0u8; bs];
            decoder
                .read_exact(&mut out)
                .map_err(|e| Error::Decompression(format!("zlib: {e}")))?;
            Ok(out)
        }
        COMPRESS_LZO => Err(Error::Decompression("LZO not yet supported".into())),
        COMPRESS_SNAPPY => {
            let mut decoder = snap::raw::Decoder::new();
            decoder
                .decompress_vec(compressed)
                .map_err(|e| Error::Decompression(format!("snappy: {e}")))
        }
        COMPRESS_ZSTD => {
            use std::io::Read as _;
            let cursor = std::io::Cursor::new(compressed);
            let mut decoder = ruzstd::decoding::StreamingDecoder::new(cursor)
                .map_err(|e| Error::Decompression(format!("zstd init: {e}")))?;
            let mut out = vec![0u8; bs];
            decoder
                .read_exact(&mut out)
                .map_err(|e| Error::Decompression(format!("zstd: {e}")))?;
            Ok(out)
        }
        other => Err(Error::Decompression(format!(
            "unknown compression flags: 0x{other:02X}"
        ))),
    }
}

impl KdumpProvider {
    /// Parse a kdump file from an in-memory byte slice.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
        Self::parse(bytes.to_vec())
    }

    /// Parse a kdump file from a file path.
    pub fn from_path(path: &Path) -> Result<Self> {
        let data = std::fs::read(path)?;
        Self::parse(data)
    }

    /// Internal: parse the kdump file from owned data.
    fn parse(data: Vec<u8>) -> Result<Self> {
        if !is_kdump_signature(&data) {
            return Err(Error::Corrupt("not a kdump/diskdump file".into()));
        }

        // Header field offsets:
        // utsname starts at 0x0C, is 390 bytes (6 * 65).
        // Align to 4: (0x0C + 390 + 3) & !3 = 0x19C
        let fields_off = (0x0C + 390 + 3) & !3; // 0x19C

        let block_size_raw = read_i32(&data, fields_off)?;
        let sub_hdr_size_raw = read_i32(&data, fields_off + 4)?;
        let block_size = u32::try_from(block_size_raw)
            .map_err(|_| Error::Corrupt(format!("negative block_size: {block_size_raw}")))?;
        let sub_hdr_size = u32::try_from(sub_hdr_size_raw)
            .map_err(|_| Error::Corrupt(format!("negative sub_hdr_size: {sub_hdr_size_raw}")))?;
        let bitmap_blocks = read_u32(&data, fields_off + 8)?;
        let max_mapnr = read_u32(&data, fields_off + 12)?;

        let bs = block_size as usize;
        if bs == 0 {
            return Err(Error::Corrupt("block_size is 0".into()));
        }

        // Bitmaps start after disk_dump_header (block 0) + kdump_sub_header (sub_hdr_size blocks).
        let bitmap_start_block = 1 + sub_hdr_size as usize;
        let bm1_offset = bitmap_start_block * bs;
        let bm_byte_len = bitmap_blocks as usize * bs;

        // 2nd bitmap follows immediately after the 1st.
        let bm2_offset = bm1_offset + bm_byte_len;

        // Validate bounds.
        if bm2_offset + bm_byte_len > data.len() {
            return Err(Error::Corrupt("bitmaps extend beyond file".into()));
        }

        // Count total dumped pages from bitmap2 to determine descriptor count.
        let bitmap2 = &data[bm2_offset..bm2_offset + bm_byte_len];
        let mut num_descs = 0usize;
        for pfn in 0..max_mapnr as usize {
            if bitmap_test(bitmap2, pfn) {
                num_descs += 1;
            }
        }

        // Page descriptors start after both bitmaps.
        let desc_offset = bm2_offset + bm_byte_len;
        let descs_raw_size = num_descs * PAGE_DESC_SIZE;
        if desc_offset + descs_raw_size > data.len() {
            return Err(Error::Corrupt("page descriptors extend beyond file".into()));
        }

        // Build physical ranges from the 2nd bitmap.
        let ranges = ranges_from_bitmap(bitmap2, max_mapnr, block_size);

        // CACHE_CAPACITY is a non-zero compile-time constant; the fallback to
        // NonZeroUsize::MIN keeps construction infallible if it is ever set to 0.
        let cache = Mutex::new(lru::LruCache::new(
            NonZeroUsize::new(CACHE_CAPACITY).unwrap_or(NonZeroUsize::MIN),
        ));

        Ok(Self {
            data,
            block_size,
            max_mapnr,
            bitmap2_offset: bm2_offset,
            bitmap2_len: bm_byte_len,
            desc_offset,
            num_descs,
            ranges,
            cache,
        })
    }

    /// Get the 2nd bitmap slice.
    fn bitmap2(&self) -> &[u8] {
        &self.data[self.bitmap2_offset..self.bitmap2_offset + self.bitmap2_len]
    }

    /// Read and decompress a page by its PFN.
    fn load_page(&self, pfn: u64) -> Result<Vec<u8>> {
        let bitmap2 = self.bitmap2();

        // Check if PFN is in the dumped bitmap.
        if !bitmap_test(bitmap2, pfn as usize) {
            // Not dumped — return zeros.
            return Ok(vec![]);
        }

        // Count set bits before this PFN to get the descriptor index.
        let desc_idx = bitmap_popcount_before(bitmap2, pfn as usize);
        if desc_idx >= self.num_descs {
            return Err(Error::Corrupt(format!(
                "descriptor index {desc_idx} out of range (max {})",
                self.num_descs
            )));
        }

        let desc = parse_page_desc(&self.data, self.desc_offset + desc_idx * PAGE_DESC_SIZE)?;
        let file_offset = usize::try_from(desc.offset)
            .map_err(|_| Error::Corrupt(format!("negative page offset: {}", desc.offset)))?;
        let size = desc.size as usize;

        if file_offset + size > self.data.len() {
            return Err(Error::Corrupt(format!(
                "page data at offset {file_offset} + size {size} extends beyond file"
            )));
        }

        let compressed = &self.data[file_offset..file_offset + size];
        decompress_page(compressed, desc.flags, self.block_size)
    }
}

impl PhysicalMemoryProvider for KdumpProvider {
    fn read_phys(&self, addr: u64, buf: &mut [u8]) -> Result<usize> {
        if buf.is_empty() {
            return Ok(0);
        }

        let bs = u64::from(self.block_size);
        let pfn = addr / bs;
        let page_offset = (addr % bs) as usize;

        // Check LRU cache first.
        {
            let mut cache = self
                .cache
                .lock()
                .map_err(|_| crate::Error::Corrupt("cache lock poisoned".into()))?;
            if let Some(page) = cache.get(&pfn) {
                let avail = page.len().saturating_sub(page_offset);
                let to_read = buf.len().min(avail);
                buf[..to_read].copy_from_slice(&page[page_offset..page_offset + to_read]);
                return Ok(to_read);
            }
        }

        // Check bitmap: if PFN not dumped, return 0.
        if pfn >= u64::from(self.max_mapnr) || !bitmap_test(self.bitmap2(), pfn as usize) {
            return Ok(0);
        }

        // Load and decompress the page.
        let page = self.load_page(pfn)?;
        let avail = page.len().saturating_sub(page_offset);
        let to_read = buf.len().min(avail);
        buf[..to_read].copy_from_slice(&page[page_offset..page_offset + to_read]);

        // Cache the decompressed page.
        {
            let mut cache = self
                .cache
                .lock()
                .map_err(|_| crate::Error::Corrupt("cache lock poisoned".into()))?;
            cache.put(pfn, page);
        }

        Ok(to_read)
    }

    fn ranges(&self) -> &[PhysicalRange] {
        &self.ranges
    }

    fn format_name(&self) -> &str {
        "kdump"
    }

    fn metadata(&self) -> Option<DumpMetadata> {
        Some(DumpMetadata {
            dump_type: Some("kdump".into()),
            ..DumpMetadata::default()
        })
    }
}

/// Format plugin for kdump files.
pub struct KdumpPlugin;

impl FormatPlugin for KdumpPlugin {
    fn name(&self) -> &str {
        "kdump"
    }

    fn probe(&self, header: &[u8]) -> u8 {
        if is_kdump_signature(header) {
            90
        } else {
            0
        }
    }

    fn open(&self, path: &Path) -> Result<Box<dyn PhysicalMemoryProvider>> {
        Ok(Box::new(KdumpProvider::from_path(path)?))
    }
}

inventory::submit!(&KdumpPlugin as &dyn FormatPlugin);

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_builders::KdumpBuilder;

    #[test]
    fn probe_kdump_signature() {
        let dump = KdumpBuilder::new().add_page(0, &[0xAAu8; 4096]).build();
        let plugin = KdumpPlugin;
        assert_eq!(plugin.probe(&dump), 90);
    }

    #[test]
    fn probe_diskdump_signature() {
        // Build a kdump and overwrite signature to "DISKDUMP"
        let mut dump = KdumpBuilder::new().add_page(0, &[0xAAu8; 4096]).build();
        dump[0..8].copy_from_slice(b"DISKDUMP");
        let plugin = KdumpPlugin;
        assert_eq!(plugin.probe(&dump), 90);
    }

    #[test]
    fn probe_non_kdump() {
        let zeros = vec![0u8; 4096];
        let plugin = KdumpPlugin;
        assert_eq!(plugin.probe(&zeros), 0);
    }

    #[test]
    fn probe_short_header_returns_zero() {
        let plugin = KdumpPlugin;
        // Less than 8 bytes
        assert_eq!(plugin.probe(&[0u8; 4]), 0);
        // Empty
        assert_eq!(plugin.probe(&[]), 0);
    }

    #[test]
    fn single_page_snappy_read() {
        let mut page = vec![0u8; 4096];
        page[0] = 0xDE;
        page[1] = 0xAD;
        page[2] = 0xBE;
        page[3] = 0xEF;
        let dump = KdumpBuilder::new()
            .compression(0x04)
            .add_page(1, &page)
            .build();
        let provider = KdumpProvider::from_bytes(&dump).unwrap();
        let mut buf = [0u8; 4];
        let n = provider.read_phys(4096, &mut buf).unwrap();
        assert_eq!(n, 4);
        assert_eq!(buf, [0xDE, 0xAD, 0xBE, 0xEF]);
    }

    #[test]
    fn single_page_zlib_read() {
        let mut page = vec![0u8; 4096];
        page[100] = 0x42;
        page[101] = 0x43;
        let dump = KdumpBuilder::new()
            .compression(0x01)
            .add_page(2, &page)
            .build();
        let provider = KdumpProvider::from_bytes(&dump).unwrap();
        let mut buf = [0u8; 2];
        let n = provider.read_phys(2 * 4096 + 100, &mut buf).unwrap();
        assert_eq!(n, 2);
        assert_eq!(buf, [0x42, 0x43]);
    }

    #[test]
    fn uncompressed_page_read() {
        let mut page = vec![0u8; 4096];
        page[0] = 0xFF;
        page[4095] = 0x01;
        let dump = KdumpBuilder::new()
            .compression(0x00)
            .add_page(0, &page)
            .build();
        let provider = KdumpProvider::from_bytes(&dump).unwrap();
        let mut buf = [0u8; 1];
        let n = provider.read_phys(0, &mut buf).unwrap();
        assert_eq!(n, 1);
        assert_eq!(buf, [0xFF]);
        let n = provider.read_phys(4095, &mut buf).unwrap();
        assert_eq!(n, 1);
        assert_eq!(buf, [0x01]);
    }

    #[test]
    fn multi_page_read() {
        let mut page_a = vec![0xAAu8; 4096];
        page_a[0] = 0x11;
        let mut page_b = vec![0xBBu8; 4096];
        page_b[0] = 0x22;
        // PFN 2 and PFN 5: gap between them
        let dump = KdumpBuilder::new()
            .add_page(2, &page_a)
            .add_page(5, &page_b)
            .build();
        let provider = KdumpProvider::from_bytes(&dump).unwrap();

        let mut buf = [0u8; 1];
        let n = provider.read_phys(2 * 4096, &mut buf).unwrap();
        assert_eq!(n, 1);
        assert_eq!(buf, [0x11]);

        let n = provider.read_phys(5 * 4096, &mut buf).unwrap();
        assert_eq!(n, 1);
        assert_eq!(buf, [0x22]);
    }

    #[test]
    fn read_gap_returns_zero() {
        let page = vec![0xAAu8; 4096];
        // Only PFN 1 is mapped
        let dump = KdumpBuilder::new().add_page(1, &page).build();
        let provider = KdumpProvider::from_bytes(&dump).unwrap();

        // Read PFN 0 (unmapped)
        let mut buf = [0u8; 4];
        let n = provider.read_phys(0, &mut buf).unwrap();
        assert_eq!(n, 0);
    }

    #[test]
    fn read_empty_buffer() {
        let page = vec![0xAAu8; 4096];
        let dump = KdumpBuilder::new().add_page(0, &page).build();
        let provider = KdumpProvider::from_bytes(&dump).unwrap();

        let mut buf = [0u8; 0];
        let n = provider.read_phys(0, &mut buf).unwrap();
        assert_eq!(n, 0);
    }

    #[test]
    fn metadata_extraction() {
        let page = vec![0u8; 4096];
        let dump = KdumpBuilder::new().add_page(0, &page).build();
        let provider = KdumpProvider::from_bytes(&dump).unwrap();

        let meta = provider.metadata().expect("should return metadata");
        assert_eq!(meta.dump_type.as_deref(), Some("kdump"));
    }

    #[test]
    fn lru_cache_hit() {
        let mut page = vec![0u8; 4096];
        page[0] = 0xCA;
        page[100] = 0xFE;
        let dump = KdumpBuilder::new().add_page(0, &page).build();
        let provider = KdumpProvider::from_bytes(&dump).unwrap();

        // First read: offset 0
        let mut buf = [0u8; 1];
        let n = provider.read_phys(0, &mut buf).unwrap();
        assert_eq!(n, 1);
        assert_eq!(buf, [0xCA]);

        // Second read: offset 100 (same page, should hit cache)
        let n = provider.read_phys(100, &mut buf).unwrap();
        assert_eq!(n, 1);
        assert_eq!(buf, [0xFE]);
    }

    #[test]
    fn lzo_returns_error() {
        // Build a dump but manually set flags to 0x02 (LZO) in the page_desc.
        // We can't use the builder for LZO, so build snappy then patch the flags.
        let page = vec![0xAAu8; 4096];
        let mut dump = KdumpBuilder::new()
            .compression(0x04)
            .add_page(0, &page)
            .build();

        // Find the page_desc and patch flags from 0x04 to 0x02.
        // page_desc is at desc_start = (2 + 2*bitmap_blocks) * 4096
        // For 1 PFN (max_pfn=1), bitmap needs ceil(1/8)=1 byte, ceil(1/4096)=1 block
        // desc_start = (2 + 2*1) * 4096 = 4 * 4096 = 16384
        let desc_start = 4 * 4096;
        // flags field is at offset 12 within page_desc
        let flags_off = desc_start + 12;
        dump[flags_off..flags_off + 4].copy_from_slice(&0x02u32.to_le_bytes());

        let provider = KdumpProvider::from_bytes(&dump).unwrap();
        let mut buf = [0u8; 4];
        let result = provider.read_phys(0, &mut buf);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("LZO"),
            "error should mention LZO: {err}"
        );
    }

    #[test]
    fn plugin_name() {
        let plugin = KdumpPlugin;
        assert_eq!(plugin.name(), "kdump");
    }

    #[test]
    fn from_path_roundtrip() {
        let mut page = vec![0u8; 4096];
        page[0] = 0x99;
        let dump = KdumpBuilder::new().add_page(0, &page).build();

        let path = std::env::temp_dir().join("memf_test_kdump.bin");
        std::fs::write(&path, &dump).unwrap();

        let provider = KdumpProvider::from_path(&path).unwrap();
        let mut buf = [0u8; 1];
        let n = provider.read_phys(0, &mut buf).unwrap();
        assert_eq!(n, 1);
        assert_eq!(buf, [0x99]);

        std::fs::remove_file(&path).ok();
    }

    #[test]
    fn builder_produces_kdump_signature() {
        let dump = KdumpBuilder::new().add_page(0, &[0u8; 4096]).build();
        assert_eq!(&dump[0..8], b"KDUMP   ");
    }

    #[test]
    fn from_bytes_tiny_input_returns_error_not_panic() {
        let result = KdumpProvider::from_bytes(&[0u8; 4]);
        assert!(result.is_err(), "4 bytes is too short for kdump header");
    }
}