Skip to main content

memf_linux/
unix_sockets.rs

1//! Linux Unix domain socket walker.
2//!
3//! Enumerates Unix domain sockets from kernel memory by walking the
4//! `unix_socket_table` hash table of `unix_sock` structures. Unix sockets
5//! are used for local IPC and can reveal hidden communication channels
6//! between processes. Malware uses abstract Unix sockets (names starting
7//! with `\0`) for covert C2 channels. Equivalent to Volatility's
8//! `linux.sockstat` for `AF_UNIX`.
9
10use memf_core::object_reader::ObjectReader;
11use memf_format::PhysicalMemoryProvider;
12
13use crate::Result;
14
15/// Information about a Unix domain socket extracted from kernel memory.
16#[derive(Debug, Clone, serde::Serialize)]
17pub struct UnixSocketInfo {
18    /// Inode number of the socket.
19    pub inode: u64,
20    /// Socket path (empty for abstract sockets, `@`-prefixed in display).
21    pub path: String,
22    /// Socket type: STREAM, DGRAM, or SEQPACKET.
23    pub socket_type: String,
24    /// Socket state (e.g. UNCONNECTED, CONNECTED, LISTENING).
25    pub state: String,
26    /// PID of the process that owns this socket.
27    pub owner_pid: u32,
28    /// PID of the peer process (0 if none).
29    pub peer_pid: u32,
30    /// Whether this socket is classified as suspicious.
31    pub is_suspicious: bool,
32}
33
34/// Map a kernel `sk_type` value to a human-readable socket type name.
35pub fn socket_type_name(sk_type: u32) -> &'static str {
36    match sk_type {
37        1 => "STREAM",
38        2 => "DGRAM",
39        5 => "SEQPACKET",
40        _ => "UNKNOWN",
41    }
42}
43
44/// Classify whether a Unix socket is suspicious.
45///
46/// A socket is suspicious if:
47/// - It is an abstract socket (empty path or path starts with `@`) owned by
48///   a non-system PID (pid >= 1000), or
49/// - Its path is under `/tmp` or `/dev/shm` (common malware staging areas).
50pub use crate::heuristics::classify_unix_socket;
51
52/// Safety limit: maximum number of Unix sockets to enumerate.
53const MAX_UNIX_SOCKETS: usize = 65536;
54/// Number of hash table buckets in `unix_socket_table`.
55const UNIX_HASH_SIZE: u64 = 256;
56
57/// Walk Unix domain sockets from kernel memory.
58///
59/// Looks up `unix_socket_table` (or `init_net.unx.table`) and walks the
60/// hash table of `unix_sock` structures, reading path, type, state, and
61/// owning PID from each entry.
62///
63/// Returns `Ok(Vec::new())` when required kernel symbols are absent.
64pub fn walk_unix_sockets<P: PhysicalMemoryProvider>(
65    reader: &ObjectReader<P>,
66) -> Result<Vec<UnixSocketInfo>> {
67    // Locate the unix_socket_table hash array.
68    let table_addr = match reader.symbols().symbol_address("unix_socket_table") {
69        Some(addr) => addr,
70        None => return Ok(Vec::new()),
71    };
72
73    // Resolve key offsets within unix_sock / sock.
74    // unix_sock embeds `struct sock sk` at offset 0 (sk.sk_node is the hlist).
75    // The path (sun_path) is in `struct sockaddr_un` embedded in unix_sock.
76    let sk_type_off = reader
77        .symbols()
78        .field_offset("sock", "sk_type")
79        .unwrap_or(0x12);
80    let sk_state_off = reader
81        .symbols()
82        .field_offset("sock", "sk_state")
83        .unwrap_or(0x14);
84    let sk_socket_off = reader
85        .symbols()
86        .field_offset("sock", "sk_socket")
87        .unwrap_or(0x30);
88    let unix_addr_off = reader
89        .symbols()
90        .field_offset("unix_sock", "addr")
91        .unwrap_or(0x288);
92    let sun_path_off: u64 = 2; // offsetof(sockaddr_un, sun_path) after sa_family u16
93
94    let mut results = Vec::new();
95    let mut seen = std::collections::HashSet::new();
96
97    // Walk each hash bucket (hlist_head: first pointer at offset 0).
98    for bucket in 0..UNIX_HASH_SIZE {
99        let bucket_addr = table_addr + bucket * 8;
100        let first = match reader.read_bytes(bucket_addr, 8) {
101            Ok(b) if b.len() == 8 => b[..8].try_into().map_or(0, u64::from_le_bytes),
102            _ => continue,
103        };
104        if first == 0 {
105            continue;
106        }
107
108        // Walk hlist: each node's `next` is the first field.
109        let mut node = first;
110        while node != 0 && results.len() < MAX_UNIX_SOCKETS {
111            if !seen.insert(node) {
112                break; // cycle detected
113            }
114
115            // `unix_sock` starts with embedded `struct sock` (sk) at offset 0,
116            // and `sk.sk_node` (hlist_node: next, pprev) is at offset 0 of sk.
117            // The node pointer IS the address of sk_node inside unix_sock,
118            // so unix_sock starts at node (no adjustment needed for the first field).
119            let sock_addr = node;
120
121            // Follow hlist next pointer (offset 0 within hlist_node).
122            let next = match reader.read_bytes(node, 8) {
123                Ok(b) if b.len() == 8 => b[..8].try_into().map_or(0, u64::from_le_bytes),
124                _ => break,
125            };
126
127            // Read sk_type (u16) and sk_state (u8).
128            let sk_type: u32 = reader
129                .read_bytes(sock_addr + sk_type_off, 2)
130                .ok()
131                .and_then(|b| Some(u32::from(u16::from_le_bytes(b[..2].try_into().ok()?))))
132                .unwrap_or(0);
133            let sk_state: u8 = reader
134                .read_bytes(sock_addr + sk_state_off, 1)
135                .ok()
136                .and_then(|b| b.first().copied())
137                .unwrap_or(0);
138
139            let state_str = match sk_state {
140                1 => "UNCONNECTED",
141                2 => "CONNECTING",
142                3 => "CONNECTED",
143                4 => "DISCONNECTING",
144                _ => "UNKNOWN",
145            }
146            .to_string();
147
148            // Read unix path from unix_sock.addr -> unix_address.name.sun_path.
149            let path = 'path: {
150                let addr_ptr = reader
151                    .read_bytes(sock_addr + unix_addr_off, 8)
152                    .ok()
153                    .and_then(|b| Some(u64::from_le_bytes(b[..8].try_into().ok()?)))
154                    .unwrap_or(0);
155                if addr_ptr == 0 {
156                    break 'path String::new();
157                }
158                // sun_path starts at addr_ptr + sun_path_off (skip sa_family u16).
159                let path_bytes = reader
160                    .read_bytes(addr_ptr + sun_path_off, 108)
161                    .unwrap_or_default();
162                // Abstract socket: first byte is '\0', display as '@' prefix.
163                if path_bytes.first().copied() == Some(0) {
164                    let inner: String = path_bytes[1..]
165                        .iter()
166                        .take_while(|&&b| b != 0)
167                        .map(|&b| b as char)
168                        .collect();
169                    if inner.is_empty() {
170                        String::new()
171                    } else {
172                        format!("@{inner}")
173                    }
174                } else {
175                    path_bytes
176                        .iter()
177                        .take_while(|&&b| b != 0)
178                        .map(|&b| b as char)
179                        .collect()
180                }
181            };
182
183            // Read socket inode via sk_socket -> socket -> inode.
184            let inode: u64 = reader
185                .read_bytes(sock_addr + sk_socket_off, 8)
186                .ok()
187                .and_then(|b| {
188                    let socket_ptr = u64::from_le_bytes(b[..8].try_into().ok()?);
189                    if socket_ptr == 0 {
190                        return None;
191                    }
192                    // socket.file offset varies; inode is typically at +0x18.
193                    reader
194                        .read_bytes(socket_ptr + 0x18, 8)
195                        .ok()
196                        .and_then(|ib| Some(u64::from_le_bytes(ib[..8].try_into().ok()?)))
197                })
198                .unwrap_or(0);
199
200            // Resolve owner_pid via sk.__sk_common.skc_peer_pid → struct pid → numbers[0].nr.
201            // If the ISF lacks either field or the pointer is null, owner_pid stays 0.
202            let owner_pid: u32 = {
203                let skc_peer_pid_off = reader
204                    .symbols()
205                    .field_offset("sock_common", "skc_peer_pid")
206                    .unwrap_or(0);
207                let pid_nr_off = reader.symbols().field_offset("pid", "nr").unwrap_or(0);
208                if skc_peer_pid_off == 0 || pid_nr_off == 0 {
209                    0
210                } else {
211                    let pid_ptr = reader
212                        .read_bytes(sock_addr + skc_peer_pid_off, 8)
213                        .ok()
214                        .and_then(|b| Some(u64::from_le_bytes(b[..8].try_into().ok()?)))
215                        .unwrap_or(0);
216                    if pid_ptr == 0 {
217                        0
218                    } else {
219                        reader
220                            .read_bytes(pid_ptr + pid_nr_off, 4)
221                            .ok()
222                            .and_then(|b| Some(u32::from_le_bytes(b[..4].try_into().ok()?)))
223                            .unwrap_or(0)
224                    }
225                }
226            };
227
228            let is_suspicious = classify_unix_socket(&path, owner_pid);
229
230            results.push(UnixSocketInfo {
231                inode,
232                path,
233                socket_type: socket_type_name(sk_type).to_string(),
234                state: state_str,
235                owner_pid,
236                peer_pid: 0,
237                is_suspicious,
238            });
239
240            node = next;
241        }
242    }
243
244    Ok(results)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn socket_type_stream() {
253        assert_eq!(socket_type_name(1), "STREAM");
254    }
255
256    #[test]
257    fn socket_type_dgram() {
258        assert_eq!(socket_type_name(2), "DGRAM");
259    }
260
261    #[test]
262    fn socket_type_seqpacket() {
263        assert_eq!(socket_type_name(5), "SEQPACKET");
264    }
265
266    #[test]
267    fn socket_type_unknown() {
268        assert_eq!(socket_type_name(0), "UNKNOWN");
269        assert_eq!(socket_type_name(3), "UNKNOWN");
270        assert_eq!(socket_type_name(99), "UNKNOWN");
271    }
272
273    #[test]
274    fn classify_abstract_socket_high_pid_is_suspicious() {
275        // Abstract socket (empty path) with non-system PID
276        assert!(classify_unix_socket("", 1000));
277        assert!(classify_unix_socket("", 31337));
278        // Abstract socket with @ prefix
279        assert!(classify_unix_socket("@hidden_channel", 2000));
280    }
281
282    #[test]
283    fn classify_abstract_socket_system_pid_not_suspicious() {
284        // Abstract socket with system PID (< 1000) is not suspicious on its own
285        assert!(!classify_unix_socket("", 0));
286        assert!(!classify_unix_socket("", 1));
287        assert!(!classify_unix_socket("", 999));
288        assert!(!classify_unix_socket("@/org/freedesktop/systemd1", 1));
289    }
290
291    #[test]
292    fn classify_tmp_socket_always_suspicious() {
293        // Sockets in /tmp are always suspicious regardless of PID
294        assert!(classify_unix_socket("/tmp/hidden.sock", 0));
295        assert!(classify_unix_socket("/tmp/hidden.sock", 1));
296        assert!(classify_unix_socket("/tmp/.X11-unix/X0", 500));
297    }
298
299    #[test]
300    fn classify_dev_shm_socket_always_suspicious() {
301        // Sockets in /dev/shm are always suspicious regardless of PID
302        assert!(classify_unix_socket("/dev/shm/malware.sock", 0));
303        assert!(classify_unix_socket("/dev/shm/c2_channel", 2000));
304    }
305
306    #[test]
307    fn classify_normal_socket_not_suspicious() {
308        // Normal filesystem sockets in standard locations
309        assert!(!classify_unix_socket("/var/run/dbus/system_bus_socket", 1));
310        assert!(!classify_unix_socket("/run/systemd/journal/socket", 500));
311        assert!(!classify_unix_socket("/var/lib/mysql/mysql.sock", 999));
312    }
313
314    #[test]
315    fn unix_socket_info_is_serializable() {
316        let info = UnixSocketInfo {
317            inode: 12345,
318            path: "/var/run/test.sock".to_string(),
319            socket_type: "STREAM".to_string(),
320            state: "CONNECTED".to_string(),
321            owner_pid: 100,
322            peer_pid: 200,
323            is_suspicious: false,
324        };
325        let json = serde_json::to_string(&info).unwrap();
326        assert!(json.contains("\"inode\":12345"));
327        assert!(json.contains("\"path\":\"/var/run/test.sock\""));
328        assert!(json.contains("\"is_suspicious\":false"));
329    }
330
331    #[test]
332    fn unix_socket_info_clone_and_debug() {
333        let info = UnixSocketInfo {
334            inode: 1,
335            path: "@abstract".to_string(),
336            socket_type: "DGRAM".to_string(),
337            state: "UNCONNECTED".to_string(),
338            owner_pid: 0,
339            peer_pid: 0,
340            is_suspicious: true,
341        };
342        let cloned = info.clone();
343        assert_eq!(cloned.inode, 1);
344        let dbg = format!("{cloned:?}");
345        assert!(dbg.contains("abstract"));
346    }
347
348    // -----------------------------------------------------------------------
349    // socket_type_name boundary: all possible named values
350    // -----------------------------------------------------------------------
351
352    #[test]
353    fn socket_type_all_named() {
354        assert_eq!(socket_type_name(1), "STREAM");
355        assert_eq!(socket_type_name(2), "DGRAM");
356        assert_eq!(socket_type_name(5), "SEQPACKET");
357        // All other values → UNKNOWN
358        assert_eq!(socket_type_name(4), "UNKNOWN");
359        assert_eq!(socket_type_name(u32::MAX), "UNKNOWN");
360    }
361
362    // -----------------------------------------------------------------------
363    // classify_unix_socket: edge cases
364    // -----------------------------------------------------------------------
365
366    #[test]
367    fn classify_abstract_pid_boundary() {
368        // pid=999 is just below threshold → not suspicious (abstract path)
369        assert!(!classify_unix_socket("", 999));
370        // pid=1000 is at threshold → suspicious
371        assert!(classify_unix_socket("", 1000));
372        // pid=1001 → suspicious
373        assert!(classify_unix_socket("", 1001));
374    }
375
376    #[test]
377    fn classify_at_prefix_with_system_pid_not_suspicious() {
378        // @ prefix, system PID → not suspicious
379        assert!(!classify_unix_socket("@/org/freedesktop/systemd1", 999));
380    }
381
382    #[test]
383    fn classify_dev_shm_prefix_match() {
384        // Exact prefix match /dev/shm
385        assert!(classify_unix_socket("/dev/shm", 0));
386        // Path that starts with /dev/shm/ but is longer
387        assert!(classify_unix_socket("/dev/shm/nested/path.sock", 0));
388    }
389
390    #[test]
391    fn classify_tmp_prefix_exact() {
392        // /tmp itself (edge: path == /tmp prefix)
393        assert!(classify_unix_socket("/tmp", 0));
394    }
395
396    #[test]
397    fn classify_normal_non_suspicious_paths() {
398        assert!(!classify_unix_socket("/run/user/1000/pulse/native", 1000));
399        assert!(!classify_unix_socket("/var/run/docker.sock", 0));
400        assert!(!classify_unix_socket("/run/systemd/private/tmp-sock", 0));
401    }
402
403    // -----------------------------------------------------------------------
404    // walk_unix_sockets: no symbol → empty Vec
405    // -----------------------------------------------------------------------
406
407    #[test]
408    fn walk_unix_sockets_no_symbol_returns_empty() {
409        use memf_core::test_builders::{PageTableBuilder, SyntheticPhysMem};
410        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
411        use memf_symbols::isf::IsfResolver;
412        use memf_symbols::test_builders::IsfBuilder;
413
414        let isf = IsfBuilder::new().build_json();
415        let resolver = IsfResolver::from_value(&isf).unwrap();
416        let (cr3, mem) = PageTableBuilder::new().build();
417        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
418        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
419
420        let result = walk_unix_sockets(&reader).unwrap();
421        assert!(
422            result.is_empty(),
423            "missing unix_socket_table symbol must yield empty vec"
424        );
425    }
426
427    // --- walk_unix_sockets: symbol present, all 256 buckets are zero → exercises loop body ---
428    // Exercises the hash-table scanning loop: each bucket's first pointer is 0 → no sockets.
429    #[test]
430    fn walk_unix_sockets_symbol_present_empty_buckets_returns_empty() {
431        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
432        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
433        use memf_symbols::isf::IsfResolver;
434        use memf_symbols::test_builders::IsfBuilder;
435
436        // unix_socket_table is an array of 256 hlist_head (each 8 bytes = 2048 bytes total).
437        // All zeros → every bucket's first pointer is 0 → no sockets enumerated.
438        // We need two 4K pages to cover: page 0 (buckets 0–511 bytes fit in 4K).
439        let table_vaddr: u64 = 0xFFFF_8800_0070_0000;
440        let table_paddr: u64 = 0x0070_0000; // unique, < 16 MB
441
442        let isf = IsfBuilder::new()
443            .add_symbol("unix_socket_table", table_vaddr)
444            .build_json();
445        let resolver = IsfResolver::from_value(&isf).unwrap();
446
447        // All-zero page → all 256 bucket pointers are 0.
448        let page = [0u8; 4096];
449
450        let (cr3, mem) = PageTableBuilder::new()
451            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
452            .write_phys(table_paddr, &page)
453            .build();
454
455        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
456        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
457
458        let result = walk_unix_sockets(&reader).unwrap();
459        assert!(
460            result.is_empty(),
461            "all-zero hash buckets → no unix sockets found"
462        );
463    }
464
465    // --- walk_unix_sockets: bucket[0] has a valid non-zero hlist node that self-terminates ---
466    // Exercises the inner while loop: node != 0, hlist next == 0 → one iteration → one socket.
467    #[test]
468    fn walk_unix_sockets_single_node_one_entry() {
469        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
470        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
471        use memf_symbols::isf::IsfResolver;
472        use memf_symbols::test_builders::IsfBuilder;
473
474        // Layout:
475        //   table_vaddr  = unix_socket_table (256 buckets × 8 bytes = 2 KB, fits in 4K page)
476        //   node_vaddr   = the single unix_sock entry
477        //
478        // The walker reads:
479        //   bucket[0] = node_vaddr              (first hlist node)
480        //   node[0..8] = next pointer = 0       (terminates the hlist)
481        //   node[sk_type_off..+2] = 1 (STREAM)
482        //   node[sk_state_off..+1] = 3 (CONNECTED)
483        //   node[unix_addr_off..+8] = 0         (no path → empty string)
484        //   node[sk_socket_off..+8] = 0         (no socket → inode = 0)
485        //
486        // With an empty path and owner_pid=0 → classify_unix_socket("", 0) = false.
487
488        let table_vaddr: u64 = 0xFFFF_8800_0071_0000;
489        let table_paddr: u64 = 0x0071_0000;
490
491        let node_vaddr: u64 = 0xFFFF_8800_0072_0000;
492        let node_paddr: u64 = 0x0072_0000;
493
494        // Default offsets used by walk_unix_sockets when ISF fields are missing:
495        //   sk_type_off  = unwrap_or(0x12) = 0x12
496        //   sk_state_off = unwrap_or(0x14) = 0x14
497        //   sk_socket_off= unwrap_or(0x30) = 0x30
498        //   unix_addr_off= unwrap_or(0x288)= 0x288
499        let sk_type_off: usize = 0x12;
500        let sk_state_off: usize = 0x14;
501        // unix_addr_off = 0x288 — leave as zero (addr_ptr=0 → path="")
502        // sk_socket_off = 0x30  — leave as zero (socket_ptr=0 → inode=0)
503
504        // Build the hash-table page: bucket[0] = node_vaddr; rest = 0.
505        let mut table_page = [0u8; 4096];
506        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
507
508        // Build the node page.
509        let mut node_page = [0u8; 4096];
510        // hlist next (offset 0) = 0 → terminates after one iteration
511        node_page[0..8].copy_from_slice(&0u64.to_le_bytes());
512        // sk_type = 1 (STREAM)
513        node_page[sk_type_off..sk_type_off + 2].copy_from_slice(&1u16.to_le_bytes());
514        // sk_state = 3 (CONNECTED)
515        node_page[sk_state_off] = 3u8;
516        // unix_addr_off at 0x288 — bytes remain 0 (addr_ptr=0 → path="")
517        // sk_socket_off at 0x30 — bytes remain 0 (socket_ptr=0 → inode=0)
518
519        let isf = IsfBuilder::new()
520            .add_symbol("unix_socket_table", table_vaddr)
521            .build_json();
522        let resolver = IsfResolver::from_value(&isf).unwrap();
523
524        let (cr3, mem) = PageTableBuilder::new()
525            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
526            .write_phys(table_paddr, &table_page)
527            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
528            .write_phys(node_paddr, &node_page)
529            .build();
530
531        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
532        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
533
534        let result = walk_unix_sockets(&reader).unwrap();
535        assert_eq!(
536            result.len(),
537            1,
538            "one hlist node → exactly one unix socket entry"
539        );
540        assert_eq!(result[0].socket_type, "STREAM");
541        assert_eq!(result[0].state, "CONNECTED");
542        assert_eq!(result[0].inode, 0);
543        assert!(result[0].path.is_empty());
544        assert!(
545            !result[0].is_suspicious,
546            "empty path + pid=0 must not be suspicious"
547        );
548    }
549
550    // --- walk_unix_sockets: node with abstract path (@name) is classified correctly ---
551    #[test]
552    fn walk_unix_sockets_node_with_abstract_path_high_pid() {
553        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
554        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
555        use memf_symbols::isf::IsfResolver;
556        use memf_symbols::test_builders::IsfBuilder;
557
558        // The walker uses classify_unix_socket(&path, 0) internally — owner_pid is always 0
559        // at this point in the code. So an abstract path with pid=0 is NOT suspicious.
560        // We test that the abstract-path decoding (first byte == 0 → '@' prefix) works.
561
562        let table_vaddr: u64 = 0xFFFF_8800_0073_0000;
563        let table_paddr: u64 = 0x0073_0000;
564
565        let node_vaddr: u64 = 0xFFFF_8800_0074_0000;
566        let node_paddr: u64 = 0x0074_0000;
567
568        // unix_address struct at addr_ptr: 2 bytes sa_family + sun_path
569        let addr_vaddr: u64 = 0xFFFF_8800_0075_0000;
570        let addr_paddr: u64 = 0x0075_0000;
571
572        let unix_addr_off: usize = 0x288; // default used by walker
573
574        let mut table_page = [0u8; 4096];
575        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
576
577        let mut node_page = [0u8; 4096];
578        // hlist next = 0
579        node_page[0..8].copy_from_slice(&0u64.to_le_bytes());
580        // sk_type = 2 (DGRAM) at 0x12
581        node_page[0x12..0x14].copy_from_slice(&2u16.to_le_bytes());
582        // sk_state = 1 (UNCONNECTED) at 0x14
583        node_page[0x14] = 1u8;
584        // unix_addr pointer at unix_addr_off = addr_vaddr
585        node_page[unix_addr_off..unix_addr_off + 8].copy_from_slice(&addr_vaddr.to_le_bytes());
586
587        // addr page: sun_path_off = 2; first byte of sun_path = 0 (abstract), then "hidden\0"
588        let mut addr_page = [0u8; 4096];
589        // sa_family at [0..2], sun_path at [2..]:
590        // sun_path[0] = 0 → abstract; "hidden" as inner name
591        addr_page[2] = 0u8; // abstract marker
592        addr_page[3..9].copy_from_slice(b"hidden");
593        addr_page[9] = 0u8;
594
595        let isf = IsfBuilder::new()
596            .add_symbol("unix_socket_table", table_vaddr)
597            .build_json();
598        let resolver = IsfResolver::from_value(&isf).unwrap();
599
600        let (cr3, mem) = PageTableBuilder::new()
601            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
602            .write_phys(table_paddr, &table_page)
603            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
604            .write_phys(node_paddr, &node_page)
605            .map_4k(addr_vaddr, addr_paddr, ptf::WRITABLE)
606            .write_phys(addr_paddr, &addr_page)
607            .build();
608
609        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
610        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
611
612        let result = walk_unix_sockets(&reader).unwrap();
613        assert_eq!(result.len(), 1, "one node → one entry");
614        assert_eq!(
615            result[0].path, "@hidden",
616            "abstract path must be decoded as @<name>"
617        );
618        assert_eq!(result[0].socket_type, "DGRAM");
619        assert_eq!(result[0].state, "UNCONNECTED");
620        // classify_unix_socket("@hidden", 0) → is_abstract=true, owner_pid=0 < 1000 → false
621        assert!(
622            !result[0].is_suspicious,
623            "abstract path with pid=0 is not suspicious"
624        );
625    }
626
627    // --- walk_unix_sockets: cycle detection via seen set ---
628    // Two nodes that point to each other → cycle detected → second iteration breaks.
629    #[test]
630    fn walk_unix_sockets_cycle_detected_breaks() {
631        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
632        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
633        use memf_symbols::isf::IsfResolver;
634        use memf_symbols::test_builders::IsfBuilder;
635
636        let table_vaddr: u64 = 0xFFFF_8800_0076_0000;
637        let table_paddr: u64 = 0x0076_0000;
638
639        // Two nodes: nodeA.next = nodeB, nodeB.next = nodeA (cycle)
640        let node_a_vaddr: u64 = 0xFFFF_8800_0077_0000;
641        let node_a_paddr: u64 = 0x0077_0000;
642
643        let node_b_vaddr: u64 = 0xFFFF_8800_0078_0000;
644        let node_b_paddr: u64 = 0x0078_0000;
645
646        let mut table_page = [0u8; 4096];
647        table_page[0..8].copy_from_slice(&node_a_vaddr.to_le_bytes());
648
649        // nodeA: next = node_b_vaddr; sk_type = 1 (STREAM); sk_state = 1 (UNCONNECTED)
650        let mut node_a_page = [0u8; 4096];
651        node_a_page[0..8].copy_from_slice(&node_b_vaddr.to_le_bytes());
652        node_a_page[0x12..0x14].copy_from_slice(&1u16.to_le_bytes());
653        node_a_page[0x14] = 1u8;
654
655        // nodeB: next = node_a_vaddr (cycle!); sk_type = 2 (DGRAM); sk_state = 1
656        let mut node_b_page = [0u8; 4096];
657        node_b_page[0..8].copy_from_slice(&node_a_vaddr.to_le_bytes());
658        node_b_page[0x12..0x14].copy_from_slice(&2u16.to_le_bytes());
659        node_b_page[0x14] = 1u8;
660
661        let isf = IsfBuilder::new()
662            .add_symbol("unix_socket_table", table_vaddr)
663            .build_json();
664        let resolver = IsfResolver::from_value(&isf).unwrap();
665
666        let (cr3, mem) = PageTableBuilder::new()
667            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
668            .write_phys(table_paddr, &table_page)
669            .map_4k(node_a_vaddr, node_a_paddr, ptf::WRITABLE)
670            .write_phys(node_a_paddr, &node_a_page)
671            .map_4k(node_b_vaddr, node_b_paddr, ptf::WRITABLE)
672            .write_phys(node_b_paddr, &node_b_page)
673            .build();
674
675        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
676        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
677
678        let result = walk_unix_sockets(&reader).unwrap();
679        // Should get exactly 2 entries (nodeA + nodeB), then cycle detected → stop
680        assert_eq!(
681            result.len(),
682            2,
683            "cycle detected after 2 unique nodes → exactly 2 entries"
684        );
685    }
686
687    // --- walk_unix_sockets: sk_state unknown value → state = "UNKNOWN" ---
688    #[test]
689    fn walk_unix_sockets_unknown_sk_state() {
690        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
691        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
692        use memf_symbols::isf::IsfResolver;
693        use memf_symbols::test_builders::IsfBuilder;
694
695        let table_vaddr: u64 = 0xFFFF_8800_0079_0000;
696        let table_paddr: u64 = 0x0079_0000;
697
698        let node_vaddr: u64 = 0xFFFF_8800_007A_0000;
699        let node_paddr: u64 = 0x007A_0000;
700
701        let mut table_page = [0u8; 4096];
702        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
703
704        let mut node_page = [0u8; 4096];
705        node_page[0..8].copy_from_slice(&0u64.to_le_bytes()); // next = 0
706        node_page[0x12..0x14].copy_from_slice(&5u16.to_le_bytes()); // SEQPACKET
707        node_page[0x14] = 99u8; // unknown sk_state → "UNKNOWN"
708
709        let isf = IsfBuilder::new()
710            .add_symbol("unix_socket_table", table_vaddr)
711            .build_json();
712        let resolver = IsfResolver::from_value(&isf).unwrap();
713
714        let (cr3, mem) = PageTableBuilder::new()
715            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
716            .write_phys(table_paddr, &table_page)
717            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
718            .write_phys(node_paddr, &node_page)
719            .build();
720
721        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
722        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
723
724        let result = walk_unix_sockets(&reader).unwrap();
725        assert_eq!(result.len(), 1);
726        assert_eq!(result[0].state, "UNKNOWN");
727        assert_eq!(result[0].socket_type, "SEQPACKET");
728    }
729
730    // --- walk_unix_sockets: sk_state == 2 (CONNECTING) ---
731    #[test]
732    fn walk_unix_sockets_connecting_state() {
733        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
734        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
735        use memf_symbols::isf::IsfResolver;
736        use memf_symbols::test_builders::IsfBuilder;
737
738        let table_vaddr: u64 = 0xFFFF_8800_007B_0000;
739        let table_paddr: u64 = 0x007B_0000;
740        let node_vaddr: u64 = 0xFFFF_8800_007C_0000;
741        let node_paddr: u64 = 0x007C_0000;
742
743        let mut table_page = [0u8; 4096];
744        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
745
746        let mut node_page = [0u8; 4096];
747        node_page[0..8].copy_from_slice(&0u64.to_le_bytes()); // next = 0
748        node_page[0x12..0x14].copy_from_slice(&1u16.to_le_bytes()); // STREAM
749        node_page[0x14] = 2u8; // CONNECTING
750
751        let isf = IsfBuilder::new()
752            .add_symbol("unix_socket_table", table_vaddr)
753            .build_json();
754        let resolver = IsfResolver::from_value(&isf).unwrap();
755
756        let (cr3, mem) = PageTableBuilder::new()
757            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
758            .write_phys(table_paddr, &table_page)
759            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
760            .write_phys(node_paddr, &node_page)
761            .build();
762
763        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
764        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
765
766        let result = walk_unix_sockets(&reader).unwrap();
767        assert_eq!(result.len(), 1);
768        assert_eq!(result[0].state, "CONNECTING");
769    }
770
771    // --- walk_unix_sockets: sk_state == 4 (DISCONNECTING) ---
772    #[test]
773    fn walk_unix_sockets_disconnecting_state() {
774        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
775        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
776        use memf_symbols::isf::IsfResolver;
777        use memf_symbols::test_builders::IsfBuilder;
778
779        let table_vaddr: u64 = 0xFFFF_8800_007D_0000;
780        let table_paddr: u64 = 0x007D_0000;
781        let node_vaddr: u64 = 0xFFFF_8800_007E_0000;
782        let node_paddr: u64 = 0x007E_0000;
783
784        let mut table_page = [0u8; 4096];
785        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
786
787        let mut node_page = [0u8; 4096];
788        node_page[0..8].copy_from_slice(&0u64.to_le_bytes()); // next = 0
789        node_page[0x12..0x14].copy_from_slice(&2u16.to_le_bytes()); // DGRAM
790        node_page[0x14] = 4u8; // DISCONNECTING
791
792        let isf = IsfBuilder::new()
793            .add_symbol("unix_socket_table", table_vaddr)
794            .build_json();
795        let resolver = IsfResolver::from_value(&isf).unwrap();
796
797        let (cr3, mem) = PageTableBuilder::new()
798            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
799            .write_phys(table_paddr, &table_page)
800            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
801            .write_phys(node_paddr, &node_page)
802            .build();
803
804        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
805        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
806
807        let result = walk_unix_sockets(&reader).unwrap();
808        assert_eq!(result.len(), 1);
809        assert_eq!(result[0].state, "DISCONNECTING");
810    }
811
812    // --- walk_unix_sockets: filesystem path (non-abstract, first byte != 0) ---
813    #[test]
814    fn walk_unix_sockets_filesystem_path_decoded() {
815        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
816        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
817        use memf_symbols::isf::IsfResolver;
818        use memf_symbols::test_builders::IsfBuilder;
819
820        // Like abstract test but sun_path first byte is '/' (non-abstract → filesystem path).
821        let table_vaddr: u64 = 0xFFFF_8800_0080_0000;
822        let table_paddr: u64 = 0x0080_1000; // offset within page to avoid collision
823        let node_vaddr: u64 = 0xFFFF_8800_0081_0000;
824        let node_paddr: u64 = 0x0081_0000;
825        let addr_vaddr: u64 = 0xFFFF_8800_0082_0000;
826        let addr_paddr: u64 = 0x0082_0000;
827
828        let unix_addr_off: usize = 0x288;
829
830        let mut table_page = [0u8; 4096];
831        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
832
833        let mut node_page = [0u8; 4096];
834        node_page[0..8].copy_from_slice(&0u64.to_le_bytes()); // next = 0
835        node_page[0x12..0x14].copy_from_slice(&1u16.to_le_bytes()); // STREAM
836        node_page[0x14] = 3u8; // CONNECTED
837        node_page[unix_addr_off..unix_addr_off + 8].copy_from_slice(&addr_vaddr.to_le_bytes());
838
839        // addr page: sa_family at [0..2], sun_path at [2..]:
840        // first byte of sun_path is '/' → filesystem path
841        let mut addr_page = [0u8; 4096];
842        let path_bytes = b"/var/run/test.sock\0";
843        addr_page[2..2 + path_bytes.len()].copy_from_slice(path_bytes);
844
845        let isf = IsfBuilder::new()
846            .add_symbol("unix_socket_table", table_vaddr)
847            .build_json();
848        let resolver = IsfResolver::from_value(&isf).unwrap();
849
850        let (cr3, mem) = PageTableBuilder::new()
851            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
852            .write_phys(table_paddr, &table_page)
853            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
854            .write_phys(node_paddr, &node_page)
855            .map_4k(addr_vaddr, addr_paddr, ptf::WRITABLE)
856            .write_phys(addr_paddr, &addr_page)
857            .build();
858
859        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
860        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
861
862        let result = walk_unix_sockets(&reader).unwrap();
863        assert_eq!(result.len(), 1);
864        assert_eq!(result[0].path, "/var/run/test.sock");
865        assert_eq!(result[0].state, "CONNECTED");
866        assert!(
867            !result[0].is_suspicious,
868            "/var/run/ path should not be suspicious"
869        );
870    }
871
872    // --- walk_unix_sockets: abstract socket with empty inner name → path="" ---
873    #[test]
874    fn walk_unix_sockets_abstract_empty_inner_returns_empty_path() {
875        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
876        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
877        use memf_symbols::isf::IsfResolver;
878        use memf_symbols::test_builders::IsfBuilder;
879
880        let table_vaddr: u64 = 0xFFFF_8800_0083_0000;
881        let table_paddr: u64 = 0x0083_1000;
882        let node_vaddr: u64 = 0xFFFF_8800_0084_0000;
883        let node_paddr: u64 = 0x0084_0000;
884        let addr_vaddr: u64 = 0xFFFF_8800_0085_0000;
885        let addr_paddr: u64 = 0x0085_0000;
886
887        let unix_addr_off: usize = 0x288;
888
889        let mut table_page = [0u8; 4096];
890        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
891
892        let mut node_page = [0u8; 4096];
893        node_page[0..8].copy_from_slice(&0u64.to_le_bytes());
894        node_page[0x12..0x14].copy_from_slice(&1u16.to_le_bytes());
895        node_page[0x14] = 1u8; // UNCONNECTED
896        node_page[unix_addr_off..unix_addr_off + 8].copy_from_slice(&addr_vaddr.to_le_bytes());
897
898        // addr page: sun_path[0]=0 (abstract), sun_path[1]=0 → inner is empty → path=""
899        let mut addr_page = [0u8; 4096];
900        addr_page[2] = 0u8; // abstract marker
901        addr_page[3] = 0u8; // immediately null-terminated → empty inner
902
903        let isf = IsfBuilder::new()
904            .add_symbol("unix_socket_table", table_vaddr)
905            .build_json();
906        let resolver = IsfResolver::from_value(&isf).unwrap();
907
908        let (cr3, mem) = PageTableBuilder::new()
909            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
910            .write_phys(table_paddr, &table_page)
911            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
912            .write_phys(node_paddr, &node_page)
913            .map_4k(addr_vaddr, addr_paddr, ptf::WRITABLE)
914            .write_phys(addr_paddr, &addr_page)
915            .build();
916
917        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
918        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
919
920        let result = walk_unix_sockets(&reader).unwrap();
921        assert_eq!(result.len(), 1);
922        // Abstract with empty inner → path=""
923        assert!(
924            result[0].path.is_empty(),
925            "abstract with empty inner name must produce empty path"
926        );
927    }
928
929    // --- walk_unix_sockets: owner_pid resolved from sk_peer_pid chain ---
930    // ISF supplies skc_peer_pid offset on sock_common, and nr offset on pid.
931    // Walker reads sock_common.skc_peer_pid (a pointer), then pid.numbers[0].nr (u32).
932    #[test]
933    fn owner_pid_resolved_from_sk_peer_pid() {
934        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
935        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
936        use memf_symbols::isf::IsfResolver;
937        use memf_symbols::test_builders::IsfBuilder;
938
939        // Layout:
940        //   table page  → bucket[0] = node_vaddr
941        //   node page   → unix_sock (sock_common.skc_peer_pid at offset 0x40 = pid_vaddr)
942        //   pid page    → struct pid (numbers[0].nr at offset 0x20 = 1234u32)
943        //
944        // ISF provides:
945        //   sock_common.skc_peer_pid at offset 0x40
946        //   pid.nr at offset 0x20  (represents numbers[0].nr flattened)
947
948        let table_vaddr: u64 = 0xFFFF_8800_0090_0000;
949        let table_paddr: u64 = 0x0090_0000;
950        let node_vaddr: u64 = 0xFFFF_8800_0091_0000;
951        let node_paddr: u64 = 0x0091_0000;
952        let pid_vaddr: u64 = 0xFFFF_8800_0092_0000;
953        let pid_paddr: u64 = 0x0092_0000;
954
955        // Chosen ISF-driven offsets (will be returned by field_offset calls):
956        let skc_peer_pid_off: usize = 0x40; // sock_common.skc_peer_pid
957        let pid_nr_off: usize = 0x20; // pid.numbers[0].nr
958
959        let mut table_page = [0u8; 4096];
960        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
961
962        let mut node_page = [0u8; 4096];
963        // hlist next = 0 (terminates immediately)
964        node_page[0..8].copy_from_slice(&0u64.to_le_bytes());
965        // sk_type = 1 (STREAM) at default 0x12
966        node_page[0x12..0x14].copy_from_slice(&1u16.to_le_bytes());
967        // sk_state = 3 (CONNECTED) at default 0x14
968        node_page[0x14] = 3u8;
969        // sock_common.skc_peer_pid pointer at skc_peer_pid_off → pid_vaddr
970        node_page[skc_peer_pid_off..skc_peer_pid_off + 8].copy_from_slice(&pid_vaddr.to_le_bytes());
971
972        let mut pid_page = [0u8; 4096];
973        // pid.numbers[0].nr = 1234 at pid_nr_off
974        pid_page[pid_nr_off..pid_nr_off + 4].copy_from_slice(&1234u32.to_le_bytes());
975
976        let isf = IsfBuilder::new()
977            .add_symbol("unix_socket_table", table_vaddr)
978            // sock_common struct with skc_peer_pid field
979            .add_struct("sock_common", 0x80)
980            .add_field(
981                "sock_common",
982                "skc_peer_pid",
983                skc_peer_pid_off as u64,
984                "pointer",
985            )
986            // pid struct with nr field (represents numbers[0].nr)
987            .add_struct("pid", 0x60)
988            .add_field("pid", "nr", pid_nr_off as u64, "unsigned int")
989            .build_json();
990        let resolver = IsfResolver::from_value(&isf).unwrap();
991
992        let (cr3, mem) = PageTableBuilder::new()
993            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
994            .write_phys(table_paddr, &table_page)
995            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
996            .write_phys(node_paddr, &node_page)
997            .map_4k(pid_vaddr, pid_paddr, ptf::WRITABLE)
998            .write_phys(pid_paddr, &pid_page)
999            .build();
1000
1001        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
1002        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
1003
1004        let result = walk_unix_sockets(&reader).unwrap();
1005        assert_eq!(result.len(), 1, "expected exactly one socket entry");
1006        assert_eq!(
1007            result[0].owner_pid, 1234,
1008            "owner_pid must be resolved from pid.numbers[0].nr via skc_peer_pid chain"
1009        );
1010    }
1011
1012    // --- walk_unix_sockets: skc_peer_pid == 0 → owner_pid stays 0 ---
1013    #[test]
1014    fn owner_pid_zero_when_peer_pid_null() {
1015        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
1016        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
1017        use memf_symbols::isf::IsfResolver;
1018        use memf_symbols::test_builders::IsfBuilder;
1019
1020        // ISF provides skc_peer_pid and pid.nr, but skc_peer_pid value is 0.
1021        // Walker must leave owner_pid = 0 when the pointer is null.
1022
1023        let table_vaddr: u64 = 0xFFFF_8800_0093_0000;
1024        let table_paddr: u64 = 0x0093_0000;
1025        let node_vaddr: u64 = 0xFFFF_8800_0094_0000;
1026        let node_paddr: u64 = 0x0094_0000;
1027
1028        let skc_peer_pid_off: usize = 0x40;
1029
1030        let mut table_page = [0u8; 4096];
1031        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
1032
1033        let mut node_page = [0u8; 4096];
1034        node_page[0..8].copy_from_slice(&0u64.to_le_bytes()); // hlist next = 0
1035        node_page[0x12..0x14].copy_from_slice(&1u16.to_le_bytes()); // STREAM
1036        node_page[0x14] = 1u8; // UNCONNECTED
1037                               // skc_peer_pid at skc_peer_pid_off = 0 (null pointer)
1038        node_page[skc_peer_pid_off..skc_peer_pid_off + 8].copy_from_slice(&0u64.to_le_bytes());
1039
1040        let isf = IsfBuilder::new()
1041            .add_symbol("unix_socket_table", table_vaddr)
1042            .add_struct("sock_common", 0x80)
1043            .add_field(
1044                "sock_common",
1045                "skc_peer_pid",
1046                skc_peer_pid_off as u64,
1047                "pointer",
1048            )
1049            .add_struct("pid", 0x60)
1050            .add_field("pid", "nr", 0x20u64, "unsigned int")
1051            .build_json();
1052        let resolver = IsfResolver::from_value(&isf).unwrap();
1053
1054        let (cr3, mem) = PageTableBuilder::new()
1055            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
1056            .write_phys(table_paddr, &table_page)
1057            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
1058            .write_phys(node_paddr, &node_page)
1059            .build();
1060
1061        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
1062        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
1063
1064        let result = walk_unix_sockets(&reader).unwrap();
1065        assert_eq!(result.len(), 1, "expected exactly one socket entry");
1066        assert_eq!(
1067            result[0].owner_pid, 0,
1068            "owner_pid must remain 0 when skc_peer_pid is null"
1069        );
1070    }
1071
1072    // --- walk_unix_sockets: sk_socket non-null → inode read from socket+0x18 ---
1073    #[test]
1074    fn walk_unix_sockets_non_null_sk_socket_reads_inode() {
1075        use memf_core::test_builders::{flags as ptf, PageTableBuilder, SyntheticPhysMem};
1076        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
1077        use memf_symbols::isf::IsfResolver;
1078        use memf_symbols::test_builders::IsfBuilder;
1079
1080        let table_vaddr: u64 = 0xFFFF_8800_0086_0000;
1081        let table_paddr: u64 = 0x0086_0000;
1082        let node_vaddr: u64 = 0xFFFF_8800_0087_0000;
1083        let node_paddr: u64 = 0x0087_0000;
1084        let socket_vaddr: u64 = 0xFFFF_8800_0088_0000;
1085        let socket_paddr: u64 = 0x0088_0000;
1086
1087        let sk_socket_off: usize = 0x30; // default used by walker
1088
1089        let mut table_page = [0u8; 4096];
1090        table_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
1091
1092        let mut node_page = [0u8; 4096];
1093        node_page[0..8].copy_from_slice(&0u64.to_le_bytes()); // next = 0
1094        node_page[0x12..0x14].copy_from_slice(&1u16.to_le_bytes()); // STREAM
1095        node_page[0x14] = 3u8; // CONNECTED
1096                               // sk_socket at 0x30 → socket_vaddr
1097        node_page[sk_socket_off..sk_socket_off + 8].copy_from_slice(&socket_vaddr.to_le_bytes());
1098
1099        // socket page: inode at +0x18 = 99999
1100        let mut socket_page = [0u8; 4096];
1101        socket_page[0x18..0x20].copy_from_slice(&99999u64.to_le_bytes());
1102
1103        let isf = IsfBuilder::new()
1104            .add_symbol("unix_socket_table", table_vaddr)
1105            .build_json();
1106        let resolver = IsfResolver::from_value(&isf).unwrap();
1107
1108        let (cr3, mem) = PageTableBuilder::new()
1109            .map_4k(table_vaddr, table_paddr, ptf::WRITABLE)
1110            .write_phys(table_paddr, &table_page)
1111            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
1112            .write_phys(node_paddr, &node_page)
1113            .map_4k(socket_vaddr, socket_paddr, ptf::WRITABLE)
1114            .write_phys(socket_paddr, &socket_page)
1115            .build();
1116
1117        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
1118        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
1119
1120        let result = walk_unix_sockets(&reader).unwrap();
1121        assert_eq!(result.len(), 1);
1122        assert_eq!(
1123            result[0].inode, 99999,
1124            "inode must be read from socket+0x18"
1125        );
1126    }
1127}