Skip to main content

memf_linux/
tmpfs_recovery.rs

1//! Tmpfs/ramfs inode enumeration for ephemeral file recovery.
2//!
3//! Walks the kernel `super_blocks` list to find all tmpfs/ramfs superblocks,
4//! then enumerates their in-memory inodes via `s_inodes` (`i_sb_list`).
5//! Executable or hidden files are flagged as suspicious.
6
7use memf_core::object_reader::ObjectReader;
8use memf_format::PhysicalMemoryProvider;
9
10use crate::Result;
11
12/// Information about a file found in an in-memory tmpfs/ramfs filesystem.
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct TmpfsFileInfo {
15    /// Inode number.
16    pub inode_number: u64,
17    /// Filename recovered from dentry cache (empty if not cached).
18    pub filename: String,
19    /// File size in bytes.
20    pub file_size: u64,
21    /// User ID of the file owner.
22    pub uid: u32,
23    /// Group ID of the file owner.
24    pub gid: u32,
25    /// File mode (permissions + type bits).
26    pub mode: u32,
27    /// Last access time (seconds since epoch).
28    pub atime_sec: u64,
29    /// Last modification time (seconds since epoch).
30    pub mtime_sec: u64,
31    /// Last status-change time (seconds since epoch).
32    pub ctime_sec: u64,
33    /// True when the file has the executable bit set or starts with `.` (hidden).
34    pub is_suspicious: bool,
35}
36
37/// Classify whether a tmpfs file is suspicious.
38///
39/// A file is suspicious when:
40/// - It is a regular file (`S_ISREG`: mode type bits == 0o100000) with any
41///   executable bit set (`mode & 0o111 != 0`), or
42/// - Its name starts with `.` and has more than one character (hidden file).
43///
44/// Directories (type bits 0o040000) with execute bits are normal and not flagged.
45pub use crate::heuristics::classify_tmpfs_file;
46
47/// Walk all tmpfs/ramfs inodes across all superblocks in memory.
48///
49/// Returns `Ok(Vec::new())` when the `super_blocks` symbol or required ISF
50/// offsets are absent (graceful degradation).
51pub fn walk_tmpfs_files<P: PhysicalMemoryProvider>(
52    reader: &ObjectReader<P>,
53) -> Result<Vec<TmpfsFileInfo>> {
54    // Graceful degradation: require super_blocks symbol.
55    let sb_list_addr = match reader.symbols().symbol_address("super_blocks") {
56        Some(addr) => addr,
57        None => return Ok(Vec::new()),
58    };
59
60    // Require super_block.s_list offset to walk the superblock list.
61    let sb_list_offset = match reader.symbols().field_offset("super_block", "s_list") {
62        Some(off) => off,
63        None => return Ok(Vec::new()),
64    };
65
66    let mut results = Vec::new();
67
68    // Walk super_blocks list (list_head embedded in super_block at s_list).
69    let first_sb_list: u64 = match reader.read_bytes(sb_list_addr, 8) {
70        Ok(b) => u64::from_le_bytes(b.try_into().unwrap_or([0u8; 8])),
71        Err(_) => return Ok(Vec::new()),
72    };
73
74    let mut sb_cursor = first_sb_list;
75    let mut sb_guard = 0usize;
76    loop {
77        if sb_cursor == 0 || sb_cursor == sb_list_addr || sb_guard > 1024 {
78            break;
79        }
80        // Recover super_block base from s_list offset.
81        let sb_addr = sb_cursor.saturating_sub(sb_list_offset);
82
83        // Read s_type pointer → file_system_type → name string.
84        let s_type_ptr: u64 = if let Ok(v) = reader.read_field(sb_addr, "super_block", "s_type") {
85            v
86        } else {
87            sb_cursor = match reader.read_bytes(sb_cursor, 8) {
88                Ok(b) => u64::from_le_bytes(b.try_into().unwrap_or([0u8; 8])),
89                Err(_) => break,
90            };
91            sb_guard += 1;
92            continue;
93        };
94
95        let is_tmpfs = if s_type_ptr != 0 {
96            // file_system_type.name is a `const char *` pointer at offset 0.
97            let name_ptr: u64 = reader
98                .read_bytes(s_type_ptr, 8)
99                .ok()
100                .and_then(|b| b.try_into().ok())
101                .map_or(0, u64::from_le_bytes);
102            if name_ptr != 0 {
103                let name_bytes: Vec<u8> = reader.read_bytes(name_ptr, 8).unwrap_or_default();
104                let fs_name = std::str::from_utf8(&name_bytes)
105                    .unwrap_or("")
106                    .split('\0')
107                    .next()
108                    .unwrap_or("");
109                fs_name == "tmpfs" || fs_name == "ramfs"
110            } else {
111                false
112            }
113        } else {
114            false
115        };
116
117        if is_tmpfs {
118            // Walk s_inodes list: inode.i_sb_list list_head.
119            let s_inodes_offset =
120                if let Some(off) = reader.symbols().field_offset("super_block", "s_inodes") {
121                    off
122                } else {
123                    sb_cursor = match reader.read_bytes(sb_cursor, 8) {
124                        Ok(b) => u64::from_le_bytes(b.try_into().unwrap_or([0u8; 8])),
125                        Err(_) => break,
126                    };
127                    sb_guard += 1;
128                    continue;
129                };
130
131            let inode_sb_list_offset =
132                if let Some(off) = reader.symbols().field_offset("inode", "i_sb_list") {
133                    off
134                } else {
135                    sb_cursor = match reader.read_bytes(sb_cursor, 8) {
136                        Ok(b) => u64::from_le_bytes(b.try_into().unwrap_or([0u8; 8])),
137                        Err(_) => break,
138                    };
139                    sb_guard += 1;
140                    continue;
141                };
142
143            let inode_list_head = sb_addr + s_inodes_offset;
144            let first_inode_list: u64 = reader
145                .read_bytes(inode_list_head, 8)
146                .ok()
147                .and_then(|b| b.try_into().ok())
148                .map_or(0, u64::from_le_bytes);
149
150            let mut inode_cursor = first_inode_list;
151            let mut inode_guard = 0usize;
152            loop {
153                if inode_cursor == 0 || inode_cursor == inode_list_head || inode_guard > 65536 {
154                    break;
155                }
156                let inode_addr = inode_cursor.saturating_sub(inode_sb_list_offset);
157
158                let i_ino: u64 = reader.read_field(inode_addr, "inode", "i_ino").unwrap_or(0);
159                let i_size: u64 = reader
160                    .read_field(inode_addr, "inode", "i_size")
161                    .unwrap_or(0);
162                let i_uid: u32 = reader.read_field(inode_addr, "inode", "i_uid").unwrap_or(0);
163                let i_gid: u32 = reader.read_field(inode_addr, "inode", "i_gid").unwrap_or(0);
164                let i_mode: u32 = reader
165                    .read_field(inode_addr, "inode", "i_mode")
166                    .unwrap_or(0);
167                let atime_sec: u64 = reader
168                    .read_field(inode_addr, "inode", "i_atime")
169                    .unwrap_or(0);
170                let mtime_sec: u64 = reader
171                    .read_field(inode_addr, "inode", "i_mtime")
172                    .unwrap_or(0);
173                let ctime_sec: u64 = reader
174                    .read_field(inode_addr, "inode", "i_ctime")
175                    .unwrap_or(0);
176
177                // Resolve the filename by walking the inode's i_dentry hlist.
178                // i_dentry is an hlist_head whose `first` pointer (if non-null) points
179                // at the hlist_node embedded inside the first dentry at offset d_alias.
180                // To get the dentry base: dentry_addr = first - d_alias_offset.
181                // Then read dentry.d_name_name (the char* pointer) and read the
182                // null-terminated string. Falls back to "" on any failure or null pointer.
183                let filename: String = (|| -> String {
184                    // Read i_dentry.first — the hlist_head first pointer.
185                    let first: u64 = reader
186                        .read_field(inode_addr, "inode", "i_dentry")
187                        .unwrap_or(0);
188                    if first == 0 {
189                        return String::new();
190                    }
191                    // d_alias offset tells us how far into dentry the hlist_node sits.
192                    let d_alias_offset = reader
193                        .symbols()
194                        .field_offset("dentry", "d_alias")
195                        .unwrap_or(0);
196                    let dentry_addr = first.saturating_sub(d_alias_offset);
197                    if dentry_addr == 0 {
198                        return String::new();
199                    }
200                    // Read d_name.name pointer (stored as "d_name_name" field on dentry).
201                    let name_ptr: u64 = reader
202                        .read_field(dentry_addr, "dentry", "d_name_name")
203                        .unwrap_or(0);
204                    if name_ptr == 0 {
205                        return String::new();
206                    }
207                    // Read up to 256 bytes and split on the first null byte.
208                    let bytes = reader.read_bytes(name_ptr, 256).unwrap_or_default();
209                    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
210                    String::from_utf8_lossy(&bytes[..end]).into_owned()
211                })();
212                let is_suspicious = classify_tmpfs_file(&filename, i_mode);
213
214                results.push(TmpfsFileInfo {
215                    inode_number: i_ino,
216                    filename,
217                    file_size: i_size,
218                    uid: i_uid,
219                    gid: i_gid,
220                    mode: i_mode,
221                    atime_sec,
222                    mtime_sec,
223                    ctime_sec,
224                    is_suspicious,
225                });
226
227                inode_cursor = match reader.read_bytes(inode_cursor, 8) {
228                    Ok(b) => u64::from_le_bytes(b.try_into().unwrap_or([0u8; 8])),
229                    Err(_) => break,
230                };
231                inode_guard += 1;
232            }
233        }
234
235        sb_cursor = match reader.read_bytes(sb_cursor, 8) {
236            Ok(b) => u64::from_le_bytes(b.try_into().unwrap_or([0u8; 8])),
237            Err(_) => break,
238        };
239        sb_guard += 1;
240    }
241
242    Ok(results)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use memf_core::object_reader::ObjectReader;
249    use memf_core::test_builders::{PageTableBuilder, SyntheticPhysMem};
250    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
251    use memf_symbols::isf::IsfResolver;
252    use memf_symbols::test_builders::IsfBuilder;
253
254    fn make_no_symbol_reader() -> ObjectReader<SyntheticPhysMem> {
255        let isf = IsfBuilder::new().build_json();
256        let resolver = IsfResolver::from_value(&isf).unwrap();
257        let (cr3, mem) = PageTableBuilder::new().build();
258        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
259        ObjectReader::new(vas, Box::new(resolver))
260    }
261
262    #[test]
263    fn classify_executable_tmpfs_file_suspicious() {
264        assert!(
265            classify_tmpfs_file("script.sh", 0o100_755),
266            "executable file must be suspicious"
267        );
268    }
269
270    #[test]
271    fn classify_hidden_file_suspicious() {
272        assert!(
273            classify_tmpfs_file(".hidden_file", 0o100_644),
274            "hidden file must be suspicious"
275        );
276    }
277
278    #[test]
279    fn classify_dot_alone_not_suspicious() {
280        assert!(
281            !classify_tmpfs_file(".", 0o040_755),
282            "bare '.' directory must not be suspicious"
283        );
284    }
285
286    #[test]
287    fn classify_normal_tmpfs_file_benign() {
288        assert!(
289            !classify_tmpfs_file("data.bin", 0o100_644),
290            "non-executable non-hidden file must not be suspicious"
291        );
292    }
293
294    #[test]
295    fn classify_executable_and_hidden_suspicious() {
296        assert!(
297            classify_tmpfs_file(".runme", 0o100_755),
298            "executable hidden file must be suspicious"
299        );
300    }
301
302    #[test]
303    fn walk_tmpfs_no_symbol_returns_empty() {
304        let reader = make_no_symbol_reader();
305        let result = walk_tmpfs_files(&reader).unwrap();
306        assert!(
307            result.is_empty(),
308            "no super_blocks symbol → empty vec expected"
309        );
310    }
311
312    // --- additional classify_tmpfs_file coverage ---
313
314    #[test]
315    fn classify_empty_filename_not_suspicious() {
316        // Empty filename: does not start with '.', not executable
317        assert!(
318            !classify_tmpfs_file("", 0o100_644),
319            "empty filename non-executable must not be suspicious"
320        );
321    }
322
323    #[test]
324    fn classify_dot_with_exec_bit_not_suspicious_because_len_1() {
325        // "." starts with '.' but len == 1, so hidden check fails.
326        // Also a directory (0o040_755) so exec check also fails.
327        assert!(
328            !classify_tmpfs_file(".", 0o040_755),
329            "bare '.' must not be suspicious"
330        );
331    }
332
333    #[test]
334    fn classify_directory_with_exec_bits_not_suspicious() {
335        // S_IFDIR = 0o040000; exec bits set but it's not S_IFREG
336        assert!(
337            !classify_tmpfs_file("mydir", 0o040_755),
338            "directory with exec bits must not be suspicious"
339        );
340    }
341
342    #[test]
343    fn classify_regular_file_no_exec_not_suspicious() {
344        // S_IFREG = 0o100000, mode 0o600 — no exec bit
345        assert!(
346            !classify_tmpfs_file("secret.dat", 0o100_600),
347            "regular non-executable non-hidden file must not be suspicious"
348        );
349    }
350
351    #[test]
352    fn classify_regular_file_group_exec_suspicious() {
353        // Group execute bit (0o010) set on regular file
354        assert!(
355            classify_tmpfs_file("grpexec", 0o100_610),
356            "regular file with group exec bit must be suspicious"
357        );
358    }
359
360    #[test]
361    fn classify_regular_file_other_exec_suspicious() {
362        // Other execute bit (0o001) set on regular file
363        assert!(
364            classify_tmpfs_file("otherexec", 0o100_601),
365            "regular file with other exec bit must be suspicious"
366        );
367    }
368
369    #[test]
370    fn classify_dotdot_not_suspicious() {
371        // ".." starts with '.' but len == 2; the hidden check passes
372        // however ".." is a valid directory reference — verify behaviour is consistent
373        // The function does NOT special-case ".."; len > 1 makes it suspicious
374        assert!(
375            classify_tmpfs_file("..", 0o040_755),
376            "'..' is two chars starting with '.'; hidden-check flags it"
377        );
378    }
379
380    #[test]
381    fn classify_non_regular_non_exec_file_benign() {
382        // S_IFLNK = 0o120000 — symlink, with rwx bits; not S_IFREG so exec check fails
383        assert!(
384            !classify_tmpfs_file("mylink", 0o120_777),
385            "symlink with rwx bits must not be suspicious (not S_IFREG)"
386        );
387    }
388
389    // --- walk_tmpfs_files: symbol present but s_list field missing ---
390
391    #[test]
392    fn walk_tmpfs_missing_s_list_offset_returns_empty() {
393        // Build a reader that HAS the super_blocks symbol but NO s_list field
394        let isf = IsfBuilder::new()
395            .add_symbol("super_blocks", 0xFFFF_8000_1234_0000)
396            .build_json();
397        let resolver = IsfResolver::from_value(&isf).unwrap();
398        let (cr3, mem) = PageTableBuilder::new().build();
399        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
400        let reader = ObjectReader::new(vas, Box::new(resolver));
401
402        let result = walk_tmpfs_files(&reader).unwrap();
403        assert!(
404            result.is_empty(),
405            "missing s_list field_offset → empty vec expected"
406        );
407    }
408
409    // --- walk_tmpfs_files: symbol + s_list present but read fails (sb_list_addr unreadable) ---
410
411    #[test]
412    fn walk_tmpfs_unreadable_first_sb_returns_empty() {
413        // super_blocks symbol points to an unmapped address; read_bytes will fail
414        let isf = IsfBuilder::new()
415            .add_symbol("super_blocks", 0xDEAD_BEEF_0000_0000)
416            .add_struct("super_block", 512)
417            .add_field("super_block", "s_list", 0, "pointer")
418            .build_json();
419        let resolver = IsfResolver::from_value(&isf).unwrap();
420        let (cr3, mem) = PageTableBuilder::new().build();
421        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
422        let reader = ObjectReader::new(vas, Box::new(resolver));
423
424        let result = walk_tmpfs_files(&reader).unwrap();
425        assert!(
426            result.is_empty(),
427            "unreadable super_blocks address → empty vec expected"
428        );
429    }
430
431    // --- walk_tmpfs_files: symbol + s_list present, self-pointing list → empty ---
432    // Exercises the superblock scanning loop body with an empty (self-pointing) list.
433    #[test]
434    fn walk_tmpfs_symbol_present_self_pointing_list_returns_empty() {
435        use memf_core::test_builders::flags as ptf;
436
437        // super_blocks points to a mapped page; the first 8 bytes (s_list.next)
438        // point back to super_blocks itself → loop exits immediately (cursor == sb_list_addr).
439        let sym_vaddr: u64 = 0xFFFF_8800_0010_0000;
440        let sym_paddr: u64 = 0x0030_0000; // unique paddr, < 16 MB
441
442        let isf = IsfBuilder::new()
443            .add_symbol("super_blocks", sym_vaddr)
444            .add_struct("super_block", 0x200)
445            .add_field("super_block", "s_list", 0x00, "pointer")
446            .add_field("super_block", "s_type", 0x08, "pointer")
447            .build_json();
448        let resolver = IsfResolver::from_value(&isf).unwrap();
449
450        // Write a page where offset 0 (s_list.next) == sym_vaddr (self-pointer).
451        let mut page = [0u8; 4096];
452        page[0..8].copy_from_slice(&sym_vaddr.to_le_bytes());
453
454        let (cr3, mem) = PageTableBuilder::new()
455            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
456            .write_phys(sym_paddr, &page)
457            .build();
458
459        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
460        let reader = ObjectReader::new(vas, Box::new(resolver));
461
462        let result = walk_tmpfs_files(&reader).unwrap();
463        assert!(
464            result.is_empty(),
465            "self-pointing superblock list → no entries"
466        );
467    }
468
469    // --- walk_tmpfs_files: first_sb_list != 0 && != sb_list_addr, exercises loop body ---
470    // The sb_cursor enters the loop; s_type read fails (unreadable sb_addr), so it
471    // advances via read_bytes(sb_cursor) which also fails → breaks out → empty result.
472    // This gets the loop-body code path covered without needing tmpfs name matching.
473    #[test]
474    fn walk_tmpfs_first_sb_nonzero_but_unreadable_sb_body() {
475        use memf_core::test_builders::flags as ptf;
476
477        // super_blocks page at sym_vaddr.
478        // s_list is at offset 0x10 (sb_list_offset = 0x10).
479        // The first 8 bytes at sym_vaddr = some_sb_list_addr pointing INTO a mapped page
480        // so that sb_cursor != 0 and != sym_vaddr, and sb_cursor is readable.
481        // But s_type (offset 0x08 inside super_block) at sb_addr = sb_cursor - 0x10
482        // is NOT mapped → read_field fails → we fall into the advance-cursor branch,
483        // which reads sb_cursor's first 8 bytes → they point back to sym_vaddr → loop ends.
484
485        let sym_vaddr: u64 = 0xFFFF_8800_0040_0000; // super_blocks list head
486        let sym_paddr: u64 = 0x0040_0000;
487
488        // sb_cursor will be the value stored at sym_vaddr + 0 (first pointer).
489        // We want it to be a distinct mapped address so the loop body runs.
490        let sb_list_vaddr: u64 = 0xFFFF_8800_0041_0000; // points into the same page region
491        let sb_list_paddr: u64 = 0x0041_0000;
492
493        // s_list offset = 0x10 in super_block; s_type at 0x08.
494        // sb_addr = sb_cursor - 0x10 → sb_cursor - 0x10 is unmapped → s_type read fails.
495        // Then advance: read_bytes(sb_cursor, 8) returns sym_vaddr → loop exits (== sb_list_addr).
496
497        // Page for super_blocks list head: first 8 bytes = sb_list_vaddr.
498        let mut sym_page = [0u8; 4096];
499        sym_page[0..8].copy_from_slice(&sb_list_vaddr.to_le_bytes());
500
501        // Page for the single fake superblock entry:
502        // offset 0 = next pointer = sym_vaddr (so cursor wraps back to list head, ending the loop).
503        let mut sb_page = [0u8; 4096];
504        sb_page[0..8].copy_from_slice(&sym_vaddr.to_le_bytes());
505
506        let isf = IsfBuilder::new()
507            .add_symbol("super_blocks", sym_vaddr)
508            .add_struct("super_block", 0x200)
509            .add_field("super_block", "s_list", 0x10, "pointer")
510            .add_field("super_block", "s_type", 0x08, "pointer")
511            .build_json();
512        let resolver = IsfResolver::from_value(&isf).unwrap();
513
514        let (cr3, mem) = PageTableBuilder::new()
515            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
516            .write_phys(sym_paddr, &sym_page)
517            .map_4k(sb_list_vaddr, sb_list_paddr, ptf::WRITABLE)
518            .write_phys(sb_list_paddr, &sb_page)
519            .build();
520
521        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
522        let reader = ObjectReader::new(vas, Box::new(resolver));
523
524        // sb_cursor = sb_list_vaddr ≠ 0 and ≠ sym_vaddr → enters loop body.
525        // s_type read on sb_addr (sb_list_vaddr - 0x10 = unmapped) fails → advance cursor.
526        // read_bytes(sb_list_vaddr, 8) = sym_vaddr → sb_cursor == sb_list_addr → break.
527        let result = walk_tmpfs_files(&reader).unwrap();
528        assert!(
529            result.is_empty(),
530            "loop body ran but s_type unreadable → no results"
531        );
532    }
533
534    // --- walk_tmpfs_files: exercises s_inodes / inode walk when s_type is not tmpfs ---
535    // Provides a super_block with s_type pointing to a page that has a name pointer
536    // that leads to a non-tmpfs fs name → is_tmpfs = false → inode walk skipped.
537    // Cursor advances back to sym_vaddr (self-loop) → exits.
538    #[test]
539    fn walk_tmpfs_non_tmpfs_superblock_skipped() {
540        use memf_core::test_builders::flags as ptf;
541
542        let sym_vaddr: u64 = 0xFFFF_8800_0042_0000; // super_blocks list head
543        let sym_paddr: u64 = 0x0042_0000;
544
545        let sb_entry_vaddr: u64 = 0xFFFF_8800_0043_0000;
546        let sb_entry_paddr: u64 = 0x0043_0000;
547
548        let fs_type_vaddr: u64 = 0xFFFF_8800_0044_0000; // file_system_type struct
549        let fs_type_paddr: u64 = 0x0044_0000;
550
551        let name_str_vaddr: u64 = 0xFFFF_8800_0045_0000; // name string "ext4\0"
552        let name_str_paddr: u64 = 0x0045_0000;
553
554        // s_list at offset 0, s_type at offset 8 within super_block.
555        // super_blocks page: first 8 bytes = sb_entry_vaddr (the superblock entry's s_list).
556        let mut sym_page = [0u8; 4096];
557        sym_page[0..8].copy_from_slice(&sb_entry_vaddr.to_le_bytes());
558
559        // sb_entry page:
560        //   offset 0x00 (s_list.next) = sym_vaddr  (wraps back → loop ends after this entry)
561        //   offset 0x08 (s_type ptr)  = fs_type_vaddr
562        let mut sb_page = [0u8; 4096];
563        sb_page[0x00..0x08].copy_from_slice(&sym_vaddr.to_le_bytes()); // s_list.next = head
564        sb_page[0x08..0x10].copy_from_slice(&fs_type_vaddr.to_le_bytes()); // s_type
565
566        // fs_type page: first 8 bytes = name_str_vaddr (the const char *name pointer).
567        let mut fs_type_page = [0u8; 4096];
568        fs_type_page[0..8].copy_from_slice(&name_str_vaddr.to_le_bytes());
569
570        // name_str page: "ext4\0..." — not "tmpfs" or "ramfs".
571        let mut name_page = [0u8; 4096];
572        name_page[..5].copy_from_slice(b"ext4\0");
573
574        let isf = IsfBuilder::new()
575            .add_symbol("super_blocks", sym_vaddr)
576            .add_struct("super_block", 0x200)
577            .add_field("super_block", "s_list", 0x00, "pointer")
578            .add_field("super_block", "s_type", 0x08, "pointer")
579            .build_json();
580        let resolver = IsfResolver::from_value(&isf).unwrap();
581
582        let (cr3, mem) = PageTableBuilder::new()
583            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
584            .write_phys(sym_paddr, &sym_page)
585            .map_4k(sb_entry_vaddr, sb_entry_paddr, ptf::WRITABLE)
586            .write_phys(sb_entry_paddr, &sb_page)
587            .map_4k(fs_type_vaddr, fs_type_paddr, ptf::WRITABLE)
588            .write_phys(fs_type_paddr, &fs_type_page)
589            .map_4k(name_str_vaddr, name_str_paddr, ptf::WRITABLE)
590            .write_phys(name_str_paddr, &name_page)
591            .build();
592
593        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
594        let reader = ObjectReader::new(vas, Box::new(resolver));
595
596        let result = walk_tmpfs_files(&reader).unwrap();
597        assert!(
598            result.is_empty(),
599            "non-tmpfs superblock must not produce entries"
600        );
601    }
602
603    // --- walk_tmpfs_files: s_type ptr == 0 → is_tmpfs = false ---
604    // Exercises the s_type_ptr == 0 branch in the is_tmpfs block.
605    #[test]
606    fn walk_tmpfs_null_s_type_ptr_skipped() {
607        use memf_core::test_builders::flags as ptf;
608
609        let sym_vaddr: u64 = 0xFFFF_8800_0046_0000;
610        let sym_paddr: u64 = 0x0046_0000;
611
612        let sb_entry_vaddr: u64 = 0xFFFF_8800_0047_0000;
613        let sb_entry_paddr: u64 = 0x0047_0000;
614
615        let mut sym_page = [0u8; 4096];
616        sym_page[0..8].copy_from_slice(&sb_entry_vaddr.to_le_bytes());
617
618        let mut sb_page = [0u8; 4096];
619        // s_list.next = sym_vaddr (loop ends after one entry)
620        sb_page[0x00..0x08].copy_from_slice(&sym_vaddr.to_le_bytes());
621        // s_type = 0 (null pointer → is_tmpfs = false)
622        sb_page[0x08..0x10].copy_from_slice(&0u64.to_le_bytes());
623
624        let isf = IsfBuilder::new()
625            .add_symbol("super_blocks", sym_vaddr)
626            .add_struct("super_block", 0x200)
627            .add_field("super_block", "s_list", 0x00, "pointer")
628            .add_field("super_block", "s_type", 0x08, "pointer")
629            .build_json();
630        let resolver = IsfResolver::from_value(&isf).unwrap();
631
632        let (cr3, mem) = PageTableBuilder::new()
633            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
634            .write_phys(sym_paddr, &sym_page)
635            .map_4k(sb_entry_vaddr, sb_entry_paddr, ptf::WRITABLE)
636            .write_phys(sb_entry_paddr, &sb_page)
637            .build();
638
639        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
640        let reader = ObjectReader::new(vas, Box::new(resolver));
641
642        let result = walk_tmpfs_files(&reader).unwrap();
643        assert!(
644            result.is_empty(),
645            "null s_type ptr → is_tmpfs false → no entries"
646        );
647    }
648
649    // --- walk_tmpfs_files: tmpfs superblock found, s_inodes missing → skips inode walk ---
650    // Exercises the is_tmpfs == true branch and the missing-s_inodes graceful path.
651    #[test]
652    fn walk_tmpfs_tmpfs_sb_no_s_inodes_field_skips() {
653        use memf_core::test_builders::flags as ptf;
654
655        let sym_vaddr: u64 = 0xFFFF_8800_0048_0000;
656        let sym_paddr: u64 = 0x0048_0000;
657
658        let sb_entry_vaddr: u64 = 0xFFFF_8800_0049_0000;
659        let sb_entry_paddr: u64 = 0x0049_0000;
660
661        let fs_type_vaddr: u64 = 0xFFFF_8800_004A_0000;
662        let fs_type_paddr: u64 = 0x004A_0000;
663
664        let name_str_vaddr: u64 = 0xFFFF_8800_004B_0000;
665        let name_str_paddr: u64 = 0x004B_0000;
666
667        let mut sym_page = [0u8; 4096];
668        sym_page[0..8].copy_from_slice(&sb_entry_vaddr.to_le_bytes());
669
670        let mut sb_page = [0u8; 4096];
671        sb_page[0x00..0x08].copy_from_slice(&sym_vaddr.to_le_bytes()); // s_list.next = head
672        sb_page[0x08..0x10].copy_from_slice(&fs_type_vaddr.to_le_bytes()); // s_type
673
674        let mut fs_type_page = [0u8; 4096];
675        fs_type_page[0..8].copy_from_slice(&name_str_vaddr.to_le_bytes());
676
677        let mut name_page = [0u8; 4096];
678        name_page[..6].copy_from_slice(b"tmpfs\0");
679
680        // ISF: has super_block with s_list and s_type but NO s_inodes field.
681        let isf = IsfBuilder::new()
682            .add_symbol("super_blocks", sym_vaddr)
683            .add_struct("super_block", 0x200)
684            .add_field("super_block", "s_list", 0x00, "pointer")
685            .add_field("super_block", "s_type", 0x08, "pointer")
686            // deliberately omit "s_inodes"
687            .build_json();
688        let resolver = IsfResolver::from_value(&isf).unwrap();
689
690        let (cr3, mem) = PageTableBuilder::new()
691            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
692            .write_phys(sym_paddr, &sym_page)
693            .map_4k(sb_entry_vaddr, sb_entry_paddr, ptf::WRITABLE)
694            .write_phys(sb_entry_paddr, &sb_page)
695            .map_4k(fs_type_vaddr, fs_type_paddr, ptf::WRITABLE)
696            .write_phys(fs_type_paddr, &fs_type_page)
697            .map_4k(name_str_vaddr, name_str_paddr, ptf::WRITABLE)
698            .write_phys(name_str_paddr, &name_page)
699            .build();
700
701        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
702        let reader = ObjectReader::new(vas, Box::new(resolver));
703
704        // is_tmpfs == true but s_inodes field missing → skips inode walk → empty
705        let result = walk_tmpfs_files(&reader).unwrap();
706        assert!(
707            result.is_empty(),
708            "tmpfs sb without s_inodes offset → empty (graceful)"
709        );
710    }
711
712    // --- walk_tmpfs_files: tmpfs sb found, s_inodes present, missing i_sb_list → skips ---
713    #[test]
714    fn walk_tmpfs_tmpfs_sb_no_i_sb_list_field_skips() {
715        use memf_core::test_builders::flags as ptf;
716
717        let sym_vaddr: u64 = 0xFFFF_8800_004C_0000;
718        let sym_paddr: u64 = 0x004C_0000;
719
720        let sb_entry_vaddr: u64 = 0xFFFF_8800_004D_0000;
721        let sb_entry_paddr: u64 = 0x004D_0000;
722
723        let fs_type_vaddr: u64 = 0xFFFF_8800_004E_0000;
724        let fs_type_paddr: u64 = 0x004E_0000;
725
726        let name_str_vaddr: u64 = 0xFFFF_8800_004F_0000;
727        let name_str_paddr: u64 = 0x004F_0000;
728
729        let mut sym_page = [0u8; 4096];
730        sym_page[0..8].copy_from_slice(&sb_entry_vaddr.to_le_bytes());
731
732        let mut sb_page = [0u8; 4096];
733        sb_page[0x00..0x08].copy_from_slice(&sym_vaddr.to_le_bytes()); // s_list.next = head
734        sb_page[0x08..0x10].copy_from_slice(&fs_type_vaddr.to_le_bytes()); // s_type
735
736        let mut fs_type_page = [0u8; 4096];
737        fs_type_page[0..8].copy_from_slice(&name_str_vaddr.to_le_bytes());
738
739        let mut name_page = [0u8; 4096];
740        name_page[..6].copy_from_slice(b"tmpfs\0");
741
742        // ISF: has s_inodes but NO inode.i_sb_list
743        let isf = IsfBuilder::new()
744            .add_symbol("super_blocks", sym_vaddr)
745            .add_struct("super_block", 0x400)
746            .add_field("super_block", "s_list", 0x00, "pointer")
747            .add_field("super_block", "s_type", 0x08, "pointer")
748            .add_field("super_block", "s_inodes", 0x20, "pointer")
749            // deliberately omit "inode" struct / "i_sb_list" field
750            .build_json();
751        let resolver = IsfResolver::from_value(&isf).unwrap();
752
753        let (cr3, mem) = PageTableBuilder::new()
754            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
755            .write_phys(sym_paddr, &sym_page)
756            .map_4k(sb_entry_vaddr, sb_entry_paddr, ptf::WRITABLE)
757            .write_phys(sb_entry_paddr, &sb_page)
758            .map_4k(fs_type_vaddr, fs_type_paddr, ptf::WRITABLE)
759            .write_phys(fs_type_paddr, &fs_type_page)
760            .map_4k(name_str_vaddr, name_str_paddr, ptf::WRITABLE)
761            .write_phys(name_str_paddr, &name_page)
762            .build();
763
764        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
765        let reader = ObjectReader::new(vas, Box::new(resolver));
766
767        let result = walk_tmpfs_files(&reader).unwrap();
768        assert!(
769            result.is_empty(),
770            "tmpfs sb with s_inodes but no i_sb_list → empty (graceful)"
771        );
772    }
773
774    // --- walk_tmpfs_files: s_type ptr readable, name_ptr == 0 → is_tmpfs = false ---
775    // Exercises the `name_ptr != 0` guard inside the is_tmpfs block: name_ptr = 0 → false.
776    #[test]
777    fn walk_tmpfs_null_name_ptr_is_not_tmpfs() {
778        use memf_core::test_builders::flags as ptf;
779
780        let sym_vaddr: u64 = 0xFFFF_8800_0054_0000;
781        let sym_paddr: u64 = 0x0054_0000;
782        let sb_entry_vaddr: u64 = 0xFFFF_8800_0055_0000;
783        let sb_entry_paddr: u64 = 0x0055_0000;
784        let fs_type_vaddr: u64 = 0xFFFF_8800_0056_0000;
785        let fs_type_paddr: u64 = 0x0056_0000;
786
787        let mut sym_page = [0u8; 4096];
788        sym_page[0..8].copy_from_slice(&sb_entry_vaddr.to_le_bytes());
789
790        let mut sb_page = [0u8; 4096];
791        sb_page[0x00..0x08].copy_from_slice(&sym_vaddr.to_le_bytes()); // s_list.next = head
792        sb_page[0x08..0x10].copy_from_slice(&fs_type_vaddr.to_le_bytes()); // s_type
793
794        // fs_type page: name pointer at offset 0 = 0 (null) → name_ptr == 0 → is_tmpfs = false
795        let fs_type_page = [0u8; 4096];
796
797        let isf = IsfBuilder::new()
798            .add_symbol("super_blocks", sym_vaddr)
799            .add_struct("super_block", 0x200)
800            .add_field("super_block", "s_list", 0x00, "pointer")
801            .add_field("super_block", "s_type", 0x08, "pointer")
802            .build_json();
803        let resolver = IsfResolver::from_value(&isf).unwrap();
804
805        let (cr3, mem) = PageTableBuilder::new()
806            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
807            .write_phys(sym_paddr, &sym_page)
808            .map_4k(sb_entry_vaddr, sb_entry_paddr, ptf::WRITABLE)
809            .write_phys(sb_entry_paddr, &sb_page)
810            .map_4k(fs_type_vaddr, fs_type_paddr, ptf::WRITABLE)
811            .write_phys(fs_type_paddr, &fs_type_page)
812            .build();
813
814        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
815        let reader = ObjectReader::new(vas, Box::new(resolver));
816
817        let result = walk_tmpfs_files(&reader).unwrap();
818        assert!(
819            result.is_empty(),
820            "null name_ptr → is_tmpfs false → no entries"
821        );
822    }
823
824    // --- walk_tmpfs_files: full path — tmpfs sb with one real inode in the list ---
825    // Exercises the inode walk body (lines 165-207): reads i_ino, i_size, i_uid,
826    // i_gid, i_mode, timestamps, classifies the file, and pushes a TmpfsFileInfo.
827    #[test]
828    fn walk_tmpfs_tmpfs_sb_with_one_inode_produces_result() {
829        use memf_core::test_builders::flags as ptf;
830
831        // Memory layout (all physical addresses < 16 MB):
832        //   sym_vaddr         — super_blocks list head
833        //   sb_entry_vaddr    — the super_block
834        //   fs_type_vaddr     — file_system_type struct
835        //   name_str_vaddr    — "tmpfs\0"
836        //   inode_head_vaddr  — s_inodes list head (embedded in super_block at s_inodes_offset)
837        //   inode_vaddr       — the inode struct
838        //
839        // Note: s_inodes is embedded inside sb_entry_vaddr (same page), so inode_head_vaddr
840        // is actually sb_entry_vaddr + s_inodes_offset.
841        //
842        // The inode list is singly traversed via i_sb_list embedded in the inode:
843        //   inode_head (= sb_entry_vaddr + s_inodes_offset):
844        //     first 8 bytes (next ptr) = inode_vaddr + i_sb_list_offset
845        //   inode.i_sb_list (= inode_vaddr + i_sb_list_offset):
846        //     first 8 bytes (next ptr) = inode_head (wraps back → loop ends)
847
848        let sym_vaddr: u64 = 0xFFFF_8800_0057_0000;
849        let sym_paddr: u64 = 0x0057_0000;
850        let sb_vaddr: u64 = 0xFFFF_8800_0058_0000;
851        let sb_paddr: u64 = 0x0058_0000;
852        let fstype_vaddr: u64 = 0xFFFF_8800_0059_0000;
853        let fstype_paddr: u64 = 0x0059_0000;
854        let name_vaddr: u64 = 0xFFFF_8800_005A_0000;
855        let name_paddr: u64 = 0x005A_0000;
856        let inode_vaddr: u64 = 0xFFFF_8800_005B_0000;
857        let inode_paddr: u64 = 0x005B_0000;
858
859        // super_block field offsets:
860        let s_list_offset: u64 = 0x00;
861        let s_type_offset: u64 = 0x08;
862        let s_inodes_offset: u64 = 0x20;
863
864        // inode field offsets:
865        let i_sb_list_offset: u64 = 0x08;
866        let i_ino_offset: u64 = 0x10;
867        let i_size_offset: u64 = 0x18;
868        let i_uid_offset: u64 = 0x20;
869        let i_gid_offset: u64 = 0x24;
870        let i_mode_offset: u64 = 0x28;
871        let i_atime_offset: u64 = 0x30;
872        let i_mtime_offset: u64 = 0x38;
873        let i_ctime_offset: u64 = 0x40;
874
875        // The inode list head is embedded in the super_block at s_inodes_offset.
876        let inode_list_head = sb_vaddr + s_inodes_offset;
877        // The inode's i_sb_list node is at inode_vaddr + i_sb_list_offset.
878        let inode_list_node = inode_vaddr + i_sb_list_offset;
879
880        // super_blocks list head page: first 8 bytes = sb_entry's s_list
881        let mut sym_page = [0u8; 4096];
882        sym_page[0..8].copy_from_slice(&sb_vaddr.to_le_bytes());
883
884        // super_block page:
885        //   s_list.next    @ 0x00 = sym_vaddr  (wraps back → outer loop ends after this sb)
886        //   s_type         @ 0x08 = fstype_vaddr
887        //   s_inodes.next  @ 0x20 = inode_list_node  (points into inode)
888        let mut sb_page = [0u8; 4096];
889        sb_page[s_list_offset as usize..s_list_offset as usize + 8]
890            .copy_from_slice(&sym_vaddr.to_le_bytes());
891        sb_page[s_type_offset as usize..s_type_offset as usize + 8]
892            .copy_from_slice(&fstype_vaddr.to_le_bytes());
893        sb_page[s_inodes_offset as usize..s_inodes_offset as usize + 8]
894            .copy_from_slice(&inode_list_node.to_le_bytes());
895
896        // file_system_type page: first 8 bytes = name_vaddr
897        let mut fstype_page = [0u8; 4096];
898        fstype_page[0..8].copy_from_slice(&name_vaddr.to_le_bytes());
899
900        // name string page: "tmpfs\0"
901        let mut name_page = [0u8; 4096];
902        name_page[..6].copy_from_slice(b"tmpfs\0");
903
904        // inode page:
905        //   i_sb_list.next @ i_sb_list_offset = inode_list_head  (wraps back → loop ends)
906        //   i_ino          @ i_ino_offset    = 1234
907        //   i_size         @ i_size_offset   = 4096
908        //   i_uid          @ i_uid_offset    = 500
909        //   i_gid          @ i_gid_offset    = 501
910        //   i_mode         @ i_mode_offset   = 0o100755  (S_IFREG | rwxr-xr-x → executable → suspicious)
911        //   i_atime        @ i_atime_offset  = 1000
912        //   i_mtime        @ i_mtime_offset  = 2000
913        //   i_ctime        @ i_ctime_offset  = 3000
914        let mut inode_page = [0u8; 4096];
915        inode_page[i_sb_list_offset as usize..i_sb_list_offset as usize + 8]
916            .copy_from_slice(&inode_list_head.to_le_bytes());
917        inode_page[i_ino_offset as usize..i_ino_offset as usize + 8]
918            .copy_from_slice(&1234u64.to_le_bytes());
919        inode_page[i_size_offset as usize..i_size_offset as usize + 8]
920            .copy_from_slice(&4096u64.to_le_bytes());
921        inode_page[i_uid_offset as usize..i_uid_offset as usize + 4]
922            .copy_from_slice(&500u32.to_le_bytes());
923        inode_page[i_gid_offset as usize..i_gid_offset as usize + 4]
924            .copy_from_slice(&501u32.to_le_bytes());
925        inode_page[i_mode_offset as usize..i_mode_offset as usize + 4]
926            .copy_from_slice(&0o100_755u32.to_le_bytes());
927        inode_page[i_atime_offset as usize..i_atime_offset as usize + 8]
928            .copy_from_slice(&1000u64.to_le_bytes());
929        inode_page[i_mtime_offset as usize..i_mtime_offset as usize + 8]
930            .copy_from_slice(&2000u64.to_le_bytes());
931        inode_page[i_ctime_offset as usize..i_ctime_offset as usize + 8]
932            .copy_from_slice(&3000u64.to_le_bytes());
933
934        let isf = IsfBuilder::new()
935            .add_symbol("super_blocks", sym_vaddr)
936            .add_struct("super_block", 0x400)
937            .add_field("super_block", "s_list", s_list_offset, "pointer")
938            .add_field("super_block", "s_type", s_type_offset, "pointer")
939            .add_field("super_block", "s_inodes", s_inodes_offset, "pointer")
940            .add_struct("inode", 0x200)
941            .add_field("inode", "i_sb_list", i_sb_list_offset, "pointer")
942            .add_field("inode", "i_ino", i_ino_offset, "unsigned long")
943            .add_field("inode", "i_size", i_size_offset, "long long")
944            .add_field("inode", "i_uid", i_uid_offset, "unsigned int")
945            .add_field("inode", "i_gid", i_gid_offset, "unsigned int")
946            .add_field("inode", "i_mode", i_mode_offset, "unsigned int")
947            .add_field("inode", "i_atime", i_atime_offset, "long long")
948            .add_field("inode", "i_mtime", i_mtime_offset, "long long")
949            .add_field("inode", "i_ctime", i_ctime_offset, "long long")
950            .build_json();
951        let resolver = IsfResolver::from_value(&isf).unwrap();
952
953        let (cr3, mem) = PageTableBuilder::new()
954            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
955            .write_phys(sym_paddr, &sym_page)
956            .map_4k(sb_vaddr, sb_paddr, ptf::WRITABLE)
957            .write_phys(sb_paddr, &sb_page)
958            .map_4k(fstype_vaddr, fstype_paddr, ptf::WRITABLE)
959            .write_phys(fstype_paddr, &fstype_page)
960            .map_4k(name_vaddr, name_paddr, ptf::WRITABLE)
961            .write_phys(name_paddr, &name_page)
962            .map_4k(inode_vaddr, inode_paddr, ptf::WRITABLE)
963            .write_phys(inode_paddr, &inode_page)
964            .build();
965
966        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
967        let reader = ObjectReader::new(vas, Box::new(resolver));
968
969        let result = walk_tmpfs_files(&reader).unwrap();
970        assert_eq!(result.len(), 1, "should find exactly one inode");
971        let fi = &result[0];
972        assert_eq!(fi.inode_number, 1234);
973        assert_eq!(fi.file_size, 4096);
974        assert_eq!(fi.uid, 500);
975        assert_eq!(fi.gid, 501);
976        assert_eq!(fi.mode, 0o100_755);
977        assert_eq!(fi.atime_sec, 1000);
978        assert_eq!(fi.mtime_sec, 2000);
979        assert_eq!(fi.ctime_sec, 3000);
980        assert!(
981            fi.is_suspicious,
982            "executable regular file must be suspicious"
983        );
984    }
985
986    // --- dentry-based filename resolution ---
987
988    /// Full path: inode has `i_dentry.first` pointing into a mapped dentry.
989    /// `dentry.d_name.name` points to a mapped string "secret.txt\0".
990    /// Asserts that `TmpfsFileInfo.filename == "secret.txt"`.
991    #[test]
992    fn inode_with_dentry_returns_filename() {
993        use memf_core::test_builders::flags as ptf;
994
995        // Virtual address layout (all physical addrs < 16 MB):
996        //   sym_vaddr         — super_blocks list head
997        //   sb_vaddr          — the single super_block
998        //   fstype_vaddr      — file_system_type
999        //   name_vaddr        — "tmpfs\0"
1000        //   inode_vaddr       — the inode
1001        //   dentry_vaddr      — the dentry
1002        //   dname_str_vaddr   — "secret.txt\0"
1003
1004        let sym_vaddr: u64 = 0xFFFF_8800_0060_0000;
1005        let sym_paddr: u64 = 0x0060_0000;
1006        let sb_vaddr: u64 = 0xFFFF_8800_0061_0000;
1007        let sb_paddr: u64 = 0x0061_0000;
1008        let fstype_vaddr: u64 = 0xFFFF_8800_0062_0000;
1009        let fstype_paddr: u64 = 0x0062_0000;
1010        let fsname_vaddr: u64 = 0xFFFF_8800_0063_0000;
1011        let fsname_paddr: u64 = 0x0063_0000;
1012        let inode_vaddr: u64 = 0xFFFF_8800_0064_0000;
1013        let inode_paddr: u64 = 0x0064_0000;
1014        let dentry_vaddr: u64 = 0xFFFF_8800_0065_0000;
1015        let dentry_paddr: u64 = 0x0065_0000;
1016        let dname_str_vaddr: u64 = 0xFFFF_8800_0066_0000;
1017        let dname_str_paddr: u64 = 0x0066_0000;
1018
1019        // super_block field offsets
1020        let s_list_off: u64 = 0x00;
1021        let s_type_off: u64 = 0x08;
1022        let s_inodes_off: u64 = 0x20;
1023
1024        // inode field offsets
1025        let i_sb_list_off: u64 = 0x08;
1026        let i_ino_off: u64 = 0x10;
1027        let i_size_off: u64 = 0x18;
1028        let i_uid_off: u64 = 0x20;
1029        let i_gid_off: u64 = 0x24;
1030        let i_mode_off: u64 = 0x28;
1031        let i_atime_off: u64 = 0x30;
1032        let i_mtime_off: u64 = 0x38;
1033        let i_ctime_off: u64 = 0x40;
1034        let i_dentry_off: u64 = 0x48; // hlist_head.first pointer
1035
1036        // dentry field offsets
1037        let d_alias_off: u64 = 0x00; // hlist_node embedded at start of dentry
1038        let d_name_off: u64 = 0x20; // qstr struct
1039        let d_name_name_off: u64 = d_name_off + 0x08; // name pointer within qstr (after hash+len)
1040
1041        // The hlist_node pointer stored in inode.i_dentry.first points at
1042        // dentry.d_alias (which is at dentry_vaddr + d_alias_off).
1043        let hlist_node_ptr = dentry_vaddr + d_alias_off;
1044
1045        // inode list pointers
1046        let inode_list_head = sb_vaddr + s_inodes_off;
1047        let inode_list_node = inode_vaddr + i_sb_list_off;
1048
1049        // super_blocks list-head page: first 8 bytes = sb.s_list
1050        let mut sym_page = [0u8; 4096];
1051        sym_page[0..8].copy_from_slice(&sb_vaddr.to_le_bytes());
1052
1053        // super_block page
1054        let mut sb_page = [0u8; 4096];
1055        sb_page[s_list_off as usize..s_list_off as usize + 8]
1056            .copy_from_slice(&sym_vaddr.to_le_bytes()); // s_list.next = head (loop ends)
1057        sb_page[s_type_off as usize..s_type_off as usize + 8]
1058            .copy_from_slice(&fstype_vaddr.to_le_bytes());
1059        sb_page[s_inodes_off as usize..s_inodes_off as usize + 8]
1060            .copy_from_slice(&inode_list_node.to_le_bytes());
1061
1062        // file_system_type page: first 8 bytes = fsname_vaddr
1063        let mut fstype_page = [0u8; 4096];
1064        fstype_page[0..8].copy_from_slice(&fsname_vaddr.to_le_bytes());
1065
1066        // fs name page: "tmpfs\0"
1067        let mut fsname_page = [0u8; 4096];
1068        fsname_page[..6].copy_from_slice(b"tmpfs\0");
1069
1070        // inode page
1071        let mut inode_page = [0u8; 4096];
1072        inode_page[i_sb_list_off as usize..i_sb_list_off as usize + 8]
1073            .copy_from_slice(&inode_list_head.to_le_bytes()); // wrap back → inner loop ends
1074        inode_page[i_ino_off as usize..i_ino_off as usize + 8]
1075            .copy_from_slice(&42u64.to_le_bytes());
1076        inode_page[i_size_off as usize..i_size_off as usize + 8]
1077            .copy_from_slice(&512u64.to_le_bytes());
1078        inode_page[i_uid_off as usize..i_uid_off as usize + 4]
1079            .copy_from_slice(&1000u32.to_le_bytes());
1080        inode_page[i_gid_off as usize..i_gid_off as usize + 4]
1081            .copy_from_slice(&1000u32.to_le_bytes());
1082        inode_page[i_mode_off as usize..i_mode_off as usize + 4]
1083            .copy_from_slice(&0o100_644u32.to_le_bytes());
1084        inode_page[i_atime_off as usize..i_atime_off as usize + 8]
1085            .copy_from_slice(&100u64.to_le_bytes());
1086        inode_page[i_mtime_off as usize..i_mtime_off as usize + 8]
1087            .copy_from_slice(&200u64.to_le_bytes());
1088        inode_page[i_ctime_off as usize..i_ctime_off as usize + 8]
1089            .copy_from_slice(&300u64.to_le_bytes());
1090        // i_dentry.first = hlist_node_ptr (points at dentry.d_alias)
1091        inode_page[i_dentry_off as usize..i_dentry_off as usize + 8]
1092            .copy_from_slice(&hlist_node_ptr.to_le_bytes());
1093
1094        // dentry page
1095        let mut dentry_page = [0u8; 4096];
1096        // d_name.name pointer at d_name_name_off within dentry page
1097        dentry_page[d_name_name_off as usize..d_name_name_off as usize + 8]
1098            .copy_from_slice(&dname_str_vaddr.to_le_bytes());
1099
1100        // filename string page: "secret.txt\0"
1101        let mut dname_str_page = [0u8; 4096];
1102        dname_str_page[..11].copy_from_slice(b"secret.txt\0");
1103
1104        let isf = IsfBuilder::new()
1105            .add_symbol("super_blocks", sym_vaddr)
1106            .add_struct("super_block", 0x400)
1107            .add_field("super_block", "s_list", s_list_off, "pointer")
1108            .add_field("super_block", "s_type", s_type_off, "pointer")
1109            .add_field("super_block", "s_inodes", s_inodes_off, "pointer")
1110            .add_struct("inode", 0x200)
1111            .add_field("inode", "i_sb_list", i_sb_list_off, "pointer")
1112            .add_field("inode", "i_ino", i_ino_off, "unsigned long")
1113            .add_field("inode", "i_size", i_size_off, "long long")
1114            .add_field("inode", "i_uid", i_uid_off, "unsigned int")
1115            .add_field("inode", "i_gid", i_gid_off, "unsigned int")
1116            .add_field("inode", "i_mode", i_mode_off, "unsigned int")
1117            .add_field("inode", "i_atime", i_atime_off, "long long")
1118            .add_field("inode", "i_mtime", i_mtime_off, "long long")
1119            .add_field("inode", "i_ctime", i_ctime_off, "long long")
1120            .add_field("inode", "i_dentry", i_dentry_off, "pointer")
1121            .add_struct("dentry", 0x200)
1122            .add_field("dentry", "d_alias", d_alias_off, "pointer")
1123            .add_field("dentry", "d_name_name", d_name_name_off, "pointer")
1124            .build_json();
1125        let resolver = IsfResolver::from_value(&isf).unwrap();
1126
1127        let (cr3, mem) = PageTableBuilder::new()
1128            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
1129            .write_phys(sym_paddr, &sym_page)
1130            .map_4k(sb_vaddr, sb_paddr, ptf::WRITABLE)
1131            .write_phys(sb_paddr, &sb_page)
1132            .map_4k(fstype_vaddr, fstype_paddr, ptf::WRITABLE)
1133            .write_phys(fstype_paddr, &fstype_page)
1134            .map_4k(fsname_vaddr, fsname_paddr, ptf::WRITABLE)
1135            .write_phys(fsname_paddr, &fsname_page)
1136            .map_4k(inode_vaddr, inode_paddr, ptf::WRITABLE)
1137            .write_phys(inode_paddr, &inode_page)
1138            .map_4k(dentry_vaddr, dentry_paddr, ptf::WRITABLE)
1139            .write_phys(dentry_paddr, &dentry_page)
1140            .map_4k(dname_str_vaddr, dname_str_paddr, ptf::WRITABLE)
1141            .write_phys(dname_str_paddr, &dname_str_page)
1142            .build();
1143
1144        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
1145        let reader = ObjectReader::new(vas, Box::new(resolver));
1146
1147        let result = walk_tmpfs_files(&reader).unwrap();
1148        assert_eq!(result.len(), 1, "should find exactly one inode");
1149        assert_eq!(
1150            result[0].filename, "secret.txt",
1151            "filename must be resolved from dentry d_name.name"
1152        );
1153    }
1154
1155    /// Inode with `i_dentry.first == 0` (null hlist) — filename must be empty.
1156    #[test]
1157    fn inode_without_dentry_returns_empty_filename() {
1158        use memf_core::test_builders::flags as ptf;
1159
1160        let sym_vaddr: u64 = 0xFFFF_8800_0067_0000;
1161        let sym_paddr: u64 = 0x0067_0000;
1162        let sb_vaddr: u64 = 0xFFFF_8800_0068_0000;
1163        let sb_paddr: u64 = 0x0068_0000;
1164        let fstype_vaddr: u64 = 0xFFFF_8800_0069_0000;
1165        let fstype_paddr: u64 = 0x0069_0000;
1166        let fsname_vaddr: u64 = 0xFFFF_8800_006A_0000;
1167        let fsname_paddr: u64 = 0x006A_0000;
1168        let inode_vaddr: u64 = 0xFFFF_8800_006B_0000;
1169        let inode_paddr: u64 = 0x006B_0000;
1170
1171        let s_list_off: u64 = 0x00;
1172        let s_type_off: u64 = 0x08;
1173        let s_inodes_off: u64 = 0x20;
1174        let i_sb_list_off: u64 = 0x08;
1175        let i_ino_off: u64 = 0x10;
1176        let i_size_off: u64 = 0x18;
1177        let i_uid_off: u64 = 0x20;
1178        let i_gid_off: u64 = 0x24;
1179        let i_mode_off: u64 = 0x28;
1180        let i_atime_off: u64 = 0x30;
1181        let i_mtime_off: u64 = 0x38;
1182        let i_ctime_off: u64 = 0x40;
1183        let i_dentry_off: u64 = 0x48;
1184
1185        let inode_list_head = sb_vaddr + s_inodes_off;
1186        let inode_list_node = inode_vaddr + i_sb_list_off;
1187
1188        let mut sym_page = [0u8; 4096];
1189        sym_page[0..8].copy_from_slice(&sb_vaddr.to_le_bytes());
1190
1191        let mut sb_page = [0u8; 4096];
1192        sb_page[s_list_off as usize..s_list_off as usize + 8]
1193            .copy_from_slice(&sym_vaddr.to_le_bytes());
1194        sb_page[s_type_off as usize..s_type_off as usize + 8]
1195            .copy_from_slice(&fstype_vaddr.to_le_bytes());
1196        sb_page[s_inodes_off as usize..s_inodes_off as usize + 8]
1197            .copy_from_slice(&inode_list_node.to_le_bytes());
1198
1199        let mut fstype_page = [0u8; 4096];
1200        fstype_page[0..8].copy_from_slice(&fsname_vaddr.to_le_bytes());
1201
1202        let mut fsname_page = [0u8; 4096];
1203        fsname_page[..6].copy_from_slice(b"tmpfs\0");
1204
1205        let mut inode_page = [0u8; 4096];
1206        inode_page[i_sb_list_off as usize..i_sb_list_off as usize + 8]
1207            .copy_from_slice(&inode_list_head.to_le_bytes());
1208        inode_page[i_ino_off as usize..i_ino_off as usize + 8]
1209            .copy_from_slice(&99u64.to_le_bytes());
1210        inode_page[i_size_off as usize..i_size_off as usize + 8]
1211            .copy_from_slice(&0u64.to_le_bytes());
1212        inode_page[i_uid_off as usize..i_uid_off as usize + 4].copy_from_slice(&0u32.to_le_bytes());
1213        inode_page[i_gid_off as usize..i_gid_off as usize + 4].copy_from_slice(&0u32.to_le_bytes());
1214        inode_page[i_mode_off as usize..i_mode_off as usize + 4]
1215            .copy_from_slice(&0o100_644u32.to_le_bytes());
1216        inode_page[i_atime_off as usize..i_atime_off as usize + 8]
1217            .copy_from_slice(&0u64.to_le_bytes());
1218        inode_page[i_mtime_off as usize..i_mtime_off as usize + 8]
1219            .copy_from_slice(&0u64.to_le_bytes());
1220        inode_page[i_ctime_off as usize..i_ctime_off as usize + 8]
1221            .copy_from_slice(&0u64.to_le_bytes());
1222        // i_dentry.first = 0 (null — no dentry)
1223        inode_page[i_dentry_off as usize..i_dentry_off as usize + 8]
1224            .copy_from_slice(&0u64.to_le_bytes());
1225
1226        let isf = IsfBuilder::new()
1227            .add_symbol("super_blocks", sym_vaddr)
1228            .add_struct("super_block", 0x400)
1229            .add_field("super_block", "s_list", s_list_off, "pointer")
1230            .add_field("super_block", "s_type", s_type_off, "pointer")
1231            .add_field("super_block", "s_inodes", s_inodes_off, "pointer")
1232            .add_struct("inode", 0x200)
1233            .add_field("inode", "i_sb_list", i_sb_list_off, "pointer")
1234            .add_field("inode", "i_ino", i_ino_off, "unsigned long")
1235            .add_field("inode", "i_size", i_size_off, "long long")
1236            .add_field("inode", "i_uid", i_uid_off, "unsigned int")
1237            .add_field("inode", "i_gid", i_gid_off, "unsigned int")
1238            .add_field("inode", "i_mode", i_mode_off, "unsigned int")
1239            .add_field("inode", "i_atime", i_atime_off, "long long")
1240            .add_field("inode", "i_mtime", i_mtime_off, "long long")
1241            .add_field("inode", "i_ctime", i_ctime_off, "long long")
1242            .add_field("inode", "i_dentry", i_dentry_off, "pointer")
1243            .add_struct("dentry", 0x200)
1244            .add_field("dentry", "d_alias", 0x00, "pointer")
1245            .add_field("dentry", "d_name_name", 0x28, "pointer")
1246            .build_json();
1247        let resolver = IsfResolver::from_value(&isf).unwrap();
1248
1249        let (cr3, mem) = PageTableBuilder::new()
1250            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
1251            .write_phys(sym_paddr, &sym_page)
1252            .map_4k(sb_vaddr, sb_paddr, ptf::WRITABLE)
1253            .write_phys(sb_paddr, &sb_page)
1254            .map_4k(fstype_vaddr, fstype_paddr, ptf::WRITABLE)
1255            .write_phys(fstype_paddr, &fstype_page)
1256            .map_4k(fsname_vaddr, fsname_paddr, ptf::WRITABLE)
1257            .write_phys(fsname_paddr, &fsname_page)
1258            .map_4k(inode_vaddr, inode_paddr, ptf::WRITABLE)
1259            .write_phys(inode_paddr, &inode_page)
1260            .build();
1261
1262        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
1263        let reader = ObjectReader::new(vas, Box::new(resolver));
1264
1265        let result = walk_tmpfs_files(&reader).unwrap();
1266        assert_eq!(result.len(), 1, "should find exactly one inode");
1267        assert_eq!(
1268            result[0].filename, "",
1269            "null i_dentry.first → filename must be empty string"
1270        );
1271    }
1272
1273    // --- classify_tmpfs_file: TmpfsFileInfo struct coverage ---
1274    #[test]
1275    fn tmpfs_file_info_clone_debug_serialize() {
1276        let info = TmpfsFileInfo {
1277            inode_number: 42,
1278            filename: ".evil".to_string(),
1279            file_size: 1024,
1280            uid: 0,
1281            gid: 0,
1282            mode: 0o100_755,
1283            atime_sec: 1000,
1284            mtime_sec: 2000,
1285            ctime_sec: 3000,
1286            is_suspicious: true,
1287        };
1288        let cloned = info.clone();
1289        assert_eq!(cloned.inode_number, 42);
1290        let dbg = format!("{cloned:?}");
1291        assert!(dbg.contains("evil"));
1292        let json = serde_json::to_string(&cloned).unwrap();
1293        assert!(json.contains("\"inode_number\":42"));
1294        assert!(json.contains("\"is_suspicious\":true"));
1295    }
1296
1297    // --- walk_tmpfs_files: full path — tmpfs sb with self-pointing inode list → empty inodes ---
1298    // Exercises: is_tmpfs=true, s_inodes_offset found, i_sb_list_offset found,
1299    // inode list is self-pointing → inner loop terminates immediately → no TmpfsFileInfo pushed.
1300    #[test]
1301    fn walk_tmpfs_tmpfs_sb_self_pointing_inode_list_returns_empty() {
1302        use memf_core::test_builders::flags as ptf;
1303
1304        // Layout (all physical addrs < 16 MB):
1305        //   sym_vaddr      = super_blocks list head
1306        //   sb_entry_vaddr = the single super_block (s_list.next = sym_vaddr so loop ends)
1307        //   fs_type_vaddr  = file_system_type
1308        //   name_str_vaddr = "tmpfs\0"
1309        let sym_vaddr: u64 = 0xFFFF_8800_0050_0000;
1310        let sym_paddr: u64 = 0x0050_0000;
1311        let sb_entry_vaddr: u64 = 0xFFFF_8800_0051_0000;
1312        let sb_entry_paddr: u64 = 0x0051_0000;
1313        let fs_type_vaddr: u64 = 0xFFFF_8800_0052_0000;
1314        let fs_type_paddr: u64 = 0x0052_0000;
1315        let name_str_vaddr: u64 = 0xFFFF_8800_0053_0000;
1316        let name_str_paddr: u64 = 0x0053_0000;
1317
1318        // Offsets inside super_block:
1319        //   s_list   @ 0x00
1320        //   s_type   @ 0x08
1321        //   s_inodes @ 0x20  (list_head; first 8 bytes = next pointer)
1322        let s_inodes_offset: u64 = 0x20;
1323
1324        // The inode list head lives at sb_entry_vaddr + s_inodes_offset.
1325        // A self-pointing list → next == inode_list_head → inner loop exits immediately.
1326        let inode_list_head = sb_entry_vaddr + s_inodes_offset;
1327
1328        // Build the super_blocks list-head page: first 8 bytes = sb_entry_vaddr.
1329        let mut sym_page = [0u8; 4096];
1330        sym_page[0..8].copy_from_slice(&sb_entry_vaddr.to_le_bytes());
1331
1332        // Build the super_block page.
1333        let mut sb_page = [0u8; 4096];
1334        sb_page[0x00..0x08].copy_from_slice(&sym_vaddr.to_le_bytes()); // s_list.next = head → ends loop
1335        sb_page[0x08..0x10].copy_from_slice(&fs_type_vaddr.to_le_bytes()); // s_type
1336                                                                           // s_inodes.next = inode_list_head (self-pointer → inner loop exits immediately)
1337        sb_page[s_inodes_offset as usize..s_inodes_offset as usize + 8]
1338            .copy_from_slice(&inode_list_head.to_le_bytes());
1339
1340        let mut fs_type_page = [0u8; 4096];
1341        fs_type_page[0..8].copy_from_slice(&name_str_vaddr.to_le_bytes());
1342
1343        let mut name_page = [0u8; 4096];
1344        name_page[..6].copy_from_slice(b"tmpfs\0");
1345
1346        // i_sb_list offset inside inode = 0x08 (arbitrary; just needs to resolve).
1347        let isf = IsfBuilder::new()
1348            .add_symbol("super_blocks", sym_vaddr)
1349            .add_struct("super_block", 0x400)
1350            .add_field("super_block", "s_list", 0x00, "pointer")
1351            .add_field("super_block", "s_type", 0x08, "pointer")
1352            .add_field("super_block", "s_inodes", s_inodes_offset, "pointer")
1353            .add_struct("inode", 0x400)
1354            .add_field("inode", "i_sb_list", 0x08, "pointer")
1355            .add_field("inode", "i_ino", 0x10, "unsigned long")
1356            .add_field("inode", "i_size", 0x18, "long long")
1357            .add_field("inode", "i_uid", 0x20, "unsigned int")
1358            .add_field("inode", "i_gid", 0x24, "unsigned int")
1359            .add_field("inode", "i_mode", 0x28, "unsigned int")
1360            .add_field("inode", "i_atime", 0x30, "long long")
1361            .add_field("inode", "i_mtime", 0x38, "long long")
1362            .add_field("inode", "i_ctime", 0x40, "long long")
1363            .build_json();
1364        let resolver = IsfResolver::from_value(&isf).unwrap();
1365
1366        let (cr3, mem) = PageTableBuilder::new()
1367            .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
1368            .write_phys(sym_paddr, &sym_page)
1369            .map_4k(sb_entry_vaddr, sb_entry_paddr, ptf::WRITABLE)
1370            .write_phys(sb_entry_paddr, &sb_page)
1371            .map_4k(fs_type_vaddr, fs_type_paddr, ptf::WRITABLE)
1372            .write_phys(fs_type_paddr, &fs_type_page)
1373            .map_4k(name_str_vaddr, name_str_paddr, ptf::WRITABLE)
1374            .write_phys(name_str_paddr, &name_page)
1375            .build();
1376
1377        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
1378        let reader = ObjectReader::new(vas, Box::new(resolver));
1379
1380        let result = walk_tmpfs_files(&reader).unwrap();
1381        assert!(
1382            result.is_empty(),
1383            "tmpfs sb with self-pointing inode list → 0 inodes"
1384        );
1385    }
1386}