Skip to main content

secunit_capture/
schema.rs

1//! Embedded JSON schemas for every capturer envelope. Schemas live in
2//! `crates/secunit-capture/schemas/capture-*.schema.json` and are baked
3//! into the binary via `include_str!`.
4//!
5//! Capturers route output through [`validate`] before writing so the
6//! schema is the single source of truth for what a valid envelope
7//! looks like — failing it is a hard error (exit code 1, "validation
8//! failure"), not a warning.
9
10use std::sync::OnceLock;
11
12use anyhow::{anyhow, Result};
13use jsonschema::{Draft, JSONSchema};
14use serde_json::Value;
15
16use crate::canonical::Envelope;
17
18macro_rules! compiled {
19    ($name:literal) => {{
20        static CELL: OnceLock<JSONSchema> = OnceLock::new();
21        CELL.get_or_init(|| {
22            let raw = include_str!(concat!("../schemas/", $name));
23            let json: Value = serde_json::from_str(raw)
24                .unwrap_or_else(|e| panic!("schema {}: invalid JSON: {e}", $name));
25            JSONSchema::options()
26                .with_draft(Draft::Draft202012)
27                .compile(&json)
28                .unwrap_or_else(|e| panic!("schema {}: compile failed: {e}", $name))
29        })
30    }};
31}
32
33fn schema_for(capturer: &str) -> Option<&'static JSONSchema> {
34    Some(match capturer {
35        "deps.pip-audit" => compiled!("capture-deps-pip-audit.schema.json"),
36        "deps.pnpm-audit" => compiled!("capture-deps-pnpm-audit.schema.json"),
37        "deps.cargo-audit" => compiled!("capture-deps-cargo-audit.schema.json"),
38        "deps.osv-query" => compiled!("capture-deps-osv-query.schema.json"),
39        "github.dependabot-alerts" => {
40            compiled!("capture-github-dependabot-alerts.schema.json")
41        }
42        "github.branch-protection" => {
43            compiled!("capture-github-branch-protection.schema.json")
44        }
45        "github.org-members" => compiled!("capture-github-org-members.schema.json"),
46        "github.audit-log" => compiled!("capture-github-audit-log.schema.json"),
47        "github.codeql-alerts" => compiled!("capture-github-codeql-alerts.schema.json"),
48        _ => return None,
49    })
50}
51
52/// Validate `envelope` against its registered per-capturer schema.
53///
54/// Returns the list of jsonschema diagnostics on mismatch — empty
55/// when valid. Returns an error if no schema is registered for the
56/// envelope's `capturer` field (program error, not a data error).
57pub fn validate(envelope: &Envelope) -> Result<Vec<String>> {
58    let value = serde_json::to_value(envelope)?;
59    let schema = schema_for(&envelope.capturer)
60        .ok_or_else(|| anyhow!("no schema registered for capturer `{}`", envelope.capturer))?;
61    let errs = match schema.validate(&value) {
62        Ok(()) => Vec::new(),
63        Err(it) => it.map(|e| format!("{}: {}", e.instance_path, e)).collect(),
64    };
65    Ok(errs)
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use serde_json::json;
72
73    #[test]
74    fn pip_audit_schema_accepts_minimal_envelope() {
75        let env = Envelope::new(
76            "deps.pip-audit",
77            "1",
78            json!({ "path": "/x" }),
79            json!({ "dependencies": [] }),
80        );
81        assert!(validate(&env).unwrap().is_empty());
82    }
83
84    #[test]
85    fn pip_audit_schema_rejects_wrong_capturer_const() {
86        // Force-construct an envelope with a mismatched capturer.
87        let mut env = Envelope::new(
88            "deps.pip-audit",
89            "1",
90            json!({ "path": "/x" }),
91            json!({ "dependencies": [] }),
92        );
93        env.capturer = "deps.pip-audit".into();
94        // This is the happy case; flip to verify failure detection.
95        env.captured_at = "not-a-timestamp".into();
96        let errs = validate(&env).unwrap();
97        assert!(!errs.is_empty(), "expected captured_at pattern to fail");
98    }
99
100    #[test]
101    fn dependabot_alerts_schema_accepts_minimal_envelope() {
102        let env = Envelope::new(
103            "github.dependabot-alerts",
104            "1",
105            json!({ "repo": "o/r", "state": "all" }),
106            json!({ "alerts": [{"number": 1, "state": "open"}] }),
107        );
108        assert!(validate(&env).unwrap().is_empty());
109    }
110
111    #[test]
112    fn unknown_capturer_returns_err() {
113        let env = Envelope::new("made.up", "1", json!({}), json!({}));
114        assert!(validate(&env).is_err());
115    }
116}