nucleus/security/
landlock.rs1use crate::error::{NucleusError, Result};
2use landlock::{
3 Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetError,
4 RulesetStatus, ABI,
5};
6use tracing::{debug, info, warn};
7
8const TARGET_ABI: ABI = ABI::V5;
11
12const MINIMUM_PRODUCTION_ABI: ABI = ABI::V3;
18
19pub struct LandlockManager {
29 applied: bool,
30}
31
32impl LandlockManager {
33 pub fn new() -> Self {
34 Self { applied: false }
35 }
36
37 pub fn apply_container_policy(&mut self) -> Result<bool> {
51 self.apply_container_policy_with_mode(false)
52 }
53
54 pub fn assert_minimum_abi(&self, production_mode: bool) -> Result<()> {
60 let min_access = AccessFs::from_all(MINIMUM_PRODUCTION_ABI);
64 let target_access = AccessFs::from_all(TARGET_ABI);
65
66 if min_access != target_access {
69 info!(
70 "Landlock ABI: target={:?}, minimum_production={:?}",
71 TARGET_ABI, MINIMUM_PRODUCTION_ABI
72 );
73 }
74
75 match Ruleset::default().handle_access(AccessFs::from_all(MINIMUM_PRODUCTION_ABI)) {
80 Ok(_) => {
81 info!("Landlock ABI >= V3 confirmed");
82 Ok(())
83 }
84 Err(e) => {
85 let msg = format!(
86 "Kernel Landlock ABI is below minimum required version (V3): {}",
87 e
88 );
89 if production_mode {
90 Err(ll_err(e))
91 } else {
92 warn!("{}", msg);
93 Ok(())
94 }
95 }
96 }
97 }
98
99 pub fn apply_container_policy_with_mode(&mut self, best_effort: bool) -> Result<bool> {
104 if self.applied {
105 debug!("Landlock policy already applied, skipping");
106 return Ok(true);
107 }
108
109 info!("Applying Landlock filesystem policy");
110
111 match self.build_and_restrict() {
112 Ok(status) => match status {
113 RulesetStatus::FullyEnforced => {
114 self.applied = true;
115 info!("Landlock policy fully enforced");
116 Ok(true)
117 }
118 RulesetStatus::PartiallyEnforced => {
119 self.applied = true;
120 info!("Landlock policy partially enforced (kernel lacks some access rights)");
121 Ok(true)
122 }
123 RulesetStatus::NotEnforced => {
124 if best_effort {
125 warn!("Landlock not enforced (kernel does not support Landlock)");
126 Ok(false)
127 } else {
128 Err(NucleusError::LandlockError(
129 "Landlock not enforced (kernel does not support Landlock)".to_string(),
130 ))
131 }
132 }
133 },
134 Err(e) => {
135 if best_effort {
136 warn!(
137 "Failed to apply Landlock policy: {} (continuing without Landlock)",
138 e
139 );
140 Ok(false)
141 } else {
142 Err(e)
143 }
144 }
145 }
146 }
147
148 fn build_and_restrict(&self) -> Result<RulesetStatus> {
150 let access_all = AccessFs::from_all(TARGET_ABI);
151 let access_read = AccessFs::from_read(TARGET_ABI);
152
153 let access_read_exec = access_read | AccessFs::Execute;
155
156 let mut access_tmp = access_all;
159 access_tmp.remove(AccessFs::Execute);
160
161 let mut ruleset = Ruleset::default()
162 .handle_access(access_all)
163 .map_err(ll_err)?
164 .create()
165 .map_err(ll_err)?;
166
167 if let Ok(fd) = PathFd::new("/") {
170 ruleset = ruleset
171 .add_rule(PathBeneath::new(fd, AccessFs::ReadDir))
172 .map_err(ll_err)?;
173 }
174
175 for path in &["/bin", "/usr", "/sbin"] {
177 if let Ok(fd) = PathFd::new(path) {
178 ruleset = ruleset
179 .add_rule(PathBeneath::new(fd, access_read_exec))
180 .map_err(ll_err)?;
181 }
182 }
183
184 for path in &["/lib", "/lib64", "/lib32"] {
186 if let Ok(fd) = PathFd::new(path) {
187 ruleset = ruleset
188 .add_rule(PathBeneath::new(fd, access_read))
189 .map_err(ll_err)?;
190 }
191 }
192
193 for path in &["/etc", "/dev", "/proc"] {
195 if let Ok(fd) = PathFd::new(path) {
196 ruleset = ruleset
197 .add_rule(PathBeneath::new(fd, access_read))
198 .map_err(ll_err)?;
199 }
200 }
201
202 if let Ok(fd) = PathFd::new("/tmp") {
204 ruleset = ruleset
205 .add_rule(PathBeneath::new(fd, access_tmp))
206 .map_err(ll_err)?;
207 }
208
209 if let Ok(fd) = PathFd::new("/nix/store") {
211 ruleset = ruleset
212 .add_rule(PathBeneath::new(fd, access_read_exec))
213 .map_err(ll_err)?;
214 }
215
216 if let Ok(fd) = PathFd::new("/run/secrets") {
218 ruleset = ruleset
219 .add_rule(PathBeneath::new(fd, access_read))
220 .map_err(ll_err)?;
221 }
222
223 if let Ok(fd) = PathFd::new("/context") {
225 ruleset = ruleset
226 .add_rule(PathBeneath::new(fd, access_read))
227 .map_err(ll_err)?;
228 }
229
230 let status = ruleset.restrict_self().map_err(ll_err)?;
231 Ok(status.ruleset)
232 }
233
234 pub fn is_applied(&self) -> bool {
236 self.applied
237 }
238}
239
240impl Default for LandlockManager {
241 fn default() -> Self {
242 Self::new()
243 }
244}
245
246fn ll_err(e: RulesetError) -> NucleusError {
248 NucleusError::LandlockError(e.to_string())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn test_landlock_manager_initial_state() {
257 let mgr = LandlockManager::new();
258 assert!(!mgr.is_applied());
259 }
260
261 #[test]
262 fn test_apply_idempotent() {
263 let mut mgr = LandlockManager::new();
264 let _ = mgr.apply_container_policy_with_mode(true);
266 let result = mgr.apply_container_policy_with_mode(true);
268 assert!(result.is_ok());
269 }
270
271 #[test]
272 fn test_best_effort_on_unsupported_kernel() {
273 let mut mgr = LandlockManager::new();
274 let result = mgr.apply_container_policy_with_mode(true);
276 assert!(result.is_ok());
277 }
278
279 fn extract_fn_body<'a>(source: &'a str, fn_signature: &str) -> &'a str {
282 let fn_start = source.find(fn_signature)
283 .unwrap_or_else(|| panic!("function '{}' not found in source", fn_signature));
284 let after = &source[fn_start..];
285 let open = after.find('{')
286 .unwrap_or_else(|| panic!("no opening brace found for '{}'", fn_signature));
287 let mut depth = 0u32;
288 let mut end = open;
289 for (i, ch) in after[open..].char_indices() {
290 match ch {
291 '{' => depth += 1,
292 '}' => {
293 depth -= 1;
294 if depth == 0 { end = open + i + 1; break; }
295 }
296 _ => {}
297 }
298 }
299 &after[..end]
300 }
301
302 #[test]
303 fn test_policy_covers_nix_store_and_secrets() {
304 let source = include_str!("landlock.rs");
310 let fn_body = extract_fn_body(source, "fn build_and_restrict");
311 assert!(
312 fn_body.contains("\"/nix/store\"") || fn_body.contains("\"/nix\""),
313 "Landlock build_and_restrict must include a rule for /nix/store or /nix"
314 );
315 assert!(
316 fn_body.contains("\"/run/secrets\"") || fn_body.contains("\"/run\""),
317 "Landlock build_and_restrict must include a rule for /run/secrets"
318 );
319 }
320
321 #[test]
322 fn test_tmp_access_excludes_execute() {
323 let access_all = AccessFs::from_all(TARGET_ABI);
327 let mut access_tmp = access_all;
328 access_tmp.remove(AccessFs::Execute);
329 assert!(!access_tmp.contains(AccessFs::Execute));
330 assert!(access_tmp.contains(AccessFs::WriteFile));
332 assert!(access_tmp.contains(AccessFs::RemoveFile));
333 }
334
335 #[test]
336 fn test_not_enforced_returns_error_in_strict_mode() {
337 let source = include_str!("landlock.rs");
339 let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
340 let not_enforced_start = fn_body.find("NotEnforced")
342 .expect("function must handle NotEnforced status");
343 let rest = &fn_body[not_enforced_start..];
345 let arm_end = rest.find("RestrictionStatus::")
346 .unwrap_or(rest.len().min(500));
347 let not_enforced_block = &rest[..arm_end];
348 assert!(
349 not_enforced_block.contains("best_effort") && not_enforced_block.contains("Err"),
350 "NotEnforced must return Err when best_effort=false. Block: {}",
351 not_enforced_block
352 );
353 }
354}