From dc78b558209b1e9e83961a6036cbcd4891ff12a4 Mon Sep 17 00:00:00 2001 From: "Eng. Mahmoud Bek ElNemer" Date: Tue, 10 Nov 2026 02:41:13 +0200 Subject: [PATCH] fix(memory): detect unmounted cgroup sandboxes with empty /sys/fs/cgroup or empty mount source (#12426) Linux sandboxes such as Flatpak/bubblewrap can leave an empty `/sys/fs/cgroup` mount-point directory exposed through `sysfs` without mounting `cgroup`/`cgroup2`, and emit `tmpfs` mountinfo entries with an empty mount source (`- tmpfs rw,...`). Both caused `cgroup_limits` to fail closed as `Unreadable`, reporting 0 B system memory. Treat a readable empty `/sys/fs/cgroup` directory the same as `NotFound` when `/proc/self/mountinfo` parses cleanly with no cgroup mounts, and accept empty mount sources in `parse_mountinfo` while keeping missing, empty, truncated, cgroup-mounted, non-empty, or unreadable states strictly fail-closed. Fixes #12426 --- crates/raster/src/memory/system.rs | 154 ++++++++++++++++++++++++++--- 1 file changed, 138 insertions(+), 16 deletions(-) diff --git a/crates/raster/src/memory/system.rs b/crates/raster/src/memory/system.rs index 998720b8b5..5ad4ebf5e5 100644 --- a/crates/raster/src/memory/system.rs +++ b/crates/raster/src/memory/system.rs @@ -127,12 +127,12 @@ pub(super) mod linux { } #[derive(Clone, Debug, PartialEq, Eq)] - struct Mount { - root: String, - mount_point: String, - fstype: String, - options: String, - super_options: String, + pub(super) struct Mount { + pub(super) root: String, + pub(super) mount_point: String, + pub(super) fstype: String, + pub(super) options: String, + pub(super) super_options: String, } /// Measures available RAM and free swap from `/proc/meminfo`, capped by the process's memory @@ -206,14 +206,14 @@ pub(super) mod linux { MembershipParse::Malformed => return CgroupLimits::Unreadable, }; let mountinfo = std::fs::read_to_string(proc_root.join("self/mountinfo")).ok(); - // A sandbox with no cgroup filesystem mounted and no `/sys/fs/cgroup` at all (Flatpak) - // shows the process its membership path but no interface files, so no limit is - // observable. Failing closed there reported 0 bytes free and refused every PSD on a - // machine with tens of GB. A present directory still takes the guarded path below. - // Only a confirmed NotFound counts as absent: a stat that fails for another reason - // (permissions) leaves the directory possibly present, so that case stays fail-closed. - let sys_root_absent = matches!(std::fs::metadata(sys_root), Err(e) if e.kind() == std::io::ErrorKind::NotFound); - if sys_root_absent && mountinfo.as_deref().is_some_and(lists_mounts_without_cgroup) { + // A sandbox with no cgroup filesystem mounted and no `/sys/fs/cgroup` at all (Flatpak) or + // only an empty mount-point directory left by `sysfs` shows the process its membership path + // but no interface files, so no limit is observable. Failing closed there reported 0 bytes + // free and refused every PSD on a machine with tens of GB. Only a present non-empty or + // unreadable directory takes the guarded path below: a stat or read_dir that fails for + // another reason (permissions, non-directory) leaves a mounted hierarchy possibly present, + // so that case stays fail-closed. + if sys_root_absent_or_empty(sys_root) && mountinfo.as_deref().is_some_and(lists_mounts_without_cgroup) { return CgroupLimits::NotConstrained; } @@ -273,16 +273,34 @@ pub(super) mod linux { MembershipParse::Parsed(membership) } + /// Whether `/sys/fs/cgroup` is absent (`ErrorKind::NotFound`) or a readable empty directory. + /// + /// Permission errors, non-directory paths, and unreadable directories return `false` and stay + /// fail-closed. + pub(super) fn sys_root_absent_or_empty(sys_root: &Path) -> bool { + match std::fs::metadata(sys_root) { + Err(e) => e.kind() == std::io::ErrorKind::NotFound, + Ok(meta) => meta.is_dir() && std::fs::read_dir(sys_root).is_ok_and(|mut entries| entries.next().is_none()), + } + } + /// Parses `/proc/self/mountinfo` into the mount fields used to locate a cgroup hierarchy. /// /// Malformed lines are skipped; callers fail closed when no matching mount is found. - fn parse_mountinfo(text: &str) -> Vec { + pub(super) fn parse_mountinfo(text: &str) -> Vec { let mut mounts = Vec::new(); for line in text.lines() { let fields: Vec<&str> = line.split_whitespace().collect(); let Some(separator) = fields.iter().position(|f| *f == "-") else { continue }; + let super_options = fields.get(separator + 3).copied().or_else(|| { + // `show_mountinfo` writes ` `; an empty source + // (e.g. a bubblewrap tmpfs mount) leaves two spaces between `fstype` and options. + let after = line.split_once(" - ")?.1.trim_start(); + let fstype = fields.get(separator + 1).copied()?; + after.strip_prefix(fstype)?.starts_with(" ").then(|| fields.get(separator + 2).copied()).flatten() + }); let (Some(root), Some(point), Some(fstype), Some(super_options)) = - (fields.get(3).copied(), fields.get(4).copied(), fields.get(separator + 1).copied(), fields.get(separator + 3).copied()) + (fields.get(3).copied(), fields.get(4).copied(), fields.get(separator + 1).copied(), super_options) else { continue; }; @@ -966,6 +984,23 @@ mod tests { assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + // Isolated case 1: a readable empty `/sys/fs/cgroup` mount-point directory with normal + // non-cgroup `mountinfo` also reports `NotConstrained`. + let (proc, sys) = fixture("empty-cgroup-mountpoint"); + write(&proc.join("self/cgroup"), "0::/app.slice/app-flatpak.scope\n"); + write(&proc.join("self/mountinfo"), "25 20 0:23 / /proc rw - proc proc rw\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + // Isolated case 2: absent `/sys/fs/cgroup` with a bubblewrap empty-source non-cgroup mount + // (`- tmpfs rw,...`) parses cleanly and reports `NotConstrained`. + let (proc, _) = fixture("absent-cgroup-empty-source"); + let sys = proc.parent().unwrap().join("absent-sys"); + write(&proc.join("self/cgroup"), "0::/app.slice/app-flatpak.scope\n"); + write(&proc.join("self/mountinfo"), "25 20 0:23 / /proc rw - proc proc rw\n26 20 0:24 / /tmp rw,nosuid,nodev - tmpfs rw,nosuid,nodev\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + // A truncated mount line may be the cgroup mount: with /sys/fs/cgroup absent, fail closed. let (proc, _) = fixture("truncated-mountinfo-line"); let sys = proc.parent().unwrap().join("absent-sys"); @@ -1269,4 +1304,91 @@ mod tests { assert_eq!(linux::available_at(&proc, &sys), Some((4 << 30) + (512 << 20)), "legacy helper keeps RAM + swap"); std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); } + + #[cfg(target_os = "linux")] + #[test] + fn linux_parse_mountinfo_handles_empty_mount_source() { + let tmpfs = "26 20 0:24 / /tmp rw,nosuid,nodev - tmpfs rw,nosuid,nodev,size=65536k\n"; + let cg2 = "30 20 0:26 /user.slice /sys/fs/cg\\0402 rw,relatime - cgroup2 rw,nsdelegate\n"; + let truncated = "31 20 0:27 / /sys/fs/cgroup rw - cgroup2 rw\n"; + let mounts = linux::parse_mountinfo(&format!("{tmpfs}{cg2}{truncated}")); + assert_eq!(mounts.len(), 2, "single-space line missing super_options is skipped"); + let m0 = &mounts[0]; + assert_eq!((&m0.root[..], &m0.mount_point[..], &m0.fstype[..]), ("/", "/tmp", "tmpfs")); + assert_eq!((&m0.options[..], &m0.super_options[..]), ("rw,nosuid,nodev", "rw,nosuid,nodev,size=65536k")); + let m1 = &mounts[1]; + assert_eq!((&m1.root[..], &m1.mount_point[..], &m1.fstype[..]), ("/user.slice", "/sys/fs/cg 2", "cgroup2")); + assert_eq!((&m1.options[..], &m1.super_options[..]), ("rw,relatime", "rw,nsdelegate")); + } + + /// An empty `/sys/fs/cgroup` directory only reports `NotConstrained` when `mountinfo` parses + /// cleanly with no cgroup mount; cgroup mounts (including empty-source lines), missing/empty/ + /// truncated `mountinfo`, non-empty directories, files, and unreadable directories fail closed. + #[cfg(target_os = "linux")] + #[test] + fn linux_empty_or_invalid_sys_root_fails_closed_unless_proven_unmounted() { + use std::os::unix::fs::PermissionsExt; + + let bad_mountinfo: [(&str, Option<&str>); 6] = [ + ("empty-sys-missing-mountinfo", None), + ("empty-sys-empty-mountinfo", Some("")), + ("empty-sys-garbage-mountinfo", Some("not a mountinfo line\n")), + ("empty-sys-truncated-mountinfo", Some("25 20 0:23 / /proc rw - proc proc rw\n30 20 0:26 / /sys/fs/cgroup rw - cgroup2\n")), + ("empty-sys-cgroup2-mount", Some("30 20 0:26 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n")), + ("empty-sys-empty-source-cgroup2", Some("30 20 0:26 / /sys/fs/cgroup rw - cgroup2 rw\n")), + ]; + for (name, mountinfo) in bad_mountinfo { + let (proc, sys) = fixture(name); + write(&proc.join("self/cgroup"), "0::/app\n"); + if let Some(text) = mountinfo { + write(&proc.join("self/mountinfo"), text); + } + assert!(linux::sys_root_absent_or_empty(&sys), "{name}"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "{name}"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + // Empty `/sys/fs/cgroup` with a v1 memory mount in `mountinfo` also fails closed. + let (proc, sys) = fixture("empty-sys-cgroup-v1-mount"); + write(&proc.join("self/cgroup"), "4:memory:/app\n"); + write(&proc.join("self/mountinfo"), "31 24 0:26 / /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + // A non-empty `/sys/fs/cgroup` directory is not treated as unmounted even with non-cgroup mountinfo. + let (proc, sys) = fixture("non-empty-sys-root"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), "25 20 0:23 / /proc rw - proc proc rw\n"); + write(&sys.join("memory.max"), "1073741824\n"); + assert!(!linux::sys_root_absent_or_empty(&sys)); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + // `sys_root` as a regular file and a `NotADirectory` path component return false and fail closed. + let (proc, _) = fixture("file-sys-root"); + let file_sys = proc.parent().unwrap().join("sys-as-file"); + write(&file_sys, ""); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), "25 20 0:23 / /proc rw - proc proc rw\n"); + for path in [&file_sys, &file_sys.join("cgroup")] { + assert!(!linux::sys_root_absent_or_empty(path)); + assert_eq!(linux::cgroup_limits(&proc, path), linux::CgroupLimits::Unreadable); + } + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + // An unreadable `/sys/fs/cgroup` directory (`read_dir` error) returns false and fails closed. + let (proc, sys) = fixture("unreadable-sys-root"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), "25 20 0:23 / /proc rw - proc proc rw\n"); + std::fs::set_permissions(&sys, std::fs::Permissions::from_mode(0o000)).unwrap(); + let read_blocked = std::fs::read_dir(&sys).is_err(); + let absent = linux::sys_root_absent_or_empty(&sys); + let limits = linux::cgroup_limits(&proc, &sys); + std::fs::set_permissions(&sys, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + if read_blocked { + assert!(!absent); + assert_eq!(limits, linux::CgroupLimits::Unreadable); + } + } }