openlatch-client 0.1.5

The open-source security layer for AI agents — client forwarder
//! Consent resolver for the crash-report subsystem.
//!
//! Precedence (top wins) — deliberately narrower than the PostHog telemetry
//! chain because crash reports are diagnostic, not behavioural:
//!
//! 1. `SENTRY_DISABLED=1` env — hard lock
//! 2. DSN missing (empty at both compile and runtime) → disabled (no-op)
//! 3. `[crashreport] enabled = false` in `~/.openlatch/config.toml` → disabled
//! 4. Section missing or `enabled = true` → **enabled** (default-on — the
//!    single exception in Observability Strategy §11's consent table)
//!
//! `DO_NOT_TRACK` and CI-environment detection are **not** consulted. Per the
//! brainstorm "Rejected Alternatives" table, crash reports are diagnostic and
//! CI panics are exactly the bugs we want to catch.

use std::path::Path;

use super::config::read_section;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsentState {
    Enabled,
    Disabled,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecidedBy {
    /// `SENTRY_DISABLED` environment variable is set to a truthy value.
    SentryDisabledEnv,
    /// No DSN baked at build time and none provided at runtime.
    NoBakedDsn,
    /// `[crashreport] enabled = false` in `config.toml`.
    ConfigFile,
    /// Default path — section absent or explicitly `enabled = true`.
    DefaultEnabled,
}

#[derive(Debug, Clone, Copy)]
pub struct Resolved {
    pub state: ConsentState,
    pub decided_by: DecidedBy,
}

impl Resolved {
    pub fn enabled(&self) -> bool {
        self.state == ConsentState::Enabled
    }
}

/// Resolve consent for the crash-report subsystem.
///
/// `config_path` is the full path to `~/.openlatch/config.toml`. `dsn_present`
/// is the runtime view of "do we have a usable DSN string" — callers pass
/// `!resolved_dsn.is_empty()`.
pub fn resolve(config_path: &Path, dsn_present: bool) -> Resolved {
    if is_truthy_env("SENTRY_DISABLED") {
        return Resolved {
            state: ConsentState::Disabled,
            decided_by: DecidedBy::SentryDisabledEnv,
        };
    }
    if !dsn_present {
        return Resolved {
            state: ConsentState::Disabled,
            decided_by: DecidedBy::NoBakedDsn,
        };
    }
    match read_section(config_path) {
        Ok(Some(section)) if !section.enabled => Resolved {
            state: ConsentState::Disabled,
            decided_by: DecidedBy::ConfigFile,
        },
        // Section present with enabled=true, section absent, or parse error
        // → default on. Parse errors are NOT treated as "disabled" for crash
        // reporting (unlike PostHog) because a corrupt file shouldn't silently
        // stop crash diagnostics — operators need to see panics to fix them.
        Ok(Some(_)) | Ok(None) | Err(_) => Resolved {
            state: ConsentState::Enabled,
            decided_by: if matches!(read_section(config_path), Ok(Some(_))) {
                DecidedBy::ConfigFile
            } else {
                DecidedBy::DefaultEnabled
            },
        },
    }
}

fn is_truthy_env(name: &str) -> bool {
    match std::env::var(name) {
        Ok(v) => {
            let v = v.trim().to_ascii_lowercase();
            !matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
        }
        Err(_) => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;
    use tempfile::TempDir;

    // SAFETY: env vars are process-global; serialize tests that touch them.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    fn with_clean_env<F: FnOnce()>(f: F) {
        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        // SAFETY: env mutation requires serialization, which ENV_LOCK provides.
        unsafe {
            std::env::remove_var("SENTRY_DISABLED");
        }
        f();
    }

    #[test]
    fn test_resolve_disabled_when_env_set() {
        with_clean_env(|| {
            // SAFETY: serialized via ENV_LOCK.
            unsafe {
                std::env::set_var("SENTRY_DISABLED", "1");
            }
            let tmp = TempDir::new().unwrap();
            let r = resolve(&tmp.path().join("config.toml"), true);
            assert_eq!(r.state, ConsentState::Disabled);
            assert_eq!(r.decided_by, DecidedBy::SentryDisabledEnv);
            unsafe {
                std::env::remove_var("SENTRY_DISABLED");
            }
        });
    }

    #[test]
    fn test_resolve_disabled_when_no_dsn() {
        with_clean_env(|| {
            let tmp = TempDir::new().unwrap();
            let r = resolve(&tmp.path().join("config.toml"), false);
            assert_eq!(r.state, ConsentState::Disabled);
            assert_eq!(r.decided_by, DecidedBy::NoBakedDsn);
        });
    }

    #[test]
    fn test_resolve_disabled_when_config_says_false() {
        with_clean_env(|| {
            let tmp = TempDir::new().unwrap();
            let p = tmp.path().join("config.toml");
            std::fs::write(&p, "[crashreport]\nenabled = false\n").unwrap();
            let r = resolve(&p, true);
            assert_eq!(r.state, ConsentState::Disabled);
            assert_eq!(r.decided_by, DecidedBy::ConfigFile);
        });
    }

    #[test]
    fn test_resolve_enabled_by_default_when_config_missing() {
        with_clean_env(|| {
            let tmp = TempDir::new().unwrap();
            let r = resolve(&tmp.path().join("config.toml"), true);
            assert_eq!(r.state, ConsentState::Enabled);
            assert_eq!(r.decided_by, DecidedBy::DefaultEnabled);
        });
    }

    #[test]
    fn test_resolve_enabled_when_config_says_true() {
        with_clean_env(|| {
            let tmp = TempDir::new().unwrap();
            let p = tmp.path().join("config.toml");
            std::fs::write(&p, "[crashreport]\nenabled = true\n").unwrap();
            let r = resolve(&p, true);
            assert_eq!(r.state, ConsentState::Enabled);
            assert_eq!(r.decided_by, DecidedBy::ConfigFile);
        });
    }
}