//! OS-sandbox **contract & planning** (lot LP4-0, domaine pur).
//!
//! This module owns the *declarative plan* an OS sandbox adapter applies, plus
//! the **pure** translation from the resolved [`EffectivePermissions`] into that
//! plan. It is the domain half of the «sandbox OS» concern that the permission
//! model (`permission.rs`) deliberately left out of scope.
//!
//! Like the rest of `domain`, it is **pure** (ARCHITECTURE dependency rule): no
//! `tokio`, no `std::fs`, no `std::process`, no `landlock`. The concrete Landlock
//! adapter (lot LP4-1) and the launch-path wiring (lot LP4-2) live in
//! `infrastructure`/`application`; here we only define the contract
//! ([`SandboxEnforcer`], [`SandboxPlan`]) and compute the plan
//! ([`compile_sandbox_plan`]).
//!
//! ## Reality bound: Landlock locks **files only**
//!
//! Landlock can only restrict **filesystem** access. The command capability
//! ([`crate::permission::Capability::ExecuteBash`]) cannot be enforced by the OS
//! sandbox — argv matching is not a kernel concept — so command rules stay
//! **advisory** (honoured by the CLI's own prompting via the LP3 projection),
//! never OS-locked. [`compile_sandbox_plan`] therefore only ever derives grants
//! from the three **file** capabilities.
//!
//! ## The fail-closed, per-access-class translation invariant
//!
//! Landlock is **additive only**: a grant opens a whole directory subtree and a
//! sub-path cannot be carved back out. So whenever a `Deny` would fall inside (or
//! over) an `Allow`'s granted root without a directory boundary cleanly
//! separating them, we **drop the allow** rather than grant a root that would
//! leak the denied path. We always prefer losing an allow to leaking a deny — a
//! conservative under-approximation.
//!
//! Crucially this is computed **per access class** (read vs write/delete), never
//! capability-blind: a `Deny Write` fences only the write grants, it never
//! amputates a `Read` allow. This maximises agent autonomy — over-restricting a
//! read because some write was denied would force the agent to keep asking, the
//! opposite of the product goal. It is the same per-`capability`+target deny-wins
//! as [`crate::permission`], lifted to the directory granularity Landlock works
//! at and made fail-closed within each class. Encoded as a testable invariant in
//! [`compile_sandbox_plan`].

use serde::{Deserialize, Serialize};

use crate::permission::{Capability, Effect, EffectivePermissions, Posture};

/// The set of filesystem accesses a [`PathGrant`] opens on its root, as an
/// additive bit set.
///
/// Hand-rolled (no external `bitflags` crate — the domain forbids extra deps).
/// The three bits are **independent**: [`PathAccess::RW`] does not imply
/// [`PathAccess::RO`]; a grant carries exactly the accesses that were posed.
///
/// Capability → bit mapping used by [`compile_sandbox_plan`]:
/// [`Capability::Read`] ⇒ [`PathAccess::RO`], [`Capability::Write`] and
/// [`Capability::Delete`] ⇒ [`PathAccess::RW`]. [`PathAccess::EXEC`] is reserved
/// for the adapter/future use — the current permission model carries no file
/// execute capability, so the compiler never emits it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PathAccess(u8);

impl PathAccess {
    /// Read access.
    pub const RO: Self = Self(0b001);
    /// Write access (create / modify / delete).
    pub const RW: Self = Self(0b010);
    /// Execute access (reserved; not emitted by [`compile_sandbox_plan`]).
    pub const EXEC: Self = Self(0b100);

    /// The empty access set.
    #[must_use]
    pub const fn empty() -> Self {
        Self(0)
    }

    /// Whether `self` contains **all** bits of `other`.
    #[must_use]
    pub const fn contains(self, other: Self) -> bool {
        self.0 & other.0 == other.0
    }

    /// The union of `self` and `other`.
    #[must_use]
    pub const fn union(self, other: Self) -> Self {
        Self(self.0 | other.0)
    }

    /// Adds the bits of `other` to `self` in place.
    pub fn insert(&mut self, other: Self) {
        self.0 |= other.0;
    }

    /// Whether no bit is set.
    #[must_use]
    pub const fn is_empty(self) -> bool {
        self.0 == 0
    }

    /// The raw bits.
    #[must_use]
    pub const fn bits(self) -> u8 {
        self.0
    }
}

