//! Linux bwrap (bubblewrap) backend.
//!
//! Generates a `bwrap` command-line invocation that mounts the host
//! filesystem read-only, then re-binds the project root + caches as
//! read-write, and shadows koda's own secrets with `--tmpfs`.
//!
//! ## Layering
//!
//! Same conceptual three-layer overlay as the seatbelt backend:
//!
//!   1. Base view (`base_cmd`)             — `--ro-bind /` then writable carve-outs
//!   2. Hardcoded full-deny tmpfs overlays — koda secrets
//!   3. Policy overlay (`policy_overlay_args`) — caller-supplied rules
//!
//! Empty policy → behavior byte-identical to pre-#934 (Phase 0 invariant).
//!
//! ## Kernel-enforced egress (Phase 3c.1)
//!
//! When the caller supplies a proxy port AND a UDS bridge socket
//! (built by [`crate::bwrap_proxy::spawn_uds_bridge`]) is reachable,
//! [`build_command`] hands off to [`build_command_with_proxy`] which
//! adds:
//!
//!   - `--unshare-net --unshare-user --unshare-pid` — fresh net/user/pid
//!     namespaces. The fresh netns has no routes to anywhere except
//!     its own (initially-down) loopback. **This is the kernel
//!     enforcement**: direct TCP from the sandbox to anywhere
//!     fails at `connect()` with ENETUNREACH.
//!   - `--bind <uds> <uds>` — make the host UDS bridge socket
//!     reachable inside the sandbox.
//!   - Replaces `-- sh -c <cmd>` with
//!     `-- /path/to/koda-sandbox-stage2 sh -c <cmd>` so the in-netns
//!     `lo`-bring-up + TCP↔UDS bridge runs before the user command.
//!
//! ## Mapping policy → bwrap flags
//!
//! | Policy field                  | bwrap rendering                       |
//! |-------------------------------|---------------------------------------|
//! | `fs.deny_read` (subpath)      | `--tmpfs <path>` (shadows the dir)    |
//! | `fs.allow_read_within_deny`   | `--ro-bind <path> <path>` (post-shadow)|
//! | `fs.allow_write`              | `--bind <path> <path>` (writable)     |
//! | `fs.deny_write_within_allow`  | `--ro-bind <path> <path>` (post-bind) |
//!
//! bwrap is **first-match wins** for a given mountpoint, but mounts at
//! deeper paths override shallower mounts that contain them. So we emit
//! the broad rules first, then the narrower carve-outs.

#![cfg(target_os = "linux")]

use crate::defaults::{CREDENTIAL_CONFIG_FULL_DENY, PROTECTED_PROJECT_SUBDIRS};
use crate::policy::SandboxPolicy;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tokio::process::Command;

/// Env-var override for the [`stage2_binary`] discovery (mirrors
/// `KODA_FS_WORKER_BIN`). Used by the bwrap-runtime e2e tests.
pub const STAGE2_BIN_ENV_KEY: &str = "KODA_SANDBOX_STAGE2_BIN";

/// Comma-separated list of env vars stage 2 will rewrite in-netns.
/// Kept in lockstep with [`crate::proxy::env::proxy_env_vars`] — every
/// `*_PROXY` key that function emits must appear here so stage 2's
/// rewriter visits all of them.
const STAGE2_PROXY_REWRITE_KEYS: &str =
    "HTTPS_PROXY,HTTP_PROXY,ALL_PROXY,https_proxy,http_proxy,all_proxy";

/// Build a `bwrap` Command for the given command + project root.
///
/// See module docs for layering. An empty `policy` produces the same
/// arg vector as pre-#934.
pub fn build_command(
    command: &str,
    project_root: &Path,
    policy: &SandboxPolicy,
) -> Result<Command> {
    if !is_available() {
        anyhow::bail!(
            "Sandbox requested but bwrap is not installed. \
             Install with: apt install bubblewrap  /  dnf install bubblewrap"
        );
    }

    let (mut cmd, home) = base_cmd(project_root);
    apply_credential_denies(&mut cmd, &home);
    // Gap 1 of #1072: protect git hooks + config when allow_git_config=false.
    if !policy.fs.allow_git_config {
        let root = project_root.to_string_lossy();
        apply_git_config_deny(&mut cmd, &root);
    }
    apply_policy_overlay(&mut cmd, policy)?;
    cmd.args(["--", "sh", "-c", command])
        .current_dir(project_root);
    // #1228: scrub the LLM-bound shell's env down to the allowlist.
    // bwrap inherits-and-forwards parent env to the inner sh by default,
    // so without this the inner shell sees OPENAI_API_KEY etc. The kernel
    // namespaces gate filesystem + network but NOT env.
    crate::env::scrub(&mut cmd, command);
    Ok(cmd)
}

