secunit_capture/
schema.rs1use 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
52pub 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 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 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}