impl std::ops::BitOr for PathAccess {
    type Output = Self;
    fn bitor(self, rhs: Self) -> Self {
        self.union(rhs)
    }
}

impl std::ops::BitOrAssign for PathAccess {
    fn bitor_assign(&mut self, rhs: Self) {
        self.insert(rhs);
    }
}

/// One granted directory/file root and the accesses it opens.
///
/// `abs_root` is an **absolute** path (the project root joined with the glob's
/// static prefix). The adapter applies it as a Landlock `path_beneath` rule.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PathGrant {
    /// Absolute root the grant opens (file or directory subtree).
    pub abs_root: String,
    /// Accesses opened on that root.
    pub access: PathAccess,
}

/// The declarative, OS-neutral plan an OS sandbox adapter enforces.
///
/// It is a **value** (a plan), never an action: the adapter ([`SandboxEnforcer`])
/// turns it into a concrete ruleset. `allowed` may legally be empty (a policy that
/// posed a posture but no file allow) — that is **not** the same as «no plan»;
/// `compile_sandbox_plan` returns `None` for «no plan».
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxPlan {
    /// The granted roots (deduplicated, deterministic order).
    pub allowed: Vec<PathGrant>,
    /// The default posture for paths matched by no grant (mirrors
    /// [`EffectivePermissions::fallback`]).
    pub default_posture: Posture,
}

/// Immutable inputs [`compile_sandbox_plan`] interpolates into the absolute roots
/// it emits. Borrowed: a plan is computed at the launch site, never stored.
pub struct SandboxContext<'a> {
    /// Absolute project root (glob static prefixes are joined onto this).
    pub project_root: &'a str,
    /// Absolute isolated run dir of the agent (`.ideai/run/<agent-id>/`).
    ///
    /// Reserved for the adapter (the run dir must stay reachable by the agent);
    /// the pure file-rule translation does not consume it.
    pub run_dir: &'a str,
}

/// Which concrete OS-sandbox mechanism an [`SandboxEnforcer`] implements.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SandboxKind {
    /// Linux Landlock LSM.
    Landlock,
    /// No OS sandbox available on this platform/build.
    Unsupported,
}

/// The outcome of applying a [`SandboxPlan`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SandboxStatus {
    /// The plan was fully enforced by the OS.
    Enforced,
    /// No OS sandbox is available; nothing was enforced (the caller keeps the
    /// advisory LP3 projection as its only guard).
    Unsupported,
    /// The plan was only **partially** enforced; the string explains what was
    /// dropped (e.g. an older Landlock ABI lacking a needed access right).
    Degraded(String),
}

/// Errors an [`SandboxEnforcer::enforce`] may return.
///
/// An adapter raises these only for **fail-closed** situations it must surface to
/// the caller (kernel too old when a `Deny` posture demands enforcement, ruleset
/// application failure). A platform that simply has no sandbox does **not** error:
/// it returns [`SandboxStatus::Unsupported`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SandboxError {
    /// The running kernel / Landlock ABI is too old to honour a plan that must be
    /// enforced (fail-closed: a required `Deny` cannot be guaranteed).
    #[error("kernel/landlock too old to enforce sandbox: {0}")]
    KernelTooOld(String),
    /// Building or applying the OS ruleset failed.
    #[error("failed to apply sandbox ruleset: {0}")]
    Ruleset(String),
}

/// The OS-sandbox **port**: applies a [`SandboxPlan`] to the current process.
///
/// ## Liskov contract
///
/// `enforce` is a one-shot, **irreversible** tightening of the *current* process
/// and is meant to be called **after `fork`, before `exec`, in the child** — never
/// on the IdeA process itself. Every implementation must honour:
///
/// - it only ever **removes** access (a sandbox never grants more than the host);
/// - an [`SandboxKind::Unsupported`] adapter is a valid no-op: its `enforce`
///   returns `Ok(`[`SandboxStatus::Unsupported`]`)` and changes nothing;
/// - `enforce` is total for any well-formed [`SandboxPlan`]; it returns
///   [`SandboxError`] only for the fail-closed cases above.
pub trait SandboxEnforcer: Send + Sync {
    /// Which mechanism this enforcer implements.
    fn kind(&self) -> SandboxKind;

    /// Applies `plan` to the current process (post-fork / pre-exec, in the child).
    ///
    /// # Errors
    /// [`SandboxError`] only for fail-closed situations (cf. the type docs); an
    /// absent sandbox returns `Ok(`[`SandboxStatus::Unsupported`]`)`.
    fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError>;
}