/// Like [`build_command`] but also wires up the kernel-enforced egress
/// proxy via the stage 2 helper (Phase 3c.1.d).
///
/// `proxy_port` is the host-side TCP port the proxy is listening on
/// (used purely as a debug breadcrumb in the resulting Command —
/// stage 2 reads the in-netns ephemeral port from its own listener).
///
/// `uds_path` is the UDS bridge socket the host bridge
/// ([`crate::bwrap_proxy::spawn_uds_bridge`]) is listening on. Must
/// exist; we bind-mount it into the sandbox so stage 2 can
/// `connect()` to it.
///
/// `stage2_bin` is the absolute path to the `koda-sandbox-stage2`
/// helper binary (located via [`stage2_binary`]). The host filesystem
/// is bind-mounted read-only into the sandbox, so the same path is
/// reachable from inside.
pub fn build_command_with_proxy(
    command: &str,
    project_root: &Path,
    policy: &SandboxPolicy,
    proxy_port: u16,
    uds_path: &Path,
    stage2_bin: &Path,
) -> Result<Command> {
    if !is_available() {
        anyhow::bail!(
            "Sandbox requested but bwrap is not installed. \
             Install with: apt install bubblewrap  /  dnf install bubblewrap"
        );
    }

    let (mut cmd, home) = base_cmd(project_root);
    apply_credential_denies(&mut cmd, &home);
    // Gap 1 of #1072: protect git hooks + config when allow_git_config=false.
    if !policy.fs.allow_git_config {
        let root = project_root.to_string_lossy();
        apply_git_config_deny(&mut cmd, &root);
    }
    apply_policy_overlay(&mut cmd, policy)?;

    // #1228: scrub the LLM-bound shell's env down to the allowlist BEFORE
    // we add the STAGE2_* control vars below — env_clear() inside scrub()
    // would otherwise wipe stage2's env, breaking the proxy bridge.
    // Order matters: scrub first, then layer koda's own infrastructure
    // env on top.
    crate::env::scrub(&mut cmd, command);

    // Network isolation. The fresh netns has no routes anywhere
    // except its own (initially-down) loopback — that's the kernel
    // enforcement. `--unshare-user` is required for `--unshare-net`
    // when bwrap runs unprivileged. `--unshare-pid` keeps the bridge
    // child from leaking PIDs into the host PID space (and gives stage
    // 2's PR_SET_PDEATHSIG a clean parent to track).
    cmd.args(["--unshare-net", "--unshare-user", "--unshare-pid"]);

    // Bind-mount the UDS bridge socket so stage 2 can connect() to
    // it. The base `--bind /tmp /tmp` already covers our default UDS
    // location, but emit the explicit pair so the dependency is
    // visible in snapshots and survives future base layout changes.
    let uds_str = uds_path.to_string_lossy().into_owned();
    cmd.args(["--bind", &uds_str, &uds_str]);

    // Tell stage 2 (a) where the UDS lives and (b) which env keys to
    // rewrite. Setting on the parent Command propagates to the child
    // because bwrap doesn't `--clearenv` by default.
    cmd.env(crate::bwrap_proxy::STAGE2_UDS_ENV_KEY, &uds_str);
    cmd.env(
        crate::bwrap_proxy::STAGE2_REWRITE_KEYS_ENV_KEY,
        STAGE2_PROXY_REWRITE_KEYS,
    );
    // Debug breadcrumb — never read by stage 2 itself.
    cmd.env("KODA_SANDBOX_PROXY_PORT_DEBUG", proxy_port.to_string());

    // Replace the usual `-- sh -c <cmd>` terminator with the stage 2
    // helper. Stage 2 sets up the in-netns bridge then execvp's
    // ["sh", "-c", command].
    let stage2_str = stage2_bin.to_string_lossy().into_owned();
    cmd.args(["--", &stage2_str, "sh", "-c", command])
        .current_dir(project_root);
    Ok(cmd)
}

