Skip to main content

memf_format/
avml.rs

1//! AVML v2 dump format provider.
2//!
3//! Parses the binary format produced by AVML (Acquisition of Volatile Memory for Linux):
4//! <https://github.com/microsoft/avml>
5
6use std::path::Path;
7
8use crate::{Error, FormatPlugin, PhysicalMemoryProvider, PhysicalRange, Result};
9
10const AVML_MAGIC: u32 = 0x4C4D5641;
11const AVML_VERSION: u32 = 2;
12const HEADER_SIZE: usize = 32;
13
14/// Bounds-checked little-endian u32 read; out-of-range yields 0 (never panics).
15fn le_u32(data: &[u8], off: usize) -> u32 {
16    data.get(off..off + 4)
17        .and_then(|s| s.try_into().ok())
18        .map_or(0, u32::from_le_bytes)
19}
20
21/// Bounds-checked little-endian u64 read; out-of-range yields 0 (never panics).
22fn le_u64(data: &[u8], off: usize) -> u64 {
23    data.get(off..off + 8)
24        .and_then(|s| s.try_into().ok())
25        .map_or(0, u64::from_le_bytes)
26}
27
28/// A parsed AVML block: physical address range + decompressed payload bytes.
29#[derive(Debug)]
30struct AvmlBlock {
31    range: PhysicalRange,
32    /// Decompressed payload bytes for this block.
33    data: Vec<u8>,
34}
35
36/// Provider that exposes physical memory from an AVML v2 dump.
37///
38/// Each block is fully decompressed on construction so that `read_phys`
39/// requires no allocation at query time.
40#[derive(Debug)]
41pub struct AvmlProvider {
42    blocks: Vec<AvmlBlock>,
43    /// Pre-extracted ranges for the `ranges()` slice return.
44    ranges: Vec<PhysicalRange>,
45}
46
47impl AvmlProvider {
48    /// Parse an AVML v2 dump from an in-memory byte slice (used in tests).
49    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
50        let blocks = parse_blocks(bytes)?;
51        let ranges = blocks.iter().map(|b| b.range.clone()).collect();
52        Ok(Self { blocks, ranges })
53    }
54
55    /// Parse an AVML v2 dump from a file path.
56    pub fn from_path(path: &Path) -> Result<Self> {
57        let bytes = std::fs::read(path)?;
58        Self::from_bytes(&bytes)
59    }
60}
61
62/// Parse all AVML v2 blocks from `data`, returning validated `AvmlBlock`s.
63fn parse_blocks(data: &[u8]) -> Result<Vec<AvmlBlock>> {
64    let mut blocks = Vec::new();
65    let mut pos = 0usize;
66
67    while pos < data.len() {
68        // Need at least a 32-byte header.
69        if pos + HEADER_SIZE > data.len() {
70            return Err(Error::Corrupt(format!(
71                "truncated header at offset {pos:#x}"
72            )));
73        }
74
75        let magic = le_u32(data, pos);
76        let version = le_u32(data, pos + 4);
77        let start = le_u64(data, pos + 8);
78        let end = le_u64(data, pos + 16);
79        // bytes [24..32] are reserved — ignored.
80
81        if magic != AVML_MAGIC {
82            return Err(Error::Corrupt(format!(
83                "bad magic {magic:#010x} at offset {pos:#x}"
84            )));
85        }
86        if version != AVML_VERSION {
87            return Err(Error::Corrupt(format!(
88                "unsupported AVML version {version} at offset {pos:#x}"
89            )));
90        }
91        if start >= end {
92            return Err(Error::Corrupt(format!(
93                "invalid range start={start:#x} end={end:#x} at offset {pos:#x}"
94            )));
95        }
96
97        let expected_uncompressed = end - start;
98
99        let payload_start = pos + HEADER_SIZE;
100
101        let search_end = (payload_start + expected_uncompressed as usize + 64).min(data.len());
102
103        if search_end < payload_start + 8 {
104            return Err(Error::Corrupt(format!(
105                "block at {pos:#x}: not enough data for trailer"
106            )));
107        }
108
109        let mut trailer_pos: Option<usize> = None;
110        let scan_start = payload_start;
111        let scan_end = search_end - 8;
112
113        let mut i = scan_start;
114        while i <= scan_end {
115            let val = le_u64(data, i);
116            if val == expected_uncompressed {
117                let compressed = &data[payload_start..i];
118                let mut decoder = snap::raw::Decoder::new();
119                match decoder.decompress_vec(compressed) {
120                    Ok(decompressed) if decompressed.len() as u64 == expected_uncompressed => {
121                        trailer_pos = Some(i);
122                        let range = PhysicalRange { start, end };
123                        blocks.push(AvmlBlock {
124                            range,
125                            data: decompressed,
126                        });
127                        pos = i + 8; // advance past trailer
128                        break;
129                    }
130                    _ => {
131                        i += 1;
132                        continue;
133                    }
134                }
135            }
136            i += 1;
137        }
138
139        if trailer_pos.is_none() {
140            return Err(Error::Corrupt(format!(
141                "block at {pos:#x}: could not locate valid Snappy trailer \
142                 (expected uncompressed size {expected_uncompressed})"
143            )));
144        }
145    }
146
147    Ok(blocks)
148}
149
150impl PhysicalMemoryProvider for AvmlProvider {
151    fn read_phys(&self, addr: u64, buf: &mut [u8]) -> Result<usize> {
152        if buf.is_empty() {
153            return Ok(0);
154        }
155
156        for block in &self.blocks {
157            if block.range.contains_addr(addr) {
158                let offset_in_block = (addr - block.range.start) as usize;
159                let available = block.data.len().saturating_sub(offset_in_block);
160                let to_read = buf.len().min(available);
161                buf[..to_read]
162                    .copy_from_slice(&block.data[offset_in_block..offset_in_block + to_read]);
163                return Ok(to_read);
164            }
165        }
166
167        // Address not in any mapped block — gap.
168        Ok(0)
169    }
170
171    fn ranges(&self) -> &[PhysicalRange] {
172        &self.ranges
173    }
174
175    fn format_name(&self) -> &str {
176        "AVML v2"
177    }
178}
179
180/// FormatPlugin implementation for AVML v2 dumps.
181pub struct AvmlPlugin;
182
183impl FormatPlugin for AvmlPlugin {
184    fn name(&self) -> &str {
185        "AVML v2"
186    }
187
188    fn probe(&self, header: &[u8]) -> u8 {
189        if header.len() < 8 {
190            return 0;
191        }
192        let magic = le_u32(header, 0);
193        let version = le_u32(header, 4);
194        if magic == AVML_MAGIC && version == AVML_VERSION {
195            90
196        } else {
197            0
198        }
199    }
200
201    fn open(&self, path: &Path) -> Result<Box<dyn PhysicalMemoryProvider>> {
202        Ok(Box::new(AvmlProvider::from_path(path)?))
203    }
204}
205
206inventory::submit!(&AvmlPlugin as &dyn FormatPlugin);
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::test_builders::AvmlBuilder;
212
213    // ---------------------------------------------------------------------------
214    // Test 1: probe returns 90 for valid AVML magic + version=2
215    // ---------------------------------------------------------------------------
216    #[test]
217    fn probe_avml_magic() {
218        let data = AvmlBuilder::new().add_range(0x1000, &[0u8; 64]).build();
219        let plugin = AvmlPlugin;
220        assert_eq!(plugin.probe(&data), 90);
221    }
222
223    // ---------------------------------------------------------------------------
224    // Test 2: probe returns 0 for non-AVML bytes
225    // ---------------------------------------------------------------------------
226    #[test]
227    fn probe_non_avml() {
228        let data = vec![0u8; 64];
229        let plugin = AvmlPlugin;
230        assert_eq!(plugin.probe(&data), 0);
231    }
232
233    // ---------------------------------------------------------------------------
234    // Test 3: single range round-trip
235    // ---------------------------------------------------------------------------
236    #[test]
237    fn single_range_roundtrip() {
238        let payload: Vec<u8> = (0u8..=255).collect();
239        let dump = AvmlBuilder::new().add_range(0x1000, &payload).build();
240
241        let provider = AvmlProvider::from_bytes(&dump).expect("parse");
242
243        // ranges
244        let ranges = provider.ranges();
245        assert_eq!(ranges.len(), 1);
246        assert_eq!(ranges[0].start, 0x1000);
247        assert_eq!(ranges[0].end, 0x1000 + 256);
248
249        // total_size
250        assert_eq!(provider.total_size(), 256);
251
252        // format_name
253        assert_eq!(provider.format_name(), "AVML v2");
254
255        // read_phys — full range
256        let mut buf = vec![0u8; 256];
257        let n = provider.read_phys(0x1000, &mut buf).expect("read");
258        assert_eq!(n, 256);
259        assert_eq!(buf, payload);
260
261        // read_phys — mid-range
262        let mut buf2 = vec![0u8; 4];
263        let n2 = provider.read_phys(0x1010, &mut buf2).expect("read mid");
264        assert_eq!(n2, 4);
265        assert_eq!(buf2, &payload[0x10..0x14]);
266    }
267
268    // ---------------------------------------------------------------------------
269    // Test 4: two ranges round-trip
270    // ---------------------------------------------------------------------------
271    #[test]
272    fn two_ranges_roundtrip() {
273        let data_a = vec![0xAAu8; 256];
274        let data_b = vec![0xBBu8; 256];
275        let dump = AvmlBuilder::new()
276            .add_range(0x0000, &data_a)
277            .add_range(0x1000, &data_b)
278            .build();
279
280        let provider = AvmlProvider::from_bytes(&dump).expect("parse");
281
282        let ranges = provider.ranges();
283        assert_eq!(ranges.len(), 2);
284        assert_eq!(
285            ranges[0],
286            PhysicalRange {
287                start: 0x0000,
288                end: 0x0100
289            }
290        );
291        assert_eq!(
292            ranges[1],
293            PhysicalRange {
294                start: 0x1000,
295                end: 0x1100
296            }
297        );
298        assert_eq!(provider.total_size(), 512);
299
300        let mut buf = vec![0u8; 256];
301        let n = provider.read_phys(0x0000, &mut buf).expect("read a");
302        assert_eq!(n, 256);
303        assert_eq!(buf, data_a);
304
305        let n = provider.read_phys(0x1000, &mut buf).expect("read b");
306        assert_eq!(n, 256);
307        assert_eq!(buf, data_b);
308    }
309
310    // ---------------------------------------------------------------------------
311    // Test 5: reading an unmapped address returns 0 bytes written (gap)
312    // ---------------------------------------------------------------------------
313    #[test]
314    fn gap_returns_zero() {
315        let dump = AvmlBuilder::new().add_range(0x1000, &[0xCCu8; 256]).build();
316
317        let provider = AvmlProvider::from_bytes(&dump).expect("parse");
318
319        let mut buf = vec![0u8; 64];
320        let n = provider.read_phys(0x5000, &mut buf).expect("read gap");
321        assert_eq!(n, 0);
322    }
323
324    #[test]
325    fn from_path_roundtrip() {
326        let payload: Vec<u8> = (0u8..=127).collect();
327        let dump = AvmlBuilder::new().add_range(0x2000, &payload).build();
328        let path = std::env::temp_dir().join("memf_test_avml_from_path.avml");
329        std::fs::write(&path, &dump).unwrap();
330        let provider = AvmlProvider::from_path(&path).unwrap();
331        assert_eq!(provider.ranges().len(), 1);
332        assert_eq!(provider.total_size(), 128);
333        assert_eq!(provider.format_name(), "AVML v2");
334        let mut buf = [0u8; 4];
335        let n = provider.read_phys(0x2000, &mut buf).unwrap();
336        assert_eq!(n, 4);
337        assert_eq!(&buf, &[0, 1, 2, 3]);
338        std::fs::remove_file(&path).ok();
339    }
340
341    #[test]
342    fn plugin_name() {
343        let plugin = AvmlPlugin;
344        assert_eq!(plugin.name(), "AVML v2");
345    }
346
347    #[test]
348    fn probe_short_header_returns_zero() {
349        let plugin = AvmlPlugin;
350        assert_eq!(plugin.probe(&[0x41, 0x56, 0x4D]), 0); // only 3 bytes
351        assert_eq!(plugin.probe(&[]), 0);
352    }
353
354    #[test]
355    fn read_phys_empty_buffer() {
356        let dump = AvmlBuilder::new().add_range(0x1000, &[0xBB; 64]).build();
357        let provider = AvmlProvider::from_bytes(&dump).expect("parse");
358        let mut buf = [];
359        let n = provider.read_phys(0x1000, &mut buf).unwrap();
360        assert_eq!(n, 0);
361    }
362
363    // -------------------------------------------------------------------------
364    // Gap coverage (TDD audit 2026-03-31)
365    // -------------------------------------------------------------------------
366
367    /// Corrupt AVML header: correct AVML magic prefix but wrong version number.
368    /// `parse_blocks` should return `Error::Corrupt`, not panic.
369    #[test]
370    fn corrupt_header_wrong_version_returns_error() {
371        let mut dump = AvmlBuilder::new().add_range(0x1000, &[0xAA; 64]).build();
372        // Overwrite bytes [4..8] (version field) with an unsupported version.
373        dump[4..8].copy_from_slice(&99u32.to_le_bytes());
374        let result = AvmlProvider::from_bytes(&dump);
375        assert!(result.is_err(), "wrong version must return an error");
376        let err = result.unwrap_err();
377        assert!(
378            matches!(err, crate::Error::Corrupt(_)),
379            "error must be Corrupt, got: {err}"
380        );
381        assert!(
382            err.to_string().contains("99"),
383            "error message should mention the bad version number"
384        );
385    }
386
387    /// Corrupt AVML header: magic bytes are completely wrong (not AVML at all).
388    /// `parse_blocks` should return `Error::Corrupt`, not panic.
389    #[test]
390    fn corrupt_header_wrong_magic_returns_error() {
391        let mut dump = AvmlBuilder::new().add_range(0x1000, &[0xAA; 64]).build();
392        // Overwrite bytes [0..4] (magic) with garbage.
393        dump[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
394        let result = AvmlProvider::from_bytes(&dump);
395        assert!(result.is_err(), "wrong magic must return an error");
396        assert!(matches!(result.unwrap_err(), crate::Error::Corrupt(_)));
397    }
398
399    /// Truncated header: a buffer of 20 bytes (less than the required 32-byte
400    /// header) must be rejected with `Error::Corrupt`, not a panic or index OOB.
401    #[test]
402    fn truncated_header_returns_error() {
403        // A real AVML dump starts with a 32-byte block header.
404        // Provide only 20 bytes so the header-size check fires.
405        let partial: Vec<u8> = vec![
406            0x41, 0x56, 0x4D, 0x4C, // magic bytes (little-endian 0x4C4D5641 = "AVML")
407            0x02, 0x00, 0x00, 0x00, // version = 2
408            0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // start addr
409            0x40, 0x10, 0x00, 0x00, // truncated — end addr incomplete
410        ];
411        assert!(
412            partial.len() < 32,
413            "test fixture must be shorter than a full header"
414        );
415        let result = AvmlProvider::from_bytes(&partial);
416        assert!(result.is_err(), "truncated input must return an error");
417        assert!(matches!(result.unwrap_err(), crate::Error::Corrupt(_)));
418    }
419
420    /// Snappy-compressed block round-trip: the `AvmlBuilder` produces a
421    /// Snappy-compressed payload; verify that `read_phys` correctly returns
422    /// the original decompressed bytes.
423    #[test]
424    fn snappy_compressed_block_roundtrip() {
425        // Use a pattern that is not all-zeros so compression is non-trivial.
426        let payload: Vec<u8> = (0u8..=255).cycle().take(512).collect();
427        let dump = AvmlBuilder::new().add_range(0x4000, &payload).build();
428
429        let provider = AvmlProvider::from_bytes(&dump).expect("parse snappy dump");
430        assert_eq!(provider.total_size(), 512);
431
432        let mut buf = vec![0u8; 512];
433        let n = provider.read_phys(0x4000, &mut buf).expect("read_phys");
434        assert_eq!(n, 512);
435        assert_eq!(buf, payload, "decompressed data must match original");
436    }
437}