/// Compiles the resolved [`EffectivePermissions`] into a [`SandboxPlan`].
///
/// **Pure**: only computes a plan; writes nothing, touches no I/O.
///
/// ## Invariants
///
/// 1. **`eff == None ⇒ None`** — nothing posed ⇒ no sandbox ⇒ the agent runs
///    natively (mirrors [`crate::permission::resolve`]'s product invariant). A
///    `Some` result (even with an empty `allowed`) means IdeA has a policy to
///    enforce.
/// 2. Only the three **file** capabilities feed the grants; bash rules are
///    skipped (Landlock cannot lock command execution — they stay advisory).
/// 3. **Per-access-class, fail-closed roots**: grants are computed **separately
///    for each access class** so an agent keeps the widest autonomy possible.
///    - The **RO** class is fed by `Allow Read`; its fences are `Deny Read`.
///    - The **RW** class is fed by `Allow Write` **and** `Allow Delete`; its
///      fences are `Deny Write` **and** `Deny Delete`.
///
///    Within a class, each `Allow` glob is reduced to its *static prefix* root and
///    **dropped** if a fence **of the same class** overlaps it (equal, ancestor, or
///    descendant) — an additive sandbox cannot carve a sub-path deny out of a
///    granted subtree, so we lose the allow rather than leak the deny. A fence of
///    the **other** class has **no effect**: a `Deny Write` never amputates an
///    `Allow Read` (that would over-restrict and force the agent to keep asking).
///    This is exactly the per-`capability`+target deny-wins of
///    [`crate::permission`], applied at the (coarser) directory granularity Landlock
///    allows, fail-closed inside each class.
///
///    The surviving roots of both classes are then merged by `abs_root`, unioning
///    their accesses: a root surviving only in RO ⇒ [`PathAccess::RO`]; surviving
///    in both ⇒ `RO | RW`; surviving only in RW ⇒ [`PathAccess::RW`].
#[must_use]
pub fn compile_sandbox_plan(
    eff: Option<&EffectivePermissions>,
    ctx: &SandboxContext,
) -> Option<SandboxPlan> {
    let eff = eff?;

    // 1. Collect the static-prefix fences of every Deny file rule, **per access
    //    class** (RO = Deny Read; RW = Deny Write/Delete). A fence only ever
    //    blocks allows of its own class. Kept relative for the overlap tests.
    let mut ro_fences: Vec<String> = Vec::new();
    let mut rw_fences: Vec<String> = Vec::new();
    for rule in eff.rules() {
        if rule.effect() != Effect::Deny {
            continue;
        }
        let fences = match access_class(rule.capability()) {
            Some(PathAccess::RO) => &mut ro_fences,
            Some(PathAccess::RW) => &mut rw_fences,
            // Non-file (ExecuteBash) ⇒ no class ⇒ never a filesystem fence.
            _ => continue,
        };
        for glob in rule.paths().globs() {
            fences.push(static_prefix(glob.pattern()));
        }
    }

    // 2. For each Allow file rule, reduce every glob to its static-prefix root and
    //    keep it only if no same-class fence overlaps it. Merge by relative root,
    //    unioning the access bits across classes.
    let mut grants: Vec<(String, PathAccess)> = Vec::new();
    for rule in eff.rules() {
        if rule.effect() != Effect::Allow {
            continue;
        }
        let Some(access) = access_class(rule.capability()) else {
            continue;
        };
        let fences: &[String] = if access == PathAccess::RO {
            &ro_fences
        } else {
            &rw_fences
        };
        for glob in rule.paths().globs() {
            let rel = static_prefix(glob.pattern());
            if fences.iter().any(|d| paths_conflict(&rel, d)) {
                // A same-class deny falls inside/over this root ⇒ we cannot grant
                // it for this class without leaking the deny.
                continue;
            }
            match grants.iter_mut().find(|(r, _)| *r == rel) {
                Some((_, acc)) => acc.insert(access),
                None => grants.push((rel, access)),
            }
        }
    }

    // 3. Join the surviving relative roots onto the absolute project root.
    let base = ctx.project_root.trim_end_matches('/');
    let allowed = grants
        .into_iter()
        .map(|(rel, access)| PathGrant {
            abs_root: join_root(base, &rel),
            access,
        })
        .collect();

    Some(SandboxPlan {
        allowed,
        default_posture: eff.fallback(),
    })
}

