car-server-core 0.52.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Project-defined mandatory gates for consequential host commands.

use serde::Deserialize;
use std::path::Path;

pub const POLICY_PATH: &str = ".car/production-gates.toml";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionClass {
    Database,
    Deployment,
    ProductionWrite,
}

impl ActionClass {
    fn as_str(self) -> &'static str {
        match self {
            Self::Database => "database",
            Self::Deployment => "deployment",
            Self::ProductionWrite => "production_write",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Gate {
    pub name: String,
    pub action: String,
    pub check: String,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProductionGates {
    #[serde(default)]
    pub gates: Vec<Gate>,
}

impl ProductionGates {
    pub fn load(root: &Path) -> Result<Option<Self>, String> {
        let path = root.join(POLICY_PATH);
        if !path.exists() {
            return Ok(None);
        }
        let text = std::fs::read_to_string(&path)
            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
        let parsed: Self = toml::from_str(&text)
            .map_err(|e| format!("invalid mandatory-gate policy {}: {e}", path.display()))?;
        for gate in &parsed.gates {
            if gate.name.trim().is_empty() || gate.check.trim().is_empty() {
                return Err(format!(
                    "invalid mandatory gate in {}: name and check are required",
                    path.display()
                ));
            }
            if !matches!(
                gate.action.as_str(),
                "database" | "deployment" | "production_write"
            ) {
                return Err(format!(
                    "invalid mandatory gate '{}': unknown action class '{}'",
                    gate.name, gate.action
                ));
            }
        }
        Ok(Some(parsed))
    }

    pub fn gate_for(&self, class: ActionClass) -> Option<&Gate> {
        self.gates.iter().find(|gate| gate.action == class.as_str())
    }
}

/// Conservative classifier for commands that cross production boundaries.
/// Tests/builds that merely mention a database are not classified; an
/// executable mutation verb or a production/deploy command is required.
pub fn classify_command(command: &str) -> Option<ActionClass> {
    let lower = command.to_ascii_lowercase();
    let words: Vec<&str> = lower.split_whitespace().collect();
    let database_client = words.iter().any(|word| {
        matches!(
            word.trim_matches(|c: char| !c.is_ascii_alphanumeric()),
            "psql" | "sqlcmd" | "mysql" | "flyway" | "liquibase"
        )
    });
    let migration = lower.contains("migration") || lower.contains("migrate");
    if database_client || migration || lower.contains("az sql") {
        return Some(ActionClass::Database);
    }
    if lower.contains("deploy")
        || lower.contains("kubectl apply")
        || lower.contains("az webapp")
        || lower.contains("pipeline run")
    {
        return Some(ActionClass::Deployment);
    }
    if (lower.contains("production") || lower.contains("prod"))
        && words.iter().any(|word| {
            matches!(
                word.trim_matches(|c: char| !c.is_ascii_alphanumeric()),
                "write" | "update" | "delete" | "set" | "close" | "resolve"
            )
        })
    {
        return Some(ActionClass::ProductionWrite);
    }
    None
}

/// Resolve the gate for a command. Database actions fail closed when the
/// policy or matching gate is absent; other action classes enforce a matching
/// project gate when the project declares one.
pub fn required_gate(root: &Path, command: &str) -> Result<Option<Gate>, String> {
    let Some(class) = classify_command(command) else {
        return Ok(None);
    };
    let policy = ProductionGates::load(root)?;
    match (class, policy) {
        (ActionClass::Database, None) => Err(format!(
            "database action refused: required project gate {} is absent",
            root.join(POLICY_PATH).display()
        )),
        (ActionClass::Database, Some(policy)) => {
            policy.gate_for(class).cloned().map(Some).ok_or_else(|| {
                "database action refused: no database mandatory gate is defined".into()
            })
        }
        (_, Some(policy)) => Ok(policy.gate_for(class).cloned()),
        (_, None) => Ok(None),
    }
}

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

    #[test]
    fn database_actions_fail_closed_without_policy() {
        let root = tempfile::tempdir().unwrap();
        let err = required_gate(root.path(), "psql -f migrations/388.sql").unwrap_err();
        assert!(err.contains("required project gate"));
        assert_eq!(
            required_gate(root.path(), "cargo test database").unwrap(),
            None
        );
    }

    #[test]
    fn malformed_or_missing_database_gate_is_an_error() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join(".car")).unwrap();
        std::fs::write(
            root.path().join(POLICY_PATH),
            "[[gates]]\nname='deploy'\naction='deployment'\ncheck='cargo test'\n",
        )
        .unwrap();
        assert!(required_gate(root.path(), "sqlcmd -i change.sql").is_err());
    }

    #[test]
    fn matching_gate_is_returned_verbatim() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join(".car")).unwrap();
        std::fs::write(
            root.path().join(POLICY_PATH),
            "[[gates]]\nname='dba'\naction='database'\ncheck='./scripts/dba-gate.sh'\n",
        )
        .unwrap();
        let gate = required_gate(root.path(), "./scripts/run-migration")
            .unwrap()
            .unwrap();
        assert_eq!(gate.name, "dba");
    }
}