Skip to main content

cloudillo_action/dsl/
validator.rs

1//! DSL definition validator
2//!
3//! Validates action definitions for:
4//! - Syntax correctness
5//! - Type validity
6//! - Field constraints
7//! - Schema correctness
8//! - Hook well-formedness
9//! - Resource limits
10
11use super::types::*;
12use regex::Regex;
13use std::sync::LazyLock;
14
15/// Regex for action type validation: 2-16 uppercase letters/numbers, starting with letter
16static ACTION_TYPE_RE: LazyLock<Regex> = LazyLock::new(|| {
17	Regex::new(r"^[A-Z][A-Z0-9]{1,15}$").unwrap_or_else(|e| {
18		// This regex is hardcoded and should never fail to compile
19		// If it does, it's a programming error that should be caught in development
20		unreachable!("ACTION_TYPE_RE regex compilation failed: {}", e)
21	})
22});
23
24/// Regex for semver version validation
25static 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
30/// Regex for key pattern variable references
31static 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
36/// Regex for idTag format
37static 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
42/// Regex for actionId format (SHA-256 hash)
43static 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
48/// Regex for fileId format
49static 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/// Validation error
55#[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
67/// Validate an action definition
68pub fn validate_definition(def: &ActionDefinition) -> Result<(), Vec<ValidationError>> {
69	let mut errors = Vec::new();
70
71	// Validate type
72	if let Err(e) = validate_action_type(&def.r#type) {
73		errors.push(ValidationError::new(e, "type"));
74	}
75
76	// Validate version
77	if let Err(e) = validate_version(&def.version) {
78		errors.push(ValidationError::new(e, "version"));
79	}
80
81	// Validate field constraints
82	validate_field_constraints(&def.fields, &mut errors);
83
84	// Validate content schema
85	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
92	validate_hooks(&def.hooks, &mut errors);
93
94	// Validate key pattern
95	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	// Must be 2-16 uppercase letters/numbers
108	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	// Must be semver format
119	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	// Field constraints are simple (required/forbidden/optional)
130	// No complex validation needed - the type system ensures correctness
131}
132
133fn validate_content_schema(schema: &ContentSchema, errors: &mut Vec<ValidationError>) {
134	// Validate string constraints
135	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	// Validate pattern if provided
147	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	// Validate object properties
157	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	// Validate constraints
170	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	// Validate array items
182	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	// Check operation count limit
209	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	// Validate each operation
217	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	// Check depth limit
225	if depth > 10 {
226		errors.push(ValidationError::new("Maximum nesting depth (10) exceeded", path.to_string()));
227		return;
228	}
229
230	match op {
231		// Control flow operations can nest
232		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			// Other operations don't nest
251		}
252	}
253}
254
255fn validate_key_pattern(pattern: &str, errors: &mut Vec<ValidationError>) {
256	// Key pattern should contain variable references like {type}, {issuer}, etc.
257	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
265/// Validate idTag format
266pub fn validate_id_tag(id_tag: &str) -> bool {
267	ID_TAG_RE.is_match(id_tag)
268}
269
270/// Validate actionId format (SHA-256 hash)
271pub fn validate_action_id(action_id: &str) -> bool {
272	ACTION_ID_RE.is_match(action_id)
273}
274
275/// Validate fileId format
276pub 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()); // lowercase
291		assert!(validate_action_type("C").is_err()); // too short
292		assert!(validate_action_type("VERYLONGACTIONTYPE123").is_err()); // too long
293		assert!(validate_action_type("123").is_err()); // starts with number
294	}
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")); // too short
315		assert!(!validate_id_tag("Alice")); // uppercase
316		assert!(!validate_id_tag("alice_123")); // underscore not allowed
317	}
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")); // too short
326	}
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")); // Wrong prefix
333		assert!(!validate_file_id("file id with spaces"));
334	}
335}
336
337// vim: ts=4