/// Apply the credential full-deny tmpfs overlays (layer 2). Shared
/// between the proxied and unproxied build paths so they stay in
/// lockstep.
fn apply_credential_denies(cmd: &mut Command, home: &str) {
    for rel in CREDENTIAL_CONFIG_FULL_DENY {
        let p = format!("{home}/.config/{rel}");
        if Path::new(&p).exists() {
            cmd.args(["--tmpfs", &p]);
        }
    }
}

/// Protect `.git/hooks/` and `.git/config` when `allow_git_config` is
/// false (the default, Gap 1 of #1072).
///
/// Strategy mirrors `PROTECTED_PROJECT_SUBDIRS` in [`base_cmd`]:
///
/// 1. **Pre-create** `.git/hooks/` and `.git/config` (idempotent on
///    existing repos, creates the structure on plain directories).
///    This is what closes the SEC-002 TOCTOU window (#1088): on a
///    pre-#1088 build, a sandbox call on a non-git directory could
///    `git init && git config core.fsmonitor 'evil'` mid-call,
///    persisting a poisoned config because the early-return path
///    skipped the bind-mounts entirely.
/// 2. Bind-mount `.git/hooks/` read-only — sandboxed commands can
///    see hook names (e.g. for `git log --decorate`) but cannot
///    create or overwrite them.
/// 3. Bind-mount `.git/config` read-only — `git config` writes from
///    inside the sandbox fail with EACCES regardless of whether the
///    repo existed at sandbox start.
///
/// **Trade-off**: `git init` inside the sandbox will fail when it
/// tries to write its initial `[core]` block to the read-only
/// `.git/config`. Callers that legitimately need to initialise a
/// repo inside the sandbox should set `policy.fs.allow_git_config =
/// true`, which skips this entire function. The seatbelt backend
/// achieves the same end-state via SBPL deny rules without the
/// pre-create dance (deny rules apply even to paths that don't
/// exist yet).
fn apply_git_config_deny(cmd: &mut Command, root: &str) {
    let git_dir = format!("{root}/.git");
    let hooks = format!("{git_dir}/hooks");
    let config = format!("{git_dir}/config");

    // Worktree handling (#1104): when `.git` is a regular file (the
    // `gitdir: <path>` pointer git uses for worktrees / submodules),
    // `create_dir_all(".git/hooks")` returns NotADirectory. Detect and
    // skip the deny dance — surfacing the case via tracing instead of
    // silently swallowing the error and letting bwrap crash later
    // with an opaque "source doesn't exist" at launch. The hardening
    // this layer provides is best-effort (the seatbelt backend covers
    // the same ground via deny rules); skipping it for the worktree
    // edge case is the right trade-off versus parsing the gitdir
    // pointer and chasing the real .git directory.
    if let Ok(meta) = std::fs::symlink_metadata(&git_dir)
        && meta.is_file()
    {
        tracing::warn!(
            target: "koda::sandbox",
            git_dir = %git_dir,
            "skipping apply_git_config_deny: .git is a file (worktree/submodule); \
             rely on seatbelt-style policy or set fs.allow_git_config=true"
        );
        return;
    }

    // Pre-create the hooks dir (this also creates `.git/` if missing).
    // `create_dir_all` is idempotent and never truncates existing
    // contents, so a real repo's hooks directory is preserved as-is.
    let _ = std::fs::create_dir_all(&hooks);

    // Pre-create `.git/config` only if it doesn't exist. Use
    // `create_new(true)` for an atomic "create-if-absent" — closes the
    // TOCTOU window between an existence check and the create call
    // (#1104). On a real repo with an existing config, `create_new`
    // fails with `AlreadyExists` without modifying the file (no mtime
    // touch, no truncation), which is exactly what we want. Any other
    // error we ignore for the same reason as below: the bind below
    // will surface a clear failure if anything is genuinely wrong.
    match std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&config)
    {
        Ok(_) | Err(_) => {} // existing file or transient FS error
    }

    // If the create_dir_all above failed (e.g. parent unwritable),
    // both `hooks` and `config` may be absent and bwrap will fail at
    // launch with a clear "source doesn't exist" error — better than
    // silently leaving the TOCTOU window open by skipping the binds.
    cmd.args(["--ro-bind", &hooks, &hooks]);
    cmd.args(["--ro-bind", &config, &config]);
}

