1use std::collections::HashSet;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Verdict {
11 Freeze,
12 CapOnly,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Coverage {
18 Full,
19 Partial,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Mechanism {
25 Unit,
26 Raw,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Candidate {
32 pub cgroup: String,
35 pub unit: Option<String>,
37 pub mechanism: Mechanism,
38}
39
40#[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
50pub 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 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 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
89pub 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 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 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 #[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 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 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 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}