Skip to main content

memf_linux/
files.rs

1//! Linux open file descriptor walker.
2//!
3//! Enumerates open file descriptors by walking `task_struct.files →
4//! files_struct.fdt → fdtable.fd[]` for each process in the task list.
5//! Each `struct file` pointer in the fd array is dereferenced to read
6//! the dentry path name and file position.
7
8use memf_core::object_reader::ObjectReader;
9use memf_format::PhysicalMemoryProvider;
10
11use crate::{Error, FileDescriptorInfo, Result};
12
13/// Walk open file descriptors for all processes.
14///
15/// For each process, follows `task_struct.files → files_struct.fdt →
16/// fdtable` to find the fd pointer array, then dereferences each
17/// non-NULL `struct file *` to read the dentry name and file position.
18pub fn walk_files<P: PhysicalMemoryProvider>(
19    reader: &ObjectReader<P>,
20) -> Result<Vec<FileDescriptorInfo>> {
21    let init_task_addr = reader
22        .symbols()
23        .symbol_address("init_task")
24        .ok_or_else(|| Error::MissingKernelSymbol {
25            name: "init_task".into(),
26        })?;
27
28    let tasks_offset = reader
29        .symbols()
30        .field_offset("task_struct", "tasks")
31        .ok_or_else(|| Error::MissingField {
32            struct_name: "task_struct".into(),
33            field_name: "tasks".into(),
34        })?;
35
36    let head_vaddr = init_task_addr + tasks_offset;
37    let task_addrs = reader.walk_list(head_vaddr, "task_struct", "tasks")?;
38
39    let mut all_fds = Vec::new();
40
41    // Include init_task itself
42    collect_process_files(reader, init_task_addr, &mut all_fds);
43
44    for &task_addr in &task_addrs {
45        collect_process_files(reader, task_addr, &mut all_fds);
46    }
47
48    Ok(all_fds)
49}
50
51/// Collect FDs for a single process, silently skipping if files is NULL.
52fn collect_process_files<P: PhysicalMemoryProvider>(
53    reader: &ObjectReader<P>,
54    task_addr: u64,
55    out: &mut Vec<FileDescriptorInfo>,
56) {
57    let files_ptr: u64 = match reader.read_field(task_addr, "task_struct", "files") {
58        Ok(v) => v,
59        Err(_) => return,
60    };
61    if files_ptr == 0 {
62        return; // kernel thread or no files_struct
63    }
64
65    if let Ok(fds) = walk_process_files(reader, task_addr) {
66        out.extend(fds);
67    }
68}
69
70/// Walk open file descriptors for a single process.
71pub fn walk_process_files<P: PhysicalMemoryProvider>(
72    reader: &ObjectReader<P>,
73    task_addr: u64,
74) -> Result<Vec<FileDescriptorInfo>> {
75    let pid: u32 = reader.read_field(task_addr, "task_struct", "pid")?;
76    let comm = reader.read_field_string(task_addr, "task_struct", "comm", 16)?;
77    let files_ptr: u64 = reader.read_field(task_addr, "task_struct", "files")?;
78
79    if files_ptr == 0 {
80        return Err(Error::WalkFailed {
81            walker: "walk_process_files",
82            reason: format!("task {comm} (PID {pid}) has NULL files pointer"),
83        });
84    }
85
86    let fdt_ptr: u64 = reader.read_field(files_ptr, "files_struct", "fdt")?;
87    let max_fds: u32 = reader.read_field(fdt_ptr, "fdtable", "max_fds")?;
88    let fd_array_ptr: u64 = reader.read_field(fdt_ptr, "fdtable", "fd")?;
89
90    // Resolve embedded struct offsets for f_path.dentry navigation
91    let f_path_offset = reader
92        .symbols()
93        .field_offset("file", "f_path")
94        .ok_or_else(|| Error::MissingField {
95            struct_name: "file".into(),
96            field_name: "f_path".into(),
97        })?;
98    let dentry_in_path_offset =
99        reader
100            .symbols()
101            .field_offset("path", "dentry")
102            .ok_or_else(|| Error::MissingField {
103                struct_name: "path".into(),
104                field_name: "dentry".into(),
105            })?;
106    let name_in_qstr_offset = reader
107        .symbols()
108        .field_offset("qstr", "name")
109        .ok_or_else(|| Error::MissingField {
110            struct_name: "qstr".into(),
111            field_name: "name".into(),
112        })?;
113
114    // Read the entire fd pointer array as raw bytes
115    let array_size = usize::try_from(max_fds).unwrap_or(0) * 8;
116    let fd_raw = reader.read_bytes(fd_array_ptr, array_size)?;
117
118    let d_name_offset = reader
119        .symbols()
120        .field_offset("dentry", "d_name")
121        .ok_or_else(|| Error::MissingField {
122            struct_name: "dentry".into(),
123            field_name: "d_name".into(),
124        })?;
125
126    let mut fds = Vec::new();
127
128    for fd_num in 0..max_fds {
129        let off = usize::try_from(fd_num).unwrap_or(0) * 8;
130        let file_ptr = fd_raw[off..off + 8]
131            .try_into()
132            .map_or(0, u64::from_le_bytes);
133
134        if file_ptr == 0 {
135            continue; // closed fd slot
136        }
137
138        // Read f_pos
139        let f_pos: u64 = reader.read_field(file_ptr, "file", "f_pos")?;
140
141        // Read f_inode pointer for inode number
142        let f_inode_ptr: u64 = reader.read_field(file_ptr, "file", "f_inode")?;
143        let inode = if f_inode_ptr != 0 {
144            reader.read_field::<u64>(f_inode_ptr, "inode", "i_ino").ok()
145        } else {
146            None
147        };
148
149        // Navigate embedded structs: file.f_path.dentry (f_path is embedded at
150        // f_path_offset, dentry pointer is at dentry_in_path_offset within path)
151        let dentry_addr = file_ptr + f_path_offset + dentry_in_path_offset;
152        let dentry_raw = reader.read_bytes(dentry_addr, 8)?;
153        let dentry_ptr = dentry_raw.try_into().map_or(0, u64::from_le_bytes);
154
155        let path = if dentry_ptr != 0 {
156            // dentry.d_name is an embedded qstr; name pointer at qstr.name offset
157            let name_addr = dentry_ptr + d_name_offset + name_in_qstr_offset;
158            let name_raw = reader.read_bytes(name_addr, 8)?;
159            let name_ptr = name_raw.try_into().map_or(0, u64::from_le_bytes);
160            if name_ptr != 0 {
161                reader.read_string(name_ptr, 256).unwrap_or_default()
162            } else {
163                String::new()
164            }
165        } else {
166            String::new()
167        };
168
169        fds.push(FileDescriptorInfo {
170            pid: u64::from(pid),
171            comm: comm.clone(),
172            fd: fd_num,
173            path,
174            inode,
175            pos: f_pos,
176        });
177    }
178
179    Ok(fds)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use memf_core::test_builders::{flags, PageTableBuilder, SyntheticPhysMem};
186    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
187    use memf_symbols::isf::IsfResolver;
188    use memf_symbols::test_builders::IsfBuilder;
189
190    fn make_test_reader(data: &[u8], vaddr: u64, paddr: u64) -> ObjectReader<SyntheticPhysMem> {
191        let isf = IsfBuilder::new()
192            // task_struct
193            .add_struct("task_struct", 128)
194            .add_field("task_struct", "pid", 0, "int")
195            .add_field("task_struct", "state", 4, "long")
196            .add_field("task_struct", "tasks", 16, "list_head")
197            .add_field("task_struct", "comm", 32, "char")
198            .add_field("task_struct", "mm", 48, "pointer")
199            .add_field("task_struct", "files", 56, "pointer")
200            // list_head
201            .add_struct("list_head", 16)
202            .add_field("list_head", "next", 0, "pointer")
203            .add_field("list_head", "prev", 8, "pointer")
204            // files_struct
205            .add_struct("files_struct", 32)
206            .add_field("files_struct", "fdt", 0, "pointer")
207            // fdtable
208            .add_struct("fdtable", 16)
209            .add_field("fdtable", "max_fds", 0, "unsigned int")
210            .add_field("fdtable", "fd", 8, "pointer")
211            // file
212            .add_struct("file", 64)
213            .add_field("file", "f_path", 0, "path")
214            .add_field("file", "f_inode", 16, "pointer")
215            .add_field("file", "f_pos", 24, "long long")
216            // path — embedded in struct file
217            .add_struct("path", 16)
218            .add_field("path", "dentry", 8, "pointer")
219            // dentry
220            .add_struct("dentry", 64)
221            .add_field("dentry", "d_name", 0, "qstr")
222            .add_field("dentry", "d_inode", 48, "pointer")
223            // qstr — contains inline name pointer
224            .add_struct("qstr", 16)
225            .add_field("qstr", "name", 8, "pointer")
226            // inode
227            .add_struct("inode", 64)
228            .add_field("inode", "i_ino", 0, "unsigned long")
229            // symbol
230            .add_symbol("init_task", vaddr)
231            .build_json();
232
233        let resolver = IsfResolver::from_value(&isf).unwrap();
234        let (cr3, mem) = PageTableBuilder::new()
235            .map_4k(vaddr, paddr, flags::WRITABLE)
236            .write_phys(paddr, data)
237            .build();
238        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
239        ObjectReader::new(vas, Box::new(resolver))
240    }
241
242    #[test]
243    fn walk_single_process_two_fds() {
244        let vaddr: u64 = 0xFFFF_8000_0010_0000;
245        let paddr: u64 = 0x0080_0000;
246        let mut data = vec![0u8; 4096];
247
248        // init_task (PID 1, "bash")
249        data[0..4].copy_from_slice(&1u32.to_le_bytes()); // pid
250        data[4..12].copy_from_slice(&0i64.to_le_bytes()); // state
251        let tasks_addr = vaddr + 16;
252        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes()); // tasks.next → self
253        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes()); // tasks.prev → self
254        data[32..36].copy_from_slice(b"bash"); // comm
255        data[48..56].copy_from_slice(&0u64.to_le_bytes()); // mm = NULL (irrelevant)
256        let files_struct_addr = vaddr + 0x200;
257        data[56..64].copy_from_slice(&files_struct_addr.to_le_bytes()); // files
258
259        // files_struct at +0x200
260        let fdtable_addr = vaddr + 0x300;
261        data[0x200..0x208].copy_from_slice(&fdtable_addr.to_le_bytes()); // fdt
262
263        // fdtable at +0x300
264        data[0x300..0x304].copy_from_slice(&3u32.to_le_bytes()); // max_fds = 3
265        let fd_array_addr = vaddr + 0x400;
266        data[0x308..0x310].copy_from_slice(&fd_array_addr.to_le_bytes()); // fd array
267
268        // fd array at +0x400: [file0_ptr, NULL, file2_ptr]
269        let file0_addr = vaddr + 0x500;
270        data[0x400..0x408].copy_from_slice(&file0_addr.to_le_bytes()); // fd 0
271        data[0x408..0x410].copy_from_slice(&0u64.to_le_bytes()); // fd 1 = NULL (closed)
272        let file2_addr = vaddr + 0x600;
273        data[0x410..0x418].copy_from_slice(&file2_addr.to_le_bytes()); // fd 2
274
275        // struct file #0 at +0x500: /dev/pts/0
276        // f_path.dentry at offset 8 within path (which is at offset 0 in file)
277        let dentry0_addr = vaddr + 0x700;
278        data[0x508..0x510].copy_from_slice(&dentry0_addr.to_le_bytes()); // f_path.dentry
279        let inode0_addr = vaddr + 0x800;
280        data[0x510..0x518].copy_from_slice(&inode0_addr.to_le_bytes()); // f_inode
281        data[0x518..0x520].copy_from_slice(&0u64.to_le_bytes()); // f_pos = 0
282
283        // dentry #0 at +0x700
284        // d_name (qstr) at offset 0, name pointer at qstr offset 8
285        let name0_addr = vaddr + 0x780;
286        data[0x708..0x710].copy_from_slice(&name0_addr.to_le_bytes()); // d_name.name
287        data[0x780..0x78A].copy_from_slice(b"/dev/pts/0"); // name string
288                                                           // d_inode at offset 48
289        data[0x730..0x738].copy_from_slice(&inode0_addr.to_le_bytes()); // d_inode
290
291        // inode #0 at +0x800
292        data[0x800..0x808].copy_from_slice(&4u64.to_le_bytes()); // i_ino = 4
293
294        // struct file #2 at +0x600: /tmp/log
295        let dentry2_addr = vaddr + 0x900;
296        data[0x608..0x610].copy_from_slice(&dentry2_addr.to_le_bytes()); // f_path.dentry
297        let inode2_addr = vaddr + 0xA00;
298        data[0x610..0x618].copy_from_slice(&inode2_addr.to_le_bytes()); // f_inode
299        data[0x618..0x620].copy_from_slice(&1024u64.to_le_bytes()); // f_pos = 1024
300
301        // dentry #2 at +0x900
302        let name2_addr = vaddr + 0x980;
303        data[0x908..0x910].copy_from_slice(&name2_addr.to_le_bytes()); // d_name.name
304        data[0x980..0x988].copy_from_slice(b"/tmp/log"); // name string
305        data[0x930..0x938].copy_from_slice(&inode2_addr.to_le_bytes()); // d_inode
306
307        // inode #2 at +0xA00
308        data[0xA00..0xA08].copy_from_slice(&42u64.to_le_bytes()); // i_ino = 42
309
310        let reader = make_test_reader(&data, vaddr, paddr);
311        let fds = walk_files(&reader).unwrap();
312
313        assert_eq!(fds.len(), 2);
314
315        assert_eq!(fds[0].pid, 1);
316        assert_eq!(fds[0].comm, "bash");
317        assert_eq!(fds[0].fd, 0);
318        assert_eq!(fds[0].path, "/dev/pts/0");
319        assert_eq!(fds[0].inode, Some(4));
320        assert_eq!(fds[0].pos, 0);
321
322        assert_eq!(fds[1].fd, 2);
323        assert_eq!(fds[1].path, "/tmp/log");
324        assert_eq!(fds[1].inode, Some(42));
325        assert_eq!(fds[1].pos, 1024);
326    }
327
328    #[test]
329    fn walk_files_skips_kernel_threads() {
330        // Kernel threads have files == NULL — should produce no FDs
331        let vaddr: u64 = 0xFFFF_8000_0010_0000;
332        let paddr: u64 = 0x0080_0000;
333        let mut data = vec![0u8; 4096];
334
335        data[0..4].copy_from_slice(&0u32.to_le_bytes()); // pid = 0
336        let tasks_addr = vaddr + 16;
337        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes()); // tasks.next → self
338        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes()); // tasks.prev → self
339        data[32..41].copy_from_slice(b"swapper/0");
340        data[56..64].copy_from_slice(&0u64.to_le_bytes()); // files = NULL
341
342        let reader = make_test_reader(&data, vaddr, paddr);
343        let fds = walk_files(&reader).unwrap();
344
345        assert!(fds.is_empty());
346    }
347
348    #[test]
349    fn walk_process_files_null_files_returns_error() {
350        let vaddr: u64 = 0xFFFF_8000_0010_0000;
351        let paddr: u64 = 0x0080_0000;
352        let mut data = vec![0u8; 4096];
353
354        data[56..64].copy_from_slice(&0u64.to_le_bytes()); // files = NULL
355
356        let reader = make_test_reader(&data, vaddr, paddr);
357        let result = walk_process_files(&reader, vaddr);
358        assert!(result.is_err());
359    }
360
361    #[test]
362    fn missing_init_task_symbol() {
363        let isf = IsfBuilder::new()
364            .add_struct("task_struct", 64)
365            .add_field("task_struct", "pid", 0, "int")
366            .add_field("task_struct", "tasks", 8, "list_head")
367            .add_struct("list_head", 16)
368            .add_field("list_head", "next", 0, "pointer")
369            .add_field("list_head", "prev", 8, "pointer")
370            .build_json();
371
372        let resolver = IsfResolver::from_value(&isf).unwrap();
373        let (cr3, mem) = PageTableBuilder::new().build();
374        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
375        let reader = ObjectReader::new(vas, Box::new(resolver));
376
377        let result = walk_files(&reader);
378        assert!(
379            matches!(result, Err(crate::Error::MissingKernelSymbol { ref name }) if name == "init_task"),
380            "expected MissingKernelSymbol {{name: \"init_task\"}}, got {result:?}"
381        );
382    }
383
384    // walk_files: tasks field missing → Err (exercises the tasks_offset error path).
385    #[test]
386    fn walk_files_missing_tasks_field_returns_error() {
387        let vaddr: u64 = 0xFFFF_8000_0010_0000;
388        let paddr: u64 = 0x0080_0000;
389        let data = vec![0u8; 4096];
390
391        let isf = IsfBuilder::new()
392            .add_struct("task_struct", 128)
393            .add_field("task_struct", "pid", 0, "int")
394            // tasks field intentionally absent
395            .add_struct("list_head", 16)
396            .add_field("list_head", "next", 0, "pointer")
397            .add_field("list_head", "prev", 8, "pointer")
398            .add_symbol("init_task", vaddr)
399            .build_json();
400
401        let resolver = IsfResolver::from_value(&isf).unwrap();
402        let (cr3, mem) = PageTableBuilder::new()
403            .map_4k(vaddr, paddr, flags::WRITABLE)
404            .write_phys(paddr, &data)
405            .build();
406        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
407        let reader = ObjectReader::new(vas, Box::new(resolver));
408
409        let result = walk_files(&reader);
410        assert!(
411            matches!(result, Err(crate::Error::MissingField { ref struct_name, ref field_name }) if struct_name == "task_struct" && field_name == "tasks"),
412            "expected MissingField task_struct.tasks, got {result:?}"
413        );
414    }
415
416    // walk_process_files: f_inode == 0 → inode field is None in result.
417    #[test]
418    fn walk_process_files_null_inode_gives_none() {
419        let vaddr: u64 = 0xFFFF_8000_0010_0000;
420        let paddr: u64 = 0x0080_0000;
421        let mut data = vec![0u8; 4096];
422
423        // task_struct: PID 1, "bash"
424        data[0..4].copy_from_slice(&1u32.to_le_bytes());
425        let tasks_addr = vaddr + 16;
426        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
427        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
428        data[32..36].copy_from_slice(b"bash");
429        let files_struct_addr = vaddr + 0x200;
430        data[56..64].copy_from_slice(&files_struct_addr.to_le_bytes());
431
432        // files_struct at +0x200
433        let fdtable_addr = vaddr + 0x300;
434        data[0x200..0x208].copy_from_slice(&fdtable_addr.to_le_bytes());
435
436        // fdtable: max_fds=1, fd array at +0x400
437        data[0x300..0x304].copy_from_slice(&1u32.to_le_bytes());
438        let fd_array_addr = vaddr + 0x400;
439        data[0x308..0x310].copy_from_slice(&fd_array_addr.to_le_bytes());
440
441        // fd array: slot 0 → file at +0x500
442        let file_addr = vaddr + 0x500;
443        data[0x400..0x408].copy_from_slice(&file_addr.to_le_bytes());
444
445        // file at +0x500: f_inode = 0 (null), f_pos = 0, dentry at +0x700
446        let dentry_addr = vaddr + 0x700;
447        data[0x508..0x510].copy_from_slice(&dentry_addr.to_le_bytes()); // f_path.dentry
448        data[0x510..0x518].copy_from_slice(&0u64.to_le_bytes()); // f_inode = NULL
449        data[0x518..0x520].copy_from_slice(&999u64.to_le_bytes()); // f_pos = 999
450
451        // dentry at +0x700: d_name.name at +0x780
452        let name_addr = vaddr + 0x780;
453        data[0x708..0x710].copy_from_slice(&name_addr.to_le_bytes());
454        data[0x780..0x789].copy_from_slice(b"/dev/null");
455
456        let reader = make_test_reader(&data, vaddr, paddr);
457        let fds = walk_process_files(&reader, vaddr).unwrap();
458
459        assert_eq!(fds.len(), 1);
460        assert_eq!(fds[0].inode, None, "f_inode=0 should yield inode=None");
461        assert_eq!(fds[0].pos, 999);
462        assert_eq!(fds[0].path, "/dev/null");
463    }
464
465    // walk_process_files: dentry_ptr == 0 → path is empty string.
466    #[test]
467    fn walk_process_files_null_dentry_gives_empty_path() {
468        let vaddr: u64 = 0xFFFF_8000_0010_0000;
469        let paddr: u64 = 0x0080_0000;
470        let mut data = vec![0u8; 4096];
471
472        data[0..4].copy_from_slice(&2u32.to_le_bytes());
473        let tasks_addr = vaddr + 16;
474        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
475        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
476        data[32..36].copy_from_slice(b"bash");
477        let files_struct_addr = vaddr + 0x200;
478        data[56..64].copy_from_slice(&files_struct_addr.to_le_bytes());
479
480        let fdtable_addr = vaddr + 0x300;
481        data[0x200..0x208].copy_from_slice(&fdtable_addr.to_le_bytes());
482        data[0x300..0x304].copy_from_slice(&1u32.to_le_bytes()); // max_fds
483        let fd_array_addr = vaddr + 0x400;
484        data[0x308..0x310].copy_from_slice(&fd_array_addr.to_le_bytes());
485
486        let file_addr = vaddr + 0x500;
487        data[0x400..0x408].copy_from_slice(&file_addr.to_le_bytes());
488
489        // file: f_path.dentry = 0 (null), f_inode = 0
490        data[0x508..0x510].copy_from_slice(&0u64.to_le_bytes()); // dentry = NULL
491        data[0x510..0x518].copy_from_slice(&0u64.to_le_bytes()); // f_inode = NULL
492        data[0x518..0x520].copy_from_slice(&0u64.to_le_bytes()); // f_pos = 0
493
494        let reader = make_test_reader(&data, vaddr, paddr);
495        let fds = walk_process_files(&reader, vaddr).unwrap();
496
497        assert_eq!(fds.len(), 1);
498        assert_eq!(fds[0].path, "", "null dentry → empty path");
499    }
500
501    // walk_process_files: name_ptr == 0 → path is empty string.
502    #[test]
503    fn walk_process_files_null_name_ptr_gives_empty_path() {
504        let vaddr: u64 = 0xFFFF_8000_0010_0000;
505        let paddr: u64 = 0x0080_0000;
506        let mut data = vec![0u8; 4096];
507
508        data[0..4].copy_from_slice(&3u32.to_le_bytes());
509        let tasks_addr = vaddr + 16;
510        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
511        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
512        data[32..36].copy_from_slice(b"bash");
513        let files_struct_addr = vaddr + 0x200;
514        data[56..64].copy_from_slice(&files_struct_addr.to_le_bytes());
515
516        let fdtable_addr = vaddr + 0x300;
517        data[0x200..0x208].copy_from_slice(&fdtable_addr.to_le_bytes());
518        data[0x300..0x304].copy_from_slice(&1u32.to_le_bytes());
519        let fd_array_addr = vaddr + 0x400;
520        data[0x308..0x310].copy_from_slice(&fd_array_addr.to_le_bytes());
521
522        let file_addr = vaddr + 0x500;
523        data[0x400..0x408].copy_from_slice(&file_addr.to_le_bytes());
524
525        // file: dentry → at +0x700
526        let dentry_addr = vaddr + 0x700;
527        data[0x508..0x510].copy_from_slice(&dentry_addr.to_le_bytes());
528        data[0x510..0x518].copy_from_slice(&0u64.to_le_bytes()); // f_inode = 0
529        data[0x518..0x520].copy_from_slice(&0u64.to_le_bytes());
530
531        // dentry: qstr.name at 0x708 → 0 (null name_ptr)
532        data[0x708..0x710].copy_from_slice(&0u64.to_le_bytes()); // name_ptr = 0
533
534        let reader = make_test_reader(&data, vaddr, paddr);
535        let fds = walk_process_files(&reader, vaddr).unwrap();
536
537        assert_eq!(fds.len(), 1);
538        assert_eq!(fds[0].path, "", "null name_ptr → empty path");
539    }
540}