//! Trusted operator policy (docs/NASH.md §4.3).
//!
//! nash's levers — the kill switch, the real-bash target, whether observation is
//! mandatory — are **operator** levers, and the process nash observes must not be
//! able to reach them. The agent owns its own environment (it writes the command
//! line, `export`s at will, and has passwordless `sudo` in the sandbox), so an env
//! var is not a channel an observation layer can trust: `NUCLEIC_NASH_DISABLE=1
//! <anything>` used to be a complete, silent opt-out of the thing whose entire job
//! is to watch that command.
//!
//! So the levers move here: a **root-owned, non-group/other-writable file** that
//! Nucleic writes (image-baked for narOS, seeded per container otherwise). nash
//! honors levers only from this file, never from the environment. Env levers that
//! *look* like an attempt to steer nash are reported as `policy` events with
//! `action: "ignored"` — a bypass attempt becomes a feed row instead of a silence.
//!
//! This is a bar, not a wall, and the doc is explicit about that: an agent with
//! `sudo` can still edit this file, exec `/usr/bin/bash.real` directly, or run its
//! command under `python3 -c`. What it can no longer do is go dark by accident, or
//! by typing one variable, without leaving a record.

use std::path::{Path, PathBuf};

/// Where the trusted policy lives. Linux (containers, the Linux VM, the runner) is
/// the surface Nucleic controls the image for, so the path is FHS-conventional; the
/// macOS twin covers the macOS VM guest, where an operator writes it with `sudo`.
#[cfg(target_os = "macos")]
pub const POLICY_PATH: &str = "/Library/Application Support/Nucleic/nash.conf";
#[cfg(not(target_os = "macos"))]
pub const POLICY_PATH: &str = "/etc/nucleic/nash.conf";

/// Fallback exec targets when the policy names no `real_bash`, in order. Compiled
/// in rather than read from `NUCLEIC_REAL_BASH` — see the module docs.
pub const DEFAULT_REAL_BASH: &[&str] = &["/usr/bin/bash.real", "/bin/bash"];

/// The spool observation falls back to when a `require_observation` policy finds no
/// transport configured (docs/NASH.md §6.2). Matches `ShellSpool.defaultDir`'s Linux
/// twin; the drain op already knows this path.
pub const DEFAULT_SPOOL: &str = "/var/spool/nucleic-nash";

/// Env vars nash deliberately no longer honors, with the note each rejection carries.
/// `NUCLEIC_FALLBACK_SHELL` never shipped as a read (docs/NASH.md §4.1 described it);
/// it is listed so that setting it is reported rather than silently inert.
const REJECTED_LEVERS: &[(&str, &str)] = &[
    (
        "NUCLEIC_NASH_DISABLE",
        "the kill switch is an operator lever and is read only from the trusted policy file",
    ),
    (
        "NUCLEIC_REAL_BASH",
        "the real-bash target is an operator lever and is read only from the trusted policy file",
    ),
    (
        "NUCLEIC_FALLBACK_SHELL",
        "not a lever nash reads; the fallback target comes from the trusted policy file",
    ),
];

/// One lever nash saw and did not honor — reported as a `policy` event so the feed
/// shows the attempt.
pub struct Rejected {
    pub lever: String,
    pub value: String,
    pub note: String,
}

#[derive(Default)]
pub struct Policy {
    /// Operator break-glass (docs/NASH.md §4.1): behave as the real bash, verbatim argv.
    pub disable: bool,
    /// Exec target for the break-glass and the parse-failure fallback.
    pub real_bash: Option<String>,
    /// Observation may not be turned off by the observed process: `capture=off` is
    /// floored to `meta` and a missing transport falls back to the spool.
    pub require_observation: bool,
    /// Spool dir for the `require_observation` fallback.
    pub spool: Option<PathBuf>,
    /// Env levers seen and ignored.
    pub rejected: Vec<Rejected>,
    /// Why an existing policy file was not trusted, if it wasn't.
    pub untrusted: Option<String>,
}

impl Policy {
    /// Load the trusted policy and scan the environment for rejected levers. Never
    /// fails: an absent, unreadable, or untrusted file yields defaults (observe, don't
    /// disable), because failing open on *observation* is the §5.5 doctrine — nash must
    /// never break a command — while failing open on *levers* is the safe direction here
    /// (no policy file means no kill switch, not an agent-steerable one).
    pub fn load() -> Self {
        Self::load_from(Path::new(POLICY_PATH))
    }

    pub fn load_from(path: &Path) -> Self {
        let mut policy = Policy {
            rejected: rejected_env_levers(),
            ..Default::default()
        };
        match read_trusted(path) {
            Ok(None) => {}
            Ok(Some(text)) => policy.apply(&text),
            Err(reason) => policy.untrusted = Some(reason),
        }
        policy
    }

    /// The exec targets for the break-glass / parse-failure fallback, in order.
    pub fn real_bash_candidates(&self) -> Vec<String> {
        let mut candidates: Vec<String> = Vec::new();
        if let Some(explicit) = &self.real_bash {
            candidates.push(explicit.clone());
        }
        candidates.extend(DEFAULT_REAL_BASH.iter().map(|s| s.to_string()));
        candidates
    }

    /// The spool the `require_observation` fallback writes to.
    pub fn fallback_spool(&self) -> PathBuf {
        self.spool
            .clone()
            .unwrap_or_else(|| PathBuf::from(DEFAULT_SPOOL))
    }

