//! [`LandlockSandbox`] — the Linux [`SandboxEnforcer`] backed by the kernel
//! Landlock LSM (lot LP4-1), via the `landlock` crate, **best-effort / ABI-compat**.
//!
//! # What it does
//!
//! [`SandboxEnforcer::enforce`] translates a domain [`SandboxPlan`] into a Landlock
//! ruleset and restricts the **current thread** (the contract says `enforce` runs
//! post-fork / pre-exec in the child, so the restriction is inherited by the exec'd
//! program — Landlock domains survive `execve`).
//!
//! # Per-access-class handling (mirrors the LP4-0 compile invariant)
//!
//! Landlock works by *handling* a set of access rights — everything **not** granted
//! is then denied **for those handled rights only**. We therefore handle a right
//! class **only if the plan actually posed a grant of that class**:
//!
//! - some `RO` grant present ⇒ handle the **read** class (`from_read`): reads become
//!   restricted to the granted roots;
//! - some `RW` grant present ⇒ handle the **write** class (`from_write`): writes /
//!   create / delete become restricted to the granted roots.
//!
//! A write-only policy thus leaves **reads globally unrestricted** (so the agent can
//! still load `libc`, read `/etc`, …) and only fences writes — exactly the autonomy
//! the per-access-class compile guarantees. The flip side: a policy that restricts
//! reads governs *all* reads, so the compiled plan must include the system read
//! paths the program needs; that enrichment is the launch-path's concern (LP4-2),
//! not this adapter's — here we translate the plan faithfully.
//!
//! # Fallback policy
//!
//! On a kernel/ABI without (sufficient) Landlock, `restrict_self` reports
//! [`RulesetStatus::NotEnforced`]. We then apply the product fallback: **fail-open**
//! ([`SandboxStatus::Unsupported`]) for any posture **except** [`Posture::Deny`],
//! where we **fail-closed** with [`SandboxError::KernelTooOld`] (a `Deny` posture
//! cannot be silently dropped). Partial enforcement (older ABI missing some rights)
//! maps to [`SandboxStatus::Degraded`].

use domain::sandbox::{
    PathAccess, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus,
};
use domain::Posture;

// Leading `::` selects the extern `landlock` crate (this module is also named
// `landlock`, so the disambiguation matters).
use ::landlock::{
    path_beneath_rules, AccessFs, BitFlags, CompatLevel, Compatible, Ruleset, RulesetAttr,
    RulesetCreatedAttr, RulesetStatus, ABI,
};

/// The Landlock ABI we author rules against. `ABI::V1` (Linux ≥ 5.13) is the broad
/// floor; `CompatLevel::BestEffort` lets newer kernels run it unchanged and older /
/// absent support degrade rather than hard-error.
const TARGET_ABI: ABI = ABI::V1;

/// The Linux Landlock [`SandboxEnforcer`].
#[derive(Debug, Default, Clone, Copy)]
pub struct LandlockSandbox;

impl LandlockSandbox {
    /// Builds the Landlock enforcer.
    #[must_use]
    pub fn new() -> Self {
        Self
    }
}

/// The Landlock access rights a single grant opens, intersected later with the
/// handled set. An `RW` grant also opens the read rights (writing a tree usually
/// implies reading it) — harmless when reads are ungoverned, correct when they are.
fn grant_access(access: PathAccess, abi: ABI) -> BitFlags<AccessFs> {
    let mut acc = BitFlags::<AccessFs>::empty();
    if access.contains(PathAccess::RO) {
        acc |= AccessFs::from_read(abi);
    }
    if access.contains(PathAccess::RW) {
        acc |= AccessFs::from_read(abi) | AccessFs::from_write(abi);
    }
    if access.contains(PathAccess::EXEC) {
        // The domain never emits EXEC today; honoured for completeness.
        acc |= AccessFs::Execute;
    }
    acc
}

impl SandboxEnforcer for LandlockSandbox {
    fn kind(&self) -> SandboxKind {
        SandboxKind::Landlock
    }

    fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
        let abi = TARGET_ABI;

        // Landlock is an **additive allowlist** — it can only *remove* access, i.e.
        // it is deny-by-default. A `Posture::Allow` fallback means the opposite,
        // **allow-by-default**: paths matched by no grant must stay reachable. An
        // allowlist cannot express that (it would lock everything down to the granted
        // roots, closing the CLI binary, its libs and `~/.<cli>` home — the very
        // "failed to open terminal" symptom). So under an Allow fallback we enforce
        // nothing: the policy's only teeth are explicit Denies, which are deny-islands
        // under an allow-all that an additive sandbox cannot carve out — they remain
        // advisory via the LP3 projection. This is a property of the allowlist
        // mechanism, hence it lives in the adapter, not the pure plan.
        if plan.default_posture == Posture::Allow {
            return Ok(SandboxStatus::Enforced);
        }

