1use super::types::*;
12use regex::Regex;
13use std::sync::LazyLock;
14
15static ACTION_TYPE_RE: LazyLock<Regex> = LazyLock::new(|| {
17 Regex::new(r"^[A-Z][A-Z0-9]{1,15}$").unwrap_or_else(|e| {
18 unreachable!("ACTION_TYPE_RE regex compilation failed: {}", e)
21 })
22});
23
24static VERSION_RE: LazyLock<Regex> = LazyLock::new(|| {
26 Regex::new(r"^\d+\.\d+(\.\d+)?$")
27 .unwrap_or_else(|e| unreachable!("VERSION_RE regex compilation failed: {}", e))
28});
29
30static KEY_PATTERN_RE: LazyLock<Regex> = LazyLock::new(|| {
32 Regex::new(r"\{[a-zA-Z_][a-zA-Z0-9_\.]*\}")
33 .unwrap_or_else(|e| unreachable!("KEY_PATTERN_RE regex compilation failed: {}", e))
34});
35
36static ID_TAG_RE: LazyLock<Regex> = LazyLock::new(|| {
38 Regex::new(r"^[a-z0-9-][a-z0-9.-]{3,60}[a-z0-9-]$")
39 .unwrap_or_else(|e| unreachable!("ID_TAG_RE regex compilation failed: {}", e))
40});
41
42static ACTION_ID_RE: LazyLock<Regex> = LazyLock::new(|| {
44 Regex::new(r"^[a-f0-9]{64}$")
45 .unwrap_or_else(|e| unreachable!("ACTION_ID_RE regex compilation failed: {}", e))
46});
47
48static FILE_ID_RE: LazyLock<Regex> = LazyLock::new(|| {
50 Regex::new(r"^f1~[a-zA-Z0-9_-]+$")
51 .unwrap_or_else(|e| unreachable!("FILE_ID_RE regex compilation failed: {}", e))
52});
53
54#[derive(Debug, Clone)]
56pub struct ValidationError {
57 pub message: String,
58 pub path: String,
59}
60
61impl ValidationError {
62 fn new(message: impl Into<String>, path: impl Into<String>) -> Self {
63 Self { message: message.into(), path: path.into() }
64 }
65}
66
67pub fn validate_definition(def: &ActionDefinition) -> Result<(), Vec<ValidationError>> {
69 let mut errors = Vec::new();
70
71 if let Err(e) = validate_action_type(&def.r#type) {
73 errors.push(ValidationError::new(e, "type"));
74 }
75
76 if let Err(e) = validate_version(&def.version) {
78 errors.push(ValidationError::new(e, "version"));
79 }
80
81 validate_field_constraints(&def.fields, &mut errors);
83
84 if let Some(schema_wrapper) = &def.schema {
86 if let Some(content_schema) = &schema_wrapper.content {
87 validate_content_schema(content_schema, &mut errors);
88 }
89 }
90
91 validate_hooks(&def.hooks, &mut errors);
93
94 if let Some(pattern) = &def.key_pattern {
96 validate_key_pattern(pattern, &mut errors);
97 }
98
99 if errors.is_empty() {
100 Ok(())
101 } else {
102 Err(errors)
103 }
104}
105
106fn validate_action_type(action_type: &str) -> Result<(), String> {
107 if !ACTION_TYPE_RE.is_match(action_type) {
109 return Err(format!(
110 "Invalid action type '{}': must be 2-16 uppercase letters/numbers, starting with letter",
111 action_type
112 ));
113 }
114 Ok(())
115}
116
117fn validate_version(version: &str) -> Result<(), String> {
118 if !VERSION_RE.is_match(version) {
120 return Err(format!(
121 "Invalid version '{}': must be semver format (e.g., '1.0' or '1.0.0')",
122 version
123 ));
124 }
125 Ok(())
126}
127
128fn validate_field_constraints(_fields: &FieldConstraints, _errors: &mut Vec<ValidationError>) {
129 }
132
133fn validate_content_schema(schema: &ContentSchema, errors: &mut Vec<ValidationError>) {
134 if let Some(min) = schema.min_length {
136 if let Some(max) = schema.max_length {
137 if min > max {
138 errors.push(ValidationError::new(
139 format!("min_length ({}) > max_length ({})", min, max),
140 "schema.content.min_length",
141 ));
142 }
143 }
144 }
145
146 if let Some(pattern) = &schema.pattern {
148 if let Err(e) = Regex::new(pattern) {
149 errors.push(ValidationError::new(
150 format!("Invalid regex pattern: {}", e),
151 "schema.content.pattern",
152 ));
153 }
154 }
155
156 if let Some(properties) = &schema.properties {
158 for (prop_name, prop_schema) in properties {
159 validate_schema_field(
160 prop_schema,
161 &format!("schema.content.properties.{}", prop_name),
162 errors,
163 );
164 }
165 }
166}
167
168fn validate_schema_field(field: &SchemaField, path: &str, errors: &mut Vec<ValidationError>) {
169 if let Some(min) = field.min_length {
171 if let Some(max) = field.max_length {
172 if min > max {
173 errors.push(ValidationError::new(
174 format!("min_length ({}) > max_length ({})", min, max),
175 format!("{}.min_length", path),
176 ));
177 }
178 }
179 }
180
181 if field.field_type == FieldType::Array && field.items.is_none() {
183 errors.push(ValidationError::new(
184 "Array type must have 'items' defined",
185 format!("{}.items", path),
186 ));
187 }
188}
189
190fn validate_hooks(hooks: &ActionHooks, errors: &mut Vec<ValidationError>) {
191 use crate::hooks::HookImplementation;
192
193 if let HookImplementation::Dsl(ops) = &hooks.on_create {
194 validate_operations(ops, "hooks.on_create", errors);
195 }
196 if let HookImplementation::Dsl(ops) = &hooks.on_receive {
197 validate_operations(ops, "hooks.on_receive", errors);
198 }
199 if let HookImplementation::Dsl(ops) = &hooks.on_accept {
200 validate_operations(ops, "hooks.on_accept", errors);
201 }
202 if let HookImplementation::Dsl(ops) = &hooks.on_reject {
203 validate_operations(ops, "hooks.on_reject", errors);
204 }
205}
206
207fn validate_operations(ops: &[Operation], path: &str, errors: &mut Vec<ValidationError>) {
208 if ops.len() > 100 {
210 errors.push(ValidationError::new(
211 format!("Too many operations ({}), maximum is 100", ops.len()),
212 path.to_string(),
213 ));
214 }
215
216 for (i, op) in ops.iter().enumerate() {
218 let op_path = format!("{}[{}]", path, i);
219 validate_operation(op, &op_path, errors, 0);
220 }
221}
222
223fn validate_operation(op: &Operation, path: &str, errors: &mut Vec<ValidationError>, depth: usize) {
224 if depth > 10 {
226 errors.push(ValidationError::new("Maximum nesting depth (10) exceeded", path.to_string()));
227 return;
228 }
229
230 match op {
231 Operation::If { then, r#else, .. } => {
233 validate_operations(then, &format!("{}.then", path), errors);
234 if let Some(else_ops) = r#else {
235 validate_operations(else_ops, &format!("{}.else", path), errors);
236 }
237 }
238 Operation::Switch { cases, default, .. } => {
239 for (case_name, case_ops) in cases {
240 validate_operations(case_ops, &format!("{}.cases.{}", path, case_name), errors);
241 }
242 if let Some(default_ops) = default {
243 validate_operations(default_ops, &format!("{}.default", path), errors);
244 }
245 }
246 Operation::Foreach { r#do, .. } => {
247 validate_operations(r#do, &format!("{}.do", path), errors);
248 }
249 _ => {
250 }
252 }
253}
254
255fn validate_key_pattern(pattern: &str, errors: &mut Vec<ValidationError>) {
256 if !KEY_PATTERN_RE.is_match(pattern) {
258 errors.push(ValidationError::new(
259 "Key pattern must contain at least one variable reference (e.g., {type}, {issuer})",
260 "key_pattern".to_string(),
261 ));
262 }
263}
264
265pub fn validate_id_tag(id_tag: &str) -> bool {
267 ID_TAG_RE.is_match(id_tag)
268}
269
270pub fn validate_action_id(action_id: &str) -> bool {
272 ACTION_ID_RE.is_match(action_id)
273}
274
275pub fn validate_file_id(file_id: &str) -> bool {
277 FILE_ID_RE.is_match(file_id)
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn test_validate_action_type() {
286 assert!(validate_action_type("CONN").is_ok());
287 assert!(validate_action_type("POST").is_ok());
288 assert!(validate_action_type("REACT").is_ok());
289
290 assert!(validate_action_type("conn").is_err()); assert!(validate_action_type("C").is_err()); assert!(validate_action_type("VERYLONGACTIONTYPE123").is_err()); assert!(validate_action_type("123").is_err()); }
295
296 #[test]
297 fn test_validate_version() {
298 assert!(validate_version("1.0").is_ok());
299 assert!(validate_version("1.0.0").is_ok());
300 assert!(validate_version("2.1").is_ok());
301 assert!(validate_version("10.5.3").is_ok());
302
303 assert!(validate_version("1").is_err());
304 assert!(validate_version("v1.0").is_err());
305 assert!(validate_version("1.0.0.0").is_err());
306 }
307
308 #[test]
309 fn test_validate_id_tag() {
310 assert!(validate_id_tag("alice"));
311 assert!(validate_id_tag("bob-123"));
312 assert!(validate_id_tag("user-name-123"));
313
314 assert!(!validate_id_tag("Al")); assert!(!validate_id_tag("Alice")); assert!(!validate_id_tag("alice_123")); }
318
319 #[test]
320 fn test_validate_action_id() {
321 assert!(validate_action_id(
322 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
323 ));
324 assert!(!validate_action_id("not-a-hash"));
325 assert!(!validate_action_id("123")); }
327
328 #[test]
329 fn test_validate_file_id() {
330 assert!(validate_file_id("f1~abc123"));
331 assert!(validate_file_id("f1~xyz_789-test"));
332 assert!(!validate_file_id("b1~xyz_789")); assert!(!validate_file_id("file id with spaces"));
334 }
335}
336
337