1use crate::error::{NucleusError, Result};
2use landlock::{
3 Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetError,
4 RulesetStatus, ABI,
5};
6use std::path::PathBuf;
7use tracing::{debug, info, warn};
8
9const TARGET_ABI: ABI = ABI::V5;
12
13const MINIMUM_PRODUCTION_ABI: ABI = ABI::V3;
19
20#[derive(Debug, Clone, Copy)]
21#[allow(clippy::enum_variant_names)]
22enum PathAccessMode {
23 ReadOnly,
24 ReadExecute,
25 ReadWrite,
26 ReadWriteExecute,
27}
28
29pub struct LandlockManager {
39 applied: bool,
40 extra_paths: Vec<(String, PathAccessMode)>,
42 workspace_access: PathAccessMode,
44}
45
46impl LandlockManager {
47 pub fn new() -> Self {
48 Self {
49 applied: false,
50 extra_paths: Vec::new(),
51 workspace_access: PathAccessMode::ReadWrite,
52 }
53 }
54
55 pub fn add_rw_path(&mut self, path: &str) {
58 self.extra_paths
59 .push((path.to_string(), PathAccessMode::ReadWrite));
60 }
61
62 pub fn add_ro_path(&mut self, path: &str) {
64 self.extra_paths
65 .push((path.to_string(), PathAccessMode::ReadOnly));
66 }
67
68 pub fn add_read_exec_path(&mut self, path: &str) {
70 self.extra_paths
71 .push((path.to_string(), PathAccessMode::ReadExecute));
72 }
73
74 pub fn add_rw_exec_path(&mut self, path: &str) {
76 self.extra_paths
77 .push((path.to_string(), PathAccessMode::ReadWriteExecute));
78 }
79
80 pub fn set_workspace_access(&mut self, read_only: bool, allow_execute: bool) {
82 self.workspace_access = match (read_only, allow_execute) {
83 (true, false) => PathAccessMode::ReadOnly,
84 (true, true) => PathAccessMode::ReadExecute,
85 (false, false) => PathAccessMode::ReadWrite,
86 (false, true) => PathAccessMode::ReadWriteExecute,
87 };
88 }
89
90 pub fn apply_container_policy(&mut self) -> Result<bool> {
106 self.apply_container_policy_with_mode(false)
107 }
108
109 pub fn assert_minimum_abi(&self, production_mode: bool) -> Result<()> {
115 let min_access = AccessFs::from_all(MINIMUM_PRODUCTION_ABI);
119 let target_access = AccessFs::from_all(TARGET_ABI);
120
121 if min_access != target_access {
124 info!(
125 "Landlock ABI: target={:?}, minimum_production={:?}",
126 TARGET_ABI, MINIMUM_PRODUCTION_ABI
127 );
128 }
129
130 match Ruleset::default().handle_access(AccessFs::from_all(MINIMUM_PRODUCTION_ABI)) {
135 Ok(_) => {
136 info!("Landlock ABI >= V3 confirmed");
137 Ok(())
138 }
139 Err(e) => {
140 let msg = format!(
141 "Kernel Landlock ABI is below minimum required version (V3): {}",
142 e
143 );
144 if production_mode {
145 Err(ll_err(e))
146 } else {
147 warn!("{}", msg);
148 Ok(())
149 }
150 }
151 }
152 }
153
154 pub fn apply_container_policy_with_mode(&mut self, best_effort: bool) -> Result<bool> {
159 if self.applied {
160 debug!("Landlock policy already applied, skipping");
161 return Ok(true);
162 }
163
164 info!("Applying Landlock filesystem policy");
165
166 match self.build_and_restrict() {
167 Ok(status) => match status {
168 RulesetStatus::FullyEnforced => {
169 self.applied = true;
170 info!("Landlock policy fully enforced");
171 Ok(true)
172 }
173 RulesetStatus::PartiallyEnforced => {
174 if best_effort {
175 self.applied = true;
176 info!(
177 "Landlock policy partially enforced (kernel lacks some access rights)"
178 );
179 Ok(true)
180 } else {
181 Err(NucleusError::LandlockError(
182 "Landlock policy only partially enforced; strict mode requires full target ABI support".to_string(),
183 ))
184 }
185 }
186 RulesetStatus::NotEnforced => {
187 if best_effort {
188 warn!("Landlock not enforced (kernel does not support Landlock)");
189 Ok(false)
190 } else {
191 Err(NucleusError::LandlockError(
192 "Landlock not enforced (kernel does not support Landlock)".to_string(),
193 ))
194 }
195 }
196 },
197 Err(e) => {
198 if best_effort {
199 warn!(
200 "Failed to apply Landlock policy: {} (continuing without Landlock)",
201 e
202 );
203 Ok(false)
204 } else {
205 Err(e)
206 }
207 }
208 }
209 }
210
211 pub fn apply_execute_allowlist_policy(
219 &mut self,
220 allowed_roots: &[PathBuf],
221 best_effort: bool,
222 ) -> Result<bool> {
223 if self.applied {
224 debug!("Landlock execute allowlist already applied, skipping");
225 return Ok(true);
226 }
227
228 info!(
229 allowed_roots = ?allowed_roots,
230 "Applying Landlock execute allowlist policy"
231 );
232
233 match self.build_execute_allowlist_and_restrict(allowed_roots) {
234 Ok(status) => match status {
235 RulesetStatus::FullyEnforced => {
236 self.applied = true;
237 info!("Landlock execute allowlist fully enforced");
238 Ok(true)
239 }
240 RulesetStatus::PartiallyEnforced => {
241 if best_effort {
242 self.applied = true;
243 info!("Landlock execute allowlist partially enforced");
244 Ok(true)
245 } else {
246 Err(NucleusError::LandlockError(
247 "Landlock execute allowlist only partially enforced; strict mode requires full enforcement".to_string(),
248 ))
249 }
250 }
251 RulesetStatus::NotEnforced => {
252 if best_effort {
253 warn!("Landlock execute allowlist not enforced");
254 Ok(false)
255 } else {
256 Err(NucleusError::LandlockError(
257 "Landlock execute allowlist not enforced".to_string(),
258 ))
259 }
260 }
261 },
262 Err(e) => {
263 if best_effort {
264 warn!(
265 "Failed to apply Landlock execute allowlist: {} (continuing without it)",
266 e
267 );
268 Ok(false)
269 } else {
270 Err(e)
271 }
272 }
273 }
274 }
275
276 fn build_and_restrict(&self) -> Result<RulesetStatus> {
278 let access_all = AccessFs::from_all(TARGET_ABI);
279 let access_read = AccessFs::from_read(TARGET_ABI);
280
281 let access_read_exec = access_read | AccessFs::Execute;
283
284 let mut access_tmp = access_all;
287 access_tmp.remove(AccessFs::Execute);
288 let access_rw_exec = access_tmp | AccessFs::Execute;
289
290 let mut ruleset = Ruleset::default()
291 .handle_access(access_all)
292 .map_err(ll_err)?
293 .create()
294 .map_err(ll_err)?;
295
296 if let Ok(fd) = PathFd::new("/") {
299 ruleset = ruleset
300 .add_rule(PathBeneath::new(fd, AccessFs::ReadDir))
301 .map_err(ll_err)?;
302 }
303
304 const MANDATORY_PATHS: &[&str] = &["/bin", "/usr", "/lib", "/etc"];
307 for path in MANDATORY_PATHS {
308 if !std::path::Path::new(path).exists() {
309 warn!(
310 "Landlock: mandatory path {} does not exist; container may not function correctly",
311 path
312 );
313 }
314 }
315
316 for path in &["/bin", "/usr", "/sbin"] {
318 if let Ok(fd) = PathFd::new(path) {
319 ruleset = ruleset
320 .add_rule(PathBeneath::new(fd, access_read_exec))
321 .map_err(ll_err)?;
322 }
323 }
324
325 for path in &["/lib", "/lib64", "/lib32"] {
327 if let Ok(fd) = PathFd::new(path) {
328 ruleset = ruleset
329 .add_rule(PathBeneath::new(fd, access_read))
330 .map_err(ll_err)?;
331 }
332 }
333
334 for path in &["/etc", "/dev", "/proc"] {
336 if let Ok(fd) = PathFd::new(path) {
337 ruleset = ruleset
338 .add_rule(PathBeneath::new(fd, access_read))
339 .map_err(ll_err)?;
340 }
341 }
342
343 if let Ok(fd) = PathFd::new("/dev/shm") {
347 ruleset = ruleset
348 .add_rule(PathBeneath::new(fd, access_tmp))
349 .map_err(ll_err)?;
350 }
351
352 if let Ok(fd) = PathFd::new("/tmp") {
354 ruleset = ruleset
355 .add_rule(PathBeneath::new(fd, access_tmp))
356 .map_err(ll_err)?;
357 }
358
359 if let Ok(fd) = PathFd::new("/nix/store") {
361 ruleset = ruleset
362 .add_rule(PathBeneath::new(fd, access_read_exec))
363 .map_err(ll_err)?;
364 }
365
366 if let Ok(fd) = PathFd::new("/run/secrets") {
368 ruleset = ruleset
369 .add_rule(PathBeneath::new(fd, access_read))
370 .map_err(ll_err)?;
371 }
372
373 if let Ok(fd) = PathFd::new("/context") {
375 ruleset = ruleset
376 .add_rule(PathBeneath::new(fd, access_read))
377 .map_err(ll_err)?;
378 }
379
380 if let Ok(fd) = PathFd::new("/workspace") {
383 let access = Self::access_for_mode(
384 self.workspace_access,
385 access_read,
386 access_read_exec,
387 access_tmp,
388 access_rw_exec,
389 );
390 ruleset = ruleset
391 .add_rule(PathBeneath::new(fd, access))
392 .map_err(ll_err)?;
393 }
394
395 for (path, mode) in &self.extra_paths {
397 if let Ok(fd) = PathFd::new(path) {
398 debug!("Landlock: granting {:?} access to path {:?}", mode, path);
399 let access = Self::access_for_mode(
400 *mode,
401 access_read,
402 access_read_exec,
403 access_tmp,
404 access_rw_exec,
405 );
406 ruleset = ruleset
407 .add_rule(PathBeneath::new(fd, access))
408 .map_err(ll_err)?;
409 }
410 }
411
412 let status = ruleset.restrict_self().map_err(ll_err)?;
413 Ok(status.ruleset)
414 }
415
416 fn access_for_mode(
417 mode: PathAccessMode,
418 access_read: landlock::BitFlags<AccessFs>,
419 access_read_exec: landlock::BitFlags<AccessFs>,
420 access_read_write: landlock::BitFlags<AccessFs>,
421 access_read_write_exec: landlock::BitFlags<AccessFs>,
422 ) -> landlock::BitFlags<AccessFs> {
423 match mode {
424 PathAccessMode::ReadOnly => access_read,
425 PathAccessMode::ReadExecute => access_read_exec,
426 PathAccessMode::ReadWrite => access_read_write,
427 PathAccessMode::ReadWriteExecute => access_read_write_exec,
428 }
429 }
430
431 fn build_execute_allowlist_and_restrict(
432 &self,
433 allowed_roots: &[PathBuf],
434 ) -> Result<RulesetStatus> {
435 let access_execute = AccessFs::Execute;
436 let mut ruleset = Ruleset::default()
437 .handle_access(access_execute)
438 .map_err(ll_err)?
439 .create()
440 .map_err(ll_err)?
441 .set_no_new_privs(false);
446
447 let mut added_rules = 0usize;
448 for root in allowed_roots {
449 let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.clone());
450 match PathFd::new(canonical.as_path()) {
451 Ok(fd) => {
452 ruleset = ruleset
453 .add_rule(PathBeneath::new(fd, access_execute))
454 .map_err(ll_err)?;
455 added_rules += 1;
456 }
457 Err(err) => {
458 warn!(
459 "Landlock execute allowlist skipped {:?}: {}",
460 canonical, err
461 );
462 }
463 }
464 }
465 if added_rules == 0 {
466 return Err(NucleusError::LandlockError(
467 "Landlock execute allowlist has no valid executable roots".to_string(),
468 ));
469 }
470
471 let status = ruleset.restrict_self().map_err(ll_err)?;
472 Ok(status.ruleset)
473 }
474
475 pub fn is_applied(&self) -> bool {
477 self.applied
478 }
479}
480
481impl Default for LandlockManager {
482 fn default() -> Self {
483 Self::new()
484 }
485}
486
487fn ll_err(e: RulesetError) -> NucleusError {
489 NucleusError::LandlockError(e.to_string())
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn test_landlock_manager_initial_state() {
498 let mgr = LandlockManager::new();
499 assert!(!mgr.is_applied());
500 }
501
502 #[test]
503 fn test_apply_idempotent() {
504 let mut mgr = LandlockManager::new();
505 let _ = mgr.apply_container_policy_with_mode(true);
507 let result = mgr.apply_container_policy_with_mode(true);
509 assert!(result.is_ok());
510 }
511
512 #[test]
513 fn test_best_effort_on_unsupported_kernel() {
514 let mut mgr = LandlockManager::new();
515 let result = mgr.apply_container_policy_with_mode(true);
517 assert!(result.is_ok());
518 }
519
520 fn extract_fn_body<'a>(source: &'a str, fn_signature: &str) -> &'a str {
523 let fn_start = source
524 .find(fn_signature)
525 .unwrap_or_else(|| panic!("function '{}' not found in source", fn_signature));
526 let after = &source[fn_start..];
527 let open = after
528 .find('{')
529 .unwrap_or_else(|| panic!("no opening brace found for '{}'", fn_signature));
530 let mut depth = 0u32;
531 let mut end = open;
532 for (i, ch) in after[open..].char_indices() {
533 match ch {
534 '{' => depth += 1,
535 '}' => {
536 depth -= 1;
537 if depth == 0 {
538 end = open + i + 1;
539 break;
540 }
541 }
542 _ => {}
543 }
544 }
545 &after[..end]
546 }
547
548 #[test]
549 fn test_policy_covers_nix_store_and_secrets() {
550 let source = include_str!("landlock.rs");
556 let fn_body = extract_fn_body(source, "fn build_and_restrict");
557 assert!(
558 fn_body.contains("\"/nix/store\"") || fn_body.contains("\"/nix\""),
559 "Landlock build_and_restrict must include a rule for /nix/store or /nix"
560 );
561 assert!(
562 fn_body.contains("\"/run/secrets\"") || fn_body.contains("\"/run\""),
563 "Landlock build_and_restrict must include a rule for /run/secrets"
564 );
565 }
566
567 #[test]
568 fn test_policy_covers_workspace_without_default_execute() {
569 let source = include_str!("landlock.rs");
570 let fn_body = extract_fn_body(source, "fn build_and_restrict");
571 assert!(
572 fn_body.contains("\"/workspace\""),
573 "Landlock build_and_restrict must include /workspace"
574 );
575 assert!(
576 source.contains("set_workspace_access"),
577 "LandlockManager must expose explicit workspace access selection"
578 );
579 }
580
581 #[test]
582 fn test_tmp_access_excludes_execute() {
583 let access_all = AccessFs::from_all(TARGET_ABI);
587 let mut access_tmp = access_all;
588 access_tmp.remove(AccessFs::Execute);
589 assert!(!access_tmp.contains(AccessFs::Execute));
590 assert!(access_tmp.contains(AccessFs::WriteFile));
592 assert!(access_tmp.contains(AccessFs::RemoveFile));
593 }
594
595 #[test]
596 fn test_execute_allowlist_handles_only_execute() {
597 let source = include_str!("landlock.rs");
598 let fn_body = extract_fn_body(source, "fn build_execute_allowlist_and_restrict");
599 assert!(
600 fn_body.contains("let access_execute = AccessFs::Execute"),
601 "execute allowlist must handle only execute access"
602 );
603 assert!(
604 fn_body.contains("handle_access(access_execute)"),
605 "execute allowlist must not handle read/write filesystem rights"
606 );
607 assert!(
608 !fn_body.contains("from_all"),
609 "execute allowlist must not accidentally become a broad filesystem policy"
610 );
611 }
612
613 #[test]
614 fn test_execute_allowlist_disables_no_new_privs_for_gvisor_supervisor() {
615 let source = include_str!("landlock.rs");
616 let fn_body = extract_fn_body(source, "fn build_execute_allowlist_and_restrict");
617 assert!(
618 fn_body.contains(".set_no_new_privs(false)"),
619 "gVisor supervisor execute allowlist must not force no_new_privs"
620 );
621 }
622
623 #[test]
624 fn test_container_policy_keeps_default_no_new_privs() {
625 let source = include_str!("landlock.rs");
626 let fn_body = extract_fn_body(source, "fn build_and_restrict");
627 assert!(
628 !fn_body.contains(".set_no_new_privs(false)"),
629 "container Landlock policy must retain the landlock crate default no_new_privs setting"
630 );
631 }
632
633 #[test]
634 fn test_not_enforced_returns_error_in_strict_mode() {
635 let source = include_str!("landlock.rs");
637 let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
638 let not_enforced_start = fn_body
640 .find("NotEnforced")
641 .expect("function must handle NotEnforced status");
642 let rest = &fn_body[not_enforced_start..];
644 let arm_end = rest
645 .find("RestrictionStatus::")
646 .unwrap_or(rest.len().min(500));
647 let not_enforced_block = &rest[..arm_end];
648 assert!(
649 not_enforced_block.contains("best_effort") && not_enforced_block.contains("Err"),
650 "NotEnforced must return Err when best_effort=false. Block: {}",
651 not_enforced_block
652 );
653 }
654
655 #[test]
656 fn test_partially_enforced_returns_error_in_strict_mode() {
657 let source = include_str!("landlock.rs");
658 let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
659 let partial_start = fn_body
660 .find("PartiallyEnforced")
661 .expect("function must handle PartiallyEnforced status");
662 let rest = &fn_body[partial_start..];
663 let arm_end = rest.find("NotEnforced").unwrap_or(rest.len().min(500));
664 let partial_block = &rest[..arm_end];
665 assert!(
666 partial_block.contains("best_effort") && partial_block.contains("Err"),
667 "PartiallyEnforced must return Err when best_effort=false. Block: {}",
668 partial_block
669 );
670 }
671}