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 the **parent** here
4//! (§6 D2) and packaged in [`CompiledLandlock`]. The `pre_exec` closure
5//! issues only the `landlock_restrict_self` syscall — no allocation, no FD
6//! opens.
7//!
8//! The compiler also enforces two backend-time lints required by §8:
9//! - `allow_exec` subpath entries that overlap privilege-escalation binaries (sudo, su, …) are
10//!   rejected.
11//! - `deny_read` is sealed as a forbidden list — when later code tries to broaden `allow_read`, an
12//!   overlap with `forbidden_reads` is rejected.
13
14use std::{
15    collections::BTreeSet,
16    path::{Path, PathBuf},
17};
18
19use landlock::{
20    ABI, Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath,
21    PathFd, Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr,
22};
23
24use crate::{
25    config::SandboxPath,
26    error::CoreError,
27    profile::SandboxProfile,
28    sandbox::{
29        BackendOptions,
30        linux::probe::{LandlockAbi, ProbeResult},
31    },
32};
33
34/// Curated baseline read-allowlist anchors. The Linux profile YAML extends
35/// this with per-OS additions; here we keep the system-essentials list that
36/// the orchestrator always grants, regardless of ecosystem.
37///
38/// Listed paths are read-only — Landlock writes are still gated by
39/// `allow_write`.
40pub const READ_ALLOWLIST_ANCHORS: &[&str] = &[
41    // Dynamic linker, NSS, system config
42    "/etc",
43    "/lib",
44    "/lib32",
45    "/lib64",
46    "/usr",
47    "/proc",
48    "/sys",
49    // Temp
50    "/tmp",
51    "/var/tmp",
52    // Devices we explicitly allow
53    "/dev",
54    // systemd-resolved stub on Ubuntu/Debian/Fedora: /etc/resolv.conf is a
55    // symlink to /run/systemd/resolve/stub-resolv.conf. Landlock follows
56    // symlinks to the canonical path, so the resolver can't read the
57    // nameserver list without granting read on the symlink target.
58    //
59    // We name the SPECIFIC files used by the libc resolver rather than the
60    // whole directory. The directory also contains
61    // `/run/systemd/resolve/io.systemd.Resolve` — a varlink Unix-domain
62    // socket. Landlock pre-ABI v6 does NOT gate UDS connect by path-based
63    // access, so granting read on the directory enables a build script to
64    // connect to the varlink endpoint and ask systemd-resolved to perform
65    // arbitrary DNS lookups, bypassing the HTTP CONNECT proxy's domain
66    // allowlist. Narrow to the two read-only stub files.
67    "/run/systemd/resolve/stub-resolv.conf",
68    "/run/systemd/resolve/resolv.conf",
69];
70
71/// Baseline writable paths injected into every Linux ruleset.
72///
73/// Matches the macOS SBPL writer's `/private/tmp` / `/private/var/folders`
74/// injection: build toolchains (cc, ld, cargo) need to drop temp files in
75/// `/tmp` or `/var/tmp`, and a sandbox that allows execution but not
76/// temp-file creation breaks almost every compiler. `/dev/null` and
77/// `/dev/zero` are routinely targeted by `Stdio::null()` in build scripts.
78const BASELINE_WRITE_PATHS: &[&str] = &["/tmp", "/var/tmp", "/dev/null", "/dev/zero", "/dev/shm"];
79
80/// Privilege-escalation binaries that must never appear under an
81/// `allow_exec` subpath. The lint refuses to build the ruleset if a
82/// user-supplied profile would re-enable any of these via a directory rule.
83const PRIVILEGE_ESCALATION_BINARIES: &[&str] = &[
84    // Direct UID change
85    "/usr/bin/sudo",
86    "/bin/sudo",
87    "/usr/bin/su",
88    "/bin/su",
89    "/usr/bin/runuser",
90    "/usr/sbin/runuser",
91    "/usr/bin/gosu",
92    "/usr/local/bin/gosu",
93    "/usr/bin/doas",
94    "/usr/local/bin/doas",
95    "/usr/bin/pkexec",
96    // Account / shell modification
97    "/usr/bin/chsh",
98    "/usr/bin/chfn",
99    "/usr/bin/newgrp",
100    "/usr/bin/sg",
101    "/usr/bin/passwd",
102    "/usr/bin/gpasswd",
103    // Capability / namespace manipulation (NNP defangs setuid but some
104    // of these are file-cap-based and can still raise privs).
105    "/usr/bin/capsh",
106    "/usr/sbin/capsh",
107    "/usr/bin/setpriv",
108    "/usr/bin/nsenter",
109    "/usr/bin/unshare",
110    "/usr/sbin/unshare",
111    // systemd / DBus-mediated escalation
112    "/usr/bin/systemd-run",
113    "/usr/bin/machinectl",
114    "/usr/bin/pkttyagent",
115    "/usr/bin/dbus-launch",
116    // Filesystem mount manipulation
117    "/usr/bin/mount",
118    "/usr/bin/umount",
119    "/bin/mount",
120    "/bin/umount",
121    "/usr/bin/fusermount",
122    "/usr/bin/fusermount3",
123];
124
125/// FS read access flags applied to the curated read allowlist.
126fn read_access(abi: ABI) -> BitFlags<AccessFs> {
127    AccessFs::from_read(abi)
128}
129
130/// FS write access flags applied to `allow_write`.
131fn write_access(abi: ABI) -> BitFlags<AccessFs> {
132    // From_all includes read+write+exec+make_*+ioctl_dev+truncate; sufficient
133    // for an unrestricted "this directory tree is owned by the build" rule.
134    AccessFs::from_all(abi)
135}
136
137/// FS execute access flags applied to `allow_exec`.
138fn exec_access(abi: ABI) -> BitFlags<AccessFs> {
139    BitFlags::from(AccessFs::Execute) | AccessFs::from_read(abi)
140}
141
142fn highest_abi(probe: &ProbeResult) -> ABI {
143    // Land on the highest ABI the running kernel actually supports; the
144    // `landlock` crate uses CompatLevel::BestEffort below to silently elide
145    // bits the kernel doesn't recognise.
146    match probe.abi {
147        LandlockAbi::Unsupported => ABI::V1,
148        LandlockAbi::V1 => ABI::V1,
149        LandlockAbi::V2 => ABI::V2,
150        LandlockAbi::V3 => ABI::V3,
151        LandlockAbi::V4 => ABI::V4,
152        LandlockAbi::V5 => ABI::V5,
153        LandlockAbi::V6 => ABI::V6,
154    }
155}
156
157/// A ready-to-apply Landlock ruleset. Built in the parent; the wrapped
158/// [`RulesetCreated`] holds all preopened path FDs internally.
159pub struct CompiledLandlock {
160    /// `restrict_self` consumes this in the child.
161    pub ruleset: RulesetCreated,
162}
163
164impl std::fmt::Debug for CompiledLandlock {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("CompiledLandlock").finish_non_exhaustive()
167    }
168}
169
170/// Compile the profile and return either a [`CompiledLandlock`] or an
171/// error explaining which lint/probe step failed.
172pub fn compile(
173    profile: &SandboxProfile,
174    proxy_port: Option<u16>,
175    probe: &ProbeResult,
176    options: BackendOptions,
177) -> Result<CompiledLandlock, CoreError> {
178    if options.allow_degraded {
179        // §13 D1: --allow-degraded is a single flag that bypasses
180        // *three* unrelated checks (priv-esc subpath lint, denyRead
181        // forbidden-list seal, ABI-v4 net-filter gate). Surface every
182        // bypass explicitly so users can't accidentally lose unrelated
183        // defenses while reaching for the flag to fix a kernel-version
184        // problem.
185        tracing::warn!(
186            "--allow-degraded ACTIVE: the following Linux sandbox checks are DISABLED for this \
187             run: (1) privilege-escalation subpath lint (allowExec subpaths can include \
188             sudo/su/pkexec/etc.); (2) denyRead forbidden-list seal \
189             (allowRead/allowWrite/allowExec may overlap denyRead paths); (3) \
190             refuse-on-missing-Landlock-ABI-v4 (kernel may run without per-port TCP filter). \
191             Re-run without --allow-degraded for full enforcement."
192        );
193    }
194
195    // 1. Lints.
196    lint_allow_exec_for_priv_escalation(profile, options)?;
197    let forbidden_reads = build_forbidden_reads(profile)?;
198    lint_forbidden_reads_against_grants(profile, &forbidden_reads, options)?;
199
200    let abi = highest_abi(probe);
201    let ruleset = Ruleset::default()
202        .set_compatibility(CompatLevel::BestEffort)
203        .handle_access(AccessFs::from_all(abi))?;
204    let ruleset = if probe.abi.supports_net_port_filter() && !profile.allow_all_network {
205        ruleset.handle_access(AccessNet::ConnectTcp)?
206    } else {
207        ruleset
208    };
209
210    // `set_no_new_privs(false)` here because the pre_exec closure issues the
211    // prctl explicitly. Calling it twice is harmless but contradicts the §6
212    // invariant that the closure performs exactly the documented syscalls.
213    let mut created = ruleset.create()?.set_no_new_privs(false);
214
215    // Read allowlist. Per-entry symlink policy via `symlink_policy_for`:
216    // root-owned system paths can be symlinks (usr-merge), user paths
217    // cannot (TOCTOU attack vector).
218    let baseline_reads: Vec<PathBuf> = READ_ALLOWLIST_ANCHORS.iter().map(PathBuf::from).collect();
219    for path in &baseline_reads {
220        let policy = symlink_policy_for(path);
221        created = add_path_rules(
222            created,
223            std::slice::from_ref(path),
224            read_access(abi),
225            policy,
226        )?;
227    }
228    for sp in &profile.allow_read {
229        let policy = symlink_policy_for(&sp.path);
230        created = add_path_rules(
231            created,
232            std::slice::from_ref(&sp.path),
233            read_access(abi),
234            policy,
235        )?;
236    }
237
238    // Write allowlist: ensure each writable directory exists before opening
239    // an FD — Landlock can't grant a rule on a non-existent path, and
240    // tools like npm/cargo expect their cache dirs to be writable
241    // even on first invocation. chmod 0700 keeps $HOME caches private.
242    let user_writes: Vec<PathBuf> = profile
243        .allow_write
244        .iter()
245        .map(|sp| sp.path.clone())
246        .collect();
247    let baseline_writes: Vec<PathBuf> = BASELINE_WRITE_PATHS.iter().map(PathBuf::from).collect();
248    let all_writes: Vec<PathBuf> = user_writes
249        .iter()
250        .chain(baseline_writes.iter())
251        .cloned()
252        .collect();
253    ensure_writable_dirs(&all_writes);
254    for path in &all_writes {
255        let policy = symlink_policy_for(path);
256        created = add_path_rules(
257            created,
258            std::slice::from_ref(path),
259            write_access(abi),
260            policy,
261        )?;
262    }
263
264    // Exec allowlist (read+exec); covers shared libraries too. Per-entry
265    // policy: Follow for root-owned system paths (/lib, /usr/, /bin, …)
266    // because those are symlinks on usr-merge distros and only root can
267    // tamper with them; Refuse for anything else (notably $HOME-relative
268    // entries like ~/.cargo/bin/, ~/.nvm/) because a hostile earlier
269    // build can plant a symlink there.
270    for sp in &profile.allow_exec {
271        let policy = symlink_policy_for(&sp.path);
272        created = add_path_rules(
273            created,
274            std::slice::from_ref(&sp.path),
275            exec_access(abi),
276            policy,
277        )?;
278    }
279
280    // Net rules — only on v4+. Loopback (proxy) or :443 fallback.
281    if probe.abi.supports_net_port_filter() && !profile.allow_all_network {
282        if let Some(port) = proxy_port {
283            created = created.add_rule(NetPort::new(port, AccessNet::ConnectTcp))?;
284        } else if !profile.enable_proxy {
285            created = created.add_rule(NetPort::new(443, AccessNet::ConnectTcp))?;
286        }
287    }
288
289    Ok(CompiledLandlock { ruleset: created })
290}
291
292/// Per-call policy for symlinked allowlist entries. Baseline anchors
293/// (`/lib`, `/usr/bin`, `/tmp`, …) are symlinks on usr-merge distros and
294/// sbe ships the canonical strings — they're trusted to point where
295/// they appear to point. User-derived entries (per-ecosystem additions
296/// plus `.sbe.yaml` overrides plus CLI flags) are untrusted: an earlier
297/// hostile build can plant `~/.npm → ~/.ssh` to redirect the next
298/// Landlock grant.
299#[derive(Debug, Clone, Copy)]
300enum SymlinkPolicy {
301    /// Trust the symlink (used for baseline anchors only).
302    Follow,
303    /// Refuse with a ProfileLint error.
304    Refuse,
305}
306
307fn add_path_rules(
308    mut created: RulesetCreated,
309    paths: &[PathBuf],
310    access: BitFlags<AccessFs>,
311    policy: SymlinkPolicy,
312) -> Result<RulesetCreated, CoreError> {
313    for path in paths {
314        if matches!(policy, SymlinkPolicy::Refuse) && is_symlink(path) {
315            return Err(CoreError::ProfileLint(format!(
316                "Landlock allowlist entry '{}' is a symlink. Refusing to open it — a symlink lets \
317                 an attacker redirect the grant onto a target of their choosing. Replace the \
318                 entry with the canonical target path or remove the symlink before re-running sbe.",
319                path.display(),
320            )));
321        }
322
323        // Skip paths that don't exist on the host: Landlock requires open()
324        // on the path, and OpenAt failures here would otherwise abort the
325        // whole sandbox. We log via tracing for diagnostics.
326        let fd = match PathFd::new(path) {
327            Ok(fd) => fd,
328            Err(e) => {
329                tracing::debug!(path = %path.display(), error = %e, "skipping missing landlock path");
330                continue;
331            }
332        };
333        created = created.add_rule(PathBeneath::new(fd, access))?;
334    }
335    Ok(created)
336}
337
338#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
339fn is_symlink(p: &Path) -> bool {
340    std::fs::symlink_metadata(p)
341        .map(|m| m.file_type().is_symlink())
342        .unwrap_or(false)
343}
344
345/// Hardcoded list of root-owned filesystem roots where symlinks are
346/// considered safe — only root can modify these on a stock Linux box, so
347/// a path under here being a symlink reflects distro layout (usr-merge,
348/// /bin → /usr/bin, /lib → /usr/lib, /sbin → /usr/sbin), not an attack.
349///
350/// Any path NOT under one of these prefixes is assumed user-writable
351/// (notably $HOME-relative paths from ecosystem defaults like
352/// ~/.cargo/bin/) and gets [`SymlinkPolicy::Refuse`].
353const ROOT_TRUSTED_PREFIXES: &[&str] = &[
354    "/bin",
355    "/sbin",
356    "/lib",
357    "/lib32",
358    "/lib64",
359    "/usr",
360    "/etc",
361    "/proc",
362    "/sys",
363    "/dev",
364    "/tmp",
365    "/var/tmp",
366    "/var/log",
367    "/var/cache",
368    "/var/lib",
369    "/var/run",
370    "/run",
371    "/opt",
372    "/boot",
373    "/srv",
374];
375
376fn symlink_policy_for(p: &Path) -> SymlinkPolicy {
377    if ROOT_TRUSTED_PREFIXES
378        .iter()
379        .any(|root| p == Path::new(root) || p.starts_with(root))
380    {
381        SymlinkPolicy::Follow
382    } else {
383        SymlinkPolicy::Refuse
384    }
385}
386
387/// Create any missing directories from `allow_write` so Landlock can open
388/// an FD on each one. We mode-0700 directories under $HOME to protect
389/// secrets; paths outside $HOME (e.g. `/tmp`, `/var/tmp`) are left alone.
390///
391/// Crucial: we use [`std::fs::symlink_metadata`] (does NOT follow
392/// symlinks) rather than `Path::exists` (DOES follow) so a pre-existing
393/// symlink like `~/.npm → ~/.ssh` doesn't make us conclude "already
394/// there" and silently grant Landlock write on the link target later.
395/// If the entry itself is a symlink, we leave it alone — `add_path_rules`
396/// will refuse to open it and the build aborts.
397#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
398fn ensure_writable_dirs(paths: &[PathBuf]) {
399    use std::os::unix::fs::PermissionsExt;
400    let home = std::env::var_os("HOME").map(PathBuf::from);
401    for p in paths {
402        // If a real dir already exists at this path, nothing to do.
403        // If a symlink exists, do NOT mkdir into it — add_path_rules will
404        // reject it.
405        match std::fs::symlink_metadata(p) {
406            Ok(m) if m.file_type().is_symlink() => {
407                tracing::warn!(
408                    path = %p.display(),
409                    "allow_write entry is a symlink; refusing to materialize. add_path_rules \
410                     will reject this entry."
411                );
412                continue;
413            }
414            Ok(_) => continue, // real file or dir already exists
415            Err(_) => { /* doesn't exist — fall through to mkdir */ }
416        }
417
418        // Best-effort recursive create. If any ancestor is a symlink the
419        // create can land on an attacker-chosen target — we accept that
420        // for the mkdir step but add_path_rules's PathFd::new will still
421        // follow the symlink at open time, so the rule itself is what we
422        // gate via is_symlink() at the entry level.
423        let _ = std::fs::create_dir_all(p);
424        if let Some(h) = home.as_ref()
425            && p.starts_with(h)
426            && let Ok(meta) = std::fs::symlink_metadata(p)
427            && !meta.file_type().is_symlink()
428            && meta.file_type().is_dir()
429        {
430            let mut perms = meta.permissions();
431            perms.set_mode(0o700);
432            let _ = std::fs::set_permissions(p, perms);
433        }
434    }
435}
436
437fn build_forbidden_reads(profile: &SandboxProfile) -> Result<BTreeSet<PathBuf>, CoreError> {
438    let mut set = BTreeSet::new();
439    for sp in &profile.deny_read {
440        set.insert(sp.path.clone());
441    }
442    Ok(set)
443}
444
445/// Reject any **user-supplied** `allow_write` / `allow_exec` / `allow_read`
446/// entry that overlaps a `denyRead` path. Landlock grants on write_access
447/// ([`AccessFs::from_all`]) and exec_access
448/// ([`AccessFs::Execute`] | [`AccessFs::from_read`]) **both imply read**,
449/// so without this lint a user who writes
450///   profiles.node.allowWrite: ["~/"]
451/// would silently broaden read access onto every denyRead path under `~/`.
452///
453/// The lint only inspects entries appended *after* the curated defaults
454/// (indices `>= first_user_*`). Built-in defaults intentionally overlap
455/// denyRead in places where Landlock cannot enforce the denial (e.g.
456/// `$PWD/` grants write+read, but `$PWD/.env` is in denyRead so SBPL on
457/// macOS can subtract it). Linting the defaults would block every
458/// project; the documented gap is in README's
459/// "What sbe Does *Not* Protect Against".
460fn lint_forbidden_reads_against_grants(
461    profile: &SandboxProfile,
462    forbidden: &BTreeSet<PathBuf>,
463    options: BackendOptions,
464) -> Result<(), CoreError> {
465    if options.allow_degraded {
466        return Ok(());
467    }
468    let user_slices: [(&str, &[SandboxPath]); 3] = [
469        (
470            "allowWrite",
471            &profile.allow_write[profile.first_user_allow_write..],
472        ),
473        (
474            "allowExec",
475            &profile.allow_exec[profile.first_user_allow_exec..],
476        ),
477        (
478            "allowRead",
479            &profile.allow_read[profile.first_user_allow_read..],
480        ),
481    ];
482    for (field, paths) in user_slices {
483        for sp in paths {
484            for f in forbidden {
485                if path_is_under(f, &sp.path) {
486                    return Err(CoreError::ProfileLint(format!(
487                        "denyRead path '{}' is under user-supplied {} entry '{}'. Landlock grants \
488                         on allowWrite and allowExec also imply read, so this would silently \
489                         expose the denied path. Either narrow the {} entry, remove the denyRead \
490                         entry, or pass --allow-degraded if you understand the threat model.",
491                        f.display(),
492                        field,
493                        sp.path.display(),
494                        field,
495                    )));
496                }
497            }
498        }
499    }
500    Ok(())
501}
502
503fn lint_allow_exec_for_priv_escalation(
504    profile: &SandboxProfile,
505    options: BackendOptions,
506) -> Result<(), CoreError> {
507    if options.allow_degraded {
508        return Ok(());
509    }
510
511    for sp in &profile.allow_exec {
512        if !is_subpath(sp) {
513            continue;
514        }
515        for binary in PRIVILEGE_ESCALATION_BINARIES {
516            let bin_path = Path::new(binary);
517            if path_is_under(bin_path, &sp.path) {
518                return Err(CoreError::ProfileLint(format!(
519                    "allowExec entry '{}' (directory) covers privilege-escalation binary '{}'. \
520                     This would defeat the threat model. Replace with explicit per-binary entries \
521                     or pass --allow-degraded if you know what you are doing.",
522                    sp.path.display(),
523                    binary,
524                )));
525            }
526        }
527    }
528    Ok(())
529}
530
531fn is_subpath(sp: &SandboxPath) -> bool {
532    use crate::config::PathKind;
533    matches!(sp.kind, PathKind::Subpath)
534}
535
536fn path_is_under(candidate: &Path, anchor: &Path) -> bool {
537    candidate == anchor || candidate.starts_with(anchor)
538}
539
540impl From<landlock::RulesetError> for CoreError {
541    fn from(err: landlock::RulesetError) -> Self {
542        CoreError::Backend(format!("landlock ruleset error: {err}"))
543    }
544}
545
546impl From<landlock::AddRulesError> for CoreError {
547    fn from(err: landlock::AddRulesError) -> Self {
548        CoreError::Backend(format!("landlock add_rules error: {err}"))
549    }
550}
551
552impl From<landlock::AddRuleError<AccessFs>> for CoreError {
553    fn from(err: landlock::AddRuleError<AccessFs>) -> Self {
554        CoreError::Backend(format!("landlock add_rule (fs) error: {err}"))
555    }
556}
557
558impl From<landlock::AddRuleError<AccessNet>> for CoreError {
559    fn from(err: landlock::AddRuleError<AccessNet>) -> Self {
560        CoreError::Backend(format!("landlock add_rule (net) error: {err}"))
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use std::path::PathBuf;
567
568    use super::*;
569    use crate::{
570        config::{PathKind, SandboxPath},
571        detect::Ecosystem,
572    };
573
574    #[test]
575    fn test_should_reject_priv_escalation_subpath() {
576        let mut profile = SandboxProfile::for_ecosystem(
577            Ecosystem::Rust,
578            &PathBuf::from("/home/test"),
579            &PathBuf::from("/home/test/pwd"),
580        );
581        profile.allow_exec.push(SandboxPath {
582            path: PathBuf::from("/usr/bin"),
583            kind: PathKind::Subpath,
584        });
585        let err =
586            lint_allow_exec_for_priv_escalation(&profile, BackendOptions::default()).unwrap_err();
587        assert!(format!("{err}").contains("privilege-escalation"));
588    }
589
590    #[test]
591    fn test_should_pass_priv_escalation_with_allow_degraded() {
592        let mut profile = SandboxProfile::for_ecosystem(
593            Ecosystem::Rust,
594            &PathBuf::from("/home/test"),
595            &PathBuf::from("/home/test/pwd"),
596        );
597        profile.allow_exec.push(SandboxPath {
598            path: PathBuf::from("/usr/bin"),
599            kind: PathKind::Subpath,
600        });
601        let res = lint_allow_exec_for_priv_escalation(
602            &profile,
603            BackendOptions {
604                allow_degraded: true,
605            },
606        );
607        assert!(res.is_ok());
608    }
609
610    #[test]
611    fn test_should_not_lint_baseline_anchor_overlap() {
612        // §8: the seal is a "promise to never *silently broaden* a path that
613        // overlaps denyRead". Baseline anchors (/etc, /tmp, /lib, …) are
614        // documented in the README as readable, so they don't count as a
615        // user-broadening event — the lint only inspects per-profile
616        // allow_read / allow_write / allow_exec entries.
617        let mut profile = SandboxProfile::for_ecosystem(
618            Ecosystem::Rust,
619            &PathBuf::from("/home/test"),
620            &PathBuf::from("/home/test/pwd"),
621        );
622        profile.deny_read.clear();
623        profile.deny_read.push(SandboxPath {
624            path: PathBuf::from("/etc/ssh"),
625            kind: PathKind::Subpath,
626        });
627        let forbidden = build_forbidden_reads(&profile).unwrap();
628        let lint =
629            lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default());
630        assert!(lint.is_ok(), "baseline anchor overlap must not lint");
631    }
632
633    #[test]
634    fn test_should_reject_forbidden_read_overlap_with_user_allow_read() {
635        let mut profile = SandboxProfile::for_ecosystem(
636            Ecosystem::Rust,
637            &PathBuf::from("/home/test"),
638            &PathBuf::from("/home/test/pwd"),
639        );
640        profile.deny_read.clear();
641        profile.deny_read.push(SandboxPath {
642            path: PathBuf::from("/home/test/.ssh"),
643            kind: PathKind::Subpath,
644        });
645        // User config tries to grant ~/ as readable — overlaps denyRead.
646        profile.allow_read.push(SandboxPath {
647            path: PathBuf::from("/home/test"),
648            kind: PathKind::Subpath,
649        });
650        let forbidden = build_forbidden_reads(&profile).unwrap();
651        let err =
652            lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
653                .unwrap_err();
654        assert!(format!("{err}").contains("denyRead"));
655        assert!(format!("{err}").contains("allowRead"));
656    }
657
658    /// C2: a user who broadens read-access via allowWrite (not allowRead)
659    /// must still trip the denyRead seal. The Landlock write_access bitmask
660    /// includes read_file/read_dir, so without this check the
661    /// "sealed forbidden-list" promise is bypassable trivially.
662    #[test]
663    fn test_should_reject_forbidden_read_overlap_with_allow_write() {
664        let mut profile = SandboxProfile::for_ecosystem(
665            Ecosystem::Rust,
666            &PathBuf::from("/home/test"),
667            &PathBuf::from("/home/test/pwd"),
668        );
669        profile.deny_read.clear();
670        profile.deny_read.push(SandboxPath {
671            path: PathBuf::from("/home/test/.ssh"),
672            kind: PathKind::Subpath,
673        });
674        profile.allow_write.push(SandboxPath {
675            path: PathBuf::from("/home/test"),
676            kind: PathKind::Subpath,
677        });
678        let forbidden = build_forbidden_reads(&profile).unwrap();
679        let err =
680            lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
681                .unwrap_err();
682        assert!(format!("{err}").contains("denyRead"));
683        assert!(format!("{err}").contains("allowWrite"));
684    }
685
686    /// Same path-class attack but via allowExec. Landlock exec_access
687    /// includes from_read so a directory under allowExec is read-visible.
688    #[test]
689    fn test_should_reject_forbidden_read_overlap_with_allow_exec() {
690        let mut profile = SandboxProfile::for_ecosystem(
691            Ecosystem::Rust,
692            &PathBuf::from("/home/test"),
693            &PathBuf::from("/home/test/pwd"),
694        );
695        profile.deny_read.clear();
696        profile.deny_read.push(SandboxPath {
697            path: PathBuf::from("/home/test/.aws/credentials"),
698            kind: PathKind::Literal,
699        });
700        profile.allow_exec.push(SandboxPath {
701            path: PathBuf::from("/home/test/.aws"),
702            kind: PathKind::Subpath,
703        });
704        let forbidden = build_forbidden_reads(&profile).unwrap();
705        let err =
706            lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
707                .unwrap_err();
708        assert!(format!("{err}").contains("allowExec"));
709    }
710
711    #[test]
712    fn test_should_bypass_forbidden_read_overlap_under_allow_degraded() {
713        let mut profile = SandboxProfile::for_ecosystem(
714            Ecosystem::Rust,
715            &PathBuf::from("/home/test"),
716            &PathBuf::from("/home/test/pwd"),
717        );
718        profile.deny_read.clear();
719        profile.deny_read.push(SandboxPath {
720            path: PathBuf::from("/home/test/.ssh"),
721            kind: PathKind::Subpath,
722        });
723        profile.allow_write.push(SandboxPath {
724            path: PathBuf::from("/home/test"),
725            kind: PathKind::Subpath,
726        });
727        let forbidden = build_forbidden_reads(&profile).unwrap();
728        let res = lint_forbidden_reads_against_grants(
729            &profile,
730            &forbidden,
731            BackendOptions {
732                allow_degraded: true,
733            },
734        );
735        assert!(res.is_ok(), "allow_degraded should bypass the seal lint");
736    }
737}