        // Handle a right class only if the plan posed a grant of that class, so an
        // unposed class stays globally unrestricted (per-access-class autonomy).
        let mut handled = BitFlags::<AccessFs>::empty();
        for grant in &plan.allowed {
            if grant.access.contains(PathAccess::RO) {
                handled |= AccessFs::from_read(abi);
            }
            if grant.access.contains(PathAccess::RW) {
                handled |= AccessFs::from_write(abi);
            }
            if grant.access.contains(PathAccess::EXEC) {
                handled |= AccessFs::Execute;
            }
        }

        if handled.is_empty() {
            // No file restriction to apply (empty / bash-only plan): nothing for
            // Landlock to enforce. A `Deny` posture's teeth, if any, then live only
            // in the advisory LP3 projection — not this adapter's concern.
            return Ok(SandboxStatus::Enforced);
        }

        let mut ruleset = Ruleset::default()
            .set_compatibility(CompatLevel::BestEffort)
            .handle_access(handled)
            .and_then(Ruleset::create)
            .map_err(|e| SandboxError::Ruleset(e.to_string()))?;

        for grant in &plan.allowed {
            let access = grant_access(grant.access, abi) & handled;
            if access.is_empty() {
                continue;
            }
            // `path_beneath_rules` silently skips a path it cannot open (a grant on
            // a not-yet-existing root just adds no rule — best-effort, never fatal).
            ruleset = ruleset
                .add_rules(path_beneath_rules([grant.abs_root.as_str()], access))
                .map_err(|e: ::landlock::RulesetError| SandboxError::Ruleset(e.to_string()))?;
        }

        let status = ruleset
            .restrict_self()
            .map_err(|e| SandboxError::Ruleset(e.to_string()))?;

        status_from_ruleset(status.ruleset, plan.default_posture)
    }
}

