openvet-policy 0.2.0

Claim catalog, requirement language, and Kleene evaluator for OpenVet.
Documentation
//! Policy configuration: the in-memory `Policy` struct plus the
//! TOML wire shape and a parser that produces an already-validated
//! `Policy` from a `&str`.
//!
//! TOML shape (full example):
//!
//! ```toml
//! [requirement]
//! safe-to-deploy = "safe-to-deploy"                       # bare = default-on, expr is the string
//! sandbox = { condition = "sandboxed and not unsafe-code", default = false }
//!
//! [[override]]
//! registry = "cargo"
//! package = "libc"
//! requirements = { add = ["sandbox"], remove = ["safe-to-deploy"] }
//!
//! [[override]]
//! registry = "cargo"
//! package = "serde"
//! requirements = ["safe-to-deploy"]                       # replace
//!
//! [alias]
//! safe-to-run = ["google:safe-to-run", "mozilla:runtime-safe"]
//! ```
//!
//! All matcher fields on `[[override]]` are optional (omitted = wildcard).

use crate::error::{PolicyError, Result};
use crate::expr::{self, Expr};
use serde::Deserialize;
use std::collections::HashMap;

/// Validated, ready-to-evaluate policy.
#[derive(Debug, Clone)]
pub struct Policy {
    pub requirements: Vec<Requirement>,
    pub overrides: Vec<Override>,
    pub aliases: Vec<Alias>,
}

#[derive(Debug, Clone)]
pub struct Requirement {
    pub name: String,
    pub expr: Expr,
    /// True if this requirement applies to every dependency by
    /// default; false if it's only referenceable from overrides.
    pub default: bool,
}

#[derive(Debug, Clone)]
pub struct Override {
    pub matcher: SubjectMatcher,
    pub op: OverrideOp,
}

#[derive(Debug, Clone, Default)]
pub struct SubjectMatcher {
    pub registry: Option<String>,
    pub prefix: Option<String>,
    pub package: Option<String>,
    pub version: Option<String>,
    /// Tagged hash like `sha256:hex...`, exact match. `None` matches
    /// any hash.
    pub hash: Option<String>,
}

#[derive(Debug, Clone)]
pub enum OverrideOp {
    /// Replace the effective requirement set with this one.
    Replace(Vec<String>),
    /// Patch the default requirement set: add and/or remove names.
    Patch {
        add: Vec<String>,
        remove: Vec<String>,
    },
}

#[derive(Debug, Clone)]
pub struct Alias {
    pub canonical: String,
    /// Per-log claim-name overrides. `(log_name, alt_claim_name)`.
    pub mappings: Vec<(String, String)>,
}

