1use crate::config::{
7 ExpectationsConfig, OrderExpectationConfig, OtelConfig, ScenarioConfig, ServiceConfig,
8 SpanExpectationConfig, TestConfig, VolumeConfig, WindowExpectationConfig,
9};
10use crate::error::{CleanroomError, Result};
11use glob::Pattern as GlobBuilder;
12use regex::Regex;
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15
16#[derive(Debug, Clone)]
18pub struct ShapeValidationResult {
19 pub passed: bool,
21 pub errors: Vec<ShapeValidationError>,
23 pub file_path: String,
25}
26
27#[derive(Debug, Clone)]
29pub struct ShapeValidationError {
30 pub message: String,
32 pub line: Option<usize>,
34 pub category: ErrorCategory,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ErrorCategory {
41 MissingRequired,
43 InvalidStructure,
45 OrphanReference,
47 InvalidDuration,
49 CircularOrdering,
51 InvalidGlob,
53 OtelError,
55}
56
57impl ShapeValidationError {
58 pub fn new(category: ErrorCategory, message: impl Into<String>) -> Self {
60 Self {
61 message: message.into(),
62 line: None,
63 category,
64 }
65 }
66
67 pub fn with_line(mut self, line: usize) -> Self {
69 self.line = Some(line);
70 self
71 }
72}
73
74pub struct ShapeValidator {
76 errors: Vec<ShapeValidationError>,
78}
79
80impl ShapeValidator {
81 pub fn new() -> Self {
83 Self { errors: Vec::new() }
84 }
85
86 pub fn validate_file(&mut self, path: &Path) -> Result<ShapeValidationResult> {
92 let content = std::fs::read_to_string(path).map_err(|e| {
94 CleanroomError::config_error(format!("Failed to read config file: {}", e))
95 })?;
96
97 let toml_content = if crate::is_template(&content) {
99 let mut renderer = crate::TemplateRenderer::new()?;
101 let path_str = path
102 .to_str()
103 .ok_or_else(|| CleanroomError::validation_error("Invalid file path encoding"))?;
104 renderer.render_str(&content, path_str)?
105 } else {
106 content
107 };
108
109 let config = toml::from_str::<TestConfig>(&toml_content)
111 .map_err(|e| CleanroomError::config_error(format!("TOML parse error: {}", e)))?;
112
113 self.validate_config(&config)?;
115
116 let result = ShapeValidationResult {
118 passed: self.errors.is_empty(),
119 errors: self.errors.clone(),
120 file_path: path.to_string_lossy().into_owned(),
121 };
122
123 Ok(result)
124 }
125
126 pub fn validate_config(&mut self, config: &TestConfig) -> Result<()> {
132 self.errors.clear();
134
135 self.validate_required_blocks(config);
137
138 self.validate_otel_config(config);
140
141 self.validate_scenarios(config);
143
144 self.validate_service_references(config);
146
147 self.validate_duration_constraints(config);
149
150 self.validate_temporal_ordering(config);
152
153 self.validate_glob_patterns(config);
155
156 self.validate_container_images(config);
158
159 self.validate_port_bindings(config);
161
162 self.validate_volume_mounts(config);
164
165 self.validate_environment_variables(config);
167
168 self.validate_service_dependencies(config);
170
171 Ok(())
172 }
173
174 fn validate_required_blocks(&mut self, config: &TestConfig) {
176 if config.meta.is_none() && config.test.is_none() {
178 self.errors.push(ShapeValidationError::new(
179 ErrorCategory::MissingRequired,
180 "Configuration must have either [meta] or [test.metadata] section",
181 ));
182 }
183
184 if let Some(ref meta) = config.meta {
186 if meta.name.trim().is_empty() {
187 self.errors.push(ShapeValidationError::new(
188 ErrorCategory::InvalidStructure,
189 "[meta] section missing required 'name' field",
190 ));
191 }
192 if meta.version.trim().is_empty() {
193 self.errors.push(ShapeValidationError::new(
194 ErrorCategory::InvalidStructure,
195 "[meta] section missing required 'version' field",
196 ));
197 }
198 }
199
200 if config.scenario.is_empty() && config.steps.is_empty() {
202 self.errors.push(ShapeValidationError::new(
203 ErrorCategory::MissingRequired,
204 "Configuration must have at least one [[scenario]] or [[steps]]",
205 ));
206 }
207 }
208
209 fn validate_otel_config(&mut self, config: &TestConfig) {
211 if let Some(ref otel) = config.otel {
212 self.validate_otel_exporter(otel);
213 }
214 }
215
216 fn validate_otel_exporter(&mut self, otel: &OtelConfig) {
218 let valid_exporters = [
219 "jaeger",
220 "otlp",
221 "otlp-http",
222 "otlp-grpc",
223 "datadog",
224 "newrelic",
225 ];
226
227 if !valid_exporters.contains(&otel.exporter.as_str()) {
228 self.errors.push(ShapeValidationError::new(
229 ErrorCategory::OtelError,
230 format!(
231 "Invalid OTEL exporter '{}'. Valid options: {}",
232 otel.exporter,
233 valid_exporters.join(", ")
234 ),
235 ));
236 }
237
238 if let Some(ratio) = otel.sample_ratio {
240 if !(0.0..=1.0).contains(&ratio) {
241 self.errors.push(ShapeValidationError::new(
242 ErrorCategory::OtelError,
243 format!(
244 "OTEL sample_ratio must be between 0.0 and 1.0, got {}",
245 ratio
246 ),
247 ));
248 }
249 }
250 }
251
252 fn validate_scenarios(&mut self, config: &TestConfig) {
254 for (idx, scenario) in config.scenario.iter().enumerate() {
255 if scenario.name.trim().is_empty() {
256 self.errors.push(ShapeValidationError::new(
257 ErrorCategory::InvalidStructure,
258 format!("Scenario {} missing required 'name' field", idx),
259 ));
260 }
261
262 if scenario.steps.is_empty() {
263 self.errors.push(ShapeValidationError::new(
264 ErrorCategory::InvalidStructure,
265 format!("Scenario '{}' must have at least one step", scenario.name),
266 ));
267 }
268 }
269 }
270
271 fn validate_service_references(&mut self, config: &TestConfig) {
273 let mut defined_services = HashSet::new();
275
276 if let Some(ref services) = config.services {
277 for service_name in services.keys() {
278 defined_services.insert(service_name.clone());
279 }
280 }
281
282 if let Some(ref service) = config.service {
283 for service_name in service.keys() {
284 defined_services.insert(service_name.clone());
285 }
286 }
287
288 for scenario in &config.scenario {
290 self.validate_scenario_service_refs(scenario, &defined_services);
291 }
292
293 for step in &config.steps {
295 if let Some(ref service_name) = step.service {
296 if !defined_services.contains(service_name) {
297 self.errors.push(ShapeValidationError::new(
298 ErrorCategory::OrphanReference,
299 format!(
300 "Step '{}' references undefined service '{}'",
301 step.name, service_name
302 ),
303 ));
304 }
305 }
306 }
307 }
308
309 fn validate_scenario_service_refs(
311 &mut self,
312 scenario: &ScenarioConfig,
313 defined_services: &HashSet<String>,
314 ) {
315 for step in &scenario.steps {
316 if let Some(ref service_name) = step.service {
317 if !defined_services.contains(service_name) {
318 self.errors.push(ShapeValidationError::new(
319 ErrorCategory::OrphanReference,
320 format!(
321 "Scenario '{}' step '{}' references undefined service '{}'",
322 scenario.name, step.name, service_name
323 ),
324 ));
325 }
326 }
327 }
328 }
329
330 fn validate_duration_constraints(&mut self, config: &TestConfig) {
332 if let Some(ref expect) = config.expect {
334 for span in &expect.span {
335 self.validate_span_duration(span);
336 }
337
338 for window in &expect.window {
340 self.validate_window_duration(window);
341 }
342 }
343
344 if let Some(ref otel_val) = config.otel_validation {
346 if let Some(ref spans) = otel_val.expected_spans {
347 for span in spans {
348 if let (Some(min), Some(max)) = (span.min_duration_ms, span.max_duration_ms) {
349 if min > max {
350 self.errors.push(ShapeValidationError::new(
351 ErrorCategory::InvalidDuration,
352 format!(
353 "Span '{}' has invalid duration: min ({}) > max ({})",
354 span.name, min, max
355 ),
356 ));
357 }
358 }
359 }
360 }
361 }
362 }
363
364 fn validate_span_duration(&mut self, _span: &SpanExpectationConfig) {
366 }
369
370 fn validate_window_duration(&mut self, _window: &WindowExpectationConfig) {
372 }
375
376 fn validate_temporal_ordering(&mut self, config: &TestConfig) {
378 if let Some(ref expect) = config.expect {
379 if let Some(ref order) = expect.order {
380 self.check_ordering_cycles(order);
381 }
382 }
383
384 if let Some(ref otel_val) = config.otel_validation {
386 if let Some(ref order) = otel_val.expect_order {
387 self.check_ordering_cycles(order);
388 }
389 }
390 }
391
392 fn check_ordering_cycles(&mut self, order: &OrderExpectationConfig) {
394 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
396
397 if let Some(ref must_precede) = order.must_precede {
399 for edge in must_precede {
400 if edge.len() == 2 {
401 let first = &edge[0];
402 let second = &edge[1];
403 graph.entry(first.clone()).or_default().push(second.clone());
404 }
405 }
406 }
407
408 if let Some(ref must_follow) = order.must_follow {
410 for edge in must_follow {
411 if edge.len() == 2 {
412 let first = &edge[0];
413 let second = &edge[1];
414 graph.entry(second.clone()).or_default().push(first.clone());
415 }
416 }
417 }
418
419 let mut visited = HashSet::new();
421 let mut rec_stack = HashSet::new();
422
423 for node in graph.keys() {
424 if !visited.contains(node)
425 && Self::has_cycle_dfs(node, &graph, &mut visited, &mut rec_stack)
426 {
427 self.errors.push(ShapeValidationError::new(
428 ErrorCategory::CircularOrdering,
429 format!(
430 "Circular temporal ordering detected involving span '{}'",
431 node
432 ),
433 ));
434 break;
435 }
436 }
437 }
438
439 fn has_cycle_dfs(
441 node: &str,
442 graph: &HashMap<String, Vec<String>>,
443 visited: &mut HashSet<String>,
444 rec_stack: &mut HashSet<String>,
445 ) -> bool {
446 visited.insert(node.to_string());
447 rec_stack.insert(node.to_string());
448
449 if let Some(neighbors) = graph.get(node) {
450 for neighbor in neighbors {
451 if !visited.contains(neighbor) {
452 if Self::has_cycle_dfs(neighbor, graph, visited, rec_stack) {
453 return true;
454 }
455 } else if rec_stack.contains(neighbor) {
456 return true;
457 }
458 }
459 }
460
461 rec_stack.remove(node);
462 false
463 }
464
465 fn validate_glob_patterns(&mut self, config: &TestConfig) {
467 if let Some(ref expect) = config.expect {
469 self.validate_expectation_globs(expect);
470 }
471
472 if let Some(ref otel_val) = config.otel_validation {
474 if let Some(ref spans) = otel_val.expected_spans {
475 for span in spans {
476 if let Err(e) = self.validate_glob_pattern(&span.name) {
477 self.errors.push(ShapeValidationError::new(
478 ErrorCategory::InvalidGlob,
479 format!("Invalid glob pattern in span '{}': {}", span.name, e),
480 ));
481 }
482 }
483 }
484 }
485 }
486
487 fn validate_expectation_globs(&mut self, expect: &ExpectationsConfig) {
489 for span in &expect.span {
490 if let Err(e) = self.validate_glob_pattern(&span.name) {
491 self.errors.push(ShapeValidationError::new(
492 ErrorCategory::InvalidGlob,
493 format!(
494 "Invalid glob pattern in span expectation '{}': {}",
495 span.name, e
496 ),
497 ));
498 }
499 }
500 }
501
502 fn validate_glob_pattern(&self, pattern: &str) -> Result<()> {
504 GlobBuilder::new(pattern).map_err(|e| {
505 CleanroomError::validation_error(format!("Invalid pattern '{}': {}", pattern, e))
506 })?;
507 Ok(())
508 }
509
510 pub fn errors(&self) -> &[ShapeValidationError] {
512 &self.errors
513 }
514
515 pub fn is_valid(&self) -> bool {
517 self.errors.is_empty()
518 }
519
520 fn validate_container_images(&mut self, config: &TestConfig) {
526 let services = self.collect_all_services(config);
527
528 for (service_name, service) in services {
529 if service.plugin == "network_service" || service.plugin == "ollama" {
531 continue;
532 }
533
534 if let Some(ref image) = service.image {
536 if image.trim().is_empty() {
537 self.errors.push(ShapeValidationError::new(
538 ErrorCategory::InvalidStructure,
539 format!(
540 "Service '{}' has empty image. Suggestion: Use a valid image like 'alpine:latest' or 'ubuntu:20.04'",
541 service_name
542 ),
543 ));
544 continue;
545 }
546
547 self.validate_image_format(&service_name, image);
549 }
550 }
551 }
552
553 fn validate_image_format(&mut self, service_name: &str, image: &str) {
555 if image.contains(' ') {
565 self.errors.push(ShapeValidationError::new(
566 ErrorCategory::InvalidStructure,
567 format!(
568 "Service '{}' has invalid image format '{}'. Images cannot contain spaces. Example: 'alpine:latest'",
569 service_name, image
570 ),
571 ));
572 return;
573 }
574
575 if image.contains('!') || image.contains('?') || image.contains('*') {
577 self.errors.push(ShapeValidationError::new(
578 ErrorCategory::InvalidStructure,
579 format!(
580 "Service '{}' has invalid image format '{}'. Invalid characters detected. Example: 'alpine:latest'",
581 service_name, image
582 ),
583 ));
584 return;
585 }
586
587 let parts: Vec<&str> = image.split('/').collect();
589 if parts.len() > 3 {
590 self.errors.push(ShapeValidationError::new(
591 ErrorCategory::InvalidStructure,
592 format!(
593 "Service '{}' has invalid image format '{}'. Too many path segments. Example: 'registry/namespace/repo:tag'",
594 service_name, image
595 ),
596 ));
597 }
598 }
599
600 fn validate_port_bindings(&mut self, config: &TestConfig) {
602 let mut port_usage: HashMap<u16, Vec<String>> = HashMap::new();
603 let services = self.collect_all_services(config);
604
605 for (service_name, service) in services {
606 if let Some(ref ports) = service.ports {
607 for &port in ports {
608 if port < 1024 {
610 self.errors.push(ShapeValidationError::new(
611 ErrorCategory::InvalidStructure,
612 format!(
613 "Service '{}' uses reserved port {}. Suggestion: Use ports >= 1024 (e.g., 8080, 9000, 3000)",
614 service_name, port
615 ),
616 ));
617 }
618
619 port_usage
621 .entry(port)
622 .or_default()
623 .push(service_name.clone());
624 }
625 }
626 }
627
628 for (port, services) in port_usage {
630 if services.len() > 1 {
631 self.errors.push(ShapeValidationError::new(
632 ErrorCategory::InvalidStructure,
633 format!(
634 "Port conflict detected: port {} is used by multiple services: {}. Each service must use unique ports.",
635 port,
636 services.join(", ")
637 ),
638 ));
639 }
640 }
641 }
642
643 fn validate_volume_mounts(&mut self, config: &TestConfig) {
645 let services = self.collect_all_services(config);
646
647 for (service_name, service) in services {
648 if let Some(ref volumes) = service.volumes {
649 for (idx, volume) in volumes.iter().enumerate() {
650 self.validate_single_volume(&service_name, idx, volume);
651 }
652 }
653 }
654 }
655
656 fn validate_single_volume(&mut self, service_name: &str, idx: usize, volume: &VolumeConfig) {
658 if !volume.host_path.starts_with('/') {
660 self.errors.push(ShapeValidationError::new(
661 ErrorCategory::InvalidStructure,
662 format!(
663 "Service '{}' volume {}: host path '{}' must be absolute. Suggestion: Use '/tmp/data' or '/home/user/project'",
664 service_name, idx, volume.host_path
665 ),
666 ));
667 }
668
669 if !volume.container_path.starts_with('/') {
671 self.errors.push(ShapeValidationError::new(
672 ErrorCategory::InvalidStructure,
673 format!(
674 "Service '{}' volume {}: container path '{}' must be absolute. Suggestion: Use '/app/data' or '/var/lib/app'",
675 service_name, idx, volume.container_path
676 ),
677 ));
678 }
679
680 let dangerous_paths = [
682 "/etc",
683 "/var",
684 "/proc",
685 "/sys",
686 "/dev",
687 "/boot",
688 "/root",
689 "/bin",
690 "/sbin",
691 "/lib",
692 "/lib64",
693 "/usr/bin",
694 "/usr/sbin",
695 ];
696
697 for dangerous in &dangerous_paths {
698 if volume.container_path.starts_with(dangerous)
699 && (volume.container_path == *dangerous
700 || volume
701 .container_path
702 .starts_with(&format!("{}/", dangerous)))
703 {
704 self.errors.push(ShapeValidationError::new(
705 ErrorCategory::InvalidStructure,
706 format!(
707 "Service '{}' volume {}: mounting to system path '{}' is dangerous. Suggestion: Use application paths like '/app/data'",
708 service_name, idx, volume.container_path
709 ),
710 ));
711 }
712 }
713 }
714
715 fn validate_environment_variables(&mut self, config: &TestConfig) {
717 let services = self.collect_all_services(config);
718
719 for (service_name, service) in services {
720 if let Some(ref env) = service.env {
721 for (key, value) in env {
722 self.validate_env_var(&service_name, key, value);
723 }
724 }
725 }
726
727 for step in &config.steps {
729 if let Some(ref env) = step.env {
730 for (key, value) in env {
731 self.validate_env_var(&format!("step '{}'", step.name), key, value);
732 }
733 }
734 }
735
736 for scenario in &config.scenario {
737 for step in &scenario.steps {
738 if let Some(ref env) = step.env {
739 for (key, value) in env {
740 self.validate_env_var(
741 &format!("scenario '{}' step '{}'", scenario.name, step.name),
742 key,
743 value,
744 );
745 }
746 }
747 }
748 }
749 }
750
751 fn validate_env_var(&mut self, context: &str, key: &str, value: &str) {
753 if key.is_empty() {
755 self.errors.push(ShapeValidationError::new(
756 ErrorCategory::InvalidStructure,
757 format!(
758 "{}: environment variable name cannot be empty. Use uppercase names like 'APP_ENV' or 'DATABASE_URL'",
759 context
760 ),
761 ));
762 return;
763 }
764
765 match Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$") {
768 Ok(env_var_regex) => {
769 if !env_var_regex.is_match(key) {
770 self.errors.push(ShapeValidationError::new(
771 ErrorCategory::InvalidStructure,
772 format!(
773 "{}: invalid environment variable name '{}'. Names must start with a letter or underscore and contain only alphanumeric characters and underscores. Example: 'DATABASE_URL'",
774 context, key
775 ),
776 ));
777 }
778 }
779 Err(e) => {
780 self.errors.push(ShapeValidationError::new(
782 ErrorCategory::InvalidStructure,
783 format!("{}: internal error compiling regex: {}", context, e),
784 ));
785 }
786 }
787
788 let sensitive_keys = [
790 "API_KEY",
791 "PASSWORD",
792 "SECRET",
793 "TOKEN",
794 "PRIVATE_KEY",
795 "CREDENTIALS",
796 "AUTH_TOKEN",
797 "ACCESS_KEY",
798 "SECRET_KEY",
799 ];
800
801 for sensitive in &sensitive_keys {
802 if key.to_uppercase().contains(sensitive)
803 && !value.is_empty()
804 && !value.starts_with('$')
805 {
806 self.errors.push(ShapeValidationError::new(
807 ErrorCategory::InvalidStructure,
808 format!(
809 "{}: potential hardcoded sensitive value in '{}'. Suggestion: Use environment variable references like '${{ENV_VAR}}' or template variables",
810 context, key
811 ),
812 ));
813 break;
814 }
815 }
816 }
817
818 fn validate_service_dependencies(&mut self, config: &TestConfig) {
820 let services = self.collect_all_services(config);
821
822 let mut dep_graph: HashMap<String, Vec<String>> = HashMap::new();
824
825 for (service_name, service) in &services {
826 if let Some(ref health_check) = service.health_check {
827 let deps = self.extract_service_deps_from_command(&health_check.cmd, &services);
829 if !deps.is_empty() {
830 dep_graph.insert(service_name.clone(), deps);
831 }
832 }
833 }
834
835 let mut visited = HashSet::new();
837 let mut rec_stack = HashSet::new();
838
839 for service_name in dep_graph.keys() {
840 if !visited.contains(service_name)
841 && Self::has_circular_dep(service_name, &dep_graph, &mut visited, &mut rec_stack)
842 {
843 self.errors.push(ShapeValidationError::new(
844 ErrorCategory::CircularOrdering,
845 format!(
846 "Circular service dependency detected involving '{}'. Services cannot depend on each other in a cycle.",
847 service_name
848 ),
849 ));
850 break;
851 }
852 }
853 }
854
855 fn extract_service_deps_from_command(
857 &self,
858 command: &[String],
859 services: &HashMap<String, &ServiceConfig>,
860 ) -> Vec<String> {
861 let mut deps = Vec::new();
862 let cmd_str = command.join(" ");
863
864 for service_name in services.keys() {
865 if cmd_str.contains(service_name) {
866 deps.push(service_name.clone());
867 }
868 }
869
870 deps
871 }
872
873 fn has_circular_dep(
875 service: &str,
876 graph: &HashMap<String, Vec<String>>,
877 visited: &mut HashSet<String>,
878 rec_stack: &mut HashSet<String>,
879 ) -> bool {
880 visited.insert(service.to_string());
881 rec_stack.insert(service.to_string());
882
883 if let Some(deps) = graph.get(service) {
884 for dep in deps {
885 if !visited.contains(dep) {
886 if Self::has_circular_dep(dep, graph, visited, rec_stack) {
887 return true;
888 }
889 } else if rec_stack.contains(dep) {
890 return true;
891 }
892 }
893 }
894
895 rec_stack.remove(service);
896 false
897 }
898
899 fn collect_all_services<'a>(
901 &self,
902 config: &'a TestConfig,
903 ) -> HashMap<String, &'a ServiceConfig> {
904 let mut all_services = HashMap::new();
905
906 if let Some(ref services) = config.services {
907 for (name, service) in services {
908 all_services.insert(name.clone(), service);
909 }
910 }
911
912 if let Some(ref service) = config.service {
913 for (name, svc) in service {
914 all_services.insert(name.clone(), svc);
915 }
916 }
917
918 all_services
919 }
920}
921
922impl Default for ShapeValidator {
923 fn default() -> Self {
924 Self::new()
925 }
926}