/// Maps the kernel's [`RulesetStatus`] (after `restrict_self`) plus the plan's
/// fallback [`Posture`] to the domain outcome. **Pure** (no I/O, no kernel): the
/// security-critical decision — including the *fail-closed only under `Deny`*
/// branch — lives here so it is unit-testable without a Landlock-incapable kernel.
///
/// - [`RulesetStatus::FullyEnforced`] ⇒ `Ok(`[`SandboxStatus::Enforced`]`)`
/// - [`RulesetStatus::PartiallyEnforced`] ⇒ `Ok(`[`SandboxStatus::Degraded`]`)`
/// - [`RulesetStatus::NotEnforced`] + [`Posture::Deny`] ⇒
///   `Err(`[`SandboxError::KernelTooOld`]`)` (fail-closed)
/// - [`RulesetStatus::NotEnforced`] + any other posture ⇒
///   `Ok(`[`SandboxStatus::Unsupported`]`)` (fail-open + warning)
fn status_from_ruleset(
    status: RulesetStatus,
    posture: Posture,
) -> Result<SandboxStatus, SandboxError> {
    match status {
        RulesetStatus::FullyEnforced => Ok(SandboxStatus::Enforced),
        RulesetStatus::PartiallyEnforced => Ok(SandboxStatus::Degraded(
            "landlock ABI compatibility reduced the enforced access rights (best-effort)"
                .to_owned(),
        )),
        RulesetStatus::NotEnforced => {
            if posture == Posture::Deny {
                Err(SandboxError::KernelTooOld(
                    "landlock unavailable on this kernel but the policy posture is Deny \
                     (fail-closed)"
                        .to_owned(),
                ))
            } else {
                Ok(SandboxStatus::Unsupported)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use domain::sandbox::{PathGrant, SandboxPlan};
    use std::path::PathBuf;

    /// A unique temp dir for one test (no external tempfile dep).
    fn fresh_dir(tag: &str) -> PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let p = std::env::temp_dir().join(format!("idea-landlock-{tag}-{nanos}"));
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    /// Real-kernel integration test: a write-only plan that grants RW on one dir
    /// must let writes there succeed and block writes elsewhere with EACCES, while
    /// leaving reads unrestricted. Runs the enforcement on a dedicated thread (the
    /// restriction is irreversible for that thread) and **skips** if the running
    /// kernel has no Landlock (CI / old kernels), like the SSH/WSL gated tests.
    #[test]
    fn landlock_write_only_plan_fences_writes_to_the_grant() {
        let allowed = fresh_dir("allowed");
        let denied = fresh_dir("denied");
        let allowed_for_thread = allowed.clone();
        let denied_for_thread = denied.clone();

        let plan = SandboxPlan {
            allowed: vec![PathGrant {
                abs_root: allowed.to_string_lossy().into_owned(),
                access: PathAccess::RW,
            }],
            default_posture: Posture::Ask,
        };

        let outcome = std::thread::spawn(move || {
            let status = LandlockSandbox::new()
                .enforce(&plan)
                .expect("enforce must not error under an Ask posture");
            // The thread is now sandboxed: try a write inside and outside the grant.
            let ok_write = std::fs::write(allowed_for_thread.join("inside.txt"), b"hi");
            let ko_write = std::fs::write(denied_for_thread.join("outside.txt"), b"nope");
            (status, ok_write, ko_write.map_err(|e| e.kind()))
        })
        .join()
        .unwrap();

        let (status, ok_write, ko_write) = outcome;

        if status == SandboxStatus::Unsupported {
            eprintln!("skipping: Landlock not available on this kernel");
            // Clean up best-effort and bail (no enforcement happened).
            let _ = std::fs::remove_dir_all(&allowed);
            let _ = std::fs::remove_dir_all(&denied);
            return;
        }

        assert!(
            matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
            "expected an enforced/degraded sandbox, got {status:?}"
        );
        assert!(
            ok_write.is_ok(),
            "write inside the granted root must succeed, got {ok_write:?}"
        );
        assert_eq!(
            ko_write,
            Err(std::io::ErrorKind::PermissionDenied),
            "write outside the granted root must be blocked (EACCES)"
        );

        let _ = std::fs::remove_dir_all(&allowed);
        let _ = std::fs::remove_dir_all(&denied);
    }

    /// An empty plan (no file grant) has nothing to enforce ⇒ `Enforced`, no error.
    #[test]
    fn empty_plan_is_a_noop_enforced() {
        let plan = SandboxPlan {
            allowed: vec![],
            default_posture: Posture::Ask,
        };
        // Run on a throwaway thread to avoid restricting the test runner thread.
        let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
            .join()
            .unwrap()
            .unwrap();
        assert_eq!(status, SandboxStatus::Enforced);
    }

    /// **Bash-only / empty plan under a `Deny` posture.** A bash-only policy compiles
    /// (LP4-0) to an empty `allowed`; even combined with `Posture::Deny` the adapter
    /// has no file right to *handle*, so it must early-return `Enforced` WITHOUT
    /// restricting anything — and crucially WITHOUT `KernelTooOld`: the fail-closed
    /// branch is only ever reached once a class was actually handled.
    #[test]
    fn empty_plan_under_deny_posture_is_enforced_without_restriction() {
        let plan = SandboxPlan {
            allowed: vec![],
            default_posture: Posture::Deny,
        };
        let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
            .join()
            .unwrap()
            .expect("an empty plan never fails closed (nothing handled)");
        assert_eq!(status, SandboxStatus::Enforced);
    }

    /// **Allow fallback ⇒ no OS restriction even with grants posed.** Setting the
    /// policy to allow-by-default (and, in the UI, every option to Allow) emits RO|RW
    /// grants scoped to the project root. An allowlist sandbox would then close every
    /// read/write OUTSIDE that root — locking out the CLI binary, its libs and home,
    /// the exact "failed to open terminal" bug. Under `Posture::Allow` the adapter must
    /// instead enforce nothing: from the (would-be) sandboxed thread, a read AND a
    /// write OUTSIDE the only grant must both still succeed.
    #[test]
    fn allow_fallback_does_not_restrict_even_with_grants() {
        let granted = fresh_dir("allow-granted");
        let outside = fresh_dir("allow-outside");
        std::fs::write(outside.join("pre.txt"), b"pre").unwrap();
        let outside_t = outside.clone();

        let plan = SandboxPlan {
            allowed: vec![PathGrant {
                abs_root: granted.to_string_lossy().into_owned(),
                access: PathAccess::RO.union(PathAccess::RW),
            }],
            default_posture: Posture::Allow,
        };

        let (status, read_outside, write_outside) = std::thread::spawn(move || {
            let status = LandlockSandbox::new()
                .enforce(&plan)
                .expect("an Allow fallback never fails closed");
            let read = std::fs::read(outside_t.join("pre.txt"));
            let write = std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
            (status, read, write)
        })
        .join()
        .unwrap();

        assert_eq!(
            status,
            SandboxStatus::Enforced,
            "Allow fallback is a no-op enforcement, not an error or a lock-down"
        );
        assert!(
            read_outside.is_ok(),
            "Allow fallback must keep reads outside the grant open, got {read_outside:?}"
        );
        assert_eq!(
            write_outside,
            Ok(()),
            "Allow fallback must keep writes outside the grant open"
        );

        let _ = std::fs::remove_dir_all(&granted);
        let _ = std::fs::remove_dir_all(&outside);
    }

    /// **CRITICAL SAFETY PROPERTY — IdeA is never sandboxed.** The adapter restricts
    /// the *current thread*; the launch path runs it on a throwaway thread. This test
    /// proves the Landlock domain is confined to that throwaway thread and does **not**
    /// leak onto the thread that spawned it (which simulates the IdeA process): after
    /// the child has sandboxed itself, the parent thread must still read/write OUTSIDE
    /// the plan's roots. A leak here would be catastrophic (IdeA itself locked down).
    #[test]
    fn enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed() {
        let granted = fresh_dir("safety-granted");
        let outside = fresh_dir("safety-outside");
        let granted_t = granted.clone();
        let outside_t = outside.clone();

        let plan = SandboxPlan {
            allowed: vec![PathGrant {
                abs_root: granted.to_string_lossy().into_owned(),
                access: PathAccess::RW,
            }],
            default_posture: Posture::Ask,
        };

        // Enforce on a dedicated thread, exactly as the launch path does, and report
        // whether the sandbox was actually live there (write outside must be blocked).
        let (status, child_outside_write) = std::thread::spawn(move || {
            let status = LandlockSandbox::new()
                .enforce(&plan)
                .expect("enforce must not error under an Ask posture");
            let _ = granted_t; // (writable in the child; not asserted here)
            let child_outside =
                std::fs::write(outside_t.join("from-child.txt"), b"x").map_err(|e| e.kind());
            (status, child_outside)
        })
        .join()
        .unwrap();

        if status == SandboxStatus::Unsupported {
            eprintln!("skipping: Landlock not available on this kernel");
            let _ = std::fs::remove_dir_all(&granted);
            let _ = std::fs::remove_dir_all(&outside);
            return;
        }

        // Sanity: the sandbox really was active on the throwaway thread.
        assert_eq!(
            child_outside_write,
            Err(std::io::ErrorKind::PermissionDenied),
            "the throwaway thread must be sandboxed (write outside the grant blocked)"
        );

        // THE GUARANTEE: the parent thread (≈ the IdeA process) is not a descendant of
        // the throwaway thread, so the domain must not have leaked onto it.
        let idea_write = std::fs::write(outside.join("from-idea.txt"), b"idea");
        assert!(
            idea_write.is_ok(),
            "SAFETY VIOLATION: IdeA's own thread was sandboxed — write outside the plan \
             failed ({idea_write:?}); the Landlock domain leaked off the throwaway thread"
        );
        let idea_read = std::fs::read(outside.join("from-idea.txt"));
        assert!(
            idea_read.is_ok(),
            "IdeA must still read outside the plan roots, got {idea_read:?}"
        );

        let _ = std::fs::remove_dir_all(&granted);
        let _ = std::fs::remove_dir_all(&outside);
    }

    /// **READ (RO) dimension.** A plan with a single RO grant: from the sandboxed
    /// child, reading a file UNDER the granted root succeeds, while reading a file
    /// OUTSIDE is denied (EACCES). This also documents the behaviour DevBackend
    /// flagged: as soon as *any* RO grant is posed the read class is handled, so
    /// **every** read outside the granted roots is closed — a realistic read-restricted
    /// plan must therefore include the needed system paths (an LP4-2 concern).
    #[test]
    fn read_only_plan_fences_reads_to_the_grant() {
        let granted = fresh_dir("ro-granted");
        let outside = fresh_dir("ro-outside");
        // Files must exist before enforcement (creation is a write).
        std::fs::write(granted.join("in.txt"), b"inside").unwrap();
        std::fs::write(outside.join("out.txt"), b"outside").unwrap();
        let granted_t = granted.clone();
        let outside_t = outside.clone();

        let plan = SandboxPlan {
            allowed: vec![PathGrant {
                abs_root: granted.to_string_lossy().into_owned(),
                access: PathAccess::RO,
            }],
            default_posture: Posture::Ask,
        };

        let (status, ok_read, ko_read) = std::thread::spawn(move || {
            let status = LandlockSandbox::new()
                .enforce(&plan)
                .expect("enforce must not error under an Ask posture");
            let ok = std::fs::read(granted_t.join("in.txt"));
            let ko = std::fs::read(outside_t.join("out.txt")).map_err(|e| e.kind());
            (status, ok, ko)
        })
        .join()
        .unwrap();

        if status == SandboxStatus::Unsupported {
            eprintln!("skipping: Landlock not available on this kernel");
            let _ = std::fs::remove_dir_all(&granted);
            let _ = std::fs::remove_dir_all(&outside);
            return;
        }

        assert!(
            matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
            "expected an enforced/degraded sandbox, got {status:?}"
        );
        assert!(
            ok_read.is_ok(),
            "read inside the granted RO root must succeed, got {ok_read:?}"
        );
        assert_eq!(
            ko_read,
            Err(std::io::ErrorKind::PermissionDenied),
            "once a RO grant is posed, reads outside the granted roots are closed (EACCES)"
        );

        let _ = std::fs::remove_dir_all(&granted);
        let _ = std::fs::remove_dir_all(&outside);
    }
}