/// The access **class** a file capability belongs to: [`Capability::Read`] ⇒
/// [`PathAccess::RO`]; [`Capability::Write`]/[`Capability::Delete`] ⇒
/// [`PathAccess::RW`]. Non-file capabilities ([`Capability::ExecuteBash`]) have no
/// class (`None`) — they never produce a filesystem grant or fence.
fn access_class(cap: Capability) -> Option<PathAccess> {
    match cap {
        Capability::Read => Some(PathAccess::RO),
        Capability::Write | Capability::Delete => Some(PathAccess::RW),
        Capability::ExecuteBash => None,
    }
}

/// The **static prefix** of a glob: the leading literal directory path before the
/// first glob metacharacter (`*`, `?`, `[`), with any trailing `/` trimmed.
///
/// - `"**"` / `"*.rs"` ⇒ `""` (the project root itself),
/// - `"src/**"` / `"src/*.rs"` ⇒ `"src"`,
/// - `"a/b/**/*.rs"` ⇒ `"a/b"`,
/// - `"src/foo.rs"` (no metachar) ⇒ `"src/foo.rs"` (the file itself).
fn static_prefix(pattern: &str) -> String {
    let first_meta = pattern.find(['*', '?', '[']);
    let literal = match first_meta {
        // Cut back to the last '/' *before* the metacharacter: everything up to
        // and including that slash is a settled directory path.
        Some(idx) => match pattern[..idx].rfind('/') {
            Some(slash) => &pattern[..slash],
            None => "",
        },
        // No metacharacter: the whole pattern is a literal path.
        None => pattern,
    };
    literal.trim_end_matches('/').to_string()
}

/// Whether two project-relative roots overlap such that an additive sandbox could
/// not keep them apart: they are equal, or one is a path-ancestor of the other.
fn paths_conflict(a: &str, b: &str) -> bool {
    a == b || is_descendant(a, b) || is_descendant(b, a)
}

/// Whether `child` is a **strict** path-descendant of `ancestor` (component
/// boundary aware). The empty string denotes the project root, an ancestor of
/// every non-empty path.
fn is_descendant(child: &str, ancestor: &str) -> bool {
    if ancestor.is_empty() {
        return !child.is_empty();
    }
    child.len() > ancestor.len()
        && child.starts_with(ancestor)
        && child.as_bytes()[ancestor.len()] == b'/'
}

