1#![allow(missing_docs)]
4use std::path::{Path, PathBuf};
5
6use crate::error::Error;
7
8#[derive(Debug, Clone)]
16pub struct CorePathPolicy {
17 allowed_dirs: Vec<PathBuf>,
18 deny_globs: Vec<glob::Pattern>,
19}
20
21impl CorePathPolicy {
22 pub fn builder() -> CorePathPolicyBuilder {
23 CorePathPolicyBuilder::default()
24 }
25
26 pub fn allowed_dirs(&self) -> &[PathBuf] {
29 &self.allowed_dirs
30 }
31
32 pub fn check_path(&self, path: &Path) -> Result<(), Error> {
36 let canonical = path
37 .canonicalize()
38 .map_err(|e| Error::Sandbox(format!("canonicalize {}: {e}", path.display())))?;
39
40 self.check_canonical(&canonical)
41 }
42
43 pub fn check_path_for_create(&self, path: &Path) -> Result<PathBuf, Error> {
57 let parent = path
58 .parent()
59 .ok_or_else(|| Error::Sandbox(format!("path has no parent: {}", path.display())))?;
60 let canonical_parent = parent.canonicalize().map_err(|e| {
61 Error::Sandbox(format!("canonicalize parent {}: {e}", parent.display()))
62 })?;
63 let file_name = path.file_name().ok_or_else(|| {
64 Error::Sandbox(format!(
65 "path has no file name component: {}",
66 path.display()
67 ))
68 })?;
69 let composed = canonical_parent.join(file_name);
70 self.check_canonical(&composed)?;
71 Ok(composed)
72 }
73
74 fn check_canonical(&self, canonical: &Path) -> Result<(), Error> {
75 let allowed = self
76 .allowed_dirs
77 .iter()
78 .any(|root| canonical.starts_with(root));
79 if !allowed {
80 return Err(Error::Sandbox(format!(
81 "path {} not under any allowed directory",
82 canonical.display()
83 )));
84 }
85
86 for pat in &self.deny_globs {
87 if pat.matches_path(canonical) {
88 return Err(Error::Sandbox(format!(
89 "path {} matches deny pattern {}",
90 canonical.display(),
91 pat.as_str()
92 )));
93 }
94 }
95
96 Ok(())
97 }
98}
99
100#[derive(Default, Debug)]
101pub struct CorePathPolicyBuilder {
102 allowed_dirs: Vec<PathBuf>,
103 deny_globs: Vec<String>,
104}
105
106impl CorePathPolicyBuilder {
107 pub fn allow_dir(mut self, dir: impl AsRef<Path>) -> Self {
112 self.allowed_dirs.push(dir.as_ref().to_path_buf());
113 self
114 }
115
116 pub fn deny_glob(mut self, pat: impl Into<String>) -> Self {
118 self.deny_globs.push(pat.into());
119 self
120 }
121
122 pub fn build(self) -> Result<CorePathPolicy, Error> {
123 let allowed_dirs = self
124 .allowed_dirs
125 .into_iter()
126 .map(|p| {
127 p.canonicalize()
128 .map_err(|e| Error::Sandbox(format!("allow_dir {}: {e}", p.display())))
129 })
130 .collect::<Result<Vec<_>, _>>()?;
131
132 let deny_globs = self
133 .deny_globs
134 .into_iter()
135 .map(|p| {
136 glob::Pattern::new(&p)
137 .map_err(|e| Error::Sandbox(format!("invalid deny glob {p}: {e}")))
138 })
139 .collect::<Result<Vec<_>, _>>()?;
140
141 Ok(CorePathPolicy {
142 allowed_dirs,
143 deny_globs,
144 })
145 }
146}
147
148#[cfg(all(target_os = "linux", feature = "sandbox"))]
149pub use landlock_sandbox::SandboxPolicy;
150
151#[cfg(all(target_os = "linux", feature = "sandbox"))]
152mod landlock_sandbox {
153 use std::io;
154 use std::path::PathBuf;
155 use std::sync::Arc;
156
157 use landlock::{
158 ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
159 };
160
161 use super::CorePathPolicy;
162 use crate::error::Error;
163
164 #[derive(Debug, Clone)]
169 pub struct SandboxPolicy {
170 path_policy: Arc<CorePathPolicy>,
172 pub read_paths: Vec<PathBuf>,
174 pub write_paths: Vec<PathBuf>,
176 }
177
178 impl SandboxPolicy {
179 pub fn workspace_only(workspace: &std::path::Path) -> Self {
189 let read_paths = vec![
190 PathBuf::from("/usr"),
191 PathBuf::from("/lib"),
192 PathBuf::from("/lib64"),
193 PathBuf::from("/bin"),
194 PathBuf::from("/etc"),
195 workspace.to_path_buf(),
196 ];
197 let write_paths = vec![workspace.to_path_buf()];
198 let mut builder = CorePathPolicy::builder();
200 for p in read_paths.iter().chain(write_paths.iter()) {
201 if p.exists() {
202 builder = builder.allow_dir(p);
203 }
204 }
205 let path_policy = Arc::new(builder.build().unwrap_or_else(|e| {
206 unreachable!(
212 "CorePathPolicy build failed in workspace_only despite filtered inputs: {e}"
213 )
214 }));
215 Self {
216 path_policy,
217 read_paths,
218 write_paths,
219 }
220 }
221
222 pub fn from_path_policy(path_policy: Arc<CorePathPolicy>) -> Self {
238 let dirs: Vec<PathBuf> = path_policy.allowed_dirs().to_vec();
242 Self {
243 path_policy,
244 read_paths: dirs.clone(),
245 write_paths: dirs,
246 }
247 }
248
249 pub fn path_policy(&self) -> Arc<CorePathPolicy> {
252 self.path_policy.clone()
253 }
254
255 pub fn into_pre_exec(self) -> Result<impl FnMut() -> io::Result<()>, Error> {
257 debug_assert!(
258 !self.read_paths.is_empty() || !self.write_paths.is_empty(),
259 "SandboxPolicy::into_pre_exec called with empty read_paths AND write_paths; \
260 the resulting Landlock ruleset would lock the subprocess out of all \
261 filesystem access. Check that [sandbox].allowed_dirs is non-empty in \
262 your TOML config, or use workspace_only() to derive paths from a directory."
263 );
264
265 let abi = ABI::V5;
266
267 let read_access = AccessFs::from_read(abi);
268 let write_access = AccessFs::from_all(abi);
269
270 let read_fds: Vec<_> = self
271 .read_paths
272 .iter()
273 .filter_map(|p| PathFd::new(p).ok())
274 .collect();
275
276 let write_fds: Vec<_> = self
277 .write_paths
278 .iter()
279 .filter_map(|p| PathFd::new(p).ok())
280 .collect();
281
282 Ok(move || {
283 let mut ruleset = Ruleset::default()
284 .handle_access(write_access)
285 .map_err(|e| io::Error::other(e.to_string()))?
286 .create()
287 .map_err(|e| io::Error::other(e.to_string()))?;
288
289 for fd in &read_fds {
290 ruleset = ruleset
291 .add_rule(PathBeneath::new(fd, read_access))
292 .map_err(|e| io::Error::other(e.to_string()))?;
293 }
294
295 for fd in &write_fds {
296 ruleset = ruleset
297 .add_rule(PathBeneath::new(fd, write_access))
298 .map_err(|e| io::Error::other(e.to_string()))?;
299 }
300
301 ruleset
302 .restrict_self()
303 .map_err(|e| io::Error::other(e.to_string()))?;
304
305 Ok(())
306 })
307 }
308 }
309
310 #[cfg(test)]
311 mod tests {
312 use super::*;
313
314 #[test]
315 fn workspace_only_includes_system_dirs() {
316 let dir = tempfile::tempdir().unwrap();
317 let policy = SandboxPolicy::workspace_only(dir.path());
318 assert!(policy.read_paths.contains(&PathBuf::from("/usr")));
319 assert!(policy.read_paths.contains(&PathBuf::from("/bin")));
320 assert!(policy.read_paths.contains(&PathBuf::from("/etc")));
321 }
322
323 #[test]
327 fn workspace_only_excludes_tmp() {
328 let dir = tempfile::tempdir().unwrap();
329 let policy = SandboxPolicy::workspace_only(dir.path());
330 assert!(
331 !policy.read_paths.contains(&PathBuf::from("/tmp")),
332 "/tmp must NOT be readable by default (F-FS-3)"
333 );
334 assert!(
335 !policy.write_paths.contains(&PathBuf::from("/tmp")),
336 "/tmp must NOT be writable by default (F-FS-3)"
337 );
338 }
339
340 #[test]
341 fn into_pre_exec_succeeds_on_workspace() {
342 let dir = tempfile::tempdir().unwrap();
343 let policy = SandboxPolicy::workspace_only(dir.path());
344 let result = policy.into_pre_exec();
345 assert!(result.is_ok());
346 }
347
348 #[test]
349 fn workspace_only_includes_workspace_in_read_and_write() {
350 let dir = tempfile::tempdir().unwrap();
351 let policy = SandboxPolicy::workspace_only(dir.path());
352 assert!(policy.read_paths.contains(&dir.path().to_path_buf()));
353 assert!(policy.write_paths.contains(&dir.path().to_path_buf()));
354 }
355
356 #[test]
357 fn from_path_policy_exposes_inner_policy() {
358 let path_policy = Arc::new(
359 CorePathPolicy::builder()
360 .allow_dir(std::env::temp_dir())
361 .build()
362 .unwrap(),
363 );
364 let sandbox = SandboxPolicy::from_path_policy(path_policy.clone());
365 assert!(Arc::ptr_eq(&path_policy, &sandbox.path_policy()));
366 assert!(!sandbox.read_paths.is_empty());
368 assert!(!sandbox.write_paths.is_empty());
369 }
370
371 #[test]
372 fn from_path_policy_derives_read_write_paths_from_allowed_dirs() {
373 let dir = tempfile::tempdir().unwrap();
374 let policy = Arc::new(
375 CorePathPolicy::builder()
376 .allow_dir(dir.path())
377 .build()
378 .unwrap(),
379 );
380 let sandbox = SandboxPolicy::from_path_policy(policy);
381 assert_eq!(sandbox.read_paths.len(), 1);
382 assert_eq!(sandbox.write_paths.len(), 1);
383 let canonical = dir.path().canonicalize().unwrap();
384 assert!(sandbox.read_paths.contains(&canonical));
385 assert!(sandbox.write_paths.contains(&canonical));
386 }
387
388 #[test]
389 fn workspace_only_populates_path_policy() {
390 let dir = tempfile::tempdir().unwrap();
391 let policy = SandboxPolicy::workspace_only(dir.path());
392 let inner = policy.path_policy();
394 let file = dir.path().join("ok.txt");
395 std::fs::write(&file, b"x").unwrap();
396 assert!(inner.check_path(&file).is_ok());
397 }
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use std::fs;
405
406 fn tmp() -> tempfile::TempDir {
407 tempfile::tempdir().unwrap()
408 }
409
410 #[test]
411 fn allows_path_under_allowed_dir() {
412 let root = tmp();
413 let file = root.path().join("ok.txt");
414 fs::write(&file, b"x").unwrap();
415 let policy = CorePathPolicy::builder()
416 .allow_dir(root.path())
417 .build()
418 .unwrap();
419 assert!(policy.check_path(&file).is_ok());
420 }
421
422 #[test]
423 fn denies_path_outside_allowed_dirs() {
424 let root = tmp();
425 let policy = CorePathPolicy::builder()
426 .allow_dir(root.path())
427 .build()
428 .unwrap();
429 let bad_dir = tmp();
430 let bad = bad_dir.path().join("x.txt");
431 fs::write(&bad, b"x").unwrap();
432 let err = policy.check_path(&bad).unwrap_err();
433 assert!(matches!(err, Error::Sandbox(_)));
434 }
435
436 #[test]
437 fn denies_glob_match_inside_allowed_dir() {
438 let root = tmp();
439 let dotenv = root.path().join(".env");
440 fs::write(&dotenv, b"x").unwrap();
441 let policy = CorePathPolicy::builder()
442 .allow_dir(root.path())
443 .deny_glob("**/.env")
444 .build()
445 .unwrap();
446 let err = policy.check_path(&dotenv).unwrap_err();
447 assert!(matches!(err, Error::Sandbox(_)));
448 }
449
450 #[test]
451 fn empty_allowlist_denies_everything() {
452 let policy = CorePathPolicy::builder().build().unwrap();
453 let some_path = std::env::temp_dir();
454 let err = policy.check_path(&some_path).unwrap_err();
455 assert!(matches!(err, Error::Sandbox(_)));
456 }
457
458 #[test]
459 fn invalid_glob_pattern_returns_error() {
460 let result = CorePathPolicy::builder().deny_glob("[unclosed").build();
461 assert!(result.is_err());
462 }
463
464 #[test]
465 fn allow_dir_with_nonexistent_path_fails_at_build() {
466 let bogus = std::env::temp_dir().join(format!("does-not-exist-{}", uuid::Uuid::new_v4()));
467 let result = CorePathPolicy::builder().allow_dir(&bogus).build();
468 assert!(result.is_err());
469 }
470
471 #[cfg(unix)]
472 #[test]
473 fn denies_symlink_pointing_outside_allowed_dir() {
474 use std::os::unix::fs::symlink;
475 let allowed = tmp();
476 let outside = tmp();
477 let outside_file = outside.path().join("secret.txt");
478 fs::write(&outside_file, b"secret").unwrap();
479
480 let link = allowed.path().join("link.txt");
482 symlink(&outside_file, &link).unwrap();
483
484 let policy = CorePathPolicy::builder()
485 .allow_dir(allowed.path())
486 .build()
487 .unwrap();
488 let err = policy.check_path(&link).unwrap_err();
489 assert!(matches!(err, Error::Sandbox(_)));
490 }
491}