/// Apply the policy overlay (layer 3). Shared between the proxied and
/// unproxied build paths.
fn apply_policy_overlay(cmd: &mut Command, policy: &SandboxPolicy) -> Result<()> {
    for arg_pair in policy_overlay_args(policy)? {
        cmd.args(&arg_pair);
    }
    Ok(())
}

/// Locate the `koda-sandbox-stage2` helper binary.
///
/// Resolution order (mirrors [`crate::worker_client`]'s discovery,
/// plus a deps-dir fallback for cargo-test invocations):
///
/// 1. `KODA_SANDBOX_STAGE2_BIN` env var — explicit override (e2e tests).
/// 2. `CARGO_BIN_EXE_koda-sandbox-stage2` — Cargo sets this for
///    integration tests *of this same crate*.
/// 3. Sibling of the current executable (production install).
/// 4. Parent's sibling — cargo runs test exes from `target/debug/deps/`
///    while built binaries live one level up at `target/debug/`. This
///    branch lets cross-crate integration tests (notably the
///    `koda-core` e2e suite) find the helper without setting an env
///    var.
pub fn stage2_binary() -> Result<PathBuf> {
    // Read env once at the boundary; delegate the rest to the pure
    // resolver below. This keeps the env-binding surface tiny (and
    // testable independently of the discovery logic).
    let override_path = std::env::var(STAGE2_BIN_ENV_KEY)
        .ok()
        .or_else(|| std::env::var("CARGO_BIN_EXE_koda-sandbox-stage2").ok());
    stage2_binary_from(override_path.as_deref().map(std::path::Path::new))
}

/// Pure-logic core of [`stage2_binary`]: given an optional explicit
/// override path, return it if `Some`; otherwise fall back to the
/// `current_exe`-sibling / cargo-deps-parent discovery chain.
///
/// Extracted in #1109 F1 so tests can exercise the override branch
/// without `unsafe { std::env::set_var }` (UB in Rust 2024 when other
/// threads concurrently read env). Tests pass the override path
/// explicitly; the env-binding wrapper above remains 3 trivial lines
/// not worth a dedicated test.
pub fn stage2_binary_from(env_override: Option<&std::path::Path>) -> Result<PathBuf> {
    if let Some(p) = env_override {
        return Ok(p.to_path_buf());
    }
    let exe = std::env::current_exe().context("locate koda executable")?;

    let mut sibling = exe.clone();
    sibling.set_file_name("koda-sandbox-stage2");
    if sibling.exists() {
        return Ok(sibling);
    }

    // Cargo-test fallback: target/debug/deps/<test-bin> →
    // target/debug/koda-sandbox-stage2.
    if let Some(parent) = exe.parent().and_then(|p| p.parent()) {
        let cargo_built = parent.join("koda-sandbox-stage2");
        if cargo_built.exists() {
            return Ok(cargo_built);
        }
    }

    anyhow::bail!(
        "koda-sandbox-stage2 not found next to {}; set {STAGE2_BIN_ENV_KEY} to override",
        sibling.display()
    )
}

/// Probe whether the bwrap backend is functional. Cached.
///
/// Just checking `bwrap --version` is insufficient: bwrap may be installed
/// but unable to create sandboxes (e.g. GitHub Actions runners lack
/// unprivileged user namespaces → "setting up uid map: Permission denied").
/// Run a real sandboxed command to verify.
pub fn is_available() -> bool {
    use std::sync::OnceLock;
    static AVAILABLE: OnceLock<bool> = OnceLock::new();
    *AVAILABLE.get_or_init(|| {
        std::process::Command::new("bwrap")
            .args(["--ro-bind", "/", "/", "--", "true"])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .is_ok_and(|s| s.success())
    })
}

