Skip to main content

greentic_runner_host/validate/
mod.rs

1use std::env;
2
3use once_cell::sync::Lazy;
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6
7use jsonschema::{Draft, Validator};
8
9#[derive(Copy, Clone, Debug, PartialEq, Eq)]
10pub enum ValidationMode {
11    Off,
12    Warn,
13    Error,
14}
15
16#[derive(Clone, Debug)]
17pub struct ValidationConfig {
18    pub mode: ValidationMode,
19}
20
21impl ValidationConfig {
22    pub fn from_env() -> Self {
23        if let Some(mode) = env::var("GREENTIC_VALIDATION").ok().and_then(parse_mode) {
24            return Self { mode };
25        }
26        let ci = env::var("CI")
27            .ok()
28            .map(|value| value == "true" || value == "1")
29            .unwrap_or(false);
30        Self {
31            mode: if ci {
32                ValidationMode::Warn
33            } else {
34                ValidationMode::Off
35            },
36        }
37    }
38
39    pub fn with_mode(mut self, mode: ValidationMode) -> Self {
40        self.mode = mode;
41        self
42    }
43}
44
45#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct ValidationIssue {
47    pub code: String,
48    pub path: String,
49    pub message: String,
50}
51
52pub fn validate_component_envelope(envelope: &Value) -> Vec<ValidationIssue> {
53    validate_with_required(envelope, &["component_id", "operation"])
54}
55
56pub fn validate_tool_envelope(envelope: &Value) -> Vec<ValidationIssue> {
57    validate_with_required(envelope, &["tool_id", "operation"])
58}
59
60fn validate_with_required(envelope: &Value, required: &[&str]) -> Vec<ValidationIssue> {
61    let mut issues = Vec::new();
62    for key in required {
63        match envelope.get(*key) {
64            None => issues.push(ValidationIssue {
65                code: "missing_required_field".to_string(),
66                path: format!("/{key}"),
67                message: format!("missing required field `{key}`"),
68            }),
69            Some(Value::String(value)) if !value.trim().is_empty() => {}
70            Some(_) => issues.push(ValidationIssue {
71                code: "invalid_type".to_string(),
72                path: format!("/{key}"),
73                message: format!("`{key}` must be a non-empty string"),
74            }),
75        }
76    }
77
78    issues.extend(validate_schema(envelope));
79    issues.extend(validate_metadata(envelope));
80    issues
81}
82
83fn validate_schema(envelope: &Value) -> Vec<ValidationIssue> {
84    INVOCATION_SCHEMA
85        .iter_errors(envelope)
86        .map(|err| ValidationIssue {
87            code: "schema_validation".to_string(),
88            path: err.instance_path().to_string(),
89            message: err.to_string(),
90        })
91        .collect()
92}
93
94fn validate_metadata(envelope: &Value) -> Vec<ValidationIssue> {
95    let mut issues = Vec::new();
96    let Some(metadata) = envelope.get("metadata") else {
97        return issues;
98    };
99    let Some(map) = metadata.as_object() else {
100        issues.push(ValidationIssue {
101            code: "invalid_type".to_string(),
102            path: "/metadata".to_string(),
103            message: "metadata must be an object".to_string(),
104        });
105        return issues;
106    };
107
108    if let Some(value) = map.get("tenant_id")
109        && !value.is_string()
110    {
111        issues.push(ValidationIssue {
112            code: "invalid_type".to_string(),
113            path: "/metadata/tenant_id".to_string(),
114            message: "tenant_id must be a string".to_string(),
115        });
116    }
117
118    if let Some(value) = map.get("trace_id")
119        && !value.is_string()
120    {
121        issues.push(ValidationIssue {
122            code: "invalid_type".to_string(),
123            path: "/metadata/trace_id".to_string(),
124            message: "trace_id must be a string".to_string(),
125        });
126    }
127
128    if let Some(session) = map.get("session") {
129        match session.as_object() {
130            Some(session_map) => {
131                if let Some(id) = session_map.get("id")
132                    && !id.is_string()
133                {
134                    issues.push(ValidationIssue {
135                        code: "invalid_type".to_string(),
136                        path: "/metadata/session/id".to_string(),
137                        message: "session.id must be a string".to_string(),
138                    });
139                }
140            }
141            None => {
142                issues.push(ValidationIssue {
143                    code: "invalid_type".to_string(),
144                    path: "/metadata/session".to_string(),
145                    message: "session must be an object".to_string(),
146                });
147            }
148        }
149    }
150
151    issues
152}
153
154fn parse_mode(raw: String) -> Option<ValidationMode> {
155    match raw.to_lowercase().as_str() {
156        "off" => Some(ValidationMode::Off),
157        "warn" => Some(ValidationMode::Warn),
158        "error" => Some(ValidationMode::Error),
159        _ => None,
160    }
161}
162
163static INVOCATION_SCHEMA: Lazy<Validator> = Lazy::new(|| {
164    let schema = json!({
165        "$schema": "http://json-schema.org/draft-07/schema#",
166        "type": "object",
167        "properties": {
168            "component_id": { "type": "string" },
169            "tool_id": { "type": "string" },
170            "operation": { "type": "string" },
171            "input": {},
172            "config": {},
173            "metadata": { "type": "object" }
174        },
175        "additionalProperties": true
176    });
177    jsonschema::options()
178        .with_draft(Draft::Draft7)
179        .build(&schema)
180        .expect("invocation envelope schema compile")
181});
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn validate_component_requires_fields() {
189        let envelope = json!({
190            "component_id": "demo.component",
191            "operation": "run",
192            "metadata": { "tenant_id": "acme" }
193        });
194        let issues = validate_component_envelope(&envelope);
195        assert!(issues.is_empty());
196    }
197
198    #[test]
199    fn validate_reports_missing_fields() {
200        let envelope = json!({ "operation": 123 });
201        let issues = validate_component_envelope(&envelope);
202        assert!(!issues.is_empty());
203        assert!(
204            issues
205                .iter()
206                .any(|issue| issue.code == "missing_required_field")
207        );
208    }
209}