1use std::sync::OnceLock;
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24pub enum DaemonMode {
25 Full,
27 NestedAdaptive,
29 Degraded,
31}
32
33#[allow(clippy::struct_excessive_bools)]
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct DaemonCapabilities {
45 pub is_root: bool,
47 pub is_nested: bool,
50 pub cgroup_parent: Option<String>,
54 pub can_write_cgroup_root: bool,
58 pub has_cap_net_admin: bool,
61 pub tun_device_available: bool,
64 pub effective_mode: DaemonMode,
66}
67
68static CAPS: OnceLock<DaemonCapabilities> = OnceLock::new();
71
72impl DaemonCapabilities {
73 pub fn get() -> &'static Self {
80 CAPS.get_or_init(Self::probe)
81 }
82
83 pub fn seed(caps: Self) -> &'static Self {
97 let _ = CAPS.set(caps);
98 CAPS.get()
99 .expect("CAPS is filled after set or was already filled")
100 }
101
102 #[must_use]
109 pub fn probe() -> Self {
110 let is_root = zlayer_paths::is_root();
111 let cgroup_parent = current_cgroup_v2_path();
112 let is_nested = cgroup_parent.is_some();
113 let can_write_cgroup_root = probe_can_write_cgroup_root();
114 let has_cap_net_admin = probe_has_cap_net_admin();
115 let tun_device_available = probe_tun_device_available();
116
117 let effective_mode =
118 if !is_nested && can_write_cgroup_root && has_cap_net_admin && tun_device_available {
119 DaemonMode::Full
120 } else if can_write_cgroup_root || cgroup_parent.is_some() {
121 DaemonMode::NestedAdaptive
122 } else {
123 DaemonMode::Degraded
124 };
125
126 Self {
127 is_root,
128 is_nested,
129 cgroup_parent,
130 can_write_cgroup_root,
131 has_cap_net_admin,
132 tun_device_available,
133 effective_mode,
134 }
135 }
136}
137
138#[cfg(target_os = "linux")]
146fn parse_cgroup_v2_line(content: &str) -> Option<String> {
147 for line in content.lines() {
148 if let Some(rest) = line.strip_prefix("0::") {
149 let trimmed = rest.trim();
150 if trimmed.is_empty() || trimmed == "/" {
151 return None;
152 }
153 return Some(trimmed.to_string());
154 }
155 }
156 None
157}
158
159#[cfg(target_os = "linux")]
165#[must_use]
166pub fn current_cgroup_v2_path() -> Option<String> {
167 let content = std::fs::read_to_string("/proc/self/cgroup").ok()?;
168 parse_cgroup_v2_line(&content)
169}
170
171#[cfg(not(target_os = "linux"))]
172#[must_use]
173pub fn current_cgroup_v2_path() -> Option<String> {
174 None
175}
176
177#[cfg(target_os = "linux")]
186fn compute_target_parent(scope: &str) -> String {
187 let base = scope.strip_suffix("/init").unwrap_or(scope);
188 let base = base.trim_end_matches('/');
189 format!("{base}/containers")
190}
191
192#[cfg(target_os = "linux")]
201#[must_use]
202pub fn ensure_daemon_leaf_and_container_parent() -> Option<String> {
203 let scope = current_cgroup_v2_path()?;
204 let containers = compute_target_parent(&scope);
205 if scope.ends_with("/init") {
207 let containers_fs = format!("/sys/fs/cgroup{containers}");
208 match std::fs::create_dir_all(&containers_fs) {
209 Ok(()) => {}
210 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
211 Err(_) => return None,
212 }
213 return Some(containers);
214 }
215
216 let scope = scope.trim_end_matches('/').to_string();
217 let mount = "/sys/fs/cgroup";
218 let init_dir = format!("{mount}{scope}/init");
219
220 match std::fs::create_dir_all(&init_dir) {
221 Ok(()) => {}
222 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
223 Err(_) => return None,
224 }
225
226 let pid_path = format!("{init_dir}/cgroup.procs");
227 let pid_str = format!("{}", std::process::id());
228 if std::fs::write(&pid_path, &pid_str).is_err() {
229 let now = current_cgroup_v2_path()?;
231 if now != format!("{scope}/init") {
232 return None;
233 }
234 }
235
236 let after = current_cgroup_v2_path()?;
238 if after != format!("{scope}/init") {
239 return None;
240 }
241
242 let containers_dir = format!("{mount}{containers}");
243 match std::fs::create_dir_all(&containers_dir) {
244 Ok(()) => {}
245 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
246 Err(_) => return None,
247 }
248
249 Some(containers)
250}
251
252#[cfg(not(target_os = "linux"))]
253#[must_use]
254pub fn ensure_daemon_leaf_and_container_parent() -> Option<String> {
255 None
256}
257
258#[cfg(target_os = "linux")]
259fn probe_can_write_cgroup_root() -> bool {
260 use std::ffi::CString;
261
262 let Ok(path) = CString::new("/sys/fs/cgroup/cgroup.subtree_control") else {
263 return false;
264 };
265 #[allow(unsafe_code)]
268 let rc = unsafe { libc::access(path.as_ptr(), libc::W_OK) };
269 rc == 0
270}
271
272#[cfg(not(target_os = "linux"))]
273fn probe_can_write_cgroup_root() -> bool {
274 false
275}
276
277#[cfg(target_os = "linux")]
278fn probe_has_cap_net_admin() -> bool {
279 const CAP_NET_ADMIN_BIT: u64 = 1 << 12;
285 let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
286 return false;
287 };
288 for line in status.lines() {
289 if let Some(hex) = line.strip_prefix("CapEff:") {
290 let trimmed = hex.trim();
291 if let Ok(eff) = u64::from_str_radix(trimmed, 16) {
292 return eff & CAP_NET_ADMIN_BIT != 0;
293 }
294 return false;
295 }
296 }
297 false
298}
299
300#[cfg(not(target_os = "linux"))]
301fn probe_has_cap_net_admin() -> bool {
302 false
303}
304
305#[cfg(target_os = "linux")]
306fn probe_tun_device_available() -> bool {
307 use std::os::unix::fs::OpenOptionsExt;
308
309 std::fs::OpenOptions::new()
314 .read(true)
315 .write(true)
316 .custom_flags(libc::O_NONBLOCK)
317 .open("/dev/net/tun")
318 .is_ok()
319}
320
321#[cfg(not(target_os = "linux"))]
322fn probe_tun_device_available() -> bool {
323 false
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn probe_does_not_panic_and_is_nested_agrees_with_cgroup_parent() {
332 let caps = DaemonCapabilities::probe();
333 assert_eq!(caps.is_nested, caps.cgroup_parent.is_some());
334 }
335
336 #[cfg(target_os = "linux")]
337 #[test]
338 fn probe_has_cap_net_admin_matches_cap_eff() {
339 let status = std::fs::read_to_string("/proc/self/status").unwrap();
344 let cap_eff_line = status
345 .lines()
346 .find(|l| l.starts_with("CapEff:"))
347 .expect("CapEff: present in /proc/self/status");
348 let hex = cap_eff_line.trim_start_matches("CapEff:").trim();
349 let eff: u64 = u64::from_str_radix(hex, 16).unwrap();
350 let expected = (eff & (1u64 << 12)) != 0;
351 assert_eq!(super::probe_has_cap_net_admin(), expected);
352 }
353
354 #[allow(clippy::fn_params_excessive_bools)]
358 fn classify(
359 is_nested: bool,
360 can_write_cgroup_root: bool,
361 has_cap_net_admin: bool,
362 tun_device_available: bool,
363 cgroup_parent_is_some: bool,
364 ) -> DaemonMode {
365 if !is_nested && can_write_cgroup_root && has_cap_net_admin && tun_device_available {
366 DaemonMode::Full
367 } else if can_write_cgroup_root || cgroup_parent_is_some {
368 DaemonMode::NestedAdaptive
369 } else {
370 DaemonMode::Degraded
371 }
372 }
373
374 #[test]
375 fn effective_mode_full_requires_all_four_signals() {
376 assert_eq!(
378 classify(false, true, true, true, false),
379 DaemonMode::Full,
380 "all four signals set should be Full"
381 );
382 assert_ne!(classify(true, true, true, true, true), DaemonMode::Full);
384 assert_ne!(classify(false, false, true, true, false), DaemonMode::Full);
385 assert_ne!(classify(false, true, false, true, false), DaemonMode::Full);
386 assert_ne!(classify(false, true, true, false, false), DaemonMode::Full);
387 }
388
389 #[test]
390 fn effective_mode_nested_adaptive_when_writable_or_has_parent() {
391 assert_eq!(
393 classify(false, true, false, false, false),
394 DaemonMode::NestedAdaptive
395 );
396 assert_eq!(
398 classify(true, false, false, false, true),
399 DaemonMode::NestedAdaptive
400 );
401 }
402
403 #[test]
404 fn effective_mode_degraded_when_no_writable_path() {
405 assert_eq!(
407 classify(false, false, false, false, false),
408 DaemonMode::Degraded
409 );
410 assert_eq!(
414 classify(true, false, false, false, false),
415 DaemonMode::Degraded
416 );
417 }
418
419 #[test]
420 fn serializes_round_trip_via_serde_json() {
421 let caps = DaemonCapabilities::probe();
422 let json = serde_json::to_string(&caps).expect("serialize");
423 let parsed: DaemonCapabilities = serde_json::from_str(&json).expect("deserialize");
424 assert_eq!(parsed.is_root, caps.is_root);
425 assert_eq!(parsed.is_nested, caps.is_nested);
426 assert_eq!(parsed.cgroup_parent, caps.cgroup_parent);
427 assert_eq!(parsed.can_write_cgroup_root, caps.can_write_cgroup_root);
428 assert_eq!(parsed.has_cap_net_admin, caps.has_cap_net_admin);
429 assert_eq!(parsed.tun_device_available, caps.tun_device_available);
430 assert_eq!(parsed.effective_mode, caps.effective_mode);
431 }
432
433 #[test]
434 fn daemon_mode_serde_uses_snake_case() {
435 assert_eq!(
436 serde_json::to_string(&DaemonMode::Full).unwrap(),
437 "\"full\""
438 );
439 assert_eq!(
440 serde_json::to_string(&DaemonMode::NestedAdaptive).unwrap(),
441 "\"nested_adaptive\""
442 );
443 assert_eq!(
444 serde_json::to_string(&DaemonMode::Degraded).unwrap(),
445 "\"degraded\""
446 );
447 }
448
449 #[cfg(target_os = "linux")]
450 mod target_parent {
451 use super::super::compute_target_parent;
452
453 #[test]
454 fn idempotent_when_already_under_init() {
455 assert_eq!(
457 compute_target_parent(
458 "/user.slice/user-1000.slice/user@1000.service/app.slice/run-p123.scope"
459 ),
460 "/user.slice/user-1000.slice/user@1000.service/app.slice/run-p123.scope/containers"
461 );
462 assert_eq!(
464 compute_target_parent(
465 "/user.slice/user-1000.slice/user@1000.service/app.slice/run-p123.scope/init"
466 ),
467 "/user.slice/user-1000.slice/user@1000.service/app.slice/run-p123.scope/containers"
468 );
469 assert_eq!(compute_target_parent("/foo/bar/"), "/foo/bar/containers");
471 assert_eq!(
472 compute_target_parent("/foo/bar/init"),
473 "/foo/bar/containers"
474 );
475 }
476 }
477
478 #[cfg(target_os = "linux")]
479 mod cgroup_parser {
480 use super::super::parse_cgroup_v2_line;
481
482 #[test]
483 fn parse_cgroup_v2_root_returns_none() {
484 assert_eq!(parse_cgroup_v2_line("0::/\n"), None);
485 }
486
487 #[test]
488 fn parse_cgroup_v2_path_returns_some() {
489 assert_eq!(
490 parse_cgroup_v2_line("0::/system.slice/forgejo-runner.service\n"),
491 Some("/system.slice/forgejo-runner.service".to_string())
492 );
493 }
494
495 #[test]
496 fn parse_cgroup_v2_hybrid_finds_v2_line() {
497 let input = "12:devices:/user.slice\n11:memory:/user.slice\n0::/foo\n";
498 assert_eq!(parse_cgroup_v2_line(input), Some("/foo".to_string()));
499 }
500
501 #[test]
502 fn parse_cgroup_v2_no_newline() {
503 assert_eq!(parse_cgroup_v2_line("0::/bar"), Some("/bar".to_string()));
504 }
505
506 #[test]
507 fn parse_cgroup_v2_missing_returns_none() {
508 assert_eq!(parse_cgroup_v2_line(""), None);
509 }
510 }
511}