/// Build a bwrap `Command` with the base project-mode filesystem view.
///
/// Returns `(cmd, home)` with everything set up *except* the final
/// `-- sh -c command` terminator — callers add that (plus any extra mounts
/// for strict mode) before spawning.
fn base_cmd(project_root: &Path) -> (Command, String) {
    let root = project_root.to_string_lossy().into_owned();
    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());

    // Strategy: bind the whole root filesystem read-only, then selectively
    // add read-write binds for project + temp + common caches.
    let mut cmd = Command::new("bwrap");
    cmd.args(["--ro-bind", "/", "/"]);
    cmd.args(["--bind", &root, &root]);
    cmd.args(["--bind", "/tmp", "/tmp"]);
    if Path::new("/var/tmp").exists() {
        cmd.args(["--bind", "/var/tmp", "/var/tmp"]);
    }
    // Common package caches — avoids re-downloading on every invocation.
    for subdir in &[".cargo", ".npm", ".cache"] {
        let p = format!("{home}/{subdir}");
        if Path::new(&p).exists() {
            cmd.args(["--bind", p.as_str(), p.as_str()]);
        }
    }
    cmd.args(["--dev", "/dev"]).args(["--proc", "/proc"]);

    // Deny writes to protected project subdirs (.koda/agents, .koda/skills).
    // Re-bind as read-only after the project-root writable bind (CC parity #844).
    // Pre-create if absent so bwrap has a mountpoint — otherwise a sandboxed
    // command could `mkdir -p` and write agent definitions.
    for rel in PROTECTED_PROJECT_SUBDIRS {
        let p = format!("{root}/{rel}");
        let _ = std::fs::create_dir_all(&p);
        cmd.args(["--ro-bind", &p, &p]);
    }

    (cmd, home)
}