    fn apply(&mut self, text: &str) {
        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let Some((key, value)) = line.split_once('=') else {
                continue;
            };
            let value = value.trim();
            match key.trim() {
                "disable" => self.disable = is_true(value),
                "require_observation" => self.require_observation = is_true(value),
                "real_bash" if !value.is_empty() => self.real_bash = Some(value.to_string()),
                "spool" if !value.is_empty() => self.spool = Some(PathBuf::from(value)),
                _ => {}
            }
        }
    }
}

fn is_true(value: &str) -> bool {
    matches!(value, "1" | "true" | "yes" | "on")
}

/// Read `path` if it is trusted. `Ok(None)` means "no policy" (the common case —
/// there is no file); `Err` means a file exists but is not one an operator wrote,
/// which is itself worth reporting.
///
/// Trusted means: a regular file (not a symlink — a symlink could point the read at
/// something the agent controls), owned by uid 0, and not writable by group or other.
/// The check is on the file rather than the directory because a file the agent
/// created in an agent-writable directory carries the agent's uid and fails here.
fn read_trusted(path: &Path) -> Result<Option<String>, String> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        use std::os::unix::fs::PermissionsExt;

        let meta = match std::fs::symlink_metadata(path) {
            Ok(meta) => meta,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(err) => return Err(format!("unreadable: {err}")),
        };
        if meta.file_type().is_symlink() {
            return Err("is a symlink; a trusted policy must be a regular file".into());
        }
        if !meta.is_file() {
            return Err("is not a regular file".into());
        }
        if meta.uid() != 0 {
            return Err(format!("owned by uid {}, not root", meta.uid()));
        }
        let mode = meta.permissions().mode();
        if mode & 0o022 != 0 {
            return Err(format!("mode {:04o} is group/other-writable", mode & 0o7777));
        }
        std::fs::read_to_string(path)
            .map(Some)
            .map_err(|err| format!("unreadable: {err}"))
    }
    #[cfg(not(unix))]
    {
        let _ = path;
        Ok(None)
    }
}

/// The env levers present in this process's environment, all of which are ignored.
fn rejected_env_levers() -> Vec<Rejected> {
    REJECTED_LEVERS
        .iter()
        .filter_map(|(lever, note)| {
            let value = std::env::var(lever).ok()?;
            Some(Rejected {
                lever: (*lever).to_string(),
                value,
                note: (*note).to_string(),
            })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use std::sync::atomic::{AtomicU32, Ordering};

    fn scratch(name: &str) -> PathBuf {
        static COUNTER: AtomicU32 = AtomicU32::new(0);
        let dir = std::env::temp_dir().join(format!(
            "nash-policy-{}-{}",
            std::process::id(),
            COUNTER.fetch_add(1, Ordering::Relaxed)
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir.join(name)
    }

    fn write(path: &Path, contents: &str) {
        let mut file = std::fs::File::create(path).unwrap();
        file.write_all(contents.as_bytes()).unwrap();
    }

    #[test]
    fn parses_keys_comments_and_whitespace() {
        let mut policy = Policy::default();
        policy.apply(
            "# a comment\n\n  disable = 1 \nrequire_observation=true\n\
             real_bash=/usr/bin/bash.real\nspool=/var/spool/nucleic-nash\n\
             unknown_key=whatever\nnot a pair\n",
        );
        assert!(policy.disable);
        assert!(policy.require_observation);
        assert_eq!(policy.real_bash.as_deref(), Some("/usr/bin/bash.real"));
        assert_eq!(policy.spool, Some(PathBuf::from("/var/spool/nucleic-nash")));
    }

    #[test]
    fn defaults_are_observe_and_do_not_disable() {
        let policy = Policy::default();
        assert!(!policy.disable);
        assert!(!policy.require_observation);
        assert_eq!(policy.fallback_spool(), PathBuf::from(DEFAULT_SPOOL));
        assert_eq!(policy.real_bash_candidates(), DEFAULT_REAL_BASH.to_vec());
    }

    /// The point of the whole mechanism: a policy file the *agent* could have written is not a
    /// policy file. The test process runs as the agent uid, so anything it creates is exactly
    /// that case — and the kill switch inside must not take.
    #[test]
    fn a_file_the_agent_could_have_written_is_not_trusted() {
        let path = scratch("nash.conf");
        write(&path, "disable=1\nrequire_observation=1\n");
        let policy = Policy::load_from(&path);
        assert!(!policy.disable, "an agent-owned file must not arm the kill switch");
        assert!(!policy.require_observation);
        assert!(policy.untrusted.is_some());
    }

    #[test]
    fn a_symlink_is_not_trusted() {
        let target = scratch("real.conf");
        write(&target, "disable=1\n");
        let link = target.with_file_name("nash.conf");
        std::os::unix::fs::symlink(&target, &link).unwrap();
        let policy = Policy::load_from(&link);
        assert!(!policy.disable);
        assert!(policy.untrusted.unwrap().contains("symlink"));
    }

    #[test]
    fn a_missing_file_is_not_an_error() {
        let policy = Policy::load_from(&scratch("absent.conf"));
        assert!(policy.untrusted.is_none());
        assert!(!policy.disable);
    }

    /// An explicit `real_bash` leads, but the compiled defaults always remain as fallbacks —
    /// a policy naming a path that no longer exists must not strand the break-glass.
    #[test]
    fn real_bash_candidates_keep_the_defaults() {
        let policy = Policy {
            real_bash: Some("/opt/bash".into()),
            ..Default::default()
        };
        assert_eq!(policy.real_bash_candidates()[0], "/opt/bash");
        assert_eq!(&policy.real_bash_candidates()[1..], DEFAULT_REAL_BASH);
    }
}
