greentic_runner_host/validate/
mod.rs1use 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}