// ──────────────────────────────────────────────────────────────────
// Wire (serde) types — kept separate so the public API stays clean
// ──────────────────────────────────────────────────────────────────

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WirePolicy {
    #[serde(default)]
    requirement: HashMap<String, WireRequirement>,
    #[serde(default, rename = "override")]
    overrides: Vec<WireOverride>,
    #[serde(default)]
    alias: HashMap<String, Vec<String>>,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum WireRequirement {
    /// Bare string: condition expression, default = true.
    Bare(String),
    /// Full table form.
    Full {
        condition: String,
        #[serde(default = "default_true")]
        default: bool,
    },
}

fn default_true() -> bool {
    true
}

#[derive(Deserialize)]
struct WireOverride {
    #[serde(default)]
    registry: Option<String>,
    #[serde(default)]
    prefix: Option<String>,
    #[serde(default)]
    package: Option<String>,
    #[serde(default)]
    version: Option<String>,
    #[serde(default)]
    hash: Option<String>,
    requirements: WireRequirementsOp,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum WireRequirementsOp {
    Replace(Vec<String>),
    Patch {
        #[serde(default)]
        add: Vec<String>,
        #[serde(default)]
        remove: Vec<String>,
    },
}

// ──────────────────────────────────────────────────────────────────
// Parsing + validation
// ──────────────────────────────────────────────────────────────────

pub fn parse_str(toml_str: &str) -> Result<Policy> {
    let wire: WirePolicy = toml::from_str(toml_str)?;
    build(wire)
}

/// Custom Deserialize: lets callers (e.g. the cli's `Config`)
/// `#[serde(flatten)]` a `Policy` into a larger TOML document
/// without having to mirror the wire shape themselves. Validation
/// runs as part of deserialization.
impl<'de> serde::Deserialize<'de> for Policy {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let wire = WirePolicy::deserialize(deserializer)?;
        build(wire).map_err(serde::de::Error::custom)
    }
}

pub fn parse(path: &std::path::Path) -> Result<Policy> {
    let s = std::fs::read_to_string(path)?;
    parse_str(&s)
}

fn build(wire: WirePolicy) -> Result<Policy> {
    // Requirements: parse each condition expression. Surface a
    // requirement-name with the parse error so users know which
    // entry is broken.
    let mut requirements: Vec<Requirement> = Vec::new();
    for (name, wr) in wire.requirement {
        let (condition, default) = match wr {
            WireRequirement::Bare(c) => (c, true),
            WireRequirement::Full { condition, default } => (condition, default),
        };
        let expr = expr::parse(&condition).map_err(|e| {
            PolicyError::Validation(format!("requirement {name:?}: {e}"))
        })?;
        requirements.push(Requirement {
            name,
            expr,
            default,
        });
    }
    // Sorted output makes Display/Debug deterministic — TOML tables
    // don't preserve insertion order.
    requirements.sort_by(|a, b| a.name.cmp(&b.name));

    let known: std::collections::HashSet<&str> =
        requirements.iter().map(|r| r.name.as_str()).collect();

    let mut overrides: Vec<Override> = Vec::with_capacity(wire.overrides.len());
    for o in wire.overrides {
        let op = match o.requirements {
            WireRequirementsOp::Replace(names) => {
                check_names_known(&names, &known)?;
                OverrideOp::Replace(names)
            }
            WireRequirementsOp::Patch { add, remove } => {
                check_names_known(&add, &known)?;
                check_names_known(&remove, &known)?;
                OverrideOp::Patch { add, remove }
            }
        };
        overrides.push(Override {
            matcher: SubjectMatcher {
                registry: o.registry,
                prefix: o.prefix,
                package: o.package,
                version: o.version,
                hash: o.hash,
            },
            op,
        });
    }

    let mut aliases: Vec<Alias> = Vec::with_capacity(wire.alias.len());
    for (canonical, mappings) in wire.alias {
        let mut parsed = Vec::with_capacity(mappings.len());
        for entry in mappings {
            let (log, name) = entry.split_once(':').ok_or_else(|| {
                PolicyError::Validation(format!(
                    "alias {canonical:?}: entry {entry:?} must be `log:claim-name`"
                ))
            })?;
            parsed.push((log.to_string(), name.to_string()));
        }
        aliases.push(Alias {
            canonical,
            mappings: parsed,
        });
    }
    aliases.sort_by(|a, b| a.canonical.cmp(&b.canonical));

    Ok(Policy {
        requirements,
        overrides,
        aliases,
    })
}

fn check_names_known(
    names: &[String],
    known: &std::collections::HashSet<&str>,
) -> Result<()> {
    for n in names {
        if !known.contains(n.as_str()) {
            return Err(PolicyError::Validation(format!(
                "override references unknown requirement {n:?}"
            )));
        }
    }
    Ok(())
}

impl Policy {
    pub fn requirement(&self, name: &str) -> Option<&Requirement> {
        self.requirements.iter().find(|r| r.name == name)
    }

    pub fn alias_for<'a>(&'a self, canonical: &str) -> Option<&'a Alias> {
        self.aliases.iter().find(|a| a.canonical == canonical)
    }
}

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

    #[test]
    fn empty_policy_parses() {
        let p = parse_str("").unwrap();
        assert!(p.requirements.is_empty());
        assert!(p.overrides.is_empty());
        assert!(p.aliases.is_empty());
    }

    #[test]
    fn bare_requirement_defaults_to_on() {
        let p = parse_str(r#"
            [requirement]
            safe-to-deploy = "safe-to-deploy"
        "#).unwrap();
        assert_eq!(p.requirements.len(), 1);
        assert_eq!(p.requirements[0].name, "safe-to-deploy");
        assert!(p.requirements[0].default);
    }

    #[test]
    fn full_form_can_disable_default() {
        let p = parse_str(r#"
            [requirement]
            sandbox = { condition = "sandboxed", default = false }
        "#).unwrap();
        assert!(!p.requirements[0].default);
    }

    #[test]
    fn full_form_default_true_is_kept() {
        let p = parse_str(r#"
            [requirement]
            x = { condition = "a" }
        "#).unwrap();
        assert!(p.requirements[0].default);
    }

    #[test]
    fn override_replace_form() {
        let p = parse_str(r#"
            [requirement]
            r1 = "a"
            r2 = "b"
            [[override]]
            package = "serde"
            requirements = ["r2"]
        "#).unwrap();
        assert_eq!(p.overrides.len(), 1);
        assert!(matches!(p.overrides[0].op, OverrideOp::Replace(ref v) if v == &["r2"]));
    }

    #[test]
    fn override_patch_form() {
        let p = parse_str(r#"
            [requirement]
            r1 = "a"
            r2 = "b"
            [[override]]
            package = "libc"
            requirements = { add = ["r2"], remove = ["r1"] }
        "#).unwrap();
        match &p.overrides[0].op {
            OverrideOp::Patch { add, remove } => {
                assert_eq!(add, &["r2"]);
                assert_eq!(remove, &["r1"]);
            }
            _ => panic!("expected Patch"),
        }
    }

    #[test]
    fn override_to_unknown_requirement_errors() {
        let err = parse_str(r#"
            [requirement]
            r1 = "a"
            [[override]]
            package = "x"
            requirements = ["nope"]
        "#).unwrap_err();
        assert!(matches!(err, PolicyError::Validation(_)));
    }

    #[test]
    fn aliases_parse() {
        let p = parse_str(r#"
            [alias]
            safe-to-run = ["google:safe-to-run", "mozilla:runtime-safe"]
        "#).unwrap();
        assert_eq!(p.aliases.len(), 1);
        assert_eq!(p.aliases[0].canonical, "safe-to-run");
        assert_eq!(p.aliases[0].mappings.len(), 2);
        assert_eq!(
            p.aliases[0].mappings[0],
            ("google".into(), "safe-to-run".into())
        );
    }

    #[test]
    fn alias_without_colon_errors() {
        let err = parse_str(r#"
            [alias]
            x = ["nocolonhere"]
        "#).unwrap_err();
        assert!(matches!(err, PolicyError::Validation(_)));
    }

    #[test]
    fn bad_expression_surfaces_requirement_name() {
        let err = parse_str(r#"
            [requirement]
            broken = "a and"
        "#).unwrap_err();
        if let PolicyError::Validation(msg) = err {
            assert!(msg.contains("broken"));
        } else {
            panic!("expected Validation");
        }
    }
}