/// Joins a relative root onto the absolute base. An empty `rel` denotes the base
/// itself.
fn join_root(base: &str, rel: &str) -> String {
    if rel.is_empty() {
        base.to_string()
    } else {
        format!("{base}/{rel}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::permission::{resolve, PathScope, PermissionRule, PermissionSet};

    // ---- helpers ---------------------------------------------------------

    const ROOT: &str = "/home/anthony/proj";

    fn ctx() -> SandboxContext<'static> {
        SandboxContext {
            project_root: ROOT,
            run_dir: "/home/anthony/proj/.ideai/run/agent-x",
        }
    }

    fn scope(patterns: &[&str]) -> PathScope {
        PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap()
    }

    fn file_rule(cap: Capability, effect: Effect, patterns: &[&str]) -> PermissionRule {
        PermissionRule::file(cap, effect, scope(patterns)).unwrap()
    }

    /// Resolves a single-set policy into `EffectivePermissions` (project set only).
    fn eff(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
        let set = PermissionSet::new(rules, fallback);
        resolve(Some(&set), None).unwrap()
    }

    fn grant<'a>(plan: &'a SandboxPlan, abs_root: &str) -> Option<&'a PathGrant> {
        plan.allowed.iter().find(|g| g.abs_root == abs_root)
    }

    // ---- invariant 1: presence of a policy != non-empty grants ----------

    #[test]
    fn none_eff_yields_no_plan() {
        // Nothing posed ⇒ no sandbox ⇒ the agent runs natively.
        assert!(compile_sandbox_plan(None, &ctx()).is_none());
    }

    #[test]
    fn policy_with_no_allow_still_yields_some_plan() {
        // A posed policy with an empty `allowed` is NOT the same as "no plan":
        // it must compile to `Some` with an empty grant list.
        let e = eff(vec![], Posture::Deny);
        let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("a posed policy yields a plan");
        assert!(
            plan.allowed.is_empty(),
            "no allow rule ⇒ no grant, but still a plan"
        );

        // Same with only a Deny file rule present (still a policy, still no grant).
        let e = eff(
            vec![file_rule(Capability::Write, Effect::Deny, &["secret/**"])],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("deny-only is still a plan");
        assert!(plan.allowed.is_empty());
    }

    // ---- invariant 2: capability → access mapping; bash never a grant ----

    #[test]
    fn read_maps_to_ro() {
        let e = eff(
            vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        let g = grant(&plan, "/home/anthony/proj/src").expect("src granted");
        assert_eq!(g.access, PathAccess::RO);
        assert!(!g.access.contains(PathAccess::RW));
    }

    #[test]
    fn write_and_delete_map_to_rw() {
        let e = eff(
            vec![
                file_rule(Capability::Write, Effect::Allow, &["out/**"]),
                file_rule(Capability::Delete, Effect::Allow, &["tmp/**"]),
            ],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        assert_eq!(
            grant(&plan, "/home/anthony/proj/out").unwrap().access,
            PathAccess::RW
        );
        assert_eq!(
            grant(&plan, "/home/anthony/proj/tmp").unwrap().access,
            PathAccess::RW
        );
    }

    #[test]
    fn bash_only_policy_produces_no_path_grant() {
        // ExecuteBash is never translated to a PathGrant (Landlock = files only).
        let e = eff(
            vec![
                PermissionRule::bash(Effect::Allow, vec![]),
                PermissionRule::bash(
                    Effect::Deny,
                    vec![crate::permission::CommandRule::new(
                        crate::permission::CommandMatcher::prefix("rm ").unwrap(),
                        Effect::Deny,
                    )],
                ),
            ],
            Posture::Allow,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        assert!(
            plan.allowed.is_empty(),
            "a purely-bash policy yields zero PathGrant"
        );
    }

    // ---- invariant 3: fail-closed glob → static-prefix translation -------

    #[test]
    fn root_glob_with_same_class_deny_drops_root_grant() {
        // Allow Read `**` (root) + a single SAME-CLASS Deny Read file ⇒ the RO root
        // grant is abandoned: an additive sandbox cannot carve the denied file back
        // out of the granted subtree (fail-closed within the RO class).
        let e = eff(
            vec![
                file_rule(Capability::Read, Effect::Allow, &["**"]),
                file_rule(Capability::Read, Effect::Deny, &["secret.txt"]),
            ],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        assert!(
            plan.allowed.is_empty(),
            "a same-class deny under the root fence drops the root grant"
        );
    }

    #[test]
    fn descendant_same_class_deny_drops_overlapping_allow() {
        // Allow Read `src/**` (root `src`) + Deny Read `src/secret/**`
        // (root `src/secret`): the same-class deny is a descendant of the granted
        // root ⇒ the RO grant on `src` is dropped (fail-closed within RO).
        let e = eff(
            vec![
                file_rule(Capability::Read, Effect::Allow, &["src/**"]),
                file_rule(Capability::Read, Effect::Deny, &["src/secret/**"]),
            ],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        assert!(
            grant(&plan, "/home/anthony/proj/src").is_none(),
            "an inside same-class deny abandons the enclosing allow"
        );
    }

    #[test]
    fn other_class_deny_does_not_amputate_read_allow() {
        // The DUAL of the case above and the key autonomy guarantee: a Deny of a
        // DIFFERENT class (Write) over a descendant must NOT touch the Read allow.
        // `Allow Read src/**` + `Deny Write src/secret/**` ⇒ `src` keeps its RO.
        let e = eff(
            vec![
                file_rule(Capability::Read, Effect::Allow, &["src/**"]),
                file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]),
            ],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        assert_eq!(
            grant(&plan, "/home/anthony/proj/src").map(|g| g.access),
            Some(PathAccess::RO),
            "a Deny Write must never amputate an Allow Read (per-class autonomy)"
        );
    }

    #[test]
    fn disjoint_same_class_deny_keeps_allow() {
        // Allow Read `src/**` + SAME-CLASS Deny Read `other/**`: disjoint roots ⇒
        // grant `src` kept. Same-class fence so the test proves disjointness, not
        // merely a class mismatch.
        let e = eff(
            vec![
                file_rule(Capability::Read, Effect::Allow, &["src/**"]),
                file_rule(Capability::Read, Effect::Deny, &["other/**"]),
            ],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        assert_eq!(
            grant(&plan, "/home/anthony/proj/src").unwrap().access,
            PathAccess::RO,
            "a disjoint same-class deny does not touch the allow"
        );
    }

    #[test]
    fn ancestor_same_class_deny_also_drops_allow() {
        // Deny Read `src/**` (root `src`) is an ancestor of Allow Read `src/sub/**`
        // (root `src/sub`) ⇒ same-class overlap ⇒ allow dropped (fence symmetry).
        let e = eff(
            vec![
                file_rule(Capability::Read, Effect::Allow, &["src/sub/**"]),
                file_rule(Capability::Read, Effect::Deny, &["src/**"]),
            ],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        assert!(grant(&plan, "/home/anthony/proj/src/sub").is_none());
    }

    #[test]
    fn sibling_prefix_is_not_a_descendant() {
        // Component-boundary awareness: `src2` must NOT be treated as inside `src`
        // just because the string `src` is a prefix of `src2`. Uses a SAME-CLASS
        // (Deny Read) fence so the survival proves boundary-awareness, not a class
        // mismatch.
        let e = eff(
            vec![
                file_rule(Capability::Read, Effect::Allow, &["src2/**"]),
                file_rule(Capability::Read, Effect::Deny, &["src/**"]),
            ],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        assert!(
            grant(&plan, "/home/anthony/proj/src2").is_some(),
            "`src2` is a sibling of `src`, not a descendant"
        );
    }

    #[test]
    fn accesses_union_on_a_shared_root() {
        // Read + Write allows on the same root merge into RO|RW on one grant.
        let e = eff(
            vec![
                file_rule(Capability::Read, Effect::Allow, &["src/**"]),
                file_rule(Capability::Write, Effect::Allow, &["src/**"]),
            ],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        let g = grant(&plan, "/home/anthony/proj/src").expect("single merged grant");
        assert!(g.access.contains(PathAccess::RO));
        assert!(g.access.contains(PathAccess::RW));
        assert_eq!(
            plan.allowed
                .iter()
                .filter(|g| g.abs_root == "/home/anthony/proj/src")
                .count(),
            1,
            "the two allows merge onto one root, not two grants"
        );
    }

    #[test]
    fn same_root_drops_rw_but_keeps_ro_under_a_write_deny() {
        // Direct proof of per-access-class granularity on a single root:
        // `Allow Read src/**` + `Allow Write src/**` + `Deny Write src/secret/**`.
        // The RW class is fenced (deny descendant) ⇒ RW dropped; the RO class has no
        // fence ⇒ RO survives. The grant on `src` ends up RO-only.
        let e = eff(
            vec![
                file_rule(Capability::Read, Effect::Allow, &["src/**"]),
                file_rule(Capability::Write, Effect::Allow, &["src/**"]),
                file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]),
            ],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        let g = grant(&plan, "/home/anthony/proj/src").expect("RO survives the write deny");
        assert_eq!(
            g.access,
            PathAccess::RO,
            "RW class fenced out, RO class preserved on the same root"
        );
        assert!(
            !g.access.contains(PathAccess::RW),
            "the RW grant was dropped"
        );
    }

    #[test]
    fn static_prefix_of_literal_file_is_the_file_itself() {
        // A metacharacter-free allow grants exactly that file path.
        let e = eff(
            vec![file_rule(Capability::Read, Effect::Allow, &["src/lib.rs"])],
            Posture::Ask,
        );
        let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
        assert!(grant(&plan, "/home/anthony/proj/src/lib.rs").is_some());
    }

    // ---- invariant 4: residual posture reflected in the plan -------------

    #[test]
    fn default_posture_mirrors_resolved_fallback() {
        for posture in [Posture::Allow, Posture::Ask, Posture::Deny] {
            let e = eff(
                vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])],
                posture,
            );
            let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
            assert_eq!(plan.default_posture, posture);
        }
    }

    #[test]
    fn trailing_slash_on_project_root_is_normalised() {
        let e = eff(
            vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])],
            Posture::Ask,
        );
        let ctx = SandboxContext {
            project_root: "/home/anthony/proj/",
            run_dir: "/x",
        };
        let plan = compile_sandbox_plan(Some(&e), &ctx).unwrap();
        assert!(
            grant(&plan, "/home/anthony/proj/src").is_some(),
            "the trailing slash must not produce `//`"
        );
    }
}
