Skip to main content

rlmctl_core/guard/
resolve.rs

1//! Pure target resolution — determines whether a victim cgroup should be frozen,
2//! capped, or protected based on its path and membership.
3//!
4//! No syscalls, no clock reads, no filesystem access. Pure logic over paths and process names.
5
6use std::collections::HashSet;
7
8/// Verdict: freeze or cap-only.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Verdict {
11    Freeze,
12    CapOnly,
13}
14
15/// Coverage: full freezing or partial (due to protected processes).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Coverage {
18    Full,
19    Partial,
20}
21
22/// Resolution mechanism: systemd unit or raw cgroup.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Mechanism {
25    Unit,
26    Raw,
27}
28
29/// Candidate target for resolution.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Candidate {
32    /// cgroupfs path relative to /sys/fs/cgroup, e.g.
33    /// "/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-12.scope"
34    pub cgroup: String,
35    /// systemd unit name (the final path component) when mechanism == Unit.
36    pub unit: Option<String>,
37    pub mechanism: Mechanism,
38}
39
40/// Final resolution with verdict and coverage.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Resolution {
43    pub cgroup: String,
44    pub unit: Option<String>,
45    pub verdict: Verdict,
46    pub coverage: Coverage,
47    pub mechanism: Mechanism,
48}
49
50/// Pure. `victim_cgroup` is the "0::" path from /proc/<pid>/cgroup.
51/// `rlm_base` is CgroupManager::base_path() minus the "/sys/fs/cgroup" prefix.
52pub fn candidate_target(victim_cgroup: &str, uid: u32, rlm_base: &str) -> Option<Candidate> {
53    let app_root = format!("/user.slice/user-{uid}.slice/user@{uid}.service/app.slice/");
54    if let Some(rest) = victim_cgroup.strip_prefix(&app_root) {
55        // Deepest component ending in .scope/.service wins.
56        let comps: Vec<&str> = rest.split('/').filter(|c| !c.is_empty()).collect();
57        let unit_idx = comps
58            .iter()
59            .rposition(|c| c.ends_with(".scope") || c.ends_with(".service"))?;
60        let unit = comps[unit_idx].to_string();
61        let cgroup = format!("{app_root}{}", comps[..=unit_idx].join("/"));
62        return Some(Candidate {
63            cgroup,
64            unit: Some(unit),
65            mechanism: Mechanism::Unit,
66        });
67    }
68    let rlm_root = format!("{}/", rlm_base.trim_end_matches('/'));
69    if let Some(rest) = victim_cgroup.strip_prefix(&rlm_root) {
70        let first = rest.split('/').find(|c| !c.is_empty())?;
71        // The shared "unlimit" leaf is where every `rlm unlimit`/teardown
72        // dumps released processes (and where `sweep_guard_leftovers` dumps
73        // legacy `guard-<pid>` victims on upgrade) — it's a grab-bag of
74        // processes the user explicitly released from rlm's control, not a
75        // valid freeze/cap target. `status.rs` already excludes it by the
76        // same name; mirror that here (D3 fix).
77        if first == crate::cgroup::UNLIMIT_CGROUP_NAME {
78            return None;
79        }
80        return Some(Candidate {
81            cgroup: format!("{rlm_root}{first}"),
82            unit: None,
83            mechanism: Mechanism::Raw,
84        });
85    }
86    None
87}
88
89/// Pure. `member_exes` = exe basenames of every process in the candidate cgroup.
90pub fn finalize(c: Candidate, member_exes: &[String], protect: &HashSet<String>) -> Resolution {
91    let protected = member_exes.iter().any(|e| protect.contains(e));
92    let (verdict, coverage) = if protected {
93        (Verdict::CapOnly, Coverage::Partial)
94    } else {
95        (Verdict::Freeze, Coverage::Full)
96    };
97    Resolution {
98        cgroup: c.cgroup,
99        unit: c.unit,
100        verdict,
101        coverage,
102        mechanism: c.mechanism,
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    const RLM: &str = "/user.slice/user-1000.slice/user@1000.service/rlm";
111
112    #[test]
113    fn scope_under_app_slice_resolves_to_unit() {
114        let c = candidate_target(
115            "/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-12.scope",
116            1000,
117            RLM,
118        )
119        .unwrap();
120        assert_eq!(
121            c.cgroup,
122            "/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-12.scope"
123        );
124        assert_eq!(c.unit.as_deref(), Some("app-firefox-12.scope"));
125        assert_eq!(c.mechanism, Mechanism::Unit);
126    }
127
128    #[test]
129    fn process_deep_inside_scope_resolves_to_scope_boundary() {
130        // Firefox content process nested below the scope.
131        let c = candidate_target(
132            "/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-12.scope/child",
133            1000,
134            RLM,
135        )
136        .unwrap();
137        assert_eq!(c.unit.as_deref(), Some("app-firefox-12.scope"));
138    }
139
140    #[test]
141    fn deepest_unit_wins_for_nested_service_paths() {
142        // app.slice/app-x.slice/foo.service style nesting: pick foo.service, not a slice.
143        let c = candidate_target(
144            "/user.slice/user-1000.slice/user@1000.service/app.slice/app-x.slice/foo.service/leaf",
145            1000,
146            RLM,
147        )
148        .unwrap();
149        assert_eq!(c.unit.as_deref(), Some("foo.service"));
150        assert!(c.cgroup.ends_with("app.slice/app-x.slice/foo.service"));
151    }
152
153    #[test]
154    fn rlm_rule_cgroup_resolves_raw() {
155        let c = candidate_target(&format!("{RLM}/app-firefox"), 1000, RLM).unwrap();
156        assert_eq!(c.cgroup, format!("{RLM}/app-firefox"));
157        assert_eq!(c.unit, None);
158        assert_eq!(c.mechanism, Mechanism::Raw);
159    }
160
161    /// D3 fix: the shared `unlimit` leaf holds processes the user explicitly
162    /// released from rlm's control (and, on upgrade, legacy `guard-<pid>`
163    /// victims swept there at startup) — it must never resolve as a
164    /// freeze/cap target, mirroring `status.rs`'s exclusion of the same
165    /// cgroup.
166    #[test]
167    fn unlimit_bucket_is_not_a_target() {
168        assert_eq!(
169            candidate_target(
170                &format!("{RLM}/{}", crate::cgroup::UNLIMIT_CGROUP_NAME),
171                1000,
172                RLM,
173            ),
174            None
175        );
176        // Nor is a process nested somewhere below it.
177        assert_eq!(
178            candidate_target(
179                &format!("{RLM}/{}/child", crate::cgroup::UNLIMIT_CGROUP_NAME),
180                1000,
181                RLM,
182            ),
183            None
184        );
185    }
186
187    #[test]
188    fn session_slice_is_outside_permitted_roots() {
189        assert_eq!(
190            candidate_target(
191                "/user.slice/user-1000.slice/user@1000.service/session.slice/org.gnome.Shell@ubuntu.service",
192                1000,
193                RLM,
194            ),
195            None
196        );
197    }
198
199    #[test]
200    fn rlm_base_itself_is_not_a_target() {
201        // "strictly below": the base dir itself must not resolve.
202        assert_eq!(candidate_target(RLM, 1000, RLM), None);
203    }
204
205    #[test]
206    fn app_slice_without_unit_component_is_none() {
207        assert_eq!(
208            candidate_target(
209                "/user.slice/user-1000.slice/user@1000.service/app.slice",
210                1000,
211                RLM,
212            ),
213            None
214        );
215    }
216
217    #[test]
218    fn other_uid_path_is_none() {
219        assert_eq!(
220            candidate_target(
221                "/user.slice/user-1001.slice/user@1001.service/app.slice/app-x-1.scope",
222                1000,
223                RLM,
224            ),
225            None
226        );
227    }
228
229    #[test]
230    fn finalize_unprotected_is_freeze_full() {
231        let c = candidate_target(
232            "/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-12.scope",
233            1000,
234            RLM,
235        )
236        .unwrap();
237        let protect: HashSet<String> = ["bash".to_string()].into();
238        let r = finalize(c, &["firefox".into(), "Isolated Web Co".into()], &protect);
239        assert_eq!(r.verdict, Verdict::Freeze);
240        assert_eq!(r.coverage, Coverage::Full);
241    }
242
243    #[test]
244    fn finalize_protected_member_degrades_to_caponly_partial() {
245        // alacritty case: shell shares the leaf with the runaway script.
246        let c = candidate_target(
247            "/user.slice/user-1000.slice/user@1000.service/app.slice/app-alacritty-9.scope",
248            1000,
249            RLM,
250        )
251        .unwrap();
252        let protect: HashSet<String> = ["zsh".to_string()].into();
253        let r = finalize(c, &["zsh".into(), "python3".into()], &protect);
254        assert_eq!(r.verdict, Verdict::CapOnly);
255        assert_eq!(r.coverage, Coverage::Partial);
256    }
257}