Skip to main content

prt_core/core/
namespace.rs

1//! Network namespace awareness (Linux only).
2//!
3//! Groups processes by their network namespace inode. Named namespaces
4//! from `/run/netns/` get human-readable labels; unnamed ones show the
5//! raw inode number.
6//!
7//! On non-Linux platforms, all functions return empty/default results.
8
9use std::collections::HashMap;
10
11/// A network namespace with its inode and optional human-readable name.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct NetNamespace {
14    /// The namespace inode number (unique identifier).
15    pub inode: u64,
16    /// Human-readable name from /run/netns/, if available.
17    pub name: Option<String>,
18}
19
20impl NetNamespace {
21    /// Display label: name if available, otherwise "ns:<inode>".
22    pub fn label(&self) -> String {
23        match &self.name {
24            Some(n) => n.clone(),
25            None => format!("ns:{}", self.inode),
26        }
27    }
28}
29
30/// Resolve the network namespace for a given PID.
31/// Returns None on non-Linux or if the namespace can't be read.
32pub fn resolve_namespace(pid: u32) -> Option<NetNamespace> {
33    if !cfg!(target_os = "linux") {
34        return None;
35    }
36    let inode = read_ns_inode(pid)?;
37    Some(NetNamespace { inode, name: None })
38}
39
40/// Batch-resolve namespaces for multiple PIDs.
41/// Returns a map from PID to namespace.
42pub fn resolve_namespaces(pids: &[u32]) -> HashMap<u32, NetNamespace> {
43    let mut result = HashMap::new();
44    if !cfg!(target_os = "linux") {
45        return result;
46    }
47
48    // First, build a map of named namespaces from /run/netns/
49    let named = load_named_namespaces();
50
51    for &pid in pids {
52        if let Some(inode) = read_ns_inode(pid) {
53            let name = named.get(&inode).cloned();
54            result.insert(pid, NetNamespace { inode, name });
55        }
56    }
57
58    result
59}
60
61/// Group PIDs by their namespace inode.
62/// Returns Vec<(namespace, Vec<pid>)> sorted by namespace label.
63pub fn group_by_namespace(pid_ns: &HashMap<u32, NetNamespace>) -> Vec<(NetNamespace, Vec<u32>)> {
64    let mut by_inode: HashMap<u64, (NetNamespace, Vec<u32>)> = HashMap::new();
65
66    for (&pid, ns) in pid_ns {
67        by_inode
68            .entry(ns.inode)
69            .or_insert_with(|| (ns.clone(), Vec::new()))
70            .1
71            .push(pid);
72    }
73
74    let mut groups: Vec<_> = by_inode.into_values().collect();
75    groups.sort_by(|a, b| a.0.label().cmp(&b.0.label()));
76    for (_, pids) in &mut groups {
77        pids.sort();
78    }
79    groups
80}
81
82/// Read the network namespace inode for a PID from /proc/{pid}/ns/net.
83#[allow(dead_code)]
84fn read_ns_inode(pid: u32) -> Option<u64> {
85    let link = std::fs::read_link(format!("/proc/{pid}/ns/net")).ok()?;
86    let s = link.to_string_lossy();
87    // Format: "net:[<inode>]"
88    parse_ns_inode(&s)
89}
90
91/// Parse inode from a readlink result like "net:[4026531992]".
92fn parse_ns_inode(s: &str) -> Option<u64> {
93    let start = s.find('[')?;
94    let end = s.find(']')?;
95    s[start + 1..end].parse().ok()
96}
97
98/// Load named namespaces from /run/netns/.
99/// Returns a map from inode → name.
100/// Files in /run/netns/ are bind-mounts (not symlinks), so we use
101/// metadata().ino() to get the namespace inode.
102#[allow(dead_code)]
103fn load_named_namespaces() -> HashMap<u64, String> {
104    #[cfg(target_os = "linux")]
105    {
106        use std::os::unix::fs::MetadataExt;
107        let mut result = HashMap::new();
108        if let Ok(dir) = std::fs::read_dir("/run/netns") {
109            for entry in dir.flatten() {
110                let name = entry.file_name().to_string_lossy().to_string();
111                if let Ok(meta) = std::fs::metadata(format!("/run/netns/{name}")) {
112                    result.insert(meta.ino(), name);
113                }
114            }
115        }
116        result
117    }
118
119    #[cfg(not(target_os = "linux"))]
120    HashMap::new()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn parse_ns_inode_valid() {
129        assert_eq!(parse_ns_inode("net:[4026531992]"), Some(4026531992));
130    }
131
132    #[test]
133    fn parse_ns_inode_invalid() {
134        assert_eq!(parse_ns_inode("garbage"), None);
135        assert_eq!(parse_ns_inode("net:[]"), None);
136        assert_eq!(parse_ns_inode("net:[abc]"), None);
137    }
138
139    #[test]
140    fn namespace_label_with_name() {
141        let ns = NetNamespace {
142            inode: 123,
143            name: Some("myns".into()),
144        };
145        assert_eq!(ns.label(), "myns");
146    }
147
148    #[test]
149    fn namespace_label_without_name() {
150        let ns = NetNamespace {
151            inode: 4026531992,
152            name: None,
153        };
154        assert_eq!(ns.label(), "ns:4026531992");
155    }
156
157    #[test]
158    fn group_by_namespace_groups_correctly() {
159        let mut pid_ns = HashMap::new();
160        let ns1 = NetNamespace {
161            inode: 100,
162            name: Some("default".into()),
163        };
164        let ns2 = NetNamespace {
165            inode: 200,
166            name: Some("container".into()),
167        };
168        pid_ns.insert(1, ns1.clone());
169        pid_ns.insert(2, ns1.clone());
170        pid_ns.insert(3, ns2.clone());
171
172        let groups = group_by_namespace(&pid_ns);
173        assert_eq!(groups.len(), 2);
174
175        // Sorted by label: "container" < "default"
176        assert_eq!(groups[0].0.name, Some("container".into()));
177        assert_eq!(groups[0].1, vec![3]);
178        assert_eq!(groups[1].0.name, Some("default".into()));
179        assert_eq!(groups[1].1, vec![1, 2]);
180    }
181
182    #[test]
183    fn group_by_namespace_empty() {
184        let groups = group_by_namespace(&HashMap::new());
185        assert!(groups.is_empty());
186    }
187
188    #[test]
189    fn resolve_namespaces_non_linux_returns_empty() {
190        if !cfg!(target_os = "linux") {
191            let result = resolve_namespaces(&[1, 2, 3]);
192            assert!(result.is_empty());
193        }
194    }
195}