Skip to main content

memf_linux/
library_list.rs

1//! Process shared library enumeration for Linux memory forensics.
2//!
3//! Enumerates shared libraries loaded by each process by walking the
4//! `vm_area_struct` VMAs that map `.so` files. Equivalent to combining
5//! Volatility's `linux.proc.Maps` with library-specific filtering.
6//!
7//! Useful for detecting LD_PRELOAD injected libraries, anomalous `.so`
8//! files mapped from world-writable directories, or unlinked (deleted)
9//! shared objects still resident in memory.
10
11use std::collections::{HashMap, HashSet};
12
13use memf_core::object_reader::ObjectReader;
14use memf_format::PhysicalMemoryProvider;
15
16use crate::{vma_walker::for_each_task_vma, Error, Result};
17
18/// Maximum number of unique libraries per process (cycle/corruption guard).
19const MAX_LIBS: usize = 4096;
20
21/// Information about a shared library mapped into a process's address space.
22#[derive(Debug, Clone, serde::Serialize)]
23pub struct SharedLibraryInfo {
24    /// Process ID.
25    pub pid: u32,
26    /// Process command name.
27    pub process_name: String,
28    /// File path of the shared library (from `vm_file → f_path → dentry → d_name`).
29    pub lib_path: String,
30    /// Base virtual address (lowest `vm_start` among the library's VMAs).
31    pub base_addr: u64,
32    /// Total mapped size (sum of all VMA regions for this library).
33    pub size: u64,
34    /// Whether the library path is classified as suspicious.
35    pub is_suspicious: bool,
36}
37
38/// Classify whether a library path is suspicious.
39///
40/// A library is suspicious if any of the following hold:
41/// - Path is in `/tmp`, `/dev/shm`, or `/var/tmp` (world-writable directories)
42/// - Path does not end in `.so` and does not contain `.so.` (non-standard shared library name)
43/// - Path ends with `(deleted)` (unlinked but still mapped -- common malware technique)
44/// - Basename starts with `.` (hidden file)
45pub use crate::heuristics::classify_library;
46
47/// Walk the VMA list for a single process and enumerate shared libraries.
48///
49/// Reads `task_struct.mm → mm_struct.mmap` and follows the `vm_area_struct`
50/// singly-linked list via `vm_next`. For each file-backed VMA, reads the
51/// file path from `vm_file → f_path → dentry → d_name`, filters for `.so`
52/// mappings, deduplicates by path, and classifies each library.
53///
54/// Returns an empty `Vec` if the process is a kernel thread (mm == NULL).
55pub fn walk_library_list<P: PhysicalMemoryProvider>(
56    reader: &ObjectReader<P>,
57    task_addr: u64,
58    pid: u32,
59    process_name: &str,
60) -> Result<Vec<SharedLibraryInfo>> {
61    // Resolve struct field offsets for the dentry path chain.
62    let f_path_offset = reader
63        .symbols()
64        .field_offset("file", "f_path")
65        .ok_or_else(|| Error::MissingField {
66            struct_name: "file".into(),
67            field_name: "f_path".into(),
68        })?;
69    let dentry_in_path_offset =
70        reader
71            .symbols()
72            .field_offset("path", "dentry")
73            .ok_or_else(|| Error::MissingField {
74                struct_name: "path".into(),
75                field_name: "dentry".into(),
76            })?;
77    let d_name_offset = reader
78        .symbols()
79        .field_offset("dentry", "d_name")
80        .ok_or_else(|| Error::MissingField {
81            struct_name: "dentry".into(),
82            field_name: "d_name".into(),
83        })?;
84    let name_in_qstr_offset = reader
85        .symbols()
86        .field_offset("qstr", "name")
87        .ok_or_else(|| Error::MissingField {
88            struct_name: "qstr".into(),
89            field_name: "name".into(),
90        })?;
91
92    // Track per-library aggregated info: (min vm_start, total size).
93    let mut lib_map: HashMap<String, (u64, u64)> = HashMap::new();
94    // Cycle detection: track seen VMA addresses.
95    let mut seen_addrs: HashSet<u64> = HashSet::new();
96    // Capture limit flag for the closure.
97    let mut limit_reached = false;
98
99    for_each_task_vma(reader, task_addr, &mut |e| {
100        // Cycle detection.
101        if !seen_addrs.insert(e.vma_addr) {
102            limit_reached = true;
103            return;
104        }
105        if limit_reached || lib_map.len() >= MAX_LIBS {
106            limit_reached = true;
107            return;
108        }
109
110        if e.file_ptr != 0 {
111            // Read file path: file.f_path.dentry → d_name.name
112            if let Some(name) = read_vma_file_path(
113                reader,
114                e.file_ptr,
115                f_path_offset,
116                dentry_in_path_offset,
117                d_name_offset,
118                name_in_qstr_offset,
119            ) {
120                // Only include mappings that look like shared libraries.
121                if name.contains(".so") {
122                    let size = e.end.saturating_sub(e.start);
123                    let entry = lib_map.entry(name).or_insert((e.start, 0));
124                    // Track lowest base address and accumulate size.
125                    entry.0 = entry.0.min(e.start);
126                    entry.1 += size;
127                }
128            }
129        }
130    });
131
132    // Build result vector from deduplicated map.
133    let mut libs: Vec<SharedLibraryInfo> = lib_map
134        .into_iter()
135        .map(|(lib_path, (base_addr, size))| {
136            let is_suspicious = classify_library(&lib_path);
137            SharedLibraryInfo {
138                pid,
139                process_name: process_name.to_string(),
140                lib_path,
141                base_addr,
142                size,
143                is_suspicious,
144            }
145        })
146        .collect();
147
148    // Sort by base address for deterministic output.
149    libs.sort_by_key(|lib| lib.base_addr);
150
151    Ok(libs)
152}
153
154/// Read the file path from a VMA's `vm_file` pointer.
155///
156/// Navigates `file.f_path.dentry → d_name.name` to extract the filename.
157/// Returns `None` if any pointer in the chain is NULL or unreadable.
158fn read_vma_file_path<P: PhysicalMemoryProvider>(
159    reader: &ObjectReader<P>,
160    file_ptr: u64,
161    f_path_offset: u64,
162    dentry_in_path_offset: u64,
163    d_name_offset: u64,
164    name_in_qstr_offset: u64,
165) -> Option<String> {
166    // file.f_path is an embedded struct; dentry pointer lives at
167    // file_ptr + f_path_offset + dentry_in_path_offset.
168    let dentry_addr = file_ptr + f_path_offset + dentry_in_path_offset;
169    let dentry_raw = reader.read_bytes(dentry_addr, 8).ok()?;
170    let dentry_ptr = u64::from_le_bytes(dentry_raw.try_into().ok()?);
171    if dentry_ptr == 0 {
172        return None;
173    }
174
175    // dentry.d_name is an embedded qstr; name pointer at qstr.name offset.
176    let name_addr = dentry_ptr + d_name_offset + name_in_qstr_offset;
177    let name_raw = reader.read_bytes(name_addr, 8).ok()?;
178    let name_ptr = u64::from_le_bytes(name_raw.try_into().ok()?);
179    if name_ptr == 0 {
180        return None;
181    }
182
183    let name = reader.read_string(name_ptr, 256).ok()?;
184    if name.is_empty() {
185        return None;
186    }
187
188    Some(name)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    // -------------------------------------------------------------------
196    // classify_library tests
197    // -------------------------------------------------------------------
198
199    #[test]
200    fn classify_standard_lib_benign() {
201        assert!(
202            !classify_library("/usr/lib/x86_64-linux-gnu/libc.so.6"),
203            "standard libc path should not be suspicious"
204        );
205        assert!(
206            !classify_library("/usr/lib/libpthread.so.0"),
207            "standard libpthread should not be suspicious"
208        );
209        assert!(
210            !classify_library("/lib64/ld-linux-x86-64.so.2"),
211            "dynamic linker should not be suspicious"
212        );
213    }
214
215    #[test]
216    fn classify_tmp_suspicious() {
217        assert!(
218            classify_library("/tmp/evil.so"),
219            "/tmp library should be suspicious"
220        );
221        assert!(
222            classify_library("/tmp/subdir/payload.so"),
223            "/tmp subdirectory should be suspicious"
224        );
225    }
226
227    #[test]
228    fn classify_devshm_suspicious() {
229        assert!(
230            classify_library("/dev/shm/inject.so"),
231            "/dev/shm library should be suspicious"
232        );
233        assert!(
234            classify_library("/dev/shm/hidden/hook.so.1"),
235            "/dev/shm subdirectory should be suspicious"
236        );
237    }
238
239    #[test]
240    fn classify_deleted_suspicious() {
241        assert!(
242            classify_library("/usr/lib/libfoo.so (deleted)"),
243            "deleted library should be suspicious"
244        );
245        assert!(
246            classify_library("/tmp/rootkit.so (deleted)"),
247            "deleted library from /tmp should be suspicious"
248        );
249    }
250
251    #[test]
252    fn classify_hidden_file_suspicious() {
253        assert!(
254            classify_library("/home/user/.hidden_lib.so"),
255            "hidden file should be suspicious"
256        );
257        assert!(
258            classify_library("/opt/app/.sneaky.so.1"),
259            "hidden file with version should be suspicious"
260        );
261    }
262
263    #[test]
264    fn classify_non_so_suspicious() {
265        assert!(
266            classify_library("/usr/lib/not_a_library.bin"),
267            "non-.so file should be suspicious"
268        );
269        assert!(
270            classify_library("/usr/lib/strange_mapping"),
271            "file without .so extension should be suspicious"
272        );
273    }
274
275    #[test]
276    fn classify_var_tmp_suspicious() {
277        assert!(
278            classify_library("/var/tmp/staged.so"),
279            "/var/tmp library should be suspicious"
280        );
281    }
282
283    // -------------------------------------------------------------------
284    // walk_library_list tests
285    // -------------------------------------------------------------------
286
287    use memf_core::test_builders::{flags, PageTableBuilder, SyntheticPhysMem};
288    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
289    use memf_symbols::isf::IsfResolver;
290    use memf_symbols::test_builders::IsfBuilder;
291
292    /// Build an [`ObjectReader`] with all struct definitions needed by the
293    /// library list walker (task_struct, mm_struct, vm_area_struct, file,
294    /// path, dentry, qstr).
295    fn make_test_reader(data: &[u8], vaddr: u64, paddr: u64) -> ObjectReader<SyntheticPhysMem> {
296        let isf = IsfBuilder::new()
297            // task_struct
298            .add_struct("task_struct", 128)
299            .add_field("task_struct", "pid", 0, "int")
300            .add_field("task_struct", "comm", 32, "char")
301            .add_field("task_struct", "mm", 48, "pointer")
302            // mm_struct
303            .add_struct("mm_struct", 128)
304            .add_field("mm_struct", "mmap", 8, "pointer")
305            // vm_area_struct
306            .add_struct("vm_area_struct", 64)
307            .add_field("vm_area_struct", "vm_start", 0, "unsigned long")
308            .add_field("vm_area_struct", "vm_end", 8, "unsigned long")
309            .add_field("vm_area_struct", "vm_next", 16, "pointer")
310            .add_field("vm_area_struct", "vm_file", 40, "pointer")
311            // file
312            .add_struct("file", 64)
313            .add_field("file", "f_path", 0, "path")
314            // path (embedded in struct file)
315            .add_struct("path", 16)
316            .add_field("path", "dentry", 8, "pointer")
317            // dentry
318            .add_struct("dentry", 64)
319            .add_field("dentry", "d_name", 0, "qstr")
320            // qstr
321            .add_struct("qstr", 16)
322            .add_field("qstr", "name", 8, "pointer")
323            .build_json();
324
325        let resolver = IsfResolver::from_value(&isf).unwrap();
326        let (cr3, mem) = PageTableBuilder::new()
327            .map_4k(vaddr, paddr, flags::WRITABLE)
328            .write_phys(paddr, data)
329            .build();
330        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
331        ObjectReader::new(vas, Box::new(resolver))
332    }
333
334    #[test]
335    fn walk_no_vma_returns_empty() {
336        // A kernel thread (mm == NULL) should produce an empty library list.
337        let vaddr: u64 = 0xFFFF_8000_0010_0000;
338        let paddr: u64 = 0x0080_0000;
339        let mut data = vec![0u8; 4096];
340
341        // task_struct with mm = NULL (kernel thread)
342        data[0..4].copy_from_slice(&2u32.to_le_bytes()); // pid = 2
343        data[32..41].copy_from_slice(b"kthreadd\0"); // comm
344        data[48..56].copy_from_slice(&0u64.to_le_bytes()); // mm = NULL
345
346        let reader = make_test_reader(&data, vaddr, paddr);
347
348        let result = walk_library_list(&reader, vaddr, 2, "kthreadd").unwrap();
349        assert!(result.is_empty(), "kernel thread should have no libraries");
350    }
351
352    // regression guard: file-backed VMA with .so name produces library entry
353    #[test]
354    fn walk_single_so_library() {
355        // Process with one VMA mapping libc.so.6
356        let vaddr: u64 = 0xFFFF_8000_0010_0000;
357        let paddr: u64 = 0x0080_0000;
358        let mut data = vec![0u8; 4096];
359
360        // task_struct at base: PID 1, "bash", mm at +0x200
361        data[0..4].copy_from_slice(&1u32.to_le_bytes()); // pid
362        data[32..36].copy_from_slice(b"bash"); // comm
363        let mm_addr = vaddr + 0x200;
364        data[48..56].copy_from_slice(&mm_addr.to_le_bytes()); // mm
365
366        // mm_struct at +0x200: mmap at offset 8 → VMA at +0x300
367        let vma_addr = vaddr + 0x300;
368        data[0x208..0x210].copy_from_slice(&vma_addr.to_le_bytes()); // mmap
369
370        // vm_area_struct at +0x300
371        data[0x300..0x308].copy_from_slice(&0x7F00_0000u64.to_le_bytes()); // vm_start
372        data[0x308..0x310].copy_from_slice(&0x7F01_0000u64.to_le_bytes()); // vm_end
373        data[0x310..0x318].copy_from_slice(&0u64.to_le_bytes()); // vm_next = NULL
374                                                                 // vm_file at offset 40 → file struct at +0x400
375        let file_addr = vaddr + 0x400;
376        data[0x328..0x330].copy_from_slice(&file_addr.to_le_bytes()); // vm_file
377
378        // struct file at +0x400: f_path at offset 0, path.dentry at offset 8
379        let dentry_addr = vaddr + 0x500;
380        data[0x408..0x410].copy_from_slice(&dentry_addr.to_le_bytes()); // f_path.dentry
381
382        // dentry at +0x500: d_name (qstr) at offset 0, qstr.name at offset 8
383        let name_str_addr = vaddr + 0x600;
384        data[0x508..0x510].copy_from_slice(&name_str_addr.to_le_bytes()); // d_name.name
385
386        // Name string at +0x600
387        let name = b"libc.so.6";
388        data[0x600..0x600 + name.len()].copy_from_slice(name);
389
390        let reader = make_test_reader(&data, vaddr, paddr);
391        let libs = walk_library_list(&reader, vaddr, 1, "bash").unwrap();
392
393        assert_eq!(libs.len(), 1);
394        assert_eq!(libs[0].pid, 1);
395        assert_eq!(libs[0].process_name, "bash");
396        assert_eq!(libs[0].lib_path, "libc.so.6");
397        assert_eq!(libs[0].base_addr, 0x7F00_0000);
398        assert_eq!(libs[0].size, 0x0001_0000);
399        assert!(!libs[0].is_suspicious);
400    }
401
402    #[test]
403    fn walk_deduplicates_multi_vma_library() {
404        // A single .so mapped across two VMAs (text + data) should produce one entry.
405        let vaddr: u64 = 0xFFFF_8000_0010_0000;
406        let paddr: u64 = 0x0080_0000;
407        let mut data = vec![0u8; 4096];
408
409        // task_struct
410        data[0..4].copy_from_slice(&1u32.to_le_bytes()); // pid
411        data[32..36].copy_from_slice(b"cat\0"); // comm
412        let mm_addr = vaddr + 0x200;
413        data[48..56].copy_from_slice(&mm_addr.to_le_bytes()); // mm
414
415        // mm_struct at +0x200
416        let vma1_addr = vaddr + 0x300;
417        data[0x208..0x210].copy_from_slice(&vma1_addr.to_le_bytes()); // mmap → VMA1
418
419        // Both VMAs share the same file struct (same vm_file pointer).
420        let file_addr = vaddr + 0x500;
421
422        // VMA1 at +0x300: text segment
423        data[0x300..0x308].copy_from_slice(&0x7F00_0000u64.to_le_bytes()); // vm_start
424        data[0x308..0x310].copy_from_slice(&0x7F00_4000u64.to_le_bytes()); // vm_end
425        let vma2_addr = vaddr + 0x400;
426        data[0x310..0x318].copy_from_slice(&vma2_addr.to_le_bytes()); // vm_next → VMA2
427        data[0x328..0x330].copy_from_slice(&file_addr.to_le_bytes()); // vm_file
428
429        // VMA2 at +0x400: data segment (higher address)
430        data[0x400..0x408].copy_from_slice(&0x7F00_4000u64.to_le_bytes()); // vm_start
431        data[0x408..0x410].copy_from_slice(&0x7F00_6000u64.to_le_bytes()); // vm_end
432        data[0x410..0x418].copy_from_slice(&0u64.to_le_bytes()); // vm_next = NULL
433        data[0x428..0x430].copy_from_slice(&file_addr.to_le_bytes()); // vm_file
434
435        // file struct at +0x500
436        let dentry_addr = vaddr + 0x600;
437        data[0x508..0x510].copy_from_slice(&dentry_addr.to_le_bytes()); // f_path.dentry
438
439        // dentry at +0x600
440        let name_addr = vaddr + 0x700;
441        data[0x608..0x610].copy_from_slice(&name_addr.to_le_bytes()); // d_name.name
442
443        // Name string at +0x700
444        let name = b"libpthread.so.0";
445        data[0x700..0x700 + name.len()].copy_from_slice(name);
446
447        let reader = make_test_reader(&data, vaddr, paddr);
448        let libs = walk_library_list(&reader, vaddr, 1, "cat").unwrap();
449
450        // Should be deduplicated to one entry.
451        assert_eq!(libs.len(), 1);
452        assert_eq!(libs[0].lib_path, "libpthread.so.0");
453        assert_eq!(libs[0].base_addr, 0x7F00_0000);
454        // Total size = 0x4000 + 0x2000 = 0x6000
455        assert_eq!(libs[0].size, 0x6000);
456        assert!(!libs[0].is_suspicious);
457    }
458
459    // regression guard: anonymous VMA (vm_file==0) not included in library list
460    #[test]
461    fn walk_skips_non_file_backed_vmas() {
462        // Anonymous VMAs (vm_file == 0) should be skipped.
463        let vaddr: u64 = 0xFFFF_8000_0010_0000;
464        let paddr: u64 = 0x0080_0000;
465        let mut data = vec![0u8; 4096];
466
467        // task_struct
468        data[0..4].copy_from_slice(&1u32.to_le_bytes());
469        data[32..36].copy_from_slice(b"test");
470        let mm_addr = vaddr + 0x200;
471        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
472
473        // mm_struct at +0x200
474        let vma_addr = vaddr + 0x300;
475        data[0x208..0x210].copy_from_slice(&vma_addr.to_le_bytes()); // mmap
476
477        // VMA at +0x300: anonymous mapping (vm_file = 0)
478        data[0x300..0x308].copy_from_slice(&0x7FFF_0000u64.to_le_bytes()); // vm_start
479        data[0x308..0x310].copy_from_slice(&0x7FFF_2000u64.to_le_bytes()); // vm_end
480        data[0x310..0x318].copy_from_slice(&0u64.to_le_bytes()); // vm_next = NULL
481        data[0x328..0x330].copy_from_slice(&0u64.to_le_bytes()); // vm_file = NULL
482
483        let reader = make_test_reader(&data, vaddr, paddr);
484        let libs = walk_library_list(&reader, vaddr, 1, "test").unwrap();
485
486        assert!(
487            libs.is_empty(),
488            "anonymous VMA should not produce library entries"
489        );
490    }
491
492    #[test]
493    fn classify_library_exact_tmp_dir() {
494        // Covers line 59: clean == "/tmp" (exact match without trailing slash)
495        assert!(
496            classify_library("/tmp"),
497            "exact /tmp path must be suspicious"
498        );
499        assert!(
500            classify_library("/dev/shm"),
501            "/dev/shm exact match must be suspicious"
502        );
503        assert!(
504            classify_library("/var/tmp"),
505            "/var/tmp exact match must be suspicious"
506        );
507    }
508
509    #[test]
510    fn classify_library_just_dot_basename_not_suspicious() {
511        // Covers the `basename.starts_with('.') && !basename.is_empty()` branch.
512        // A path ending in exactly '.' would start with '.' but let's test the
513        // normal hidden-file path which the existing tests already cover.
514        // This test focuses on the fallthrough: basename doesn't start with '.'.
515        // A path like "/usr/lib/normallib.so" falls through all checks → benign.
516        assert!(
517            !classify_library("/usr/lib/normallib.so"),
518            "normal .so must be benign"
519        );
520    }
521
522    #[test]
523    fn walk_cycle_detection_breaks_loop() {
524        // Covers line 133: VMA cycle → seen_addrs.insert fails → break.
525        // VMA's vm_next points back to itself (cycle).
526        let vaddr: u64 = 0xFFFF_8000_0050_0000;
527        let paddr: u64 = 0x0083_0000;
528        let mut data = vec![0u8; 4096];
529
530        // task_struct
531        data[0..4].copy_from_slice(&10u32.to_le_bytes()); // pid
532        data[32..36].copy_from_slice(b"cycl"); // comm
533        let mm_addr = vaddr + 0x200;
534        data[48..56].copy_from_slice(&mm_addr.to_le_bytes()); // mm
535
536        // mm_struct at +0x200: mmap → VMA at +0x300
537        let vma_addr = vaddr + 0x300;
538        data[0x208..0x210].copy_from_slice(&vma_addr.to_le_bytes());
539
540        // VMA at +0x300: vm_next points back to itself (cycle)
541        data[0x300..0x308].copy_from_slice(&0x7F00_0000u64.to_le_bytes()); // vm_start
542        data[0x308..0x310].copy_from_slice(&0x7F00_1000u64.to_le_bytes()); // vm_end
543        data[0x310..0x318].copy_from_slice(&vma_addr.to_le_bytes()); // vm_next = self (cycle!)
544        data[0x328..0x330].copy_from_slice(&0u64.to_le_bytes()); // vm_file = NULL
545
546        let reader = make_test_reader(&data, vaddr, paddr);
547        // Should not hang or overflow; cycle detection breaks the loop.
548        let libs = walk_library_list(&reader, vaddr, 10, "cycl").unwrap();
549        assert!(
550            libs.is_empty(),
551            "cycle VMA with null vm_file should yield no libraries"
552        );
553    }
554
555    #[test]
556    fn walk_second_vma_with_lower_base_updates_min() {
557        // Covers line 158: entry.0 = entry.0.min(vm_start)
558        // Two VMAs for the same library where the second VMA has a lower start address.
559        let vaddr: u64 = 0xFFFF_8000_0060_0000;
560        let paddr: u64 = 0x0084_0000;
561        let mut data = vec![0u8; 4096];
562
563        // task_struct
564        data[0..4].copy_from_slice(&20u32.to_le_bytes()); // pid
565        data[32..37].copy_from_slice(b"proc\0"); // comm
566        let mm_addr = vaddr + 0x100;
567        data[48..56].copy_from_slice(&mm_addr.to_le_bytes()); // mm
568
569        // mm_struct at +0x100: mmap = VMA1
570        let vma1_addr = vaddr + 0x200;
571        data[0x108..0x110].copy_from_slice(&vma1_addr.to_le_bytes());
572
573        let file_addr = vaddr + 0x600;
574
575        // VMA1 at +0x200: vm_start=0x7F00_2000 (higher), vm_next → VMA2
576        let vma2_addr = vaddr + 0x300;
577        data[0x200..0x208].copy_from_slice(&0x7F00_2000u64.to_le_bytes()); // vm_start
578        data[0x208..0x210].copy_from_slice(&0x7F00_4000u64.to_le_bytes()); // vm_end
579        data[0x210..0x218].copy_from_slice(&vma2_addr.to_le_bytes()); // vm_next
580        data[0x228..0x230].copy_from_slice(&file_addr.to_le_bytes()); // vm_file
581
582        // VMA2 at +0x300: vm_start=0x7F00_0000 (lower than VMA1), vm_next → NULL
583        data[0x300..0x308].copy_from_slice(&0x7F00_0000u64.to_le_bytes()); // vm_start (lower!)
584        data[0x308..0x310].copy_from_slice(&0x7F00_2000u64.to_le_bytes()); // vm_end
585        data[0x310..0x318].copy_from_slice(&0u64.to_le_bytes()); // vm_next = NULL
586        data[0x328..0x330].copy_from_slice(&file_addr.to_le_bytes()); // vm_file (same lib)
587
588        // file at +0x600: dentry at +0x700
589        let dentry_addr = vaddr + 0x700;
590        data[0x608..0x610].copy_from_slice(&dentry_addr.to_le_bytes());
591
592        // dentry at +0x700: name ptr at +0x800
593        let name_addr = vaddr + 0x800;
594        data[0x708..0x710].copy_from_slice(&name_addr.to_le_bytes());
595
596        // name: "libtest.so.1"
597        let name = b"libtest.so.1";
598        data[0x800..0x800 + name.len()].copy_from_slice(name);
599
600        let reader = make_test_reader(&data, vaddr, paddr);
601        let libs = walk_library_list(&reader, vaddr, 20, "proc").unwrap();
602
603        assert_eq!(libs.len(), 1, "single deduplicated library expected");
604        // base_addr should be the minimum: 0x7F00_0000 (from VMA2)
605        assert_eq!(
606            libs[0].base_addr, 0x7F00_0000,
607            "base_addr must be the minimum vm_start"
608        );
609        // size = (0x7F00_4000 - 0x7F00_2000) + (0x7F00_2000 - 0x7F00_0000) = 0x4000
610        assert_eq!(libs[0].size, 0x4000);
611    }
612
613    // --- read_vma_file_path: dentry_ptr == 0 → returns None → VMA skipped ---
614    #[test]
615    fn walk_skips_vma_when_dentry_null() {
616        let vaddr: u64 = 0xFFFF_8000_0070_0000;
617        let paddr: u64 = 0x0085_0000;
618        let mut data = vec![0u8; 4096];
619
620        // task_struct
621        data[0..4].copy_from_slice(&30u32.to_le_bytes()); // pid
622        data[32..36].copy_from_slice(b"null"); // comm
623        let mm_addr = vaddr + 0x200;
624        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
625
626        // mm_struct at +0x200: mmap → VMA at +0x300
627        let vma_addr = vaddr + 0x300;
628        data[0x208..0x210].copy_from_slice(&vma_addr.to_le_bytes());
629
630        // VMA at +0x300: vm_file → file at +0x400
631        data[0x300..0x308].copy_from_slice(&0x7F00_0000u64.to_le_bytes()); // vm_start
632        data[0x308..0x310].copy_from_slice(&0x7F00_2000u64.to_le_bytes()); // vm_end
633        data[0x310..0x318].copy_from_slice(&0u64.to_le_bytes()); // vm_next = NULL
634        let file_addr = vaddr + 0x400;
635        data[0x328..0x330].copy_from_slice(&file_addr.to_le_bytes());
636
637        // file at +0x400: f_path.dentry = 0 (null dentry)
638        // f_path at offset 0; dentry pointer at f_path_offset + dentry_in_path_offset = 0 + 8 = 8
639        data[0x408..0x410].copy_from_slice(&0u64.to_le_bytes()); // dentry = NULL
640
641        let reader = make_test_reader(&data, vaddr, paddr);
642        let libs = walk_library_list(&reader, vaddr, 30, "null").unwrap();
643        assert!(
644            libs.is_empty(),
645            "null dentry_ptr → read_vma_file_path returns None → no library"
646        );
647    }
648
649    // --- read_vma_file_path: name_ptr == 0 → returns None → VMA skipped ---
650    #[test]
651    fn walk_skips_vma_when_name_ptr_null() {
652        let vaddr: u64 = 0xFFFF_8000_0078_0000;
653        let paddr: u64 = 0x0086_0000;
654        let mut data = vec![0u8; 4096];
655
656        // task_struct
657        data[0..4].copy_from_slice(&31u32.to_le_bytes());
658        data[32..36].copy_from_slice(b"npnl");
659        let mm_addr = vaddr + 0x200;
660        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
661
662        // mm_struct at +0x200
663        let vma_addr = vaddr + 0x300;
664        data[0x208..0x210].copy_from_slice(&vma_addr.to_le_bytes());
665
666        // VMA
667        data[0x300..0x308].copy_from_slice(&0x7F00_0000u64.to_le_bytes());
668        data[0x308..0x310].copy_from_slice(&0x7F00_2000u64.to_le_bytes());
669        data[0x310..0x318].copy_from_slice(&0u64.to_le_bytes()); // vm_next = NULL
670        let file_addr = vaddr + 0x400;
671        data[0x328..0x330].copy_from_slice(&file_addr.to_le_bytes());
672
673        // file: f_path.dentry → dentry at +0x500
674        let dentry_addr = vaddr + 0x500;
675        data[0x408..0x410].copy_from_slice(&dentry_addr.to_le_bytes());
676
677        // dentry at +0x500: d_name (qstr) at offset 0; qstr.name at offset 8 → NULL
678        data[0x508..0x510].copy_from_slice(&0u64.to_le_bytes()); // name_ptr = NULL
679
680        let reader = make_test_reader(&data, vaddr, paddr);
681        let libs = walk_library_list(&reader, vaddr, 31, "npnl").unwrap();
682        assert!(
683            libs.is_empty(),
684            "name_ptr == 0 → read_vma_file_path returns None → no library"
685        );
686    }
687
688    // --- read_vma_file_path: name is empty string → returns None → VMA skipped ---
689    #[test]
690    fn walk_skips_vma_when_name_empty() {
691        let vaddr: u64 = 0xFFFF_8000_0079_0000;
692        let paddr: u64 = 0x0087_0000;
693        let mut data = vec![0u8; 4096];
694
695        data[0..4].copy_from_slice(&32u32.to_le_bytes());
696        data[32..36].copy_from_slice(b"empt");
697        let mm_addr = vaddr + 0x200;
698        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
699
700        let vma_addr = vaddr + 0x300;
701        data[0x208..0x210].copy_from_slice(&vma_addr.to_le_bytes());
702
703        data[0x300..0x308].copy_from_slice(&0x7F00_0000u64.to_le_bytes());
704        data[0x308..0x310].copy_from_slice(&0x7F00_2000u64.to_le_bytes());
705        data[0x310..0x318].copy_from_slice(&0u64.to_le_bytes());
706        let file_addr = vaddr + 0x400;
707        data[0x328..0x330].copy_from_slice(&file_addr.to_le_bytes());
708
709        // file → dentry at +0x500
710        let dentry_addr = vaddr + 0x500;
711        data[0x408..0x410].copy_from_slice(&dentry_addr.to_le_bytes());
712
713        // dentry: qstr.name at 0x508 → name_str at +0x600 (which is \0)
714        let name_str_addr = vaddr + 0x600;
715        data[0x508..0x510].copy_from_slice(&name_str_addr.to_le_bytes());
716        // name_str_addr points to a null byte → empty string
717        data[0x600] = 0u8;
718
719        let reader = make_test_reader(&data, vaddr, paddr);
720        let libs = walk_library_list(&reader, vaddr, 32, "empt").unwrap();
721        // Empty name from read_string → read_vma_file_path returns None → no library
722        assert!(
723            libs.is_empty(),
724            "empty name → read_vma_file_path returns None"
725        );
726    }
727
728    // --- SharedLibraryInfo: Debug, Clone, Serialize ---
729    #[test]
730    fn shared_library_info_debug_clone_serialize() {
731        let info = SharedLibraryInfo {
732            pid: 1,
733            process_name: "test".to_string(),
734            lib_path: "/usr/lib/libfoo.so".to_string(),
735            base_addr: 0x7F00_0000,
736            size: 0x1000,
737            is_suspicious: false,
738        };
739        let cloned = info.clone();
740        let dbg = format!("{cloned:?}");
741        assert!(dbg.contains("libfoo"));
742        let json = serde_json::to_string(&info).unwrap();
743        assert!(json.contains("\"pid\":1"));
744        assert!(json.contains("is_suspicious"));
745    }
746
747    #[test]
748    fn walk_classifies_suspicious_library() {
749        // A library from /tmp should be flagged as suspicious.
750        let vaddr: u64 = 0xFFFF_8000_0010_0000;
751        let paddr: u64 = 0x0080_0000;
752        let mut data = vec![0u8; 4096];
753
754        // task_struct
755        data[0..4].copy_from_slice(&42u32.to_le_bytes());
756        data[32..37].copy_from_slice(b"sshd\0");
757        let mm_addr = vaddr + 0x200;
758        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
759
760        // mm_struct at +0x200
761        let vma_addr = vaddr + 0x300;
762        data[0x208..0x210].copy_from_slice(&vma_addr.to_le_bytes());
763
764        // VMA at +0x300
765        data[0x300..0x308].copy_from_slice(&0x7F00_0000u64.to_le_bytes());
766        data[0x308..0x310].copy_from_slice(&0x7F00_2000u64.to_le_bytes());
767        data[0x310..0x318].copy_from_slice(&0u64.to_le_bytes()); // vm_next = NULL
768        let file_addr = vaddr + 0x400;
769        data[0x328..0x330].copy_from_slice(&file_addr.to_le_bytes());
770
771        // file at +0x400
772        let dentry_addr = vaddr + 0x500;
773        data[0x408..0x410].copy_from_slice(&dentry_addr.to_le_bytes());
774
775        // dentry at +0x500
776        let name_addr = vaddr + 0x600;
777        data[0x508..0x510].copy_from_slice(&name_addr.to_le_bytes());
778
779        // Name: suspicious library in /tmp
780        let name = b"/tmp/evil.so";
781        data[0x600..0x600 + name.len()].copy_from_slice(name);
782
783        let reader = make_test_reader(&data, vaddr, paddr);
784        let libs = walk_library_list(&reader, vaddr, 42, "sshd").unwrap();
785
786        assert_eq!(libs.len(), 1);
787        assert_eq!(libs[0].lib_path, "/tmp/evil.so");
788        assert!(libs[0].is_suspicious, "/tmp library should be suspicious");
789    }
790
791    // --- walk_library_list: file.f_path field missing → error returned ---
792    // Exercises line 107: ok_or_else for f_path offset.
793    #[test]
794    fn walk_library_list_missing_f_path_field_returns_error() {
795        let vaddr: u64 = 0xFFFF_8000_0088_0000;
796        let paddr: u64 = 0x0088_1000;
797        let mut data = vec![0u8; 4096];
798
799        // mm != 0 (non-kernel thread)
800        let mm_addr = vaddr + 0x200;
801        data[0..4].copy_from_slice(&9u32.to_le_bytes());
802        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
803        // mm.mmap = 0 so VMA loop won't run, but we need file.f_path to be missing
804
805        // Build ISF without file.f_path field
806        let isf = IsfBuilder::new()
807            .add_struct("task_struct", 128)
808            .add_field("task_struct", "pid", 0, "int")
809            .add_field("task_struct", "comm", 32, "char")
810            .add_field("task_struct", "mm", 48, "pointer")
811            .add_struct("mm_struct", 128)
812            .add_field("mm_struct", "mmap", 8, "pointer")
813            // "file" struct is absent → f_path field offset returns None → Error
814            .add_struct("path", 16)
815            .add_field("path", "dentry", 8, "pointer")
816            .add_struct("dentry", 64)
817            .add_field("dentry", "d_name", 0, "qstr")
818            .add_struct("qstr", 16)
819            .add_field("qstr", "name", 8, "pointer")
820            .build_json();
821
822        let resolver = IsfResolver::from_value(&isf).unwrap();
823        let (cr3, mem) = PageTableBuilder::new()
824            .map_4k(vaddr, paddr, flags::WRITABLE)
825            .write_phys(paddr, &data)
826            .build();
827        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
828        let reader = ObjectReader::new(vas, Box::new(resolver));
829
830        let result = walk_library_list(&reader, vaddr, 9, "proc");
831        assert!(
832            result.is_err(),
833            "missing file.f_path field must return an error"
834        );
835    }
836
837    // --- walk_library_list: path.dentry field missing → error ---
838    #[test]
839    fn walk_library_list_missing_path_dentry_field_returns_error() {
840        let vaddr: u64 = 0xFFFF_8000_0089_0000;
841        let paddr: u64 = 0x0089_0000;
842        let mut data = vec![0u8; 4096];
843
844        let mm_addr = vaddr + 0x200;
845        data[0..4].copy_from_slice(&10u32.to_le_bytes());
846        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
847
848        let isf = IsfBuilder::new()
849            .add_struct("task_struct", 128)
850            .add_field("task_struct", "pid", 0, "int")
851            .add_field("task_struct", "comm", 32, "char")
852            .add_field("task_struct", "mm", 48, "pointer")
853            .add_struct("mm_struct", 128)
854            .add_field("mm_struct", "mmap", 8, "pointer")
855            .add_struct("file", 64)
856            .add_field("file", "f_path", 0, "path")
857            // "path" struct exists but "dentry" field is missing
858            .add_struct("path", 16)
859            .add_struct("dentry", 64)
860            .add_field("dentry", "d_name", 0, "qstr")
861            .add_struct("qstr", 16)
862            .add_field("qstr", "name", 8, "pointer")
863            .build_json();
864
865        let resolver = IsfResolver::from_value(&isf).unwrap();
866        let (cr3, mem) = PageTableBuilder::new()
867            .map_4k(vaddr, paddr, flags::WRITABLE)
868            .write_phys(paddr, &data)
869            .build();
870        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
871        let reader = ObjectReader::new(vas, Box::new(resolver));
872
873        let result = walk_library_list(&reader, vaddr, 10, "proc");
874        assert!(
875            result.is_err(),
876            "missing path.dentry field must return an error"
877        );
878    }
879
880    // --- walk_library_list: dentry.d_name field missing → error ---
881    #[test]
882    fn walk_library_list_missing_d_name_field_returns_error() {
883        let vaddr: u64 = 0xFFFF_8000_008A_0000;
884        let paddr: u64 = 0x008A_0000;
885        let mut data = vec![0u8; 4096];
886
887        let mm_addr = vaddr + 0x200;
888        data[0..4].copy_from_slice(&11u32.to_le_bytes());
889        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
890
891        let isf = IsfBuilder::new()
892            .add_struct("task_struct", 128)
893            .add_field("task_struct", "pid", 0, "int")
894            .add_field("task_struct", "comm", 32, "char")
895            .add_field("task_struct", "mm", 48, "pointer")
896            .add_struct("mm_struct", 128)
897            .add_field("mm_struct", "mmap", 8, "pointer")
898            .add_struct("file", 64)
899            .add_field("file", "f_path", 0, "path")
900            .add_struct("path", 16)
901            .add_field("path", "dentry", 8, "pointer")
902            // "dentry" struct exists but "d_name" field is missing
903            .add_struct("dentry", 64)
904            .add_struct("qstr", 16)
905            .add_field("qstr", "name", 8, "pointer")
906            .build_json();
907
908        let resolver = IsfResolver::from_value(&isf).unwrap();
909        let (cr3, mem) = PageTableBuilder::new()
910            .map_4k(vaddr, paddr, flags::WRITABLE)
911            .write_phys(paddr, &data)
912            .build();
913        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
914        let reader = ObjectReader::new(vas, Box::new(resolver));
915
916        let result = walk_library_list(&reader, vaddr, 11, "proc");
917        assert!(
918            result.is_err(),
919            "missing dentry.d_name field must return an error"
920        );
921    }
922
923    // --- walk_library_list: qstr.name field missing → error ---
924    #[test]
925    fn walk_library_list_missing_qstr_name_field_returns_error() {
926        let vaddr: u64 = 0xFFFF_8000_008B_0000;
927        let paddr: u64 = 0x008B_0000;
928        let mut data = vec![0u8; 4096];
929
930        let mm_addr = vaddr + 0x200;
931        data[0..4].copy_from_slice(&12u32.to_le_bytes());
932        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
933
934        let isf = IsfBuilder::new()
935            .add_struct("task_struct", 128)
936            .add_field("task_struct", "pid", 0, "int")
937            .add_field("task_struct", "comm", 32, "char")
938            .add_field("task_struct", "mm", 48, "pointer")
939            .add_struct("mm_struct", 128)
940            .add_field("mm_struct", "mmap", 8, "pointer")
941            .add_struct("file", 64)
942            .add_field("file", "f_path", 0, "path")
943            .add_struct("path", 16)
944            .add_field("path", "dentry", 8, "pointer")
945            .add_struct("dentry", 64)
946            .add_field("dentry", "d_name", 0, "qstr")
947            // "qstr" struct exists but "name" field is missing
948            .add_struct("qstr", 16)
949            .build_json();
950
951        let resolver = IsfResolver::from_value(&isf).unwrap();
952        let (cr3, mem) = PageTableBuilder::new()
953            .map_4k(vaddr, paddr, flags::WRITABLE)
954            .write_phys(paddr, &data)
955            .build();
956        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
957        let reader = ObjectReader::new(vas, Box::new(resolver));
958
959        let result = walk_library_list(&reader, vaddr, 12, "proc");
960        assert!(
961            result.is_err(),
962            "missing qstr.name field must return an error"
963        );
964    }
965
966    // --- classify_library: path without any '/' → basename = whole path ---
967    // Exercises the rsplit('/').next() branch where the string has no '/'
968    // (basename == whole path, which may or may not start with '.').
969    #[test]
970    fn classify_library_no_slash_path() {
971        // A path without '/' — basename is the whole string.
972        // "libc.so.6" does not start with '.' and contains ".so." → benign.
973        assert!(
974            !classify_library("libc.so.6"),
975            "bare name with .so. must be benign"
976        );
977        // ".hidden.so.1" starts with '.' → suspicious.
978        assert!(
979            classify_library(".hidden.so.1"),
980            "hidden bare name must be suspicious"
981        );
982    }
983
984    #[test]
985    fn missing_file_f_path_field_returns_missing_field() {
986        // walk_library_list: file.f_path field absent → MissingField
987        let isf = IsfBuilder::new()
988            // file struct exists but no f_path field
989            .add_struct("file", 64)
990            .build_json();
991        let resolver = IsfResolver::from_value(&isf).unwrap();
992        let (cr3, mem) = PageTableBuilder::new().build();
993        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
994        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
995        let result = walk_library_list(&reader, 0xFFFF_8000_0010_0000, 1, "init");
996        assert!(
997            matches!(result, Err(crate::Error::MissingField { ref struct_name, ref field_name }) if struct_name == "file" && field_name == "f_path"),
998            "expected MissingField file.f_path, got {result:?}"
999        );
1000    }
1001}