Skip to main content

gander_core/linux/
fsops.rs

1//! Filesystem helpers that are not on the per-tick hot path.
2//!
3//! `smaps_rollup` is expensive (the kernel walks every VMA), so it is fetched
4//! only when a PSS/EPSS column is visible, and always through this module so the
5//! call site stays obvious.
6
7use std::path::Path;
8
9/// Memory figures from `/proc/<pid>/smaps_rollup`, in bytes.
10#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
11pub struct SmapsRollup {
12    pub rss: u64,
13    pub pss: u64,
14    pub pss_anon: u64,
15    pub pss_file: u64,
16    pub pss_shmem: u64,
17    pub private_clean: u64,
18    pub private_dirty: u64,
19    pub swap: u64,
20    pub swap_pss: u64,
21}
22
23impl SmapsRollup {
24    /// Effective PSS: the sum of memory and swap PSS, as EPSS displays.
25    pub fn epss(&self) -> u64 {
26        self.pss.saturating_add(self.swap_pss)
27    }
28
29    /// Memory that is exclusively this process's: PSS minus the shared parts.
30    pub fn private(&self) -> u64 {
31        self.private_clean.saturating_add(self.private_dirty)
32    }
33}
34
35/// Parse `/proc/<pid>/smaps_rollup`.
36pub fn parse_smaps_rollup(buf: &[u8]) -> Option<SmapsRollup> {
37    let mut out = SmapsRollup::default();
38    let mut seen = false;
39
40    for line in buf.split(|b| *b == b'\n') {
41        let Some(colon) = memchr::memchr(b':', line) else {
42            continue;
43        };
44        let key = &line[..colon];
45        let rest = &line[colon + 1..];
46        let Some(kb) = first_number(rest) else {
47            continue;
48        };
49        let bytes = kb.saturating_mul(1024);
50
51        match key {
52            b"Rss" => out.rss = bytes,
53            b"Pss" => out.pss = bytes,
54            b"Pss_Anon" => out.pss_anon = bytes,
55            b"Pss_File" => out.pss_file = bytes,
56            b"Pss_Shmem" => out.pss_shmem = bytes,
57            b"Private_Clean" => out.private_clean = bytes,
58            b"Private_Dirty" => out.private_dirty = bytes,
59            b"Swap" => out.swap = bytes,
60            b"SwapPss" => out.swap_pss = bytes,
61            _ => continue,
62        }
63        seen = true;
64    }
65
66    if seen {
67        Some(out)
68    } else {
69        None
70    }
71}
72
73fn first_number(buf: &[u8]) -> Option<u64> {
74    let start = buf.iter().position(|c| c.is_ascii_digit())?;
75    let mut v: u64 = 0;
76    for &c in &buf[start..] {
77        if !c.is_ascii_digit() {
78            break;
79        }
80        v = v.wrapping_mul(10).wrapping_add((c - b'0') as u64);
81    }
82    Some(v)
83}
84
85/// A cgroup membership entry from `/proc/<pid>/cgroup`.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct CgroupEntry {
88    pub hierarchy: u32,
89    pub controllers: String,
90    pub path: String,
91}
92
93/// Parse `/proc/<pid>/cgroup`.
94///
95/// Lines are `hierarchy-ID:controller-list:path`. On a unified (v2) hierarchy the
96/// controller list is empty.
97pub fn parse_cgroup(buf: &[u8]) -> Vec<CgroupEntry> {
98    let mut out = Vec::new();
99    for line in buf.split(|b| *b == b'\n') {
100        let line = trim(line);
101        if line.is_empty() {
102            continue;
103        }
104        let mut parts = line.splitn(3, |b| *b == b':');
105        let (Some(hier), Some(ctrls), Some(path)) = (parts.next(), parts.next(), parts.next())
106        else {
107            continue;
108        };
109        let hierarchy = std::str::from_utf8(hier)
110            .ok()
111            .and_then(|s| s.parse().ok())
112            .unwrap_or(0);
113        out.push(CgroupEntry {
114            hierarchy,
115            controllers: String::from_utf8_lossy(ctrls).into_owned(),
116            path: String::from_utf8_lossy(path).into_owned(),
117        });
118    }
119    out
120}
121
122/// Choose which cgroup path to display when `/proc/<pid>/cgroup` lists several.
123///
124/// On a hybrid host (v1 controllers plus a v2 entry) the v2 line has an empty
125/// controller list and its path is the one shared by every hierarchy, so it is
126/// the honest answer for "which cgroup is this process in". Falling back to the
127/// first entry keeps v1-only hosts working.
128pub fn preferred_path(entries: &[CgroupEntry]) -> Option<&str> {
129    // The unified entry: hierarchy 0 with no controllers.
130    if let Some(unified) = entries
131        .iter()
132        .find(|e| e.hierarchy == 0 && e.controllers.is_empty())
133    {
134        return Some(&unified.path);
135    }
136    entries.first().map(|e| e.path.as_str())
137}
138
139/// What a cgroup path says about the process's container membership.
140///
141/// htop decides this from cgroup membership rather than heuristics, and so do we.
142/// The distinction that matters for a *column* is not "is it in a container" but
143/// *which* one: `container_from_cgroup` returning the bare marker (`"docker"`)
144/// made every containerised process on the machine render the same word, which is
145/// a boolean dressed up as data.
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct Container {
148    /// The runtime that owns it: `docker`, `kubepods`, `lxc`, …
149    pub runtime: &'static str,
150    /// The container's identifier, as the cgroup path spells it.
151    ///
152    /// For Docker and Podman this is the 64-hex id (or a prefix of it); for
153    /// Kubernetes it is the pod uid. Empty when the path names a runtime but no
154    /// identifiable container — a `kubepods.slice` with no pod below it, say.
155    pub id: String,
156}
157
158impl Container {
159    /// The id shortened to the first 12 characters, which is what Docker prints
160    /// and enough to tell containers apart on screen.
161    ///
162    /// Shortening here rather than at render time keeps the column and the
163    /// inspector agreeing; it also means the sort key and the displayed text are
164    /// the same string.
165    pub fn short_id(&self) -> &str {
166        let end = self.id.len().min(12);
167        // Ids are hex or uuid, so this never splits a character.
168        &self.id[..end]
169    }
170}
171
172/// Best-effort container detection from cgroup membership.
173///
174/// # What the paths look like
175///
176/// | runtime | example path |
177/// |---|---|
178/// | Docker (cgroup v1) | `/docker/1f2e…` |
179/// | Docker (v2, systemd) | `/system.slice/docker-1f2e….scope` |
180/// | Podman | `/machine.slice/libpod-1f2e….scope` |
181/// | Kubernetes | `/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/cri-containerd-<id>.scope` |
182/// | LXC | `/lxc.payload.<name>` |
183/// | systemd-nspawn | `/machine.slice/machine-<name>.scope` |
184///
185/// A host process sits at `/`, `/init.scope`, `/user.slice/…` or `/system.slice/…`
186/// and must report `None`: the marker check alone would classify a host process
187/// under `system.slice/containerd.service` as a container.
188pub fn container_from_cgroup(entries: &[CgroupEntry]) -> Option<Container> {
189    for entry in entries {
190        if let Some(c) = container_from_path(&entry.path) {
191            return Some(c);
192        }
193    }
194    None
195}
196
197/// Classify one cgroup path.
198///
199/// Split out from [`container_from_cgroup`] so it is testable on its own, and so
200/// the caller can decide which hierarchy to prefer when there are several.
201pub fn container_from_path(path: &str) -> Option<Container> {
202    // Order matters: `kubepods` paths also contain `containerd` in the leaf
203    // (`cri-containerd-<id>.scope`), and the pod id is the useful identifier, so
204    // Kubernetes has to be tested before the plain runtimes.
205    if let Some(rest) = path.strip_prefix("/kubepods") {
206        return Some(Container {
207            runtime: "kubepods",
208            id: kubernetes_id(rest),
209        });
210    }
211
212    // Docker v1 (`/docker/<id>`) and the systemd-scoped form
213    // (`/system.slice/docker-<id>.scope`).
214    if let Some(rest) = path.strip_prefix("/docker/") {
215        return Some(Container {
216            runtime: "docker",
217            id: trim_scope(rest).to_string(),
218        });
219    }
220    if let Some(pos) = path.find("docker-") {
221        // `docker-<id>.scope`, and the v1-style `docker/<id>` after a prefix.
222        let rest = &path[pos + "docker-".len()..];
223        return Some(Container {
224            runtime: "docker",
225            id: trim_scope(rest).to_string(),
226        });
227    }
228
229    if let Some(pos) = path.find("libpod-") {
230        let rest = &path[pos + "libpod-".len()..];
231        return Some(Container {
232            runtime: "podman",
233            id: trim_scope(rest).to_string(),
234        });
235    }
236    // `podman-<id>.scope` appears on newer systemd-driven Podman.
237    if let Some(pos) = path.find("podman-") {
238        let rest = &path[pos + "podman-".len()..];
239        return Some(Container {
240            runtime: "podman",
241            id: trim_scope(rest).to_string(),
242        });
243    }
244
245    if let Some(pos) = path.find("lxc.payload.") {
246        let rest = &path[pos + "lxc.payload.".len()..];
247        return Some(Container {
248            runtime: "lxc",
249            id: rest.trim_end_matches('/').to_string(),
250        });
251    }
252
253    // systemd-nspawn: `/machine.slice/machine-<name>.scope`. htop treats these as
254    // containers (`[SNC:*]`), so we do too.
255    if let Some(pos) = path.find("machine-") {
256        let rest = &path[pos + "machine-".len()..];
257        return Some(Container {
258            runtime: "nspawn",
259            id: trim_scope(rest).to_string(),
260        });
261    }
262
263    // A bare `containerd` leaf with no pod above it, e.g.
264    // `/system.slice/containerd.service` — that is the *host* daemon, not a
265    // container, and classifying it would label every process on the box.
266    None
267}
268
269/// Trim a `.scope` / `.slice` suffix and any trailing slash.
270fn trim_scope(s: &str) -> &str {
271    let s = s.trim_end_matches('/');
272    s.strip_suffix(".scope")
273        .or_else(|| s.strip_suffix(".slice"))
274        .unwrap_or(s)
275}
276
277/// The PID namespace inode of the running process, i.e. gander's own.
278///
279/// This is the baseline for [`in_different_pid_namespace`], and it is deliberately
280/// *our* namespace rather than `/proc/1`'s. htop does the same
281/// (`LinuxProcessTable.c` reads `PROCDIR "/self/ns/pid"` into `rootPidNs`).
282///
283/// Using `/proc/1` looks equivalent and is not: inside a container, pid 1 *is* the
284/// container's init, so comparing against it says "same namespace" for every
285/// process in that container, and no process is ever reported as containerised.
286/// That is exactly backwards — everything visible from inside a container is in
287/// it. Anchoring on our own namespace answers the question the column asks:
288/// "is this process in a *nested* namespace below the one I am watching from?"
289pub fn self_pid_namespace(proc_root: &Path) -> Option<u64> {
290    use std::os::unix::fs::MetadataExt as _;
291    std::fs::metadata(proc_root.join("self/ns/pid"))
292        .ok()
293        .map(|m| m.ino())
294}
295
296/// Whether `pid` lives in a different PID namespace than the caller.
297///
298/// # Why this exists on top of the cgroup path
299///
300/// The cgroup path is the *informative* signal — it names the runtime and the
301/// container — but it is not a reliable *membership* test, because a container
302/// with its own cgroup namespace sees `/` in `/proc/<pid>/cgroup`. A cgroup-only
303/// check therefore reports "no container" for a process whose cgroup path says
304/// nothing.
305///
306/// The PID namespace is the membership test: two processes in the same namespace
307/// share an `ns/pid` inode, and a process in a *child* namespace has a different
308/// one. Needs no privileges for a process we can already see.
309///
310/// Returns `None` when the comparison cannot be made (no `/proc/<pid>/ns/pid`,
311/// which is the normal case for another user's process on a hardened kernel), so
312/// the caller can distinguish "not containerised" from "unknown".
313pub fn in_different_pid_namespace(proc_root: &Path, pid: crate::Pid) -> Option<bool> {
314    use std::os::unix::fs::MetadataExt as _;
315    let mine = std::fs::metadata(proc_root.join(pid.to_string()).join("ns/pid"))
316        .ok()?
317        .ino();
318    let ours = self_pid_namespace(proc_root)?;
319    Some(mine != ours)
320}
321
322/// Pull the pod uid and, if present, the container id out of a `kubepods` path.
323///
324/// Kubernetes nests one slice per QoS class and one per pod, then a leaf per
325/// container. The pod uid identifies *which* pod, which is what a column should
326/// show; the leaf id changes on every restart.
327fn kubernetes_id(rest: &str) -> String {
328    // `kubepods-burstable-pod<uid>_<uid>.slice` — the uid appears twice, once in
329    // the slice name and once as the slice's own suffix.
330    if let Some(pos) = rest.find("-pod") {
331        let after = &rest[pos + "-pod".len()..];
332        let end = after
333            .find(['.', '_', '/'])
334            .unwrap_or(after.len());
335        let uid = &after[..end];
336        if !uid.is_empty() {
337            return uid.to_string();
338        }
339    }
340    // A bare `/kubepods/<id>` leaf (cgroup v1, CRI-O).
341    rest.trim_start_matches('/')
342        .split('/')
343        .next()
344        .map(trim_scope)
345        .unwrap_or("")
346        .to_string()
347}
348
349fn trim(mut b: &[u8]) -> &[u8] {
350    while let Some((first, rest)) = b.split_first() {
351        if first.is_ascii_whitespace() {
352            b = rest;
353        } else {
354            break;
355        }
356    }
357    while let Some((last, rest)) = b.split_last() {
358        if last.is_ascii_whitespace() {
359            b = rest;
360        } else {
361            break;
362        }
363    }
364    b
365}
366
367/// Whether `/proc/<pid>/smaps_rollup` exists for this pid.
368pub fn smaps_rollup_available(proc_root: &Path, pid: crate::Pid) -> bool {
369    proc_root
370        .join(pid.to_string())
371        .join("smaps_rollup")
372        .exists()
373}
374
375/// Read and parse `/proc/<pid>/smaps_rollup`.
376///
377/// # Why the caller has to gate this
378///
379/// The kernel walks every VMA in the process's address space to build this file.
380/// Measured on a desktop with ~700 processes, reading it for all of them costs
381/// tens of milliseconds — more than the entire rest of the scan. It is therefore
382/// only called when a column that needs it (`PSS`, `EPSS`, `M_PRIVATE`) is
383/// actually visible, and never otherwise.
384///
385/// Returns `None` when the file is missing (a kernel older than 4.14), or when it
386/// cannot be read: another user's `smaps_rollup` needs `CAP_SYS_PTRACE`, and
387/// `EPERM` there is normal rather than an error to report.
388pub fn read_smaps_rollup(proc_root: &Path, pid: crate::Pid) -> Option<SmapsRollup> {
389    let path = proc_root.join(pid.to_string()).join("smaps_rollup");
390    let bytes = std::fs::read(path).ok()?;
391    parse_smaps_rollup(&bytes)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    const SMAPS: &[u8] = b"55d0e0000000-7ffe00000000 ---p 00000000 00:00 0                  [rollup]\nRss:               4800 kB\nPss:               2400 kB\nPss_Anon:          1600 kB\nPss_File:           500 kB\nPss_Shmem:          300 kB\nPrivate_Clean:      400 kB\nPrivate_Dirty:      900 kB\nSwap:               100 kB\nSwapPss:             50 kB\n";
399
400    #[test]
401    fn smaps_rollup_parsing() {
402        let r = parse_smaps_rollup(SMAPS).expect("must parse");
403        assert_eq!(r.rss, 4800 * 1024);
404        assert_eq!(r.pss, 2400 * 1024);
405        assert_eq!(r.swap_pss, 50 * 1024);
406        assert_eq!(r.epss(), (2400 + 50) * 1024);
407        assert_eq!(r.private(), (400 + 900) * 1024);
408    }
409
410    #[test]
411    fn smaps_rollup_rejects_garbage() {
412        assert!(parse_smaps_rollup(b"hello world\n").is_none());
413    }
414
415    #[test]
416    fn cgroup_v2() {
417        let e = parse_cgroup(b"0::/user.slice/user-1000.slice/session-1.scope\n");
418        assert_eq!(e.len(), 1);
419        assert_eq!(e[0].hierarchy, 0);
420        assert!(e[0].controllers.is_empty());
421        assert_eq!(e[0].path, "/user.slice/user-1000.slice/session-1.scope");
422    }
423
424    #[test]
425    fn cgroup_v1_multi() {
426        let buf =
427            b"12:cpuset:/docker/abc123\n5:memory:/docker/abc123\n1:name=systemd:/user.slice/x\n";
428        let e = parse_cgroup(buf);
429        assert_eq!(e.len(), 3);
430        let c = container_from_cgroup(&e).expect("docker");
431        assert_eq!(c.runtime, "docker");
432        assert_eq!(c.id, "abc123");
433    }
434
435    #[test]
436    fn no_container_for_plain_host_process() {
437        let e = parse_cgroup(b"0::/user.slice/session-2.scope\n");
438        assert_eq!(container_from_cgroup(&e), None);
439    }
440
441    /// A containerised process must be identifiable, not just flagged.
442    ///
443    /// `container_from_cgroup` used to return the bare marker, so every
444    /// containerised process on the machine rendered the same word and the column
445    /// carried no information.
446    #[test]
447    fn docker_paths_yield_the_container_id() {
448        // cgroup v1
449        let c = container_from_path("/docker/1f2e3d4c5b6a7988").expect("docker");
450        assert_eq!(c.runtime, "docker");
451        assert_eq!(c.id, "1f2e3d4c5b6a7988");
452
453        // cgroup v2 via systemd, which is what a current Docker on a systemd host
454        // writes. The `.scope` suffix must not leak into the id.
455        let c = container_from_path("/system.slice/docker-1f2e3d4c5b6a7988.scope").expect("docker");
456        assert_eq!(c.runtime, "docker");
457        assert_eq!(c.id, "1f2e3d4c5b6a7988");
458    }
459
460    #[test]
461    fn podman_paths_are_recognised() {
462        let c = container_from_path("/machine.slice/libpod-abc123def456.scope").expect("podman");
463        assert_eq!(c.runtime, "podman");
464        assert_eq!(c.id, "abc123def456");
465
466        let c = container_from_path("/user.slice/podman-999.scope").expect("podman");
467        assert_eq!(c.runtime, "podman");
468        assert_eq!(c.id, "999");
469    }
470
471    #[test]
472    fn lxc_and_nspawn_paths_are_recognised() {
473        let c = container_from_path("/lxc.payload.web-1").expect("lxc");
474        assert_eq!(c.runtime, "lxc");
475        assert_eq!(c.id, "web-1");
476
477        let c = container_from_path("/machine.slice/machine-build.scope").expect("nspawn");
478        assert_eq!(c.runtime, "nspawn");
479        assert_eq!(c.id, "build");
480    }
481
482    /// Kubernetes nests QoS class → pod → container. The pod uid is the useful
483    /// identifier; the leaf changes on every restart.
484    #[test]
485    fn kubernetes_paths_yield_the_pod_uid() {
486        let path = "/kubepods.slice/kubepods-burstable.slice/\
487                    kubepods-burstable-pod1a2b3c4d-5e6f-7890-abcd-ef1234567890.slice/\
488                    cri-containerd-9f8e7d6c5b4a3210.scope";
489        let c = container_from_path(path).expect("kubepods");
490        assert_eq!(c.runtime, "kubepods");
491        assert_eq!(c.id, "1a2b3c4d-5e6f-7890-abcd-ef1234567890");
492    }
493
494    #[test]
495    fn kubernetes_wins_over_the_containerd_leaf() {
496        // The leaf says `cri-containerd-…`, so testing `containerd` first would
497        // classify this as plain containerd and lose the pod identity.
498        let path = "/kubepods.slice/kubepods-besteffort.slice/\
499                    kubepods-besteffort-podabcd.slice/cri-containerd-1234.scope";
500        let c = container_from_path(path).expect("a container");
501        assert_eq!(c.runtime, "kubepods");
502        assert_eq!(c.id, "abcd");
503    }
504
505    /// The host's own `containerd.service` is not a container. Classifying it
506    /// would label every process on the machine as containerised.
507    #[test]
508    fn the_host_daemon_is_not_a_container() {
509        assert_eq!(container_from_path("/system.slice/containerd.service"), None);
510        assert_eq!(container_from_path("/system.slice/docker.service"), None);
511        assert_eq!(container_from_path("/"), None);
512        assert_eq!(container_from_path("/init.scope"), None);
513        assert_eq!(container_from_path("/user.slice/user-1000.slice/session-1.scope"), None);
514    }
515
516    #[test]
517    fn short_id_is_twelve_characters() {
518        let c = Container {
519            runtime: "docker",
520            id: "1f2e3d4c5b6a79880a1b2c3d4e5f6071".to_string(),
521        };
522        assert_eq!(c.short_id(), "1f2e3d4c5b6a");
523
524        // A short id (LXC name, a pod uid prefix) is returned whole.
525        let c = Container {
526            runtime: "lxc",
527            id: "web-1".to_string(),
528        };
529        assert_eq!(c.short_id(), "web-1");
530
531        let c = Container {
532            runtime: "kubepods",
533            id: String::new(),
534        };
535        assert_eq!(c.short_id(), "");
536    }
537}