Skip to main content

ghostscope_process/
pinned_bpf_maps.rs

1use crate::pid::{
2    host_pid_for_proc_pid, read_nspid_chain, read_pid_ns_inode, INITIAL_PID_NAMESPACE_INO,
3};
4use aya::maps::MapData;
5use aya_obj::maps::bpf_map_def;
6use aya_obj::{
7    generated::bpf_map_type::BPF_MAP_TYPE_HASH, maps::LegacyMap, EbpfSectionKind, Map as ObjMap,
8};
9use libc as c;
10use std::io;
11use std::os::fd::{AsFd, AsRawFd};
12use std::path::{Path, PathBuf};
13use tracing::{info, warn};
14
15const BPFFS_MOUNT_POINT: &str = "/sys/fs/bpf";
16const BPFFS_ROOT: &str = "/sys/fs/bpf/ghostscope";
17const PROC_STAT_STARTTIME_INDEX: usize = 19;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20struct CurrentProcessIdentity {
21    host_pid: u32,
22    host_pid_reliable: bool,
23    starttime: u64,
24    initial_pid_namespace: bool,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum BpffsPruneMode {
29    Stale,
30    Instance(String),
31    All,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct BpffsPruneOptions {
36    pub mode: BpffsPruneMode,
37    pub dry_run: bool,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum BpffsPruneStatus {
42    RemoveDir,
43    CleanKnownPins,
44    SkipLive,
45    Ignore,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct BpffsPruneEntry {
50    pub directory: String,
51    pub status: BpffsPruneStatus,
52    pub reason: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct BpffsPruneReport {
57    pub root: PathBuf,
58    pub dry_run: bool,
59    pub entries: Vec<BpffsPruneEntry>,
60}
61
62fn process_starttime(pid: u32) -> io::Result<u64> {
63    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?;
64    let (_, rest) = stat
65        .rsplit_once(") ")
66        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "malformed /proc stat"))?;
67    let raw = rest
68        .split_whitespace()
69        .nth(PROC_STAT_STARTTIME_INDEX)
70        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing starttime field"))?;
71    raw.parse::<u64>()
72        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
73}
74
75fn host_pid_mapping_from_chain(
76    chain: Option<&[u32]>,
77    allow_single_value_nspid: bool,
78) -> Option<u32> {
79    match chain {
80        Some([]) => None,
81        Some([only]) if allow_single_value_nspid => Some(*only),
82        Some([_only]) => None,
83        Some(values) => values.first().copied(),
84        None if allow_single_value_nspid => None,
85        None => None,
86    }
87}
88
89fn resolve_proc_pid_for_host_pid(host_pid: u32, allow_single_value_nspid: bool) -> Option<u32> {
90    let direct = Path::new("/proc").join(host_pid.to_string());
91    if direct.exists() {
92        let chain = read_nspid_chain(host_pid);
93        if host_pid_mapping_from_chain(chain.as_deref(), allow_single_value_nspid) == Some(host_pid)
94        {
95            return Some(host_pid);
96        }
97    }
98
99    let entries = std::fs::read_dir("/proc").ok()?;
100    for entry in entries.flatten() {
101        let Ok(proc_pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
102            continue;
103        };
104        let chain = read_nspid_chain(proc_pid);
105        if host_pid_mapping_from_chain(chain.as_deref(), allow_single_value_nspid) == Some(host_pid)
106        {
107            return Some(proc_pid);
108        }
109    }
110
111    None
112}
113
114fn current_process_identity() -> anyhow::Result<CurrentProcessIdentity> {
115    let proc_pid = std::process::id();
116    let initial_pid_namespace = read_pid_ns_inode(proc_pid) == Some(INITIAL_PID_NAMESPACE_INO);
117    let nspid_chain = read_nspid_chain(proc_pid);
118    let host_pid_reliable =
119        initial_pid_namespace || nspid_chain.as_ref().is_some_and(|chain| chain.len() > 1);
120    Ok(CurrentProcessIdentity {
121        host_pid: host_pid_for_proc_pid(proc_pid),
122        host_pid_reliable,
123        starttime: process_starttime(proc_pid)?,
124        initial_pid_namespace,
125    })
126}
127
128fn current_process_dir_name() -> anyhow::Result<String> {
129    let identity = current_process_identity()?;
130    Ok(format!("{}-{}", identity.host_pid, identity.starttime))
131}
132
133fn parse_pin_dir_name(name: &str) -> Option<(u32, u64)> {
134    let (pid, starttime) = name.split_once('-')?;
135    let pid = pid.parse::<u32>().ok()?;
136    let starttime = starttime.parse::<u64>().ok()?;
137    Some((pid, starttime))
138}
139
140/// Compute the bpffs pin path for the proc_module_offsets map for current process
141/// Using per-process directory avoids conflicts across multiple GhostScope instances
142pub fn proc_offsets_pin_path() -> anyhow::Result<PathBuf> {
143    Ok(PathBuf::from(format!(
144        "{BPFFS_ROOT}/{}/proc_module_offsets",
145        current_process_dir_name()?
146    )))
147}
148
149/// Pin directory containing the per-process offsets map
150pub fn proc_offsets_pin_dir() -> anyhow::Result<PathBuf> {
151    proc_offsets_pin_path()?
152        .parent()
153        .map(|p| p.to_path_buf())
154        .ok_or_else(|| anyhow::anyhow!("bpffs root has no parent for proc offsets pin path"))
155}
156
157/// Map name as embedded in BPF object
158pub const PROC_OFFSETS_MAP_NAME: &str = "proc_module_offsets";
159pub const ALLOWED_PIDS_MAP_NAME: &str = "allowed_pids";
160pub const PID_ALIASES_MAP_NAME: &str = "pid_aliases";
161
162fn bpffs_is_mounted() -> bool {
163    let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else {
164        return false;
165    };
166    mountinfo.lines().any(|line| {
167        let Some((left, right)) = line.split_once(" - ") else {
168            return false;
169        };
170        let mount_point = left.split_whitespace().nth(4);
171        let fs_type = right.split_whitespace().next();
172        mount_point == Some(BPFFS_MOUNT_POINT) && fs_type == Some("bpf")
173    })
174}
175
176fn bpffs_mount_hint_for_state(
177    pin_path: &Path,
178    bpffs_mount_point_exists: bool,
179    bpffs_mounted: bool,
180) -> Option<String> {
181    if !pin_path.starts_with(BPFFS_ROOT) {
182        return None;
183    }
184
185    if bpffs_mounted {
186        return None;
187    }
188
189    if !bpffs_mount_point_exists {
190        return Some(format!(
191            "GhostScope requires bpffs mounted at {BPFFS_MOUNT_POINT} to pin BPF maps under {BPFFS_ROOT}. That mount point does not exist. Try: `sudo mkdir -p {BPFFS_MOUNT_POINT} && sudo mount -t bpf bpf {BPFFS_MOUNT_POINT}`."
192        ));
193    }
194
195    Some(format!(
196        "GhostScope requires bpffs mounted at {BPFFS_MOUNT_POINT} to pin BPF maps under {BPFFS_ROOT}. Some systems, including WSL2 and minimal/container environments, do not mount it by default. Try: `sudo mount -t bpf bpf {BPFFS_MOUNT_POINT}` and verify with `mount | grep bpf`."
197    ))
198}
199
200pub fn bpffs_mount_hint_for_pin_path(pin_path: &Path) -> Option<String> {
201    bpffs_mount_hint_for_state(
202        pin_path,
203        Path::new(BPFFS_MOUNT_POINT).exists(),
204        bpffs_is_mounted(),
205    )
206}
207
208/// Key for proc_module_offsets map: { pid:u32, pad:u32, cookie_lo:u32, cookie_hi:u32 }
209#[repr(C)]
210#[derive(Debug, Clone, Copy)]
211pub struct ProcModuleKey {
212    pub pid: u32,
213    pub pad: u32,
214    pub cookie_lo: u32,
215    pub cookie_hi: u32,
216}
217
218/// Value for proc_module_offsets map - section offsets for a module
219#[repr(C)]
220#[derive(Debug, Clone, Copy)]
221pub struct ProcModuleOffsetsValue {
222    pub text: u64,
223    pub rodata: u64,
224    pub data: u64,
225    pub bss: u64,
226}
227
228unsafe impl aya::Pod for ProcModuleKey {}
229unsafe impl aya::Pod for ProcModuleOffsetsValue {}
230
231#[repr(C)]
232#[derive(Debug, Clone, Copy)]
233pub struct PidAliasValue {
234    pub proc_pid: u32,
235}
236
237unsafe impl aya::Pod for PidAliasValue {}
238
239impl ProcModuleOffsetsValue {
240    pub fn new(text: u64, rodata: u64, data: u64, bss: u64) -> Self {
241        Self {
242            text,
243            rodata,
244            data,
245            bss,
246        }
247    }
248}
249
250fn ensure_pin_dir(path: &Path) -> std::io::Result<()> {
251    if let Some(dir) = path.parent() {
252        std::fs::create_dir_all(dir)
253    } else {
254        Ok(())
255    }
256}
257
258/// Ensure the pinned global proc_module_offsets map exists at the standard path.
259/// If not present, create and pin it with the specified capacity.
260pub fn ensure_pinned_proc_offsets_exists(max_entries: u32) -> anyhow::Result<()> {
261    let pin_path = proc_offsets_pin_path()?;
262    // Ensure parent dir exists
263    ensure_pin_dir(&pin_path).map_err(|e| {
264        let hint = bpffs_mount_hint_for_pin_path(&pin_path)
265            .map(|hint| format!(" {hint}"))
266            .unwrap_or_default();
267        anyhow::anyhow!(
268            "Failed to create pin directory for {} at {}: {}.{}",
269            PROC_OFFSETS_MAP_NAME,
270            pin_path.display(),
271            e,
272            hint
273        )
274    })?;
275
276    // If pinned file already exists, try to reuse it directly (idempotent)
277    if pin_path.exists() {
278        if MapData::from_pin(&pin_path).is_ok() {
279            info!(
280                "Reusing existing pinned map at {} (no recreate)",
281                pin_path.display()
282            );
283            return Ok(());
284        } else {
285            // Stale/corrupted pin path, remove and recreate
286            let _ = std::fs::remove_file(&pin_path);
287        }
288    }
289
290    // Define the map as a legacy map (compatible with Aya expectations)
291    let obj_map = ObjMap::Legacy(LegacyMap {
292        section_index: 0,
293        section_kind: EbpfSectionKind::Maps,
294        symbol_index: None,
295        def: bpf_map_def {
296            map_type: BPF_MAP_TYPE_HASH as u32,
297            key_size: 16,   // pid:u32, pad:u32, cookie:u64
298            value_size: 32, // text, rodata, data, bss
299            max_entries,
300            map_flags: 0,
301            id: 0,
302            pinning: aya_obj::maps::PinningType::None,
303        },
304        data: Vec::new(),
305    });
306
307    // Create the map in kernel
308    let map = MapData::create(obj_map, PROC_OFFSETS_MAP_NAME, None)?;
309    info!(
310        "Created {} map with capacity {} entries",
311        PROC_OFFSETS_MAP_NAME, max_entries
312    );
313
314    // Pin to bpffs for global reuse; handle races safely
315    match map.pin(&pin_path) {
316        Ok(()) => {
317            info!("Pinned {} at {}", PROC_OFFSETS_MAP_NAME, pin_path.display());
318            Ok(())
319        }
320        Err(e) => {
321            // If another thread/process pinned concurrently, reuse the existing pin
322            match MapData::from_pin(&pin_path) {
323                Ok(_) => {
324                    info!(
325                        "Pin path {} already exists; reusing existing map ({}).",
326                        pin_path.display(),
327                        e
328                    );
329                    Ok(())
330                }
331                Err(_) => {
332                    // Best-effort cleanup and propagate error
333                    let _ = std::fs::remove_file(&pin_path);
334                    let hint = bpffs_mount_hint_for_pin_path(&pin_path)
335                        .map(|hint| format!(" {hint}"))
336                        .unwrap_or_default();
337                    Err(anyhow::anyhow!(
338                        "Failed to pin {} at {}: {}",
339                        PROC_OFFSETS_MAP_NAME,
340                        pin_path.display(),
341                        e
342                    )
343                    .context(format!(
344                        "Unable to persist {PROC_OFFSETS_MAP_NAME} in bpffs.{hint}"
345                    )))
346                }
347            }
348        }
349    }
350}
351
352// Low-level bpf syscall wrapper for map update (avoids tight coupling to aya map wrappers)
353#[repr(C)]
354struct BpfMapUpdateAttr {
355    map_fd: u32,
356    _pad: u32, // align to 64-bit for following fields
357    key: u64,
358    value: u64,
359    flags: u64,
360}
361
362const BPF_MAP_UPDATE_ELEM: c::c_long = 2; // from linux/bpf.h
363const BPF_MAP_DELETE_ELEM: c::c_long = 1; // from linux/bpf.h
364const BPF_MAP_GET_NEXT_KEY: c::c_long = 4; // from linux/bpf.h
365
366fn bpf_map_update_elem(
367    fd: i32,
368    key: *const c::c_void,
369    value: *const c::c_void,
370    flags: u64,
371) -> io::Result<()> {
372    let attr = BpfMapUpdateAttr {
373        map_fd: fd as u32,
374        _pad: 0,
375        key: key as usize as u64,
376        value: value as usize as u64,
377        flags,
378    };
379    let ret = unsafe {
380        c::syscall(
381            c::SYS_bpf,
382            BPF_MAP_UPDATE_ELEM,
383            &attr,
384            std::mem::size_of::<BpfMapUpdateAttr>(),
385        )
386    };
387    if ret < 0 {
388        Err(io::Error::last_os_error())
389    } else {
390        Ok(())
391    }
392}
393
394#[repr(C)]
395struct BpfMapKeyAttr {
396    map_fd: u32,
397    _pad: u32, // align to 64-bit for following fields
398    key: u64,
399    next_key: u64,
400}
401
402fn bpf_map_get_next_key(
403    fd: i32,
404    key: *const c::c_void,
405    next_key: *mut c::c_void,
406) -> io::Result<()> {
407    let attr = BpfMapKeyAttr {
408        map_fd: fd as u32,
409        _pad: 0,
410        key: key as usize as u64,
411        next_key: next_key as usize as u64,
412    };
413    let ret = unsafe {
414        c::syscall(
415            c::SYS_bpf,
416            BPF_MAP_GET_NEXT_KEY,
417            &attr,
418            std::mem::size_of::<BpfMapKeyAttr>(),
419        )
420    };
421    if ret < 0 {
422        Err(io::Error::last_os_error())
423    } else {
424        Ok(())
425    }
426}
427
428/// Compute the bpffs pin path for the allowed_pids map for current process
429pub fn allowed_pids_pin_path() -> anyhow::Result<PathBuf> {
430    Ok(PathBuf::from(format!(
431        "{BPFFS_ROOT}/{}/allowed_pids",
432        current_process_dir_name()?
433    )))
434}
435
436/// Compute the bpffs pin path for the pid_aliases map for current process.
437pub fn pid_aliases_pin_path() -> anyhow::Result<PathBuf> {
438    Ok(PathBuf::from(format!(
439        "{BPFFS_ROOT}/{}/pid_aliases",
440        current_process_dir_name()?
441    )))
442}
443
444/// Ensure the pinned allowed_pids map exists under the per-process directory.
445pub fn ensure_pinned_allowed_pids_exists(max_entries: u32) -> anyhow::Result<()> {
446    let pin_path = allowed_pids_pin_path()?;
447    ensure_pin_dir(&pin_path).map_err(|e| {
448        let hint = bpffs_mount_hint_for_pin_path(&pin_path)
449            .map(|hint| format!(" {hint}"))
450            .unwrap_or_default();
451        anyhow::anyhow!(
452            "Failed to create pin directory for {} at {}: {}.{}",
453            ALLOWED_PIDS_MAP_NAME,
454            pin_path.display(),
455            e,
456            hint
457        )
458    })?;
459
460    if pin_path.exists() {
461        if MapData::from_pin(&pin_path).is_ok() {
462            info!("Reusing existing pinned map at {}", pin_path.display());
463            return Ok(());
464        } else {
465            let _ = std::fs::remove_file(&pin_path);
466        }
467    }
468
469    let obj_map = ObjMap::Legacy(LegacyMap {
470        section_index: 0,
471        section_kind: EbpfSectionKind::Maps,
472        symbol_index: None,
473        def: bpf_map_def {
474            map_type: BPF_MAP_TYPE_HASH as u32,
475            key_size: 4,
476            value_size: 1,
477            max_entries,
478            map_flags: 0,
479            id: 0,
480            pinning: aya_obj::maps::PinningType::None,
481        },
482        data: Vec::new(),
483    });
484
485    let map = MapData::create(obj_map, ALLOWED_PIDS_MAP_NAME, None)?;
486    info!(
487        "Created {} map with capacity {} entries",
488        ALLOWED_PIDS_MAP_NAME, max_entries
489    );
490
491    match map.pin(&pin_path) {
492        Ok(()) => {
493            info!("Pinned {} at {}", ALLOWED_PIDS_MAP_NAME, pin_path.display());
494            Ok(())
495        }
496        Err(e) => match MapData::from_pin(&pin_path) {
497            Ok(_) => {
498                info!(
499                    "Pin path {} already exists; reusing existing map ({}).",
500                    pin_path.display(),
501                    e
502                );
503                Ok(())
504            }
505            Err(_) => {
506                let _ = std::fs::remove_file(&pin_path);
507                let hint = bpffs_mount_hint_for_pin_path(&pin_path)
508                    .map(|hint| format!(" {hint}"))
509                    .unwrap_or_default();
510                Err(anyhow::anyhow!(
511                    "Failed to pin {} at {}: {}",
512                    ALLOWED_PIDS_MAP_NAME,
513                    pin_path.display(),
514                    e
515                )
516                .context(format!(
517                    "Unable to persist {ALLOWED_PIDS_MAP_NAME} in bpffs.{hint}"
518                )))
519            }
520        },
521    }
522}
523
524/// Ensure the pinned pid_aliases map exists under the per-process directory.
525pub fn ensure_pinned_pid_aliases_exists(max_entries: u32) -> anyhow::Result<()> {
526    let pin_path = pid_aliases_pin_path()?;
527    ensure_pin_dir(&pin_path).map_err(|e| {
528        let hint = bpffs_mount_hint_for_pin_path(&pin_path)
529            .map(|hint| format!(" {hint}"))
530            .unwrap_or_default();
531        anyhow::anyhow!(
532            "Failed to create pin directory for {} at {}: {}.{}",
533            PID_ALIASES_MAP_NAME,
534            pin_path.display(),
535            e,
536            hint
537        )
538    })?;
539
540    if pin_path.exists() {
541        if MapData::from_pin(&pin_path).is_ok() {
542            info!("Reusing existing pinned map at {}", pin_path.display());
543            return Ok(());
544        } else {
545            let _ = std::fs::remove_file(&pin_path);
546        }
547    }
548
549    let obj_map = ObjMap::Legacy(LegacyMap {
550        section_index: 0,
551        section_kind: EbpfSectionKind::Maps,
552        symbol_index: None,
553        def: bpf_map_def {
554            map_type: BPF_MAP_TYPE_HASH as u32,
555            key_size: 4,
556            value_size: 4,
557            max_entries,
558            map_flags: 0,
559            id: 0,
560            pinning: aya_obj::maps::PinningType::None,
561        },
562        data: Vec::new(),
563    });
564
565    let map = MapData::create(obj_map, PID_ALIASES_MAP_NAME, None)?;
566    info!(
567        "Created {} map with capacity {} entries",
568        PID_ALIASES_MAP_NAME, max_entries
569    );
570
571    match map.pin(&pin_path) {
572        Ok(()) => {
573            info!("Pinned {} at {}", PID_ALIASES_MAP_NAME, pin_path.display());
574            Ok(())
575        }
576        Err(e) => match MapData::from_pin(&pin_path) {
577            Ok(_) => {
578                info!(
579                    "Pin path {} already exists; reusing existing map ({}).",
580                    pin_path.display(),
581                    e
582                );
583                Ok(())
584            }
585            Err(_) => {
586                let _ = std::fs::remove_file(&pin_path);
587                let hint = bpffs_mount_hint_for_pin_path(&pin_path)
588                    .map(|hint| format!(" {hint}"))
589                    .unwrap_or_default();
590                Err(anyhow::anyhow!(
591                    "Failed to pin {} at {}: {}",
592                    PID_ALIASES_MAP_NAME,
593                    pin_path.display(),
594                    e
595                )
596                .context(format!(
597                    "Unable to persist {PID_ALIASES_MAP_NAME} in bpffs.{hint}"
598                )))
599            }
600        },
601    }
602}
603
604/// Insert a PID into the allowed_pids pinned map.
605pub fn insert_allowed_pid(pid: u32) -> anyhow::Result<()> {
606    let map_data = MapData::from_pin(allowed_pids_pin_path()?)?;
607    let fd = map_data.fd().as_fd().as_raw_fd();
608    let key = pid;
609    let val: u8 = 1;
610    bpf_map_update_elem(
611        fd,
612        &key as *const _ as *const _,
613        &val as *const _ as *const _,
614        0,
615    )
616    .map_err(|e| anyhow::anyhow!("allowed_pids update failed for {}: {}", pid, e))
617}
618
619/// Remove a PID from the allowed_pids pinned map.
620pub fn remove_allowed_pid(pid: u32) -> anyhow::Result<()> {
621    let map_data = MapData::from_pin(allowed_pids_pin_path()?)?;
622    let fd = map_data.fd().as_fd().as_raw_fd();
623    bpf_map_delete_elem(fd, &pid as *const _ as *const _)
624        .map_err(|e| anyhow::anyhow!("allowed_pids delete failed for {}: {}", pid, e))
625}
626
627/// Insert a runtime-pid -> proc-pid alias into the pinned pid_aliases map.
628pub fn insert_pid_alias(runtime_pid: u32, proc_pid: u32) -> anyhow::Result<()> {
629    let map_data = MapData::from_pin(pid_aliases_pin_path()?)?;
630    let fd = map_data.fd().as_fd().as_raw_fd();
631    let key = runtime_pid;
632    let val = PidAliasValue { proc_pid };
633    bpf_map_update_elem(
634        fd,
635        &key as *const _ as *const _,
636        &val as *const _ as *const _,
637        0,
638    )
639    .map_err(|e| {
640        anyhow::anyhow!(
641            "pid_aliases update failed for runtime_pid={} proc_pid={}: {}",
642            runtime_pid,
643            proc_pid,
644            e
645        )
646    })
647}
648
649/// Remove a runtime-pid alias from the pinned pid_aliases map.
650pub fn remove_pid_alias(runtime_pid: u32) -> anyhow::Result<()> {
651    let map_data = MapData::from_pin(pid_aliases_pin_path()?)?;
652    let fd = map_data.fd().as_fd().as_raw_fd();
653    bpf_map_delete_elem(fd, &runtime_pid as *const _ as *const _).map_err(|e| {
654        anyhow::anyhow!(
655            "pid_aliases delete failed for runtime_pid={}: {}",
656            runtime_pid,
657            e
658        )
659    })
660}
661
662fn bpf_map_delete_elem(fd: i32, key: *const c::c_void) -> io::Result<()> {
663    let attr = BpfMapUpdateAttr {
664        map_fd: fd as u32,
665        _pad: 0,
666        key: key as usize as u64,
667        value: 0,
668        flags: 0,
669    };
670    let ret = unsafe {
671        c::syscall(
672            c::SYS_bpf,
673            BPF_MAP_DELETE_ELEM,
674            &attr,
675            std::mem::size_of::<BpfMapUpdateAttr>(),
676        )
677    };
678    if ret < 0 {
679        Err(io::Error::last_os_error())
680    } else {
681        Ok(())
682    }
683}
684
685/// Purge all entries for a given pid in the pinned proc_module_offsets map.
686pub fn purge_offsets_for_pid(pid: u32) -> anyhow::Result<usize> {
687    let map_data = MapData::from_pin(proc_offsets_pin_path()?)?;
688    let fd = map_data.fd().as_fd().as_raw_fd();
689    let mut deleted = 0usize;
690
691    // Iterate keys with GET_NEXT_KEY
692    let mut prev: Option<ProcModuleKey> = None;
693    loop {
694        let mut next: ProcModuleKey = ProcModuleKey {
695            pid: 0,
696            pad: 0,
697            cookie_lo: 0,
698            cookie_hi: 0,
699        };
700        let key_ptr = prev
701            .as_ref()
702            .map(|k| k as *const _ as *const c::c_void)
703            .unwrap_or(std::ptr::null());
704        let res = bpf_map_get_next_key(fd, key_ptr, &mut next as *mut _ as *mut _);
705        match res {
706            Ok(()) => {
707                // Check pid match
708                if next.pid == pid {
709                    // Delete and continue iteration from the same prev (do not advance prev)
710                    let _ = bpf_map_delete_elem(fd, &next as *const _ as *const _);
711                    deleted += 1;
712                    // Do not set prev = Some(next) to avoid skipping following keys
713                    continue;
714                } else {
715                    prev = Some(next);
716                }
717            }
718            Err(e) => {
719                // ENOENT means end of iteration
720                if e.raw_os_error() == Some(libc::ENOENT) {
721                    break;
722                } else {
723                    return Err(anyhow::anyhow!("bpf_map_get_next_key failed: {}", e));
724                }
725            }
726        }
727    }
728    Ok(deleted)
729}
730
731/// Open the pinned global proc_module_offsets map and insert entries via raw bpf syscall.
732pub fn insert_offsets_for_pid(
733    pid: u32,
734    items: &[(u64, ProcModuleOffsetsValue)],
735) -> anyhow::Result<usize> {
736    let map_data = MapData::from_pin(proc_offsets_pin_path()?)?;
737    let fd = map_data.fd().as_fd().as_raw_fd();
738    let mut inserted = 0usize;
739    for (cookie, off) in items {
740        let key = ProcModuleKey {
741            pid,
742            pad: 0,
743            cookie_lo: (*cookie & 0xffff_ffff) as u32,
744            cookie_hi: (*cookie >> 32) as u32,
745        };
746        match bpf_map_update_elem(
747            fd,
748            &key as *const _ as *const _,
749            off as *const _ as *const _,
750            0,
751        ) {
752            Ok(()) => {
753                tracing::debug!(
754                    "proc_module_offsets insert ok: pid={} cookie=0x{:08x}{:08x} text=0x{:x} rodata=0x{:x} data=0x{:x} bss=0x{:x}",
755                    pid, key.cookie_hi, key.cookie_lo, off.text, off.rodata, off.data, off.bss
756                );
757                inserted += 1
758            }
759            Err(e) => warn!(
760                "bpf_map_update_elem failed for pid={} cookie=0x{:08x}{:08x}: {}",
761                pid, key.cookie_hi, key.cookie_lo, e
762            ),
763        }
764    }
765    Ok(inserted)
766}
767
768#[derive(Debug, Clone, Copy, PartialEq, Eq)]
769enum DirCleanupOutcome {
770    RemovedDir,
771}
772
773fn cleanup_outcome_without_mutation(dir: &Path) -> anyhow::Result<DirCleanupOutcome> {
774    if let Err(err) = std::fs::metadata(dir) {
775        if err.kind() != io::ErrorKind::NotFound {
776            return Err(err.into());
777        }
778    }
779    Ok(DirCleanupOutcome::RemovedDir)
780}
781
782fn cleanup_pinned_maps_in_dir(dir: &Path) -> anyhow::Result<DirCleanupOutcome> {
783    match std::fs::remove_dir_all(dir) {
784        Ok(()) => Ok(DirCleanupOutcome::RemovedDir),
785        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(DirCleanupOutcome::RemovedDir),
786        Err(err) => Err(err.into()),
787    }
788}
789
790fn stale_reason_for_dir<R, S>(
791    host_pid: u32,
792    dir_starttime: u64,
793    current: CurrentProcessIdentity,
794    resolve_proc_pid: &R,
795    read_starttime: &S,
796) -> Option<&'static str>
797where
798    R: Fn(u32) -> Option<u32>,
799    S: Fn(u32) -> io::Result<u64>,
800{
801    if current.host_pid_reliable && host_pid == current.host_pid {
802        return (dir_starttime != current.starttime).then_some("starttime_mismatch");
803    }
804
805    let Some(proc_pid) = resolve_proc_pid(host_pid) else {
806        return current.initial_pid_namespace.then_some("pid_not_running");
807    };
808
809    match read_starttime(proc_pid) {
810        Ok(live_starttime) if live_starttime != dir_starttime => Some("starttime_mismatch"),
811        Ok(_) => None,
812        Err(_) => current.initial_pid_namespace.then_some("pid_not_running"),
813    }
814}
815
816fn prune_entry_for_cleanup(
817    directory: String,
818    reason: &str,
819    outcome: DirCleanupOutcome,
820) -> BpffsPruneEntry {
821    BpffsPruneEntry {
822        directory,
823        status: match outcome {
824            DirCleanupOutcome::RemovedDir => BpffsPruneStatus::RemoveDir,
825        },
826        reason: reason.to_string(),
827    }
828}
829
830fn prune_pinned_maps_under<R, S>(
831    root: &Path,
832    current: CurrentProcessIdentity,
833    options: &BpffsPruneOptions,
834    resolve_proc_pid: R,
835    read_starttime: S,
836) -> anyhow::Result<BpffsPruneReport>
837where
838    R: Fn(u32) -> Option<u32>,
839    S: Fn(u32) -> io::Result<u64>,
840{
841    if let BpffsPruneMode::Instance(instance) = &options.mode {
842        let path = root.join(instance);
843        if !path.exists() {
844            return Err(anyhow::anyhow!(
845                "bpffs pin directory not found: {}",
846                path.display()
847            ));
848        }
849        if !path.is_dir() {
850            return Err(anyhow::anyhow!(
851                "bpffs pin path is not a directory: {}",
852                path.display()
853            ));
854        }
855
856        let outcome = if options.dry_run {
857            cleanup_outcome_without_mutation(&path)?
858        } else {
859            cleanup_pinned_maps_in_dir(&path)?
860        };
861
862        return Ok(BpffsPruneReport {
863            root: root.to_path_buf(),
864            dry_run: options.dry_run,
865            entries: vec![prune_entry_for_cleanup(
866                instance.clone(),
867                "explicit_instance",
868                outcome,
869            )],
870        });
871    }
872
873    let mut entries_out = Vec::new();
874
875    let entries = match std::fs::read_dir(root) {
876        Ok(entries) => entries,
877        Err(err) if err.kind() == io::ErrorKind::NotFound => {
878            return Ok(BpffsPruneReport {
879                root: root.to_path_buf(),
880                dry_run: options.dry_run,
881                entries: entries_out,
882            });
883        }
884        Err(err) => return Err(err.into()),
885    };
886
887    for entry in entries {
888        let entry = entry?;
889        if !entry.file_type()?.is_dir() {
890            continue;
891        }
892
893        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
894            continue;
895        };
896        let Some((host_pid, dir_starttime)) = parse_pin_dir_name(&name) else {
897            entries_out.push(BpffsPruneEntry {
898                directory: name,
899                status: BpffsPruneStatus::Ignore,
900                reason: "non_matching_name".to_string(),
901            });
902            continue;
903        };
904
905        let removal_reason = match &options.mode {
906            BpffsPruneMode::Stale => stale_reason_for_dir(
907                host_pid,
908                dir_starttime,
909                current,
910                &resolve_proc_pid,
911                &read_starttime,
912            ),
913            BpffsPruneMode::All => Some("force_all"),
914            BpffsPruneMode::Instance(_) => unreachable!(),
915        };
916
917        let Some(reason) = removal_reason else {
918            entries_out.push(BpffsPruneEntry {
919                directory: name,
920                status: BpffsPruneStatus::SkipLive,
921                reason: "live_instance".to_string(),
922            });
923            continue;
924        };
925
926        let outcome = if options.dry_run {
927            cleanup_outcome_without_mutation(&entry.path())?
928        } else {
929            cleanup_pinned_maps_in_dir(&entry.path())?
930        };
931        entries_out.push(prune_entry_for_cleanup(name, reason, outcome));
932    }
933
934    entries_out.sort_by(|left, right| left.directory.cmp(&right.directory));
935
936    Ok(BpffsPruneReport {
937        root: root.to_path_buf(),
938        dry_run: options.dry_run,
939        entries: entries_out,
940    })
941}
942
943fn cleanup_stale_pinned_maps_under<R, S>(
944    root: &Path,
945    current: CurrentProcessIdentity,
946    resolve_proc_pid: R,
947    read_starttime: S,
948) -> anyhow::Result<usize>
949where
950    R: Fn(u32) -> Option<u32>,
951    S: Fn(u32) -> io::Result<u64>,
952{
953    let report = prune_pinned_maps_under(
954        root,
955        current,
956        &BpffsPruneOptions {
957            mode: BpffsPruneMode::Stale,
958            dry_run: false,
959        },
960        resolve_proc_pid,
961        read_starttime,
962    )?;
963
964    Ok(report
965        .entries
966        .iter()
967        .filter(|entry| entry.status == BpffsPruneStatus::RemoveDir)
968        .count())
969}
970
971/// Remove the current process's pinned maps and its per-process directory (best effort).
972/// Safe to call multiple times; missing paths are ignored.
973pub fn cleanup_current_pinned_maps() -> anyhow::Result<()> {
974    let _ = cleanup_pinned_maps_in_dir(&proc_offsets_pin_dir()?);
975    Ok(())
976}
977
978/// Remove stale per-process pinned map directories whose PID no longer exists.
979pub fn cleanup_stale_pinned_maps_root() -> anyhow::Result<usize> {
980    let current = current_process_identity()?;
981    cleanup_stale_pinned_maps_under(
982        Path::new(BPFFS_ROOT),
983        current,
984        |host_pid| resolve_proc_pid_for_host_pid(host_pid, current.initial_pid_namespace),
985        process_starttime,
986    )
987}
988
989pub fn prune_pinned_maps_root(options: &BpffsPruneOptions) -> anyhow::Result<BpffsPruneReport> {
990    let current = current_process_identity()?;
991    prune_pinned_maps_under(
992        Path::new(BPFFS_ROOT),
993        current,
994        options,
995        |host_pid| resolve_proc_pid_for_host_pid(host_pid, current.initial_pid_namespace),
996        process_starttime,
997    )
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::{
1003        cleanup_pinned_maps_in_dir, cleanup_stale_pinned_maps_under, parse_pin_dir_name,
1004        process_starttime, prune_pinned_maps_under, BpffsPruneMode, BpffsPruneOptions,
1005        BpffsPruneStatus, CurrentProcessIdentity, ALLOWED_PIDS_MAP_NAME, PROC_OFFSETS_MAP_NAME,
1006    };
1007    use std::{fs, io};
1008    use tempfile::tempdir;
1009
1010    fn host_test_identity(host_pid: u32, starttime: u64) -> CurrentProcessIdentity {
1011        CurrentProcessIdentity {
1012            host_pid,
1013            host_pid_reliable: true,
1014            starttime,
1015            initial_pid_namespace: true,
1016        }
1017    }
1018
1019    fn private_ns_test_identity(host_pid: u32, starttime: u64) -> CurrentProcessIdentity {
1020        CurrentProcessIdentity {
1021            host_pid,
1022            host_pid_reliable: false,
1023            starttime,
1024            initial_pid_namespace: false,
1025        }
1026    }
1027
1028    fn simulated_starttime(pid: u32) -> io::Result<u64> {
1029        match pid {
1030            222 => Ok(20),
1031            333 => Ok(30),
1032            _ => Err(io::Error::new(io::ErrorKind::NotFound, "missing pid")),
1033        }
1034    }
1035
1036    #[test]
1037    fn cleanup_removes_known_pinned_maps_and_empty_dir() {
1038        let temp = tempdir().unwrap();
1039        let dir = temp.path().join("1234");
1040        fs::create_dir_all(&dir).unwrap();
1041        fs::write(dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1042        fs::write(dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1043
1044        cleanup_pinned_maps_in_dir(&dir).unwrap();
1045
1046        assert!(!dir.exists());
1047    }
1048
1049    #[test]
1050    fn cleanup_removes_dir_even_when_unknown_files_remain() {
1051        let temp = tempdir().unwrap();
1052        let dir = temp.path().join("1234");
1053        fs::create_dir_all(&dir).unwrap();
1054        fs::write(dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1055        fs::write(dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1056        let extra = dir.join("keep-me");
1057        fs::write(&extra, b"extra").unwrap();
1058
1059        cleanup_pinned_maps_in_dir(&dir).unwrap();
1060
1061        assert!(!dir.exists());
1062        assert!(!extra.exists());
1063    }
1064
1065    #[test]
1066    fn stale_cleanup_removes_only_dead_pid_dirs() {
1067        let temp = tempdir().unwrap();
1068        let stale_dir = temp.path().join("111-10");
1069        let live_dir = temp.path().join("222-20");
1070        let current_dir = temp.path().join("333-30");
1071        let non_pid_dir = temp.path().join("not-a-pid");
1072
1073        for dir in [&stale_dir, &live_dir, &current_dir, &non_pid_dir] {
1074            fs::create_dir_all(dir).unwrap();
1075            fs::write(dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1076            fs::write(dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1077        }
1078
1079        let removed = cleanup_stale_pinned_maps_under(
1080            temp.path(),
1081            host_test_identity(333, 30),
1082            |host_pid| matches!(host_pid, 222 | 333).then_some(host_pid),
1083            simulated_starttime,
1084        )
1085        .unwrap();
1086
1087        assert_eq!(removed, 1);
1088        assert!(!stale_dir.exists());
1089        assert!(live_dir.exists());
1090        assert!(current_dir.exists());
1091        assert!(non_pid_dir.exists());
1092    }
1093
1094    #[test]
1095    fn stale_cleanup_removes_mismatched_starttime_for_reused_pid() {
1096        let temp = tempdir().unwrap();
1097        let stale_current_pid_dir = temp.path().join("333-10");
1098        let current_pid_dir = temp.path().join("333-30");
1099
1100        for dir in [&stale_current_pid_dir, &current_pid_dir] {
1101            fs::create_dir_all(dir).unwrap();
1102            fs::write(dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1103            fs::write(dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1104        }
1105
1106        let removed = cleanup_stale_pinned_maps_under(
1107            temp.path(),
1108            host_test_identity(333, 30),
1109            |host_pid| (host_pid == 333).then_some(host_pid),
1110            simulated_starttime,
1111        )
1112        .unwrap();
1113
1114        assert_eq!(removed, 1);
1115        assert!(!stale_current_pid_dir.exists());
1116        assert!(current_pid_dir.exists());
1117    }
1118
1119    #[test]
1120    fn parse_pin_dir_name_requires_pid_starttime_format() {
1121        assert_eq!(parse_pin_dir_name("1234"), None);
1122        assert_eq!(parse_pin_dir_name("1234-5678"), Some((1234, 5678)));
1123        assert_eq!(parse_pin_dir_name("bad"), None);
1124        assert_eq!(parse_pin_dir_name("1234-bad"), None);
1125    }
1126
1127    #[test]
1128    fn stale_cleanup_ignores_legacy_numeric_dirs() {
1129        let temp = tempdir().unwrap();
1130        let legacy_dir = temp.path().join("444");
1131        let current_dir = temp.path().join("333-30");
1132
1133        for dir in [&legacy_dir, &current_dir] {
1134            fs::create_dir_all(dir).unwrap();
1135            fs::write(dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1136            fs::write(dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1137        }
1138
1139        let removed = cleanup_stale_pinned_maps_under(
1140            temp.path(),
1141            host_test_identity(333, 30),
1142            |host_pid| matches!(host_pid, 333 | 444).then_some(host_pid),
1143            simulated_starttime,
1144        )
1145        .unwrap();
1146
1147        assert_eq!(removed, 0);
1148        assert!(legacy_dir.exists());
1149        assert!(current_dir.exists());
1150    }
1151
1152    #[test]
1153    fn dry_run_prune_reports_stale_and_keeps_dirs_intact() {
1154        let temp = tempdir().unwrap();
1155        let stale_dir = temp.path().join("111-10");
1156        let live_dir = temp.path().join("222-20");
1157        let legacy_dir = temp.path().join("legacy");
1158
1159        for dir in [&stale_dir, &live_dir, &legacy_dir] {
1160            fs::create_dir_all(dir).unwrap();
1161            fs::write(dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1162            fs::write(dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1163        }
1164
1165        let report = prune_pinned_maps_under(
1166            temp.path(),
1167            host_test_identity(333, 30),
1168            &BpffsPruneOptions {
1169                mode: BpffsPruneMode::Stale,
1170                dry_run: true,
1171            },
1172            |host_pid| (host_pid == 222).then_some(host_pid),
1173            simulated_starttime,
1174        )
1175        .unwrap();
1176
1177        assert!(stale_dir.exists());
1178        assert!(live_dir.exists());
1179        assert!(legacy_dir.exists());
1180        assert!(report.entries.iter().any(|entry| {
1181            entry.directory == "111-10"
1182                && entry.status == BpffsPruneStatus::RemoveDir
1183                && entry.reason == "pid_not_running"
1184        }));
1185        assert!(report.entries.iter().any(|entry| {
1186            entry.directory == "222-20"
1187                && entry.status == BpffsPruneStatus::SkipLive
1188                && entry.reason == "live_instance"
1189        }));
1190        assert!(report.entries.iter().any(|entry| {
1191            entry.directory == "legacy"
1192                && entry.status == BpffsPruneStatus::Ignore
1193                && entry.reason == "non_matching_name"
1194        }));
1195    }
1196
1197    #[test]
1198    fn instance_prune_removes_selected_dir_even_when_live() {
1199        let temp = tempdir().unwrap();
1200        let live_dir = temp.path().join("222-20");
1201        fs::create_dir_all(&live_dir).unwrap();
1202        fs::write(live_dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1203        fs::write(live_dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1204
1205        let report = prune_pinned_maps_under(
1206            temp.path(),
1207            host_test_identity(333, 30),
1208            &BpffsPruneOptions {
1209                mode: BpffsPruneMode::Instance("222-20".to_string()),
1210                dry_run: false,
1211            },
1212            |_host_pid| Some(222),
1213            simulated_starttime,
1214        )
1215        .unwrap();
1216
1217        assert!(!live_dir.exists());
1218        assert_eq!(report.entries.len(), 1);
1219        assert_eq!(report.entries[0].directory, "222-20");
1220        assert_eq!(report.entries[0].status, BpffsPruneStatus::RemoveDir);
1221        assert_eq!(report.entries[0].reason, "explicit_instance");
1222    }
1223
1224    #[test]
1225    fn force_all_prune_skips_legacy_dirs_but_removes_pid_starttime_dirs() {
1226        let temp = tempdir().unwrap();
1227        let live_dir = temp.path().join("222-20");
1228        let legacy_dir = temp.path().join("222");
1229
1230        for dir in [&live_dir, &legacy_dir] {
1231            fs::create_dir_all(dir).unwrap();
1232            fs::write(dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1233            fs::write(dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1234        }
1235
1236        let report = prune_pinned_maps_under(
1237            temp.path(),
1238            host_test_identity(333, 30),
1239            &BpffsPruneOptions {
1240                mode: BpffsPruneMode::All,
1241                dry_run: false,
1242            },
1243            |_host_pid| Some(222),
1244            simulated_starttime,
1245        )
1246        .unwrap();
1247
1248        assert!(!live_dir.exists());
1249        assert!(legacy_dir.exists());
1250        assert!(report.entries.iter().any(|entry| {
1251            entry.directory == "222-20"
1252                && entry.status == BpffsPruneStatus::RemoveDir
1253                && entry.reason == "force_all"
1254        }));
1255        assert!(report.entries.iter().any(|entry| {
1256            entry.directory == "222"
1257                && entry.status == BpffsPruneStatus::Ignore
1258                && entry.reason == "non_matching_name"
1259        }));
1260    }
1261
1262    #[test]
1263    fn stale_prune_skips_unresolvable_host_pid_in_private_namespace() {
1264        let temp = tempdir().unwrap();
1265        let foreign_live_dir = temp.path().join("999-10");
1266        fs::create_dir_all(&foreign_live_dir).unwrap();
1267        fs::write(foreign_live_dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1268        fs::write(foreign_live_dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1269
1270        let report = prune_pinned_maps_under(
1271            temp.path(),
1272            private_ns_test_identity(333, 30),
1273            &BpffsPruneOptions {
1274                mode: BpffsPruneMode::Stale,
1275                dry_run: false,
1276            },
1277            |_host_pid| None,
1278            simulated_starttime,
1279        )
1280        .unwrap();
1281
1282        assert!(foreign_live_dir.exists());
1283        assert!(report.entries.iter().any(|entry| {
1284            entry.directory == "999-10"
1285                && entry.status == BpffsPruneStatus::SkipLive
1286                && entry.reason == "live_instance"
1287        }));
1288    }
1289
1290    #[test]
1291    fn stale_prune_skips_same_numeric_pid_when_current_host_pid_is_not_reliable() {
1292        let temp = tempdir().unwrap();
1293        let foreign_live_dir = temp.path().join("333-10");
1294        fs::create_dir_all(&foreign_live_dir).unwrap();
1295        fs::write(foreign_live_dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1296        fs::write(foreign_live_dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1297
1298        let report = prune_pinned_maps_under(
1299            temp.path(),
1300            private_ns_test_identity(333, 30),
1301            &BpffsPruneOptions {
1302                mode: BpffsPruneMode::Stale,
1303                dry_run: false,
1304            },
1305            |_host_pid| None,
1306            simulated_starttime,
1307        )
1308        .unwrap();
1309
1310        assert!(foreign_live_dir.exists());
1311        assert!(report.entries.iter().any(|entry| {
1312            entry.directory == "333-10"
1313                && entry.status == BpffsPruneStatus::SkipLive
1314                && entry.reason == "live_instance"
1315        }));
1316    }
1317
1318    #[test]
1319    fn stale_prune_keeps_live_dir_when_host_pid_maps_to_proc_pid() {
1320        let temp = tempdir().unwrap();
1321        let proc_pid = std::process::id();
1322        let proc_starttime = process_starttime(proc_pid).unwrap();
1323        let live_dir = temp.path().join(format!("999-{proc_starttime}"));
1324        fs::create_dir_all(&live_dir).unwrap();
1325        fs::write(live_dir.join(PROC_OFFSETS_MAP_NAME), b"offsets").unwrap();
1326        fs::write(live_dir.join(ALLOWED_PIDS_MAP_NAME), b"allow").unwrap();
1327
1328        let report = prune_pinned_maps_under(
1329            temp.path(),
1330            private_ns_test_identity(333, 30),
1331            &BpffsPruneOptions {
1332                mode: BpffsPruneMode::Stale,
1333                dry_run: false,
1334            },
1335            |host_pid| (host_pid == 999).then_some(proc_pid),
1336            process_starttime,
1337        )
1338        .unwrap();
1339
1340        assert!(live_dir.exists());
1341        assert!(report.entries.iter().any(|entry| {
1342            entry.directory == format!("999-{proc_starttime}")
1343                && entry.status == BpffsPruneStatus::SkipLive
1344                && entry.reason == "live_instance"
1345        }));
1346    }
1347}
1348// Note: map open/write helpers will be added once we standardize on aya APIs across crates.
1349
1350#[cfg(test)]
1351mod bpffs_hint_tests {
1352    use super::bpffs_mount_hint_for_state;
1353    use std::path::Path;
1354
1355    #[test]
1356    fn bpffs_hint_mentions_mount_for_unmounted_sys_fs_bpf() {
1357        let hint =
1358            bpffs_mount_hint_for_state(Path::new("/sys/fs/bpf/ghostscope/1/test"), true, false)
1359                .expect("expected mount hint");
1360        assert!(hint.contains("mount -t bpf bpf /sys/fs/bpf"));
1361        assert!(hint.contains("WSL2"));
1362    }
1363
1364    #[test]
1365    fn bpffs_hint_omits_message_when_bpffs_is_mounted() {
1366        let hint =
1367            bpffs_mount_hint_for_state(Path::new("/sys/fs/bpf/ghostscope/1/test"), true, true);
1368        assert!(hint.is_none());
1369    }
1370
1371    #[test]
1372    fn bpffs_hint_ignores_non_bpffs_paths() {
1373        let hint = bpffs_mount_hint_for_state(Path::new("/tmp/ghostscope/test"), true, false);
1374        assert!(hint.is_none());
1375    }
1376}