Skip to main content

memscope_rs/analysis/heap_scanner/
reader.rs

1//! Safe heap memory reader with page-wise validation.
2//!
3//! Provides the `HeapScanner` which reads allocation memory content
4//! while preventing segfaults through ValidRegions checks and
5//! atomic system calls for fault tolerance.
6
7use crate::analysis::is_virtual_pointer;
8use crate::analysis::unsafe_inference::is_valid_ptr;
9use crate::snapshot::types::ActiveAllocation;
10
11mod process_memory;
12
13/// Maximum bytes to read per allocation. Metadata headers are always
14/// within the first few dozen bytes; 4KB is more than sufficient.
15const MAX_READ_BYTES: usize = 4096;
16
17/// Page size for memory validation checks.
18const PAGE_SIZE: usize = 4096;
19
20/// Result of scanning a single allocation.
21#[derive(Debug)]
22pub struct ScanResult {
23    /// Pointer address of the allocation.
24    pub ptr: usize,
25    /// Original allocation size.
26    pub size: usize,
27    /// Memory content that was successfully read (capped at MAX_READ_BYTES).
28    pub memory: Option<Vec<u8>>,
29}
30
31/// HeapScanner reads heap memory for active allocations during snapshot analysis.
32///
33/// All memory reads are validated through `ValidRegions` to prevent segfaults.
34/// This module only operates during offline analysis and has zero runtime overhead.
35pub struct HeapScanner;
36
37impl HeapScanner {
38    /// Scan a list of active allocations, reading their memory content.
39    ///
40    /// Only scans HeapOwner allocations and performs deduplication
41    /// to avoid redundant scanning of duplicate heap regions.
42    ///
43    /// Returns a `ScanResult` for each unique heap region. Allocations whose
44    /// pointers fall outside valid regions will have `memory: None`.
45    ///
46    /// # Arguments
47    ///
48    /// * `allocations` - List of active allocations to scan.
49    ///
50    /// # Example
51    ///
52    /// ```ignore
53    /// let results = HeapScanner::scan(&allocations);
54    /// for result in results {
55    ///     if let Some(ref memory) = result.memory {
56    ///         let view = MemoryView::new(memory);
57    ///         // ... pass to UTI Engine
58    ///     }
59    /// }
60    /// ```
61    pub fn scan(allocations: &[ActiveAllocation]) -> Vec<ScanResult> {
62        // Step 1: Filter HeapOwner + deduplicate regions
63        let regions = Self::dedup_heap_regions(allocations);
64
65        // Step 2: Scan deduplicated regions
66        regions
67            .iter()
68            .map(|&(ptr, size)| {
69                let memory = safe_read_memory(ptr, size);
70                ScanResult { ptr, size, memory }
71            })
72            .collect()
73    }
74
75    /// Deduplicate heap regions to avoid redundant scanning.
76    ///
77    /// Filters for HeapOwner allocations and removes duplicates
78    /// based on (ptr, size) pairs.
79    ///
80    /// Also skips virtual pointers (>= VIRTUAL_PTR_BASE) used for Container types.
81    /// This threshold is set high enough to avoid conflicts with real heap addresses
82    /// on all platforms (including macOS which can have addresses > 0x100000000).
83    fn dedup_heap_regions(allocs: &[ActiveAllocation]) -> Vec<(usize, usize)> {
84        use std::collections::HashSet;
85
86        let mut seen = HashSet::new();
87        let mut regions = Vec::new();
88
89        for alloc in allocs {
90            if let crate::core::types::TrackKind::HeapOwner { ptr, size } = alloc.kind {
91                if is_virtual_pointer(ptr) {
92                    continue;
93                }
94
95                let key = (ptr, size);
96
97                if seen.insert(key) {
98                    regions.push(key);
99                }
100            }
101        }
102
103        regions
104    }
105}
106
107/// Safely read memory at `ptr` for up to `size` bytes.
108///
109/// Returns `None` if the address is not in valid regions, or if any
110/// page within the read range is unmapped.
111///
112/// Reads through the OS; a region check alone cannot prove a Rust allocation
113/// is still alive. Captured addresses may have been released or reused.
114fn safe_read_memory(ptr: usize, size: usize) -> Option<Vec<u8>> {
115    if size == 0 || ptr == 0 {
116        return None;
117    }
118
119    if !is_valid_ptr(ptr) {
120        return None;
121    }
122
123    let read_size = size.min(MAX_READ_BYTES);
124    if !are_pages_valid(ptr, read_size) {
125        return None;
126    }
127
128    let mut buf = crate::analysis::unsafe_inference::scan_buffer(read_size);
129    buf.resize(read_size, 0);
130
131    process_memory::read(ptr, &mut buf).then_some(buf)
132}
133
134/// Check that every page in [ptr, ptr + size) is in a valid region.
135fn are_pages_valid(ptr: usize, size: usize) -> bool {
136    let page_start = ptr & !(PAGE_SIZE - 1);
137    let Some(end) = ptr
138        .checked_add(size)
139        .and_then(|end| end.checked_add(PAGE_SIZE - 1))
140    else {
141        return false;
142    };
143    let page_end = end & !(PAGE_SIZE - 1);
144
145    let mut p = page_start;
146    while p < page_end {
147        if !is_valid_ptr(p) {
148            return false;
149        }
150        p += PAGE_SIZE;
151    }
152    true
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::core::types::TrackKind;
159
160    #[test]
161    fn test_safe_read_memory_zero_size() {
162        assert!(safe_read_memory(0x10000, 0).is_none());
163    }
164
165    #[test]
166    fn test_safe_read_memory_null_ptr() {
167        assert!(safe_read_memory(0, 100).is_none());
168    }
169
170    #[test]
171    #[cfg(target_os = "macos")]
172    fn test_are_pages_valid_single_page() {
173        assert!(are_pages_valid(0x10000, 100));
174    }
175
176    #[test]
177    #[cfg(target_os = "macos")]
178    fn test_are_pages_valid_cross_page() {
179        // Use a heap-like address that should be valid on all platforms
180        let ptr = 0x10000;
181        let size = 200;
182        assert!(are_pages_valid(ptr, size));
183    }
184
185    #[test]
186    fn test_scan_result_creation() {
187        let result = ScanResult {
188            ptr: 0x1000,
189            size: 64,
190            memory: None,
191        };
192        assert_eq!(result.ptr, 0x1000);
193        assert_eq!(result.size, 64);
194        assert!(result.memory.is_none());
195    }
196
197    #[test]
198    #[cfg(not(target_os = "linux"))]
199    fn test_heap_scanner_scan_real_allocations() {
200        let data1 = vec![42u8; 64];
201        let data2 = vec![99u8; 128];
202        let ptr1 = data1.as_ptr() as usize;
203        let ptr2 = data2.as_ptr() as usize;
204
205        let allocations = vec![
206            ActiveAllocation {
207                ptr: Some(ptr1),
208                size: 64,
209                kind: TrackKind::HeapOwner {
210                    ptr: ptr1,
211                    size: 64,
212                },
213                allocated_at: 1000,
214                var_name: None,
215                type_name: None,
216                thread_id: 0,
217                call_stack_hash: None,
218                module_path: None,
219                stack_ptr: None,
220            },
221            ActiveAllocation {
222                ptr: Some(ptr2),
223                size: 128,
224                kind: TrackKind::HeapOwner {
225                    ptr: ptr2,
226                    size: 128,
227                },
228                allocated_at: 2000,
229                var_name: None,
230                type_name: None,
231                thread_id: 0,
232                call_stack_hash: None,
233                module_path: None,
234                stack_ptr: None,
235            },
236        ];
237
238        let results = HeapScanner::scan(&allocations);
239        assert_eq!(results.len(), 2);
240
241        assert!(results[0].memory.is_some(), "Should read memory at ptr1");
242        assert!(results[1].memory.is_some(), "Should read memory at ptr2");
243
244        drop(data1);
245        drop(data2);
246    }
247
248    #[test]
249    fn test_heap_scanner_scan_empty_allocations() {
250        let allocations: Vec<ActiveAllocation> = vec![];
251        let results = HeapScanner::scan(&allocations);
252        assert!(results.is_empty());
253    }
254
255    #[test]
256    fn test_heap_scanner_scan_zero_size_allocation() {
257        let allocations = vec![ActiveAllocation {
258            ptr: Some(0x10000),
259            size: 0,
260            kind: TrackKind::HeapOwner {
261                ptr: 0x10000,
262                size: 0,
263            },
264            allocated_at: 1000,
265            var_name: None,
266            type_name: None,
267            thread_id: 0,
268            call_stack_hash: None,
269            module_path: None,
270            stack_ptr: None,
271        }];
272
273        let results = HeapScanner::scan(&allocations);
274        assert_eq!(results.len(), 1);
275        // Zero-size allocation should return None for memory (nothing to read).
276        assert!(results[0].memory.is_none());
277    }
278
279    #[test]
280    #[cfg(not(target_os = "linux"))]
281    #[ignore = "Heap pointer addresses may exceed VIRTUAL_PTR_BASE in some CI environments"]
282    fn test_heap_scanner_content_preserved_after_scan() {
283        let data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE];
284        let ptr = data.as_ptr() as usize;
285        let size = data.len();
286
287        let alloc = ActiveAllocation {
288            ptr: Some(ptr),
289            size,
290            kind: TrackKind::HeapOwner { ptr, size },
291            allocated_at: 1000,
292            var_name: None,
293            type_name: None,
294            thread_id: 0,
295            call_stack_hash: None,
296            module_path: None,
297            stack_ptr: None,
298        };
299
300        let results = HeapScanner::scan(&[alloc]);
301        assert_eq!(results.len(), 1);
302
303        let mem = results[0]
304            .memory
305            .as_ref()
306            .expect("Should read memory at allocated address");
307        assert_eq!(mem.len(), size, "Should read expected number of bytes");
308
309        drop(data);
310    }
311}