/// Phase 1b of #934: render `policy.fs.*` into bwrap arg pairs.
///
/// Each returned `Vec<String>` is one bwrap option pair (e.g. `["--bind",
/// "/work", "/work"]`) that the caller appends to the Command. We return
/// them as a `Vec<Vec<String>>` rather than mutating a `Command` directly
/// so this function stays pure-ish and easily snapshot-testable.
///
/// Returns an empty vec for an all-default policy, preserving the
/// pre-#934 byte-identical baseline guaranteed by [`build_command`].
pub(crate) fn policy_overlay_args(policy: &SandboxPolicy) -> Result<Vec<Vec<String>>> {
    let fs = &policy.fs;
    if fs.deny_read.is_empty()
        && fs.allow_read_within_deny.is_empty()
        && fs.allow_write.is_empty()
        && fs.deny_write_within_allow.is_empty()
    {
        return Ok(Vec::new());
    }

    let mut out = Vec::new();

    // Layer 1: deny reads — shadow with tmpfs so the contents disappear.
    for p in &fs.deny_read {
        let s = p.as_path().to_string_lossy().into_owned();
        out.push(vec!["--tmpfs".to_string(), s]);
    }
    // Layer 2: re-bind read-only for explicit carve-outs inside denies.
    for p in &fs.allow_read_within_deny {
        let s = p.as_path().to_string_lossy().into_owned();
        out.push(vec!["--ro-bind".to_string(), s.clone(), s]);
    }
    // Layer 3: widen the writable set.
    for p in &fs.allow_write {
        let s = p.as_path().to_string_lossy().into_owned();
        out.push(vec!["--bind".to_string(), s.clone(), s]);
    }
    // Layer 4: protect specific spots inside the writable set.
    for p in &fs.deny_write_within_allow {
        let s = p.as_path().to_string_lossy().into_owned();
        out.push(vec!["--ro-bind".to_string(), s.clone(), s]);
    }

    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn policy_overlay_args_empty_for_default_policy() {
        let args = policy_overlay_args(&SandboxPolicy::strict_default()).unwrap();
        assert!(args.is_empty(), "default policy must add zero args");
    }

    #[test]
    fn policy_overlay_args_render_deny_read_as_tmpfs() {
        let mut policy = SandboxPolicy::strict_default();
        policy.fs.deny_read = vec!["/secrets".into()];
        let args = policy_overlay_args(&policy).unwrap();
        assert_eq!(args.len(), 1);
        assert_eq!(args[0], vec!["--tmpfs", "/secrets"]);
    }

    #[test]
    fn policy_overlay_args_render_allow_write_as_bind() {
        let mut policy = SandboxPolicy::strict_default();
        policy.fs.allow_write = vec!["/work".into()];
        let args = policy_overlay_args(&policy).unwrap();
        assert_eq!(args.len(), 1);
        assert_eq!(args[0], vec!["--bind", "/work", "/work"]);
    }

    #[test]
    fn policy_overlay_args_render_deny_write_within_as_ro_bind() {
        let mut policy = SandboxPolicy::strict_default();
        policy.fs.deny_write_within_allow = vec!["/work/.git/config".into()];
        let args = policy_overlay_args(&policy).unwrap();
        assert_eq!(args.len(), 1);
        assert_eq!(
            args[0],
            vec!["--ro-bind", "/work/.git/config", "/work/.git/config"]
        );
    }

    #[test]
    fn policy_overlay_args_emit_layers_in_correct_order() {
        // Mount-shadowing semantics: deeper / later mounts override the
        // earlier ones for the same prefix. So we emit broad rules first,
        // narrower carve-outs second.
        let mut policy = SandboxPolicy::strict_default();
        policy.fs.deny_read = vec!["/secrets".into()];
        policy.fs.allow_read_within_deny = vec!["/secrets/public".into()];
        policy.fs.allow_write = vec!["/work".into()];
        policy.fs.deny_write_within_allow = vec!["/work/.git".into()];

        let args = policy_overlay_args(&policy).unwrap();
        assert_eq!(args.len(), 4);
        assert_eq!(args[0][0], "--tmpfs"); // deny_read
        assert_eq!(args[1][0], "--ro-bind"); // allow_read_within_deny
        assert_eq!(args[2][0], "--bind"); // allow_write
        assert_eq!(args[3][0], "--ro-bind"); // deny_write_within_allow
    }

    /// Phase 3c.1.d contract: the proxied build path adds the network
    /// isolation flags, the UDS bind-mount, and replaces the `sh -c`
    /// terminator with the stage 2 helper.
    #[test]
    fn build_command_with_proxy_adds_unshare_and_stage2() {
        if !is_available() {
            eprintln!("bwrap not available; skipping");
            return;
        }
        let dir = tempfile::tempdir().unwrap();
        let uds = dir.path().join("bridge.sock");
        std::fs::write(&uds, b"placeholder").unwrap();
        let stage2 = dir.path().join("koda-sandbox-stage2");
        std::fs::write(&stage2, b"#!/bin/sh\n").unwrap();

        let cmd = build_command_with_proxy(
            "echo hi",
            dir.path(),
            &SandboxPolicy::strict_default(),
            12345,
            &uds,
            &stage2,
        )
        .unwrap();

        let args: Vec<String> = cmd
            .as_std()
            .get_args()
            .map(|a| a.to_string_lossy().to_string())
            .collect();

        // Network isolation flags must appear.
        assert!(
            args.iter().any(|a| a == "--unshare-net"),
            "--unshare-net missing"
        );
        assert!(
            args.iter().any(|a| a == "--unshare-user"),
            "--unshare-user missing"
        );
        assert!(
            args.iter().any(|a| a == "--unshare-pid"),
            "--unshare-pid missing"
        );

        // UDS bind-mount must reference the host path on both sides.
        let uds_str = uds.to_string_lossy().to_string();
        let bind_idx = args
            .windows(3)
            .position(|w| w[0] == "--bind" && w[1] == uds_str && w[2] == uds_str)
            .expect("--bind <uds> <uds> missing");
        // Should appear *after* --unshare-net so the path resolves
        // inside the new mount namespace bwrap creates.
        let unshare_idx = args.iter().position(|a| a == "--unshare-net").unwrap();
        assert!(
            bind_idx > unshare_idx,
            "--bind UDS must follow --unshare-net (got bind@{bind_idx} unshare@{unshare_idx})"
        );

        // Terminator: -- <stage2_bin> sh -c "echo hi".
        let dash_dash = args.iter().rposition(|a| a == "--").expect("-- missing");
        assert_eq!(args[dash_dash + 1], stage2.to_string_lossy());
        assert_eq!(args[dash_dash + 2], "sh");
        assert_eq!(args[dash_dash + 3], "-c");
        assert_eq!(args[dash_dash + 4], "echo hi");
    }

    /// Companion: env vars are set on the parent Command so they
    /// propagate to the bwrap child (and through to stage 2).
    #[test]
    fn build_command_with_proxy_sets_stage2_env_vars() {
        if !is_available() {
            eprintln!("bwrap not available; skipping");
            return;
        }
        let dir = tempfile::tempdir().unwrap();
        let uds = dir.path().join("bridge.sock");
        std::fs::write(&uds, b"placeholder").unwrap();
        let stage2 = dir.path().join("koda-sandbox-stage2");
        std::fs::write(&stage2, b"#!/bin/sh\n").unwrap();

        let cmd = build_command_with_proxy(
            "true",
            dir.path(),
            &SandboxPolicy::strict_default(),
            54321,
            &uds,
            &stage2,
        )
        .unwrap();

        let envs: std::collections::HashMap<String, String> = cmd
            .as_std()
            .get_envs()
            .filter_map(|(k, v)| {
                Some((
                    k.to_string_lossy().to_string(),
                    v?.to_string_lossy().to_string(),
                ))
            })
            .collect();

        assert_eq!(
            envs.get(crate::bwrap_proxy::STAGE2_UDS_ENV_KEY),
            Some(&uds.to_string_lossy().to_string()),
            "stage 2 must learn the UDS path via env"
        );
        let rewrite = envs
            .get(crate::bwrap_proxy::STAGE2_REWRITE_KEYS_ENV_KEY)
            .expect("stage 2 rewrite-keys env missing");
        // All six proxy env keys (upper + lower) must be in the list,
        // matching what proxy::env::proxy_env_vars emits. Otherwise
        // stage 2 would leave some keys pointing at the host port.
        for key in [
            "HTTPS_PROXY",
            "HTTP_PROXY",
            "ALL_PROXY",
            "https_proxy",
            "http_proxy",
            "all_proxy",
        ] {
            assert!(
                rewrite.split(',').any(|k| k == key),
                "rewrite key {key} missing from {rewrite}"
            );
        }
    }

    /// Pinned: stage 2 binary discovery honors the override path
    /// before falling back to current_exe sibling lookup. E2E tests
    /// rely on this.
    ///
    /// **#1109 F1**: was `unsafe { std::env::set_var(...) }` which
    /// is UB in Rust 2024 if any other test in the same binary
    /// reads env concurrently. Now exercises the pure resolver
    /// directly — no env mutation, no UB risk.
    #[test]
    fn stage2_binary_respects_env_override() {
        let p = stage2_binary_from(Some(std::path::Path::new("/tmp/fake-stage2"))).unwrap();
        assert_eq!(p, std::path::PathBuf::from("/tmp/fake-stage2"));
    }

    // ── Gap 1 of #1072: apply_git_config_deny ──────────────

    /// SEC-002 / #1088: even on plain (non-git) directories, the
    /// function pre-creates `.git/hooks/` and `.git/config` and binds
    /// them read-only. This closes the TOCTOU window where a sandbox
    /// call could `git init && git config core.fsmonitor 'evil'` and
    /// persist a poisoned config in a single invocation.
    #[test]
    fn git_config_deny_pre_creates_for_non_git_dir_to_close_toctou() {
        let dir = tempfile::tempdir().unwrap();
        // Confirm the precondition: no .git/ initially.
        assert!(
            !dir.path().join(".git").exists(),
            "precondition: tempdir should not be a git repo"
        );

        let mut cmd = Command::new("true");
        let root = dir.path().to_string_lossy();
        apply_git_config_deny(&mut cmd, &root);

        // Side effect: .git/hooks/ and .git/config now exist on disk
        // (pre-creation is required so the ro-bind has a source).
        assert!(
            dir.path().join(".git/hooks").is_dir(),
            "hooks dir must be pre-created to give the bind-mount a source"
        );
        assert!(
            dir.path().join(".git/config").is_file(),
            "config file must be pre-created (empty) to close SEC-002"
        );
        assert_eq!(
            std::fs::read(dir.path().join(".git/config")).unwrap().len(),
            0,
            "pre-created config must be empty (a valid git config = use defaults)"
        );

        // The bind-mount args must reference both .git/hooks and .git/config.
        let args: Vec<String> = cmd
            .as_std()
            .get_args()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        assert!(
            args.windows(3)
                .any(|w| { w[0] == "--ro-bind" && w[1].ends_with("/.git/hooks") }),
            "hooks ro-bind must be present even on a fresh dir: {args:?}"
        );
        assert!(
            args.windows(3)
                .any(|w| { w[0] == "--ro-bind" && w[1].ends_with("/.git/config") }),
            "config ro-bind must be present even on a fresh dir: {args:?}"
        );
    }

    /// Once `.git/` exists, the hooks directory is pre-created and
    /// bound read-only, and the config file (when present) is also
    /// bound read-only.
    #[test]
    fn git_config_deny_adds_ro_bind_for_existing_git_dir() {
        let dir = tempfile::tempdir().unwrap();
        let git = dir.path().join(".git");
        std::fs::create_dir_all(git.join("hooks")).unwrap();
        std::fs::write(git.join("config"), b"[core]\n").unwrap();

        let mut cmd = Command::new("true");
        let root = dir.path().to_string_lossy();
        apply_git_config_deny(&mut cmd, &root);

        // Existing config must NOT be truncated.
        assert_eq!(
            std::fs::read(dir.path().join(".git/config")).unwrap(),
            b"[core]\n",
            "pre-existing config contents must be preserved"
        );

        let args: Vec<String> = cmd
            .as_std()
            .get_args()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        // Should see --ro-bind for hooks and --ro-bind for config.
        assert!(
            args.windows(3)
                .any(|w| { w[0] == "--ro-bind" && w[1].ends_with("/.git/hooks") }),
            "hooks directory must be ro-bound: {args:?}"
        );
        assert!(
            args.windows(3)
                .any(|w| { w[0] == "--ro-bind" && w[1].ends_with("/.git/config") }),
            "git config file must be ro-bound when it exists: {args:?}"
        );
    }

    /// When `allow_git_config` is explicitly set, `build_command`
    /// must NOT add the ro-bind mounts.
    #[test]
    fn build_command_skips_git_deny_when_allowed() {
        if !is_available() {
            eprintln!("bwrap not available; skipping");
            return;
        }
        let dir = tempfile::tempdir().unwrap();
        // Create a .git dir so `apply_git_config_deny` would fire if
        // incorrectly called.
        std::fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
        let mut policy = SandboxPolicy::strict_default();
        policy.fs.allow_git_config = true;
        let cmd = build_command("true", dir.path(), &policy).unwrap();
        let args: Vec<String> = cmd
            .as_std()
            .get_args()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        let has_hooks_ro_bind = args
            .windows(3)
            .any(|w| w[0] == "--ro-bind" && w[1].ends_with("/.git/hooks"));
        assert!(
            !has_hooks_ro_bind,
            "allow_git_config=true must not add ro-bind for .git/hooks"
        );
    }

    /// **#1104**: when `.git` is a regular file (the `gitdir: ...`
    /// pointer used by git worktrees / submodules), the deny dance
    /// must skip cleanly instead of (a) silently swallowing
    /// `NotADirectory` and (b) causing bwrap to crash later with
    /// "source doesn't exist".
    #[test]
    fn git_config_deny_skips_worktree_gitdir_pointer() {
        let dir = tempfile::tempdir().unwrap();
        // Worktree shape: .git is a file, not a directory.
        std::fs::write(
            dir.path().join(".git"),
            b"gitdir: /some/main/.git/worktrees/my-wt\n",
        )
        .unwrap();

        let mut cmd = Command::new("true");
        let root = dir.path().to_string_lossy();
        apply_git_config_deny(&mut cmd, &root);

        // The .git file must remain a file (we didn't try to convert
        // it into a directory or otherwise touch it).
        assert!(
            std::fs::symlink_metadata(dir.path().join(".git"))
                .unwrap()
                .is_file(),
            ".git file must remain unchanged"
        );

        // No --ro-bind for hooks/config should have been added.
        let args: Vec<String> = cmd
            .as_std()
            .get_args()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        assert!(
            args.is_empty(),
            "worktree case must add zero bind args (got: {args:?})"
        );
    }

    /// **#1104**: the TOCTOU fix uses `create_new(true)` which fails
    /// atomically on existing files — verify the existing config's
    /// mtime is not bumped (the property the original existence-check
    /// guard was protecting). Sleep-free: compare snapshots taken
    /// before/after the call.
    #[test]
    fn git_config_deny_preserves_existing_config_mtime() {
        let dir = tempfile::tempdir().unwrap();
        let git = dir.path().join(".git");
        std::fs::create_dir_all(git.join("hooks")).unwrap();
        let config = git.join("config");
        std::fs::write(&config, b"[core]\nexisting=yes\n").unwrap();
        let before = std::fs::metadata(&config).unwrap().modified().unwrap();

        let mut cmd = Command::new("true");
        let root = dir.path().to_string_lossy();
        apply_git_config_deny(&mut cmd, &root);

        let after = std::fs::metadata(&config).unwrap().modified().unwrap();
        assert_eq!(
            before, after,
            "existing git config mtime must not be touched (TOCTOU fix invariant)"
        );
        // And contents must be byte-identical.
        assert_eq!(
            std::fs::read(&config).unwrap(),
            b"[core]\nexisting=yes\n",
            "contents must be unchanged"
        );
    }
}
