Skip to main content

sbe_core/sandbox/linux/
landlock.rs

1//! Compile a [`SandboxProfile`] into a Landlock [`Ruleset`].
2//!
3//! All path FDs that the kernel needs are opened in SBE's single-threaded
4//! internal launcher and packaged in [`CompiledLandlock`]. No complex policy
5//! work occurs in the multithreaded parent's `pre_exec` closure.
6
7#![allow(unsafe_code)] // audited openat2/mkdirat descriptor traversal
8//!
9//! The compiler also enforces two backend-time lints required by §8:
10//! - `allow_exec` subpath entries that overlap privilege-escalation binaries (sudo, su, …) are
11//!   rejected.
12//! - `deny_read` is sealed as a forbidden list — when later code tries to broaden `allow_read`, an
13//!   overlap with `forbidden_reads` is rejected.
14
15use std::{
16    collections::BTreeSet,
17    ffi::CString,
18    os::{
19        fd::{AsRawFd, FromRawFd, OwnedFd},
20        unix::{ffi::OsStrExt, fs::MetadataExt},
21    },
22    path::{Path, PathBuf},
23};
24
25use landlock::{
26    ABI, Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath,
27    Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr, Scope,
28};
29
30use crate::{
31    config::SandboxPath,
32    error::CoreError,
33    profile::{NetworkMode, SandboxProfile},
34    sandbox::{
35        BackendOptions,
36        linux::probe::{LandlockAbi, ProbeResult},
37    },
38};
39
40/// Curated baseline read-allowlist anchors. The Linux profile YAML extends
41/// this with per-OS additions; here we keep the system-essentials list that
42/// the orchestrator always grants, regardless of ecosystem.
43///
44/// Listed paths are read-only — Landlock writes are still gated by
45/// `allow_write`.
46pub const READ_ALLOWLIST_ANCHORS: &[&str] = &[
47    // Dynamic linker, NSS, system config
48    "/etc",
49    "/lib",
50    "/lib32",
51    "/lib64",
52    "/usr",
53    "/sys",
54    // Named devices only; never expose the complete /dev tree.
55    "/dev/null",
56    "/dev/zero",
57    "/dev/random",
58    "/dev/urandom",
59    "/dev/tty",
60    // systemd-resolved stub on Ubuntu/Debian/Fedora: /etc/resolv.conf is a
61    // symlink to /run/systemd/resolve/stub-resolv.conf. Landlock follows
62    // symlinks to the canonical path, so the resolver can't read the
63    // nameserver list without granting read on the symlink target.
64    //
65    // We name the SPECIFIC files used by the libc resolver rather than the
66    // whole directory. The directory also contains
67    // `/run/systemd/resolve/io.systemd.Resolve` — a varlink Unix-domain
68    // socket. Landlock pre-ABI v9 does NOT gate UDS connect by path-based
69    // access, so granting read on the directory enables a build script to
70    // connect to the varlink endpoint and ask systemd-resolved to perform
71    // arbitrary DNS lookups, bypassing the HTTP CONNECT proxy's domain
72    // allowlist. Narrow to the two read-only stub files.
73    "/run/systemd/resolve/stub-resolv.conf",
74    "/run/systemd/resolve/resolv.conf",
75];
76
77/// Public procfs data needed by common runtimes. `/proc` itself is
78/// deliberately not granted: doing so would expose `/proc/$PPID/environ` and
79/// undo the child environment allowlist.
80pub const PROC_READ_ALLOWLIST_ANCHORS: &[&str] = &[
81    "/proc/cpuinfo",
82    "/proc/filesystems",
83    "/proc/loadavg",
84    "/proc/meminfo",
85    "/proc/stat",
86    "/proc/sys",
87    "/proc/uptime",
88    "/proc/version",
89];
90
91/// Baseline writable paths injected into every Linux ruleset.
92///
93/// Named device files required by ordinary command-line programs. Temporary
94/// storage is supplied by the private per-invocation path in the profile.
95const BASELINE_WRITE_PATHS: &[&str] = &["/dev/null", "/dev/zero"];
96const MAX_CARVED_READ_ENTRIES: usize = 100_000;
97
98#[derive(Clone, Copy, Debug, Eq, PartialEq)]
99enum UntrustedSymlinkBehavior {
100    Reject,
101    Skip,
102}
103
104/// Privilege-escalation binaries that must never appear under an
105/// `allow_exec` subpath. The lint refuses to build the ruleset if a
106/// user-supplied profile would re-enable any of these via a directory rule.
107const PRIVILEGE_ESCALATION_BINARIES: &[&str] = &[
108    // Direct UID change
109    "/usr/bin/sudo",
110    "/bin/sudo",
111    "/usr/bin/su",
112    "/bin/su",
113    "/usr/bin/runuser",
114    "/usr/sbin/runuser",
115    "/usr/bin/gosu",
116    "/usr/local/bin/gosu",
117    "/usr/bin/doas",
118    "/usr/local/bin/doas",
119    "/usr/bin/pkexec",
120    // Account / shell modification
121    "/usr/bin/chsh",
122    "/usr/bin/chfn",
123    "/usr/bin/newgrp",
124    "/usr/bin/sg",
125    "/usr/bin/passwd",
126    "/usr/bin/gpasswd",
127    // Capability / namespace manipulation (NNP defangs setuid but some
128    // of these are file-cap-based and can still raise privs).
129    "/usr/bin/capsh",
130    "/usr/sbin/capsh",
131    "/usr/bin/setpriv",
132    "/usr/bin/nsenter",
133    "/usr/bin/unshare",
134    "/usr/sbin/unshare",
135    // systemd / DBus-mediated escalation
136    "/usr/bin/systemd-run",
137    "/usr/bin/machinectl",
138    "/usr/bin/pkttyagent",
139    "/usr/bin/dbus-launch",
140    // Filesystem mount manipulation
141    "/usr/bin/mount",
142    "/usr/bin/umount",
143    "/bin/mount",
144    "/bin/umount",
145    "/usr/bin/fusermount",
146    "/usr/bin/fusermount3",
147];
148
149/// Data-only read rights. `AccessFs::from_read()` also contains `Execute`,
150/// which would silently turn broad read anchors such as `/usr` into broad
151/// executable grants and defeat the explicit exec allowlist.
152fn read_access(_abi: ABI) -> BitFlags<AccessFs> {
153    BitFlags::from(AccessFs::ReadFile) | AccessFs::ReadDir
154}
155
156fn read_directory_access(_abi: ABI) -> BitFlags<AccessFs> {
157    BitFlags::from(AccessFs::ReadDir)
158}
159
160/// FS write access flags applied to `allow_write`.
161fn write_access(abi: ABI) -> BitFlags<AccessFs> {
162    // `from_write()` also includes device ioctl from ABI v5 and Unix-socket
163    // resolution from ABI v9. Neither is ordinary file mutation authority:
164    // ioctl could operate on a granted device and ResolveUnix could connect
165    // to a pre-existing local service in a persistent writable tree.
166    let mut access = AccessFs::from_write(abi);
167    access.remove(AccessFs::IoctlDev | AccessFs::ResolveUnix);
168    access
169}
170
171/// Per-run private roots additionally support Unix-domain sockets used by
172/// compilers and build coordinators. The root is mode 0700 and deleted after
173/// the invocation, so it cannot name an ambient or persistent local service.
174fn ephemeral_write_access(abi: ABI) -> BitFlags<AccessFs> {
175    write_access(abi) | (AccessFs::from_all(abi) & AccessFs::ResolveUnix)
176}
177
178/// FS execute access flags applied to `allow_exec`.
179fn exec_access(abi: ABI) -> BitFlags<AccessFs> {
180    BitFlags::from(AccessFs::Execute) | read_access(abi)
181}
182
183fn highest_abi(probe: &ProbeResult) -> ABI {
184    // Land on the highest ABI the running kernel actually supports. The
185    // ruleset uses HardRequirement below, so promised rights cannot be
186    // silently elided.
187    match probe.abi {
188        LandlockAbi::Unsupported => ABI::V1,
189        LandlockAbi::V1 => ABI::V1,
190        LandlockAbi::V2 => ABI::V2,
191        LandlockAbi::V3 => ABI::V3,
192        LandlockAbi::V4 => ABI::V4,
193        LandlockAbi::V5 => ABI::V5,
194        LandlockAbi::V6 => ABI::V6,
195        LandlockAbi::V7 => ABI::V7,
196        LandlockAbi::V8 => ABI::V8,
197        LandlockAbi::V9 => ABI::V9,
198    }
199}
200
201fn handled_fs_access(abi: ABI, mode: NetworkMode) -> BitFlags<AccessFs> {
202    let mut access = AccessFs::from_all(abi);
203    if mode == NetworkMode::AllowAll {
204        // ResolveUnix is classified as a filesystem right by Landlock, but it
205        // is network mediation. Handling it in allow-all mode would still
206        // block ambient sockets outside the private runtime root.
207        access.remove(AccessFs::ResolveUnix);
208    }
209    access
210}
211
212/// A ready-to-apply Landlock ruleset. Built in the single-threaded launcher;
213/// the wrapped [`RulesetCreated`] holds all preopened path FDs internally.
214pub struct CompiledLandlock {
215    /// `restrict_self` consumes this in the child.
216    pub ruleset: RulesetCreated,
217}
218
219impl std::fmt::Debug for CompiledLandlock {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        f.debug_struct("CompiledLandlock").finish_non_exhaustive()
222    }
223}
224
225/// Compile the profile and return either a [`CompiledLandlock`] or an
226/// error explaining which lint/probe step failed.
227pub fn compile(
228    profile: &SandboxProfile,
229    proxy_port: Option<u16>,
230    probe: &ProbeResult,
231    options: BackendOptions,
232) -> Result<CompiledLandlock, CoreError> {
233    // 1. Lints.
234    lint_allow_exec_for_priv_escalation(profile, options)?;
235    let forbidden_reads = build_forbidden_reads(profile)?;
236    lint_forbidden_reads_against_grants(profile, &forbidden_reads, options)?;
237
238    let abi = highest_abi(probe);
239    let ruleset = Ruleset::default()
240        .set_compatibility(CompatLevel::HardRequirement)
241        .handle_access(handled_fs_access(abi, profile.network_mode))?;
242    // Signals are a process-isolation capability, not a network capability,
243    // so keep them scoped even when the user explicitly requests AllowAll
244    // networking. Abstract Unix sockets, on the other hand, are deliberately
245    // left ambient only in AllowAll mode. Descendants remain in the same
246    // Landlock domain and can signal/connect to one another.
247    let ruleset = if probe.abi.supports_scopes() {
248        let ruleset = ruleset.scope(Scope::Signal)?;
249        if profile.network_mode == NetworkMode::AllowAll {
250            ruleset
251        } else {
252            ruleset.scope(Scope::AbstractUnixSocket)?
253        }
254    } else {
255        ruleset
256    };
257    let ruleset =
258        if probe.abi.supports_net_port_filter() && profile.network_mode != NetworkMode::AllowAll {
259            ruleset.handle_access(AccessNet::ConnectTcp | AccessNet::BindTcp)?
260        } else {
261            ruleset
262        };
263
264    // `no_new_privs(false)` here because the single-threaded launcher issues
265    // the prctl explicitly immediately before installing the compiled rules.
266    let mut created = ruleset.create()?.no_new_privs(false);
267
268    // Every rule is opened atomically without following user-controlled
269    // symlinks. Root-owned distro symlinks are resolved only after verifying
270    // every lexical and canonical ancestor.
271    let baseline_reads: Vec<PathBuf> = READ_ALLOWLIST_ANCHORS
272        .iter()
273        .chain(PROC_READ_ALLOWLIST_ANCHORS)
274        .map(PathBuf::from)
275        .collect();
276    let mut carved_entries = 0_usize;
277    for path in &baseline_reads {
278        let sandbox_path = if path.is_dir() {
279            SandboxPath::dir(path.clone())
280        } else {
281            SandboxPath::file(path.clone())
282        };
283        created = add_read_rule(
284            created,
285            &sandbox_path,
286            &forbidden_reads,
287            abi,
288            &mut carved_entries,
289        )?;
290    }
291    for sp in &profile.allow_read {
292        created = add_read_rule(created, sp, &forbidden_reads, abi, &mut carved_entries)?;
293    }
294
295    for sp in &profile.allow_write {
296        let access = if profile
297            .ephemeral_write_exec
298            .iter()
299            .any(|root| sp.path.starts_with(root))
300        {
301            ephemeral_write_access(abi)
302        } else {
303            write_access(abi)
304        };
305        created = add_write_rule(created, sp, access, abi)?;
306        // Mutable caches and outputs must be readable to be useful, but they
307        // remain non-executable. The forbidden-read lint rejects overlaps.
308        created = add_path_rules(
309            created,
310            std::slice::from_ref(&sp.path),
311            read_access(abi),
312            abi,
313            UntrustedSymlinkBehavior::Reject,
314        )?;
315    }
316    for path in BASELINE_WRITE_PATHS {
317        created = add_write_rule(
318            created,
319            &SandboxPath::file(PathBuf::from(path)),
320            write_access(abi),
321            abi,
322        )?;
323    }
324
325    // Exec allowlist (read+exec); covers shared libraries too. Per-entry
326    // policy: Follow for root-owned system paths (/lib, /usr/, /bin, …)
327    // because those are symlinks on usr-merge distros and only root can
328    // tamper with them; Refuse for anything else (notably $HOME-relative
329    // entries like ~/.cargo/bin/, ~/.nvm/) because a hostile earlier
330    // build can plant a symlink there.
331    for (index, sp) in profile.allow_exec.iter().enumerate() {
332        // Built-in profiles list alternatives for multiple distributions and
333        // tool managers. If an optional alias exists under a mutable parent,
334        // omitting its rule is fail-closed and lets unrelated commands use
335        // the profile. User/runtime grants remain strict because silently
336        // ignoring an explicitly requested capability would be misleading.
337        let symlink_behavior = if index < profile.first_user_allow_exec {
338            UntrustedSymlinkBehavior::Skip
339        } else {
340            UntrustedSymlinkBehavior::Reject
341        };
342        created = add_path_rules(
343            created,
344            std::slice::from_ref(&sp.path),
345            exec_access(abi),
346            abi,
347            symlink_behavior,
348        )?;
349    }
350
351    // Net rules — only on v4+. Loopback (proxy) or :443 fallback.
352    if probe.abi.supports_net_port_filter() {
353        match profile.network_mode {
354            NetworkMode::Proxy => {
355                let port = proxy_port.ok_or_else(|| {
356                    CoreError::Backend("proxy network mode has no live proxy port".to_owned())
357                })?;
358                created = created.add_rule(NetPort::new(port, AccessNet::ConnectTcp))?;
359            }
360            NetworkMode::DirectHttps443 => {
361                created = created.add_rule(NetPort::new(443, AccessNet::ConnectTcp))?;
362            }
363            NetworkMode::DenyAll | NetworkMode::AllowAll => {}
364        }
365    }
366
367    Ok(CompiledLandlock { ruleset: created })
368}
369
370/// Add a read subtree while preserving holes for built-in `denyRead` paths.
371/// Landlock cannot subtract a child rule from a parent grant, so a subtree
372/// containing a denied descendant is represented as ReadDir-only ancestors
373/// plus data-read rules for each safe sibling inode.
374fn add_read_rule(
375    created: RulesetCreated,
376    path: &SandboxPath,
377    forbidden: &BTreeSet<PathBuf>,
378    abi: ABI,
379    visited: &mut usize,
380) -> Result<RulesetCreated, CoreError> {
381    use crate::config::PathKind;
382
383    if forbidden
384        .iter()
385        .any(|denied| path_is_under(&path.path, denied))
386    {
387        return Ok(created);
388    }
389    let has_denied_descendant = forbidden
390        .iter()
391        .any(|denied| denied != &path.path && path_is_under(denied, &path.path));
392    if !has_denied_descendant {
393        return add_path_rules(
394            created,
395            std::slice::from_ref(&path.path),
396            read_access(abi),
397            abi,
398            UntrustedSymlinkBehavior::Reject,
399        );
400    }
401    if !matches!(path.kind, PathKind::Subpath) {
402        return Err(CoreError::ProfileLint(format!(
403            "literal read grant '{}' contains a denied descendant",
404            path.path.display()
405        )));
406    }
407    let Some(fd) = open_existing_safely(&path.path)? else {
408        return Ok(created);
409    };
410    add_carved_read_directory(created, fd, &path.path, forbidden, abi, visited)
411}
412
413#[allow(
414    clippy::disallowed_methods,
415    reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
416)]
417fn add_carved_read_directory(
418    mut created: RulesetCreated,
419    directory: OwnedFd,
420    logical_path: &Path,
421    forbidden: &BTreeSet<PathBuf>,
422    abi: ABI,
423    visited: &mut usize,
424) -> Result<RulesetCreated, CoreError> {
425    let proc_path = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
426    let mut entries = Vec::new();
427    for entry in std::fs::read_dir(&proc_path).map_err(CoreError::Io)? {
428        let entry = entry.map_err(CoreError::Io)?;
429        *visited = visited.saturating_add(1);
430        if *visited > MAX_CARVED_READ_ENTRIES {
431            return Err(CoreError::ProfileLint(format!(
432                "read policy under '{}' exceeds {MAX_CARVED_READ_ENTRIES} entries",
433                logical_path.display()
434            )));
435        }
436        entries.push(entry.file_name());
437    }
438
439    // Preserve directory traversal/listing without granting ReadFile to all
440    // descendants. A duplicate keeps the original FD available for openat2.
441    created = created.add_rule(PathBeneath::new(
442        duplicate_fd(directory.as_raw_fd())?,
443        read_directory_access(abi),
444    ))?;
445
446    for name in entries {
447        let child_path = logical_path.join(&name);
448        if forbidden
449            .iter()
450            .any(|denied| path_is_under(&child_path, denied))
451        {
452            continue;
453        }
454        let nested_hole = forbidden
455            .iter()
456            .any(|denied| denied != &child_path && path_is_under(denied, &child_path));
457        let name = CString::new(name.as_bytes()).map_err(|_| {
458            CoreError::ProfileLint(format!(
459                "sandbox path contains NUL below '{}'",
460                logical_path.display()
461            ))
462        })?;
463        let child = match openat2_component(directory.as_raw_fd(), &name, nested_hole) {
464            Ok(child) => child,
465            // Symlinks receive no grant of their own. If their target is an
466            // already-authorized safe inode, normal resolution still works;
467            // otherwise access remains denied.
468            Err(error) if error.raw_os_error() == Some(libc::ELOOP) => continue,
469            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
470            Err(error) => return Err(CoreError::Io(error)),
471        };
472        if nested_hole {
473            created =
474                add_carved_read_directory(created, child, &child_path, forbidden, abi, visited)?;
475        } else {
476            let compatible = access_for_fd(&child, read_access(abi), abi)?;
477            created = created.add_rule(PathBeneath::new(child, compatible))?;
478        }
479    }
480    Ok(created)
481}
482
483fn duplicate_fd(fd: libc::c_int) -> Result<OwnedFd, CoreError> {
484    let duplicated = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 3) };
485    if duplicated < 0 {
486        return Err(CoreError::Io(std::io::Error::last_os_error()));
487    }
488    Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
489}
490
491fn add_path_rules(
492    mut created: RulesetCreated,
493    paths: &[PathBuf],
494    access: BitFlags<AccessFs>,
495    abi: ABI,
496    symlink_behavior: UntrustedSymlinkBehavior,
497) -> Result<RulesetCreated, CoreError> {
498    for path in paths {
499        if let Some(fd) = open_existing_safely_with(path, symlink_behavior)? {
500            let compatible = access_for_fd(&fd, access, abi)?;
501            created = created.add_rule(PathBeneath::new(fd, compatible))?;
502        }
503    }
504    Ok(created)
505}
506
507fn add_write_rule(
508    mut created: RulesetCreated,
509    path: &SandboxPath,
510    access: BitFlags<AccessFs>,
511    abi: ABI,
512) -> Result<RulesetCreated, CoreError> {
513    use crate::config::PathKind;
514    let fd = match path.kind {
515        PathKind::Subpath => Some(open_or_create_directory(&path.path)?),
516        PathKind::Literal => open_existing_safely(&path.path)?,
517        PathKind::Regex => {
518            return Err(CoreError::ProfileLint(format!(
519                "regex write grants are not safely enforceable on Linux: '{}'",
520                path.path.display()
521            )));
522        }
523    };
524    if let Some(fd) = fd {
525        let compatible = access_for_fd(&fd, access, abi)?;
526        created = created.add_rule(PathBeneath::new(fd, compatible))?;
527    } else {
528        tracing::debug!(
529            path = %path.path.display(),
530            "literal write target does not exist; refusing to broaden its parent"
531        );
532    }
533    Ok(created)
534}
535
536/// Landlock rejects directory-only rights (for example `ReadDir`, `MakeReg`,
537/// or `RemoveFile`) when a rule targets a regular file or device node. Keep
538/// the requested subtree rights for directories, but narrow literal file
539/// rules to the ABI's file-compatible access set.
540fn access_for_fd(
541    fd: &OwnedFd,
542    access: BitFlags<AccessFs>,
543    abi: ABI,
544) -> Result<BitFlags<AccessFs>, CoreError> {
545    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
546    if unsafe { libc::fstat(fd.as_raw_fd(), &mut stat) } != 0 {
547        return Err(CoreError::Io(std::io::Error::last_os_error()));
548    }
549    if stat.st_mode & libc::S_IFMT == libc::S_IFDIR {
550        Ok(access)
551    } else {
552        Ok(access & AccessFs::from_file(abi))
553    }
554}
555
556/// Open an existing path with no symlink or magic-link traversal. A distro
557/// symlink is accepted only if every original and canonical ancestor is
558/// root-owned and not group/world writable.
559fn open_existing_safely(path: &Path) -> Result<Option<OwnedFd>, CoreError> {
560    open_existing_safely_with(path, UntrustedSymlinkBehavior::Reject)
561}
562
563#[allow(
564    clippy::disallowed_methods,
565    reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
566)]
567fn open_existing_safely_with(
568    path: &Path,
569    symlink_behavior: UntrustedSymlinkBehavior,
570) -> Result<Option<OwnedFd>, CoreError> {
571    match open_no_symlinks(path, false) {
572        Ok(fd) => Ok(Some(fd)),
573        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
574        Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
575            // `RESOLVE_NO_SYMLINKS` reports ELOOP as soon as it encounters an
576            // intermediate system link (for example `/lib -> /usr/lib`),
577            // even when the final cross-distribution fallback path does not
578            // exist. A missing target receives no Landlock rule and is safe
579            // to skip; existing targets still have both chains verified.
580            let canonical = match std::fs::canonicalize(path) {
581                Ok(canonical) => canonical,
582                Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
583                Err(error) => return Err(CoreError::Io(error)),
584            };
585            if let Err(reason) = root_owned_chain(path) {
586                return handle_untrusted_symlink(path, &reason, symlink_behavior);
587            }
588            if let Err(reason) = root_owned_chain(&canonical) {
589                let reason = format!("canonical target is not immutable: {reason}");
590                return handle_untrusted_symlink(path, &reason, symlink_behavior);
591            }
592            open_no_symlinks(&canonical, false)
593                .map(Some)
594                .map_err(CoreError::Io)
595        }
596        Err(error) => Err(CoreError::Io(error)),
597    }
598}
599
600fn handle_untrusted_symlink(
601    path: &Path,
602    reason: &str,
603    behavior: UntrustedSymlinkBehavior,
604) -> Result<Option<OwnedFd>, CoreError> {
605    match behavior {
606        UntrustedSymlinkBehavior::Reject => Err(CoreError::ProfileLint(format!(
607            "allowlist path '{}' traverses an untrusted symlink: {reason}",
608            path.display()
609        ))),
610        UntrustedSymlinkBehavior::Skip => {
611            tracing::warn!(
612                path = %path.display(),
613                reason,
614                "skipping unsafe optional built-in executable"
615            );
616            Ok(None)
617        }
618    }
619}
620
621/// Atomically walk and create a writable directory using directory FDs. No
622/// component may be a symlink, so a concurrent rename cannot redirect the
623/// grant outside the requested path.
624fn open_or_create_directory(path: &Path) -> Result<OwnedFd, CoreError> {
625    if !path.is_absolute() {
626        return Err(CoreError::ProfileLint(format!(
627            "sandbox path must be absolute: '{}'",
628            path.display()
629        )));
630    }
631    let mut current = open_root().map_err(CoreError::Io)?;
632    for component in path.components() {
633        use std::path::Component;
634        let name = match component {
635            Component::RootDir => continue,
636            Component::Normal(name) => name,
637            _ => {
638                return Err(CoreError::ProfileLint(format!(
639                    "sandbox path contains traversal: '{}'",
640                    path.display()
641                )));
642            }
643        };
644        let name = CString::new(name.as_bytes()).map_err(|_| {
645            CoreError::ProfileLint(format!("sandbox path contains NUL: '{}'", path.display()))
646        })?;
647        let next = match openat2_component(current.as_raw_fd(), &name, true) {
648            Ok(fd) => fd,
649            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
650                let rc = unsafe { libc::mkdirat(current.as_raw_fd(), name.as_ptr(), 0o700) };
651                if rc != 0 {
652                    let mkdir_error = std::io::Error::last_os_error();
653                    if mkdir_error.kind() != std::io::ErrorKind::AlreadyExists {
654                        return Err(CoreError::Io(mkdir_error));
655                    }
656                }
657                openat2_component(current.as_raw_fd(), &name, true).map_err(CoreError::Io)?
658            }
659            Err(error) => return Err(CoreError::Io(error)),
660        };
661        current = next;
662    }
663    Ok(current)
664}
665
666fn open_no_symlinks(path: &Path, directory: bool) -> std::io::Result<OwnedFd> {
667    if !path.is_absolute() {
668        return Err(std::io::Error::new(
669            std::io::ErrorKind::InvalidInput,
670            "path is not absolute",
671        ));
672    }
673    if path == Path::new("/") {
674        return open_root();
675    }
676    let relative = path.strip_prefix("/").expect("absolute path has root");
677    let relative = CString::new(relative.as_os_str().as_bytes())
678        .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL"))?;
679    let root = open_root()?;
680    openat2_component(root.as_raw_fd(), &relative, directory)
681}
682
683fn open_root() -> std::io::Result<OwnedFd> {
684    let root = c"/";
685    let fd = unsafe { libc::open(root.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
686    if fd < 0 {
687        Err(std::io::Error::last_os_error())
688    } else {
689        Ok(unsafe { OwnedFd::from_raw_fd(fd) })
690    }
691}
692
693fn openat2_component(
694    directory_fd: libc::c_int,
695    path: &CString,
696    directory: bool,
697) -> std::io::Result<OwnedFd> {
698    let mut flags = (libc::O_PATH | libc::O_CLOEXEC) as u64;
699    if directory {
700        flags |= libc::O_DIRECTORY as u64;
701    }
702    let mut how: libc::open_how = unsafe { std::mem::zeroed() };
703    how.flags = flags;
704    how.mode = 0;
705    how.resolve = libc::RESOLVE_BENEATH | libc::RESOLVE_NO_SYMLINKS | libc::RESOLVE_NO_MAGICLINKS;
706    let fd = unsafe {
707        libc::syscall(
708            libc::SYS_openat2,
709            directory_fd,
710            path.as_ptr(),
711            &how,
712            std::mem::size_of::<libc::open_how>(),
713        ) as libc::c_int
714    };
715    if fd < 0 {
716        Err(std::io::Error::last_os_error())
717    } else {
718        Ok(unsafe { OwnedFd::from_raw_fd(fd) })
719    }
720}
721
722#[allow(
723    clippy::disallowed_methods,
724    reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
725)]
726fn root_owned_chain(path: &Path) -> Result<(), String> {
727    let mut current = PathBuf::from("/");
728    for component in path.components() {
729        use std::path::Component;
730        match component {
731            Component::RootDir => continue,
732            Component::Normal(name) => current.push(name),
733            _ => {
734                return Err(format!(
735                    "'{}' contains a non-normal path component",
736                    path.display()
737                ));
738            }
739        }
740        let metadata = std::fs::symlink_metadata(&current)
741            .map_err(|error| format!("cannot inspect '{}': {error}", current.display()))?;
742        // A symlink has no mutable payload: replacing it requires write
743        // access to its parent directory. We verify every non-symlink
744        // lexical ancestor here and separately verify the complete
745        // canonical target chain in `open_existing_safely`. Requiring the
746        // link inode itself to be root-owned rejects immutable system links
747        // on distributions that preserve a non-root package-builder UID,
748        // without adding any security boundary.
749        if metadata.file_type().is_symlink() {
750            continue;
751        }
752        if metadata.uid() != 0 {
753            return Err(format!(
754                "'{}' is owned by UID {}, not root",
755                current.display(),
756                metadata.uid()
757            ));
758        }
759        if metadata.mode() & 0o022 != 0 {
760            return Err(format!(
761                "'{}' is group/world writable (mode {:o})",
762                current.display(),
763                metadata.mode() & 0o7777
764            ));
765        }
766    }
767    Ok(())
768}
769
770fn build_forbidden_reads(profile: &SandboxProfile) -> Result<BTreeSet<PathBuf>, CoreError> {
771    let mut set = BTreeSet::new();
772    let mut inspected = 0_usize;
773    for sp in &profile.deny_read {
774        match open_no_symlinks(&sp.path, false) {
775            Ok(fd) => reject_aliased_forbidden_tree(&fd, &sp.path, &mut inspected)?,
776            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
777            Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
778                return Err(CoreError::ProfileLint(format!(
779                    "denyRead path '{}' traverses a symlink; refusing a policy whose canonical \
780                     target could receive a read grant",
781                    sp.path.display()
782                )));
783            }
784            Err(error) => return Err(CoreError::Io(error)),
785        }
786        set.insert(sp.path.clone());
787    }
788    Ok(set)
789}
790
791/// Landlock authorizes filesystem objects, not pathnames. If a denied regular
792/// file has another hard link, granting the alias would also authorize the
793/// denied name. Recursively verify denied directories and fail closed instead
794/// of pretending that path carving can distinguish names for one inode.
795#[allow(
796    clippy::disallowed_methods,
797    reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
798)]
799fn reject_aliased_forbidden_tree(
800    fd: &OwnedFd,
801    logical_path: &Path,
802    inspected: &mut usize,
803) -> Result<(), CoreError> {
804    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
805    if unsafe { libc::fstat(fd.as_raw_fd(), &mut stat) } != 0 {
806        return Err(CoreError::Io(std::io::Error::last_os_error()));
807    }
808    let file_type = stat.st_mode & libc::S_IFMT;
809    if file_type == libc::S_IFREG {
810        if stat.st_nlink > 1 {
811            return Err(CoreError::ProfileLint(format!(
812                "denyRead file '{}' has {} hard links; Landlock cannot deny one pathname while \
813                 an alias grants the same inode",
814                logical_path.display(),
815                stat.st_nlink,
816            )));
817        }
818        return Ok(());
819    }
820    if file_type != libc::S_IFDIR {
821        return Ok(());
822    }
823
824    let proc_path = PathBuf::from(format!("/proc/self/fd/{}", fd.as_raw_fd()));
825    for entry in std::fs::read_dir(proc_path).map_err(CoreError::Io)? {
826        let entry = entry.map_err(CoreError::Io)?;
827        *inspected = inspected.saturating_add(1);
828        if *inspected > MAX_CARVED_READ_ENTRIES {
829            return Err(CoreError::ProfileLint(format!(
830                "denyRead policy under '{}' exceeds {MAX_CARVED_READ_ENTRIES} entries",
831                logical_path.display()
832            )));
833        }
834        let name = entry.file_name();
835        let child_path = logical_path.join(&name);
836        let name = CString::new(name.as_bytes()).map_err(|_| {
837            CoreError::ProfileLint(format!(
838                "sandbox path contains NUL below '{}'",
839                logical_path.display()
840            ))
841        })?;
842        let child = match openat2_component(fd.as_raw_fd(), &name, false) {
843            Ok(child) => child,
844            Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
845                return Err(CoreError::ProfileLint(format!(
846                    "denyRead path '{}' traverses a symlink; refusing a policy whose canonical \
847                     target could receive a read grant",
848                    child_path.display()
849                )));
850            }
851            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
852            Err(error) => return Err(CoreError::Io(error)),
853        };
854        reject_aliased_forbidden_tree(&child, &child_path, inspected)?;
855    }
856    Ok(())
857}
858
859/// Reject any **user-supplied** `allow_write` / `allow_exec` / `allow_read`
860/// entry that overlaps a `denyRead` path. Mutable paths receive explicit
861/// data-read rights and executable paths receive data-read plus Execute, so
862/// without this lint a user who writes
863///   profiles.node.allowWrite: ["~/"]
864/// would silently broaden read access onto every denyRead path under `~/`.
865///
866/// The lint only inspects entries appended *after* the curated defaults
867/// (indices `>= first_user_*`). Built-in defaults intentionally overlap
868/// denyRead in places such as `$PWD/.env`; those built-in read roots are
869/// compiled as carved descriptor rules instead of a broad parent grant.
870fn lint_forbidden_reads_against_grants(
871    profile: &SandboxProfile,
872    forbidden: &BTreeSet<PathBuf>,
873    options: BackendOptions,
874) -> Result<(), CoreError> {
875    let _ = options;
876    let user_slices: [(&str, &[SandboxPath]); 3] = [
877        (
878            "allowWrite",
879            &profile.allow_write[profile.first_user_allow_write..],
880        ),
881        (
882            "allowExec",
883            &profile.allow_exec[profile.first_user_allow_exec..],
884        ),
885        (
886            "allowRead",
887            &profile.allow_read[profile.first_user_allow_read..],
888        ),
889    ];
890    for (field, paths) in user_slices {
891        for sp in paths {
892            for f in forbidden {
893                if path_is_under(f, &sp.path) || path_is_under(&sp.path, f) {
894                    return Err(CoreError::ProfileLint(format!(
895                        "denyRead path '{}' overlaps user-supplied {} entry '{}'. Landlock grants \
896                         on allowWrite and allowExec include data-read, so this would silently \
897                         expose the denied path. Remove or relocate the {} entry, or remove the \
898                         denyRead entry.",
899                        f.display(),
900                        field,
901                        sp.path.display(),
902                        field,
903                    )));
904                }
905            }
906        }
907    }
908    Ok(())
909}
910
911fn lint_allow_exec_for_priv_escalation(
912    profile: &SandboxProfile,
913    options: BackendOptions,
914) -> Result<(), CoreError> {
915    let _ = options;
916
917    for sp in &profile.allow_exec {
918        if !is_subpath(sp) {
919            continue;
920        }
921        for binary in PRIVILEGE_ESCALATION_BINARIES {
922            let bin_path = Path::new(binary);
923            if path_is_under(bin_path, &sp.path) {
924                return Err(CoreError::ProfileLint(format!(
925                    "allowExec entry '{}' (directory) covers privilege-escalation binary '{}'. \
926                     This would defeat the threat model. Replace it with explicit per-binary \
927                     entries.",
928                    sp.path.display(),
929                    binary,
930                )));
931            }
932        }
933    }
934    Ok(())
935}
936
937fn is_subpath(sp: &SandboxPath) -> bool {
938    use crate::config::PathKind;
939    matches!(sp.kind, PathKind::Subpath)
940}
941
942fn path_is_under(candidate: &Path, anchor: &Path) -> bool {
943    candidate == anchor || candidate.starts_with(anchor)
944}
945
946impl From<landlock::RulesetError> for CoreError {
947    fn from(err: landlock::RulesetError) -> Self {
948        CoreError::Backend(format!("landlock ruleset error: {err}"))
949    }
950}
951
952impl From<landlock::AddRulesError> for CoreError {
953    fn from(err: landlock::AddRulesError) -> Self {
954        CoreError::Backend(format!("landlock add_rules error: {err}"))
955    }
956}
957
958impl From<landlock::AddRuleError<AccessFs>> for CoreError {
959    fn from(err: landlock::AddRuleError<AccessFs>) -> Self {
960        CoreError::Backend(format!("landlock add_rule (fs) error: {err}"))
961    }
962}
963
964impl From<landlock::AddRuleError<AccessNet>> for CoreError {
965    fn from(err: landlock::AddRuleError<AccessNet>) -> Self {
966        CoreError::Backend(format!("landlock add_rule (net) error: {err}"))
967    }
968}
969
970#[cfg(test)]
971mod tests {
972    use std::path::PathBuf;
973
974    use super::*;
975    use crate::{
976        config::{PathKind, SandboxPath},
977        detect::Ecosystem,
978    };
979
980    #[test]
981    fn data_read_grants_never_include_execute() {
982        let access = read_access(ABI::V9);
983        assert!(access.contains(AccessFs::ReadFile));
984        assert!(access.contains(AccessFs::ReadDir));
985        assert!(!access.contains(AccessFs::Execute));
986    }
987
988    #[test]
989    fn persistent_write_grants_exclude_execute_ioctl_and_unix_resolution() {
990        for abi in [ABI::V1, ABI::V4, ABI::V5, ABI::V9] {
991            let access = write_access(abi);
992            assert!(!access.contains(AccessFs::Execute));
993            assert!(!access.contains(AccessFs::IoctlDev));
994            assert!(!access.contains(AccessFs::ResolveUnix));
995        }
996        assert!(ephemeral_write_access(ABI::V9).contains(AccessFs::ResolveUnix));
997        assert!(!ephemeral_write_access(ABI::V9).contains(AccessFs::IoctlDev));
998    }
999
1000    #[test]
1001    fn allow_all_does_not_handle_unix_socket_resolution() {
1002        assert!(handled_fs_access(ABI::V9, NetworkMode::Proxy).contains(AccessFs::ResolveUnix));
1003        assert!(!handled_fs_access(ABI::V9, NetworkMode::AllowAll).contains(AccessFs::ResolveUnix));
1004    }
1005
1006    #[test]
1007    fn literal_file_rules_drop_directory_only_rights() {
1008        let temp = tempfile::tempdir().unwrap();
1009        let file = tempfile::NamedTempFile::new_in(temp.path()).unwrap();
1010
1011        let directory = open_no_symlinks(temp.path(), true).unwrap();
1012        let file = open_no_symlinks(file.path(), false).unwrap();
1013        let requested = read_access(ABI::V9) | write_access(ABI::V9);
1014
1015        assert_eq!(
1016            access_for_fd(&directory, requested, ABI::V9).unwrap(),
1017            requested
1018        );
1019        let file_access = access_for_fd(&file, requested, ABI::V9).unwrap();
1020        assert_eq!(file_access, requested & AccessFs::from_file(ABI::V9));
1021        assert!(file_access.contains(AccessFs::ReadFile));
1022        assert!(file_access.contains(AccessFs::WriteFile));
1023        assert!(!file_access.contains(AccessFs::ReadDir));
1024        assert!(!file_access.contains(AccessFs::MakeReg));
1025    }
1026
1027    #[test]
1028    #[allow(
1029        clippy::disallowed_methods,
1030        reason = "the Linux-only policy test is synchronous"
1031    )]
1032    fn immutable_system_symlinks_are_trusted() {
1033        for path in [
1034            Path::new("/bin/sh"),
1035            Path::new("/lib64/ld-linux-x86-64.so.2"),
1036            Path::new("/lib/ld-linux-x86-64.so.2"),
1037            Path::new("/lib/ld-linux-aarch64.so.1"),
1038        ] {
1039            if path.exists() {
1040                root_owned_chain(path).unwrap();
1041                let canonical = std::fs::canonicalize(path).unwrap();
1042                root_owned_chain(&canonical).unwrap();
1043            }
1044        }
1045    }
1046
1047    #[test]
1048    fn missing_target_through_symlink_is_skipped_without_a_rule() {
1049        use std::os::unix::fs::symlink;
1050
1051        let temp = tempfile::tempdir().unwrap();
1052        let outside = tempfile::tempdir().unwrap();
1053        symlink(outside.path(), temp.path().join("redirect")).unwrap();
1054
1055        let missing = temp.path().join("redirect/missing");
1056        assert!(open_existing_safely(&missing).unwrap().is_none());
1057    }
1058
1059    #[test]
1060    fn existing_target_through_untrusted_symlink_is_rejected() {
1061        use std::os::unix::fs::symlink;
1062
1063        let temp = tempfile::tempdir().unwrap();
1064        let outside = tempfile::tempdir().unwrap();
1065        let target = tempfile::NamedTempFile::new_in(outside.path()).unwrap();
1066        symlink(target.path(), temp.path().join("redirect")).unwrap();
1067
1068        let redirect = temp.path().join("redirect");
1069        assert!(open_existing_safely(&redirect).is_err());
1070        assert!(
1071            open_existing_safely_with(&redirect, UntrustedSymlinkBehavior::Skip)
1072                .unwrap()
1073                .is_none()
1074        );
1075    }
1076
1077    #[test]
1078    fn test_should_reject_priv_escalation_subpath() {
1079        let mut profile = SandboxProfile::for_ecosystem(
1080            Ecosystem::Rust,
1081            &PathBuf::from("/home/test"),
1082            &PathBuf::from("/home/test/pwd"),
1083        );
1084        profile.allow_exec.push(SandboxPath {
1085            path: PathBuf::from("/usr/bin"),
1086            kind: PathKind::Subpath,
1087        });
1088        let err =
1089            lint_allow_exec_for_priv_escalation(&profile, BackendOptions::default()).unwrap_err();
1090        assert!(format!("{err}").contains("privilege-escalation"));
1091    }
1092
1093    #[test]
1094    fn test_should_not_bypass_priv_escalation_lint_with_allow_degraded() {
1095        let mut profile = SandboxProfile::for_ecosystem(
1096            Ecosystem::Rust,
1097            &PathBuf::from("/home/test"),
1098            &PathBuf::from("/home/test/pwd"),
1099        );
1100        profile.allow_exec.push(SandboxPath {
1101            path: PathBuf::from("/usr/bin"),
1102            kind: PathKind::Subpath,
1103        });
1104        let res = lint_allow_exec_for_priv_escalation(
1105            &profile,
1106            BackendOptions {
1107                allow_degraded: true,
1108                ..BackendOptions::default()
1109            },
1110        );
1111        assert!(res.is_err());
1112    }
1113
1114    #[test]
1115    fn test_should_not_lint_baseline_anchor_overlap() {
1116        // §8: the seal is a "promise to never *silently broaden* a path that
1117        // overlaps denyRead". Baseline anchors (/etc, /tmp, /lib, …) are
1118        // documented in the README as readable, so they don't count as a
1119        // user-broadening event — the lint only inspects per-profile
1120        // allow_read / allow_write / allow_exec entries.
1121        let mut profile = SandboxProfile::for_ecosystem(
1122            Ecosystem::Rust,
1123            &PathBuf::from("/home/test"),
1124            &PathBuf::from("/home/test/pwd"),
1125        );
1126        profile.deny_read.clear();
1127        profile.deny_read.push(SandboxPath {
1128            path: PathBuf::from("/etc/ssh"),
1129            kind: PathKind::Subpath,
1130        });
1131        let forbidden = build_forbidden_reads(&profile).unwrap();
1132        let lint =
1133            lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default());
1134        assert!(lint.is_ok(), "baseline anchor overlap must not lint");
1135    }
1136
1137    #[test]
1138    fn test_should_reject_forbidden_read_overlap_with_user_allow_read() {
1139        let mut profile = SandboxProfile::for_ecosystem(
1140            Ecosystem::Rust,
1141            &PathBuf::from("/home/test"),
1142            &PathBuf::from("/home/test/pwd"),
1143        );
1144        profile.deny_read.clear();
1145        profile.deny_read.push(SandboxPath {
1146            path: PathBuf::from("/home/test/.ssh"),
1147            kind: PathKind::Subpath,
1148        });
1149        // User config tries to grant ~/ as readable — overlaps denyRead.
1150        profile.allow_read.push(SandboxPath {
1151            path: PathBuf::from("/home/test"),
1152            kind: PathKind::Subpath,
1153        });
1154        let forbidden = build_forbidden_reads(&profile).unwrap();
1155        let err =
1156            lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
1157                .unwrap_err();
1158        assert!(format!("{err}").contains("denyRead"));
1159        assert!(format!("{err}").contains("allowRead"));
1160    }
1161
1162    /// C2: a user who broadens read-access via allowWrite (not allowRead)
1163    /// must still trip the denyRead seal. The Landlock write_access bitmask
1164    /// includes read_file/read_dir, so without this check the
1165    /// "sealed forbidden-list" promise is bypassable trivially.
1166    #[test]
1167    fn test_should_reject_forbidden_read_overlap_with_allow_write() {
1168        let mut profile = SandboxProfile::for_ecosystem(
1169            Ecosystem::Rust,
1170            &PathBuf::from("/home/test"),
1171            &PathBuf::from("/home/test/pwd"),
1172        );
1173        profile.deny_read.clear();
1174        profile.deny_read.push(SandboxPath {
1175            path: PathBuf::from("/home/test/.ssh"),
1176            kind: PathKind::Subpath,
1177        });
1178        profile.allow_write.push(SandboxPath {
1179            path: PathBuf::from("/home/test"),
1180            kind: PathKind::Subpath,
1181        });
1182        let forbidden = build_forbidden_reads(&profile).unwrap();
1183        let err =
1184            lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
1185                .unwrap_err();
1186        assert!(format!("{err}").contains("denyRead"));
1187        assert!(format!("{err}").contains("allowWrite"));
1188    }
1189
1190    /// Same path-class attack but via allowExec. Landlock exec_access
1191    /// includes from_read so a directory under allowExec is read-visible.
1192    #[test]
1193    fn test_should_reject_forbidden_read_overlap_with_allow_exec() {
1194        let mut profile = SandboxProfile::for_ecosystem(
1195            Ecosystem::Rust,
1196            &PathBuf::from("/home/test"),
1197            &PathBuf::from("/home/test/pwd"),
1198        );
1199        profile.deny_read.clear();
1200        profile.deny_read.push(SandboxPath {
1201            path: PathBuf::from("/home/test/.aws/credentials"),
1202            kind: PathKind::Literal,
1203        });
1204        profile.allow_exec.push(SandboxPath {
1205            path: PathBuf::from("/home/test/.aws"),
1206            kind: PathKind::Subpath,
1207        });
1208        let forbidden = build_forbidden_reads(&profile).unwrap();
1209        let err =
1210            lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
1211                .unwrap_err();
1212        assert!(format!("{err}").contains("allowExec"));
1213    }
1214
1215    #[test]
1216    fn test_should_reject_user_grants_nested_beneath_forbidden_read() {
1217        for field in ["allowRead", "allowWrite", "allowExec"] {
1218            let mut profile = SandboxProfile::for_ecosystem(
1219                Ecosystem::Rust,
1220                &PathBuf::from("/home/test"),
1221                &PathBuf::from("/home/test/pwd"),
1222            );
1223            profile.deny_read.clear();
1224            profile
1225                .deny_read
1226                .push(SandboxPath::dir(PathBuf::from("/home/test/.ssh")));
1227            let grant = SandboxPath::file(PathBuf::from("/home/test/.ssh/id_rsa"));
1228            match field {
1229                "allowRead" => profile.allow_read.push(grant),
1230                "allowWrite" => profile.allow_write.push(grant),
1231                "allowExec" => profile.allow_exec.push(grant),
1232                _ => unreachable!(),
1233            }
1234
1235            let forbidden = build_forbidden_reads(&profile).unwrap();
1236            let error = lint_forbidden_reads_against_grants(
1237                &profile,
1238                &forbidden,
1239                BackendOptions::default(),
1240            )
1241            .unwrap_err();
1242            assert!(format!("{error}").contains(field));
1243        }
1244    }
1245
1246    #[test]
1247    #[allow(
1248        clippy::disallowed_methods,
1249        reason = "synchronous filesystem setup is isolated to this Linux policy unit test"
1250    )]
1251    fn forbidden_read_symlink_fails_closed() {
1252        use std::os::unix::fs::symlink;
1253
1254        let project = tempfile::tempdir().unwrap();
1255        let target = project.path().join("config.env");
1256        std::fs::write(&target, "secret").unwrap();
1257        let denied = project.path().join(".env");
1258        symlink(&target, &denied).unwrap();
1259        let mut profile =
1260            SandboxProfile::for_ecosystem(Ecosystem::Rust, project.path(), project.path());
1261        profile.deny_read = vec![SandboxPath::file(denied)];
1262
1263        let error = build_forbidden_reads(&profile).unwrap_err();
1264        assert!(format!("{error}").contains("traverses a symlink"));
1265    }
1266
1267    #[test]
1268    #[allow(
1269        clippy::disallowed_methods,
1270        reason = "synchronous filesystem setup is isolated to this Linux policy unit test"
1271    )]
1272    fn forbidden_read_hard_link_fails_closed() {
1273        let project = tempfile::tempdir().unwrap();
1274        let denied = project.path().join(".env");
1275        let alias = project.path().join("config.env");
1276        std::fs::write(&denied, "secret").unwrap();
1277        std::fs::hard_link(&denied, &alias).unwrap();
1278        let mut profile =
1279            SandboxProfile::for_ecosystem(Ecosystem::Rust, project.path(), project.path());
1280        profile.deny_read = vec![SandboxPath::file(denied)];
1281
1282        let error = build_forbidden_reads(&profile).unwrap_err();
1283        assert!(format!("{error}").contains("hard links"));
1284    }
1285
1286    #[test]
1287    #[allow(
1288        clippy::disallowed_methods,
1289        reason = "synchronous filesystem setup is isolated to this Linux policy unit test"
1290    )]
1291    fn forbidden_directory_checks_descendant_hard_links() {
1292        let project = tempfile::tempdir().unwrap();
1293        let denied_directory = project.path().join("credentials");
1294        std::fs::create_dir(&denied_directory).unwrap();
1295        let denied = denied_directory.join("token");
1296        std::fs::write(&denied, "secret").unwrap();
1297        std::fs::hard_link(&denied, project.path().join("token-alias")).unwrap();
1298        let mut profile =
1299            SandboxProfile::for_ecosystem(Ecosystem::Rust, project.path(), project.path());
1300        profile.deny_read = vec![SandboxPath::dir(denied_directory)];
1301
1302        let error = build_forbidden_reads(&profile).unwrap_err();
1303        assert!(format!("{error}").contains("hard links"));
1304    }
1305
1306    #[test]
1307    fn test_should_not_bypass_forbidden_read_overlap_under_allow_degraded() {
1308        let mut profile = SandboxProfile::for_ecosystem(
1309            Ecosystem::Rust,
1310            &PathBuf::from("/home/test"),
1311            &PathBuf::from("/home/test/pwd"),
1312        );
1313        profile.deny_read.clear();
1314        profile.deny_read.push(SandboxPath {
1315            path: PathBuf::from("/home/test/.ssh"),
1316            kind: PathKind::Subpath,
1317        });
1318        profile.allow_write.push(SandboxPath {
1319            path: PathBuf::from("/home/test"),
1320            kind: PathKind::Subpath,
1321        });
1322        let forbidden = build_forbidden_reads(&profile).unwrap();
1323        let res = lint_forbidden_reads_against_grants(
1324            &profile,
1325            &forbidden,
1326            BackendOptions {
1327                allow_degraded: true,
1328                ..BackendOptions::default()
1329            },
1330        );
1331        assert!(res.is_err(), "allow_degraded must not bypass the seal lint");
1332    }
1333
1334    #[test]
1335    fn test_open_or_create_directory_refuses_symlink_ancestor() {
1336        use std::os::unix::fs::symlink;
1337        let temp = tempfile::tempdir().unwrap();
1338        let outside = tempfile::tempdir().unwrap();
1339        symlink(outside.path(), temp.path().join("redirect")).unwrap();
1340        let target = temp.path().join("redirect/cache");
1341        assert!(open_or_create_directory(&target).is_err());
1342        assert!(!outside.path().join("cache").exists());
1343    }
1344}