1use std::collections::{HashMap, HashSet};
13use std::net::SocketAddr;
14use tracing::{debug, trace, warn};
15
16use crate::{Config, Filter, NamespaceConfig, ServiceConfig, ServiceType, WafMode};
17use grapsus_common::ids::Scope;
18use grapsus_common::types::{Priority, TlsVersion};
19
20pub fn validate_socket_addr(addr: &str) -> Result<(), validator::ValidationError> {
26 addr.parse::<SocketAddr>()
27 .map(|_| ())
28 .map_err(|_| {
29 let mut err = validator::ValidationError::new("invalid_socket_address");
30 err.message = Some(std::borrow::Cow::Owned(format!(
31 "Invalid socket address '{}'. Expected format: IP:PORT (e.g., '127.0.0.1:8080' or '0.0.0.0:443')",
32 addr
33 )));
34 err
35 })
36}
37
38#[derive(Debug, Default)]
47pub struct ValidationContext {
48 pub all_ids: HashSet<String>,
50
51 upstreams_by_scope: HashMap<Scope, HashSet<String>>,
53
54 agents_by_scope: HashMap<Scope, HashSet<String>>,
56
57 filters_by_scope: HashMap<Scope, HashSet<String>>,
59
60 routes_by_scope: HashMap<Scope, HashSet<String>>,
62
63 exported_upstreams: HashSet<String>,
65
66 exported_agents: HashSet<String>,
68
69 exported_filters: HashSet<String>,
71}
72
73impl ValidationContext {
74 pub fn from_config(config: &Config) -> Self {
76 let mut ctx = Self::default();
77
78 ctx.register_global_resources(config);
80
81 for ns in &config.namespaces {
83 ctx.register_namespace_resources(ns);
84 }
85
86 ctx
87 }
88
89 fn register_global_resources(&mut self, config: &Config) {
90 let scope = Scope::Global;
91
92 for id in config.upstreams.keys() {
94 self.upstreams_by_scope
95 .entry(scope.clone())
96 .or_default()
97 .insert(id.clone());
98 self.all_ids.insert(id.clone());
99 }
100
101 for agent in &config.agents {
103 self.agents_by_scope
104 .entry(scope.clone())
105 .or_default()
106 .insert(agent.id.clone());
107 self.all_ids.insert(agent.id.clone());
108 }
109
110 for id in config.filters.keys() {
112 self.filters_by_scope
113 .entry(scope.clone())
114 .or_default()
115 .insert(id.clone());
116 self.all_ids.insert(id.clone());
117 }
118
119 for route in &config.routes {
121 self.routes_by_scope
122 .entry(scope.clone())
123 .or_default()
124 .insert(route.id.clone());
125 self.all_ids.insert(route.id.clone());
126 }
127 }
128
129 fn register_namespace_resources(&mut self, ns: &NamespaceConfig) {
130 let scope = Scope::Namespace(ns.id.clone());
131
132 for id in ns.upstreams.keys() {
134 self.upstreams_by_scope
135 .entry(scope.clone())
136 .or_default()
137 .insert(id.clone());
138 self.all_ids.insert(format!("{}:{}", ns.id, id));
139
140 if ns.exports.upstreams.contains(id) {
142 self.exported_upstreams.insert(id.clone());
143 }
144 }
145
146 for agent in &ns.agents {
148 self.agents_by_scope
149 .entry(scope.clone())
150 .or_default()
151 .insert(agent.id.clone());
152 self.all_ids.insert(format!("{}:{}", ns.id, agent.id));
153
154 if ns.exports.agents.contains(&agent.id) {
156 self.exported_agents.insert(agent.id.clone());
157 }
158 }
159
160 for id in ns.filters.keys() {
162 self.filters_by_scope
163 .entry(scope.clone())
164 .or_default()
165 .insert(id.clone());
166 self.all_ids.insert(format!("{}:{}", ns.id, id));
167
168 if ns.exports.filters.contains(id) {
170 self.exported_filters.insert(id.clone());
171 }
172 }
173
174 for route in &ns.routes {
176 self.routes_by_scope
177 .entry(scope.clone())
178 .or_default()
179 .insert(route.id.clone());
180 self.all_ids.insert(format!("{}:{}", ns.id, route.id));
181 }
182
183 for svc in &ns.services {
185 self.register_service_resources(&ns.id, svc);
186 }
187 }
188
189 fn register_service_resources(&mut self, ns_id: &str, svc: &ServiceConfig) {
190 let scope = Scope::Service {
191 namespace: ns_id.to_string(),
192 service: svc.id.clone(),
193 };
194
195 for id in svc.upstreams.keys() {
197 self.upstreams_by_scope
198 .entry(scope.clone())
199 .or_default()
200 .insert(id.clone());
201 self.all_ids.insert(format!("{}:{}:{}", ns_id, svc.id, id));
202 }
203
204 for agent in &svc.agents {
206 self.agents_by_scope
207 .entry(scope.clone())
208 .or_default()
209 .insert(agent.id.clone());
210 self.all_ids
211 .insert(format!("{}:{}:{}", ns_id, svc.id, agent.id));
212 }
213
214 for id in svc.filters.keys() {
216 self.filters_by_scope
217 .entry(scope.clone())
218 .or_default()
219 .insert(id.clone());
220 self.all_ids.insert(format!("{}:{}:{}", ns_id, svc.id, id));
221 }
222
223 for route in &svc.routes {
225 self.routes_by_scope
226 .entry(scope.clone())
227 .or_default()
228 .insert(route.id.clone());
229 self.all_ids
230 .insert(format!("{}:{}:{}", ns_id, svc.id, route.id));
231 }
232 }
233
234 pub fn can_resolve_upstream(&self, reference: &str, from_scope: &Scope) -> bool {
238 if reference.contains(':') {
240 return self.all_ids.contains(reference);
242 }
243
244 for scope in from_scope.chain() {
246 if let Some(upstreams) = self.upstreams_by_scope.get(&scope) {
247 if upstreams.contains(reference) {
248 return true;
249 }
250 }
251 }
252
253 if self.exported_upstreams.contains(reference) {
255 return true;
256 }
257
258 false
259 }
260
261 pub fn can_resolve_agent(&self, reference: &str, from_scope: &Scope) -> bool {
263 if reference.contains(':') {
264 return self.all_ids.contains(reference);
265 }
266
267 for scope in from_scope.chain() {
268 if let Some(agents) = self.agents_by_scope.get(&scope) {
269 if agents.contains(reference) {
270 return true;
271 }
272 }
273 }
274
275 if self.exported_agents.contains(reference) {
276 return true;
277 }
278
279 false
280 }
281
282 pub fn can_resolve_filter(&self, reference: &str, from_scope: &Scope) -> bool {
284 if reference.contains(':') {
285 return self.all_ids.contains(reference);
286 }
287
288 for scope in from_scope.chain() {
289 if let Some(filters) = self.filters_by_scope.get(&scope) {
290 if filters.contains(reference) {
291 return true;
292 }
293 }
294 }
295
296 if self.exported_filters.contains(reference) {
297 return true;
298 }
299
300 false
301 }
302
303 pub fn available_upstreams(&self, from_scope: &Scope) -> HashSet<String> {
305 let mut available = HashSet::new();
306
307 for scope in from_scope.chain() {
308 if let Some(upstreams) = self.upstreams_by_scope.get(&scope) {
309 available.extend(upstreams.iter().cloned());
310 }
311 }
312
313 available.extend(self.exported_upstreams.iter().cloned());
314 available
315 }
316
317 pub fn available_agents(&self, from_scope: &Scope) -> HashSet<String> {
319 let mut available = HashSet::new();
320
321 for scope in from_scope.chain() {
322 if let Some(agents) = self.agents_by_scope.get(&scope) {
323 available.extend(agents.iter().cloned());
324 }
325 }
326
327 available.extend(self.exported_agents.iter().cloned());
328 available
329 }
330
331 pub fn available_filters(&self, from_scope: &Scope) -> HashSet<String> {
333 let mut available = HashSet::new();
334
335 for scope in from_scope.chain() {
336 if let Some(filters) = self.filters_by_scope.get(&scope) {
337 available.extend(filters.iter().cloned());
338 }
339 }
340
341 available.extend(self.exported_filters.iter().cloned());
342 available
343 }
344}
345
346pub fn validate_config_semantics(config: &Config) -> Result<(), validator::ValidationError> {
352 trace!(
353 routes = config.routes.len(),
354 upstreams = config.upstreams.len(),
355 agents = config.agents.len(),
356 filters = config.filters.len(),
357 listeners = config.listeners.len(),
358 "Starting semantic validation"
359 );
360
361 let mut errors: Vec<String> = Vec::new();
362 let mut warnings: Vec<String> = Vec::new();
363
364 let route_ids: HashSet<_> = config.routes.iter().map(|r| r.id.as_str()).collect();
366 let upstream_ids: HashSet<_> = config.upstreams.keys().map(|s| s.as_str()).collect();
367 let agent_ids: HashSet<_> = config.agents.iter().map(|a| a.id.as_str()).collect();
368 let filter_ids: HashSet<_> = config.filters.keys().map(|s| s.as_str()).collect();
369
370 trace!(
371 route_count = route_ids.len(),
372 upstream_count = upstream_ids.len(),
373 agent_count = agent_ids.len(),
374 filter_count = filter_ids.len(),
375 "Collected IDs for cross-reference validation"
376 );
377
378 trace!("Validating routes");
380 validate_routes(config, &route_ids, &upstream_ids, &filter_ids, &mut errors);
381
382 trace!("Validating listeners");
384 validate_listeners(config, &route_ids, &mut errors);
385
386 trace!("Validating filters");
388 validate_filters(config, &agent_ids, &mut errors);
389
390 trace!("Validating upstreams");
392 validate_upstreams(config, &mut errors);
393
394 trace!("Checking for duplicates");
396 validate_duplicates(config, &mut errors);
397
398 trace!("Validating namespaces");
400 let ctx = ValidationContext::from_config(config);
401 validate_namespaces(config, &ctx, &mut errors);
402
403 warn_orphaned_upstreams(config, &upstream_ids);
405
406 trace!("Validating implementation status");
408 validate_implementation_status(config, &mut errors, &mut warnings);
409
410 for warning in &warnings {
412 warn!("Unwired feature: {}", warning);
413 }
414
415 if errors.is_empty() {
417 debug!("Semantic validation passed");
418 } else {
419 debug!(
420 error_count = errors.len(),
421 "Semantic validation found errors"
422 );
423 }
424
425 build_validation_result(errors)
426}
427
428fn validate_routes(
429 config: &Config,
430 _route_ids: &HashSet<&str>,
431 upstream_ids: &HashSet<&str>,
432 filter_ids: &HashSet<&str>,
433 errors: &mut Vec<String>,
434) {
435 trace!(
436 route_count = config.routes.len(),
437 "Validating route configurations"
438 );
439
440 let routes_needing_upstreams: Vec<_> = config
442 .routes
443 .iter()
444 .filter(|r| r.service_type != ServiceType::Static && r.upstream.is_some())
445 .collect();
446
447 let routes_missing_upstream_config: Vec<_> = config
448 .routes
449 .iter()
450 .filter(|r| {
451 r.service_type != ServiceType::Static
452 && r.service_type != ServiceType::Builtin
453 && r.upstream.is_none()
454 && r.static_files.is_none()
455 })
456 .collect();
457
458 trace!(
459 routes_with_upstreams = routes_needing_upstreams.len(),
460 routes_missing_config = routes_missing_upstream_config.len(),
461 "Categorized routes for validation"
462 );
463
464 for route in &routes_needing_upstreams {
466 if let Some(ref upstream_id) = route.upstream {
467 if !upstream_ids.contains(upstream_id.as_str()) {
468 errors.push(format!(
469 "Route '{}' references upstream '{}' which doesn't exist.\n\
470 Available upstreams: {}\n\
471 Hint: Add an upstream block or fix the reference.",
472 route.id,
473 upstream_id,
474 format_available(upstream_ids)
475 ));
476 }
477 }
478 }
479
480 for route in &routes_missing_upstream_config {
482 errors.push(format!(
483 "Route '{}' has no upstream and no static-files configuration.\n\
484 Each route must either:\n\
485 1. Reference an upstream: upstream \"my-backend\"\n\
486 2. Serve static files: static-files {{ root \"/var/www/html\" }}",
487 route.id
488 ));
489 }
490
491 for route in &config.routes {
493 for filter_id in &route.filters {
494 if !filter_ids.contains(filter_id.as_str()) {
495 errors.push(format!(
496 "Route '{}' references filter '{}' which doesn't exist.\n\
497 Available filters: {}",
498 route.id,
499 filter_id,
500 format_available(filter_ids)
501 ));
502 }
503 }
504 }
505
506 for route in &config.routes {
508 if route.matches.is_empty() && route.priority != Priority::Low {
509 errors.push(format!(
510 "Route '{}' has no match conditions.\n\
511 Add at least one match condition or set priority to \"low\" for catch-all routes.",
512 route.id
513 ));
514 }
515 }
516
517 for route in &config.routes {
519 if let Some(ref static_config) = route.static_files {
520 if !static_config.root.exists() {
521 errors.push(format!(
522 "Route '{}' static files root directory '{}' does not exist.",
523 route.id,
524 static_config.root.display()
525 ));
526 } else if !static_config.root.is_dir() {
527 errors.push(format!(
528 "Route '{}' static files root '{}' exists but is not a directory.",
529 route.id,
530 static_config.root.display()
531 ));
532 }
533
534 if route.upstream.is_some() {
535 errors.push(format!(
536 "Route '{}' has both 'upstream' and 'static-files' configured.\n\
537 A route can only serve one type of content.",
538 route.id
539 ));
540 }
541 }
542 }
543}
544
545fn validate_listeners(config: &Config, route_ids: &HashSet<&str>, errors: &mut Vec<String>) {
546 trace!(
547 listener_count = config.listeners.len(),
548 "Validating listener configurations"
549 );
550
551 for listener in &config.listeners {
552 trace!(listener_id = %listener.id, "Validating listener");
553 if let Some(ref default_route) = listener.default_route {
554 if !route_ids.contains(default_route.as_str()) {
555 warn!(
556 listener_id = %listener.id,
557 default_route = %default_route,
558 "Listener references non-existent default route"
559 );
560 errors.push(format!(
561 "Listener '{}' references default-route '{}' which doesn't exist.\n\
562 Available routes: {}",
563 listener.id,
564 default_route,
565 format_available(route_ids)
566 ));
567 }
568 }
569 }
570}
571
572fn validate_filters(config: &Config, agent_ids: &HashSet<&str>, errors: &mut Vec<String>) {
573 trace!(
574 filter_count = config.filters.len(),
575 "Validating filter configurations"
576 );
577
578 for (filter_id, filter_config) in &config.filters {
579 trace!(filter_id = %filter_id, "Validating filter");
580 if let Filter::Agent(agent_filter) = &filter_config.filter {
581 if !agent_ids.contains(agent_filter.agent.as_str()) {
582 warn!(
583 filter_id = %filter_id,
584 agent_id = %agent_filter.agent,
585 "Filter references non-existent agent"
586 );
587 errors.push(format!(
588 "Filter '{}' references agent '{}' which doesn't exist.\n\
589 Available agents: {}",
590 filter_id,
591 agent_filter.agent,
592 format_available(agent_ids)
593 ));
594 }
595 }
596 }
597}
598
599fn validate_upstreams(config: &Config, errors: &mut Vec<String>) {
600 trace!(
601 upstream_count = config.upstreams.len(),
602 "Validating upstream configurations"
603 );
604
605 for (upstream_id, upstream) in &config.upstreams {
606 trace!(
607 upstream_id = %upstream_id,
608 target_count = upstream.targets.len(),
609 "Validating upstream"
610 );
611
612 if upstream.targets.is_empty() {
613 warn!(upstream_id = %upstream_id, "Upstream has no targets");
614 errors.push(format!(
615 "Upstream '{}' has no targets defined.\n\
616 Each upstream must have at least one target.",
617 upstream_id
618 ));
619 }
620
621 for (i, target) in upstream.targets.iter().enumerate() {
622 if target.address.parse::<SocketAddr>().is_err() {
623 let parts: Vec<&str> = target.address.rsplitn(2, ':').collect();
624 if parts.len() != 2 || parts[0].parse::<u16>().is_err() {
625 warn!(
626 upstream_id = %upstream_id,
627 target_index = i,
628 address = %target.address,
629 "Invalid target address format"
630 );
631 errors.push(format!(
632 "Upstream '{}' target #{} has invalid address '{}'.\n\
633 Expected format: HOST:PORT",
634 upstream_id,
635 i + 1,
636 target.address
637 ));
638 }
639 }
640 }
641 }
642}
643
644fn validate_duplicates(config: &Config, errors: &mut Vec<String>) {
645 trace!("Checking for duplicate identifiers");
646
647 let mut seen_routes = HashSet::new();
649 for route in &config.routes {
650 if !seen_routes.insert(&route.id) {
651 warn!(route_id = %route.id, "Duplicate route ID found");
652 errors.push(format!(
653 "Duplicate route ID '{}'. Each route must have a unique identifier.",
654 route.id
655 ));
656 }
657 }
658
659 let mut seen_listeners = HashSet::new();
661 for listener in &config.listeners {
662 if !seen_listeners.insert(&listener.id) {
663 warn!(listener_id = %listener.id, "Duplicate listener ID found");
664 errors.push(format!(
665 "Duplicate listener ID '{}'. Each listener must have a unique identifier.",
666 listener.id
667 ));
668 }
669 }
670
671 let mut seen_addresses = HashSet::new();
673 for listener in &config.listeners {
674 if !seen_addresses.insert(&listener.address) {
675 warn!(address = %listener.address, "Duplicate listener address found");
676 errors.push(format!(
677 "Duplicate listener address '{}'. Multiple listeners cannot bind to the same address.",
678 listener.address
679 ));
680 }
681 }
682
683 trace!(
684 unique_routes = seen_routes.len(),
685 unique_listeners = seen_listeners.len(),
686 unique_addresses = seen_addresses.len(),
687 "Duplicate check complete"
688 );
689}
690
691fn warn_orphaned_upstreams(config: &Config, upstream_ids: &HashSet<&str>) {
692 let referenced_upstreams: HashSet<_> = config
693 .routes
694 .iter()
695 .filter_map(|r| r.upstream.as_ref())
696 .map(|s| s.as_str())
697 .collect();
698
699 for upstream_id in upstream_ids {
700 if !referenced_upstreams.contains(*upstream_id) {
701 tracing::warn!(
702 upstream_id = %upstream_id,
703 "Upstream '{}' is defined but not referenced by any route.",
704 upstream_id
705 );
706 }
707 }
708}
709
710fn format_available(ids: &HashSet<&str>) -> String {
711 if ids.is_empty() {
712 "(none defined)".to_string()
713 } else {
714 ids.iter()
715 .map(|s| format!("'{}'", s))
716 .collect::<Vec<_>>()
717 .join(", ")
718 }
719}
720
721fn validate_namespaces(config: &Config, ctx: &ValidationContext, errors: &mut Vec<String>) {
726 trace!(
727 namespace_count = config.namespaces.len(),
728 "Validating namespace configurations"
729 );
730
731 let mut seen_ns_ids = HashSet::new();
733 for ns in &config.namespaces {
734 if !seen_ns_ids.insert(&ns.id) {
735 errors.push(format!(
736 "Duplicate namespace ID '{}'. Each namespace must have a unique identifier.",
737 ns.id
738 ));
739 }
740
741 if ns.id.contains(':') {
743 errors.push(format!(
744 "Namespace ID '{}' contains reserved character ':'. \
745 The colon is reserved for qualified references.",
746 ns.id
747 ));
748 }
749
750 validate_namespace_resources(ns, ctx, errors);
751 }
752}
753
754fn validate_namespace_resources(
755 ns: &NamespaceConfig,
756 ctx: &ValidationContext,
757 errors: &mut Vec<String>,
758) {
759 let scope = Scope::Namespace(ns.id.clone());
760
761 for route in &ns.routes {
763 if let Some(ref upstream) = route.upstream {
764 if !ctx.can_resolve_upstream(upstream, &scope) {
765 let available = ctx.available_upstreams(&scope);
766 errors.push(format!(
767 "Route '{}' in namespace '{}' references upstream '{}' which cannot be resolved.\n\
768 Available upstreams: {}",
769 route.id,
770 ns.id,
771 upstream,
772 format_available_owned(&available)
773 ));
774 }
775 }
776
777 for filter_id in &route.filters {
778 if !ctx.can_resolve_filter(filter_id, &scope) {
779 let available = ctx.available_filters(&scope);
780 errors.push(format!(
781 "Route '{}' in namespace '{}' references filter '{}' which cannot be resolved.\n\
782 Available filters: {}",
783 route.id,
784 ns.id,
785 filter_id,
786 format_available_owned(&available)
787 ));
788 }
789 }
790 }
791
792 for export_name in &ns.exports.upstreams {
794 if !ns.upstreams.contains_key(export_name) {
795 errors.push(format!(
796 "Namespace '{}' exports upstream '{}' which doesn't exist in this namespace.",
797 ns.id, export_name
798 ));
799 }
800 }
801
802 for export_name in &ns.exports.agents {
803 if !ns.agents.iter().any(|a| &a.id == export_name) {
804 errors.push(format!(
805 "Namespace '{}' exports agent '{}' which doesn't exist in this namespace.",
806 ns.id, export_name
807 ));
808 }
809 }
810
811 for export_name in &ns.exports.filters {
812 if !ns.filters.contains_key(export_name) {
813 errors.push(format!(
814 "Namespace '{}' exports filter '{}' which doesn't exist in this namespace.",
815 ns.id, export_name
816 ));
817 }
818 }
819
820 let mut seen_svc_ids = HashSet::new();
822 for svc in &ns.services {
823 if !seen_svc_ids.insert(&svc.id) {
824 errors.push(format!(
825 "Duplicate service ID '{}' in namespace '{}'. Each service must have a unique identifier.",
826 svc.id, ns.id
827 ));
828 }
829
830 if svc.id.contains(':') {
831 errors.push(format!(
832 "Service ID '{}' in namespace '{}' contains reserved character ':'.",
833 svc.id, ns.id
834 ));
835 }
836
837 validate_service_resources(&ns.id, svc, ctx, errors);
838 }
839}
840
841fn validate_service_resources(
842 ns_id: &str,
843 svc: &ServiceConfig,
844 ctx: &ValidationContext,
845 errors: &mut Vec<String>,
846) {
847 let scope = Scope::Service {
848 namespace: ns_id.to_string(),
849 service: svc.id.clone(),
850 };
851
852 for route in &svc.routes {
854 if let Some(ref upstream) = route.upstream {
855 if !ctx.can_resolve_upstream(upstream, &scope) {
856 let available = ctx.available_upstreams(&scope);
857 errors.push(format!(
858 "Route '{}' in service '{}:{}' references upstream '{}' which cannot be resolved.\n\
859 Available upstreams: {}",
860 route.id,
861 ns_id,
862 svc.id,
863 upstream,
864 format_available_owned(&available)
865 ));
866 }
867 }
868
869 for filter_id in &route.filters {
870 if !ctx.can_resolve_filter(filter_id, &scope) {
871 let available = ctx.available_filters(&scope);
872 errors.push(format!(
873 "Route '{}' in service '{}:{}' references filter '{}' which cannot be resolved.\n\
874 Available filters: {}",
875 route.id,
876 ns_id,
877 svc.id,
878 filter_id,
879 format_available_owned(&available)
880 ));
881 }
882 }
883 }
884}
885
886fn format_available_owned(ids: &HashSet<String>) -> String {
887 if ids.is_empty() {
888 "(none defined)".to_string()
889 } else {
890 let mut sorted: Vec<_> = ids.iter().collect();
891 sorted.sort();
892 sorted
893 .iter()
894 .map(|s| format!("'{}'", s))
895 .collect::<Vec<_>>()
896 .join(", ")
897 }
898}
899
900fn validate_implementation_status(
914 config: &Config,
915 errors: &mut Vec<String>,
916 warnings: &mut Vec<String>,
917) {
918 if let Some(ref waf) = config.waf {
924 if waf.mode != WafMode::Off {
925 let mode_str = match waf.mode {
926 WafMode::Detection => "detection",
927 WafMode::Prevention => "prevention",
928 WafMode::Off => unreachable!(),
929 };
930 errors.push(format!(
931 "WAF mode is set to '{}' but the WAF engine is not yet wired into the proxy runtime. \
932 Requests are NOT being inspected. \
933 Set waf mode to 'off' or remove the waf block until WAF support is implemented.",
934 mode_str
935 ));
936 }
937 }
938
939 for listener in &config.listeners {
943 if let Some(ref tls) = listener.tls {
944 if !tls.cipher_suites.is_empty() {
945 warnings.push(format!(
946 "Listener '{}' has TLS cipher_suites configured ({:?}) but Pingora's TLS integration \
947 does not yet support custom cipher suites. The default rustls cipher suite selection is used.",
948 listener.id, tls.cipher_suites
949 ));
950 }
951
952 if tls.min_version != TlsVersion::Tls12 {
953 warnings.push(format!(
954 "Listener '{}' has TLS min_version set to '{}' but Pingora's TLS integration \
955 does not yet apply this setting. Default TLS 1.2+ is used.",
956 listener.id, tls.min_version
957 ));
958 }
959
960 if let Some(ref max_ver) = tls.max_version {
961 warnings.push(format!(
962 "Listener '{}' has TLS max_version set to '{}' but Pingora's TLS integration \
963 does not yet apply this setting.",
964 listener.id, max_ver
965 ));
966 }
967 }
968 }
969
970 for listener in &config.listeners {
976 if listener.max_concurrent_streams != crate::server::default_max_concurrent_streams() {
977 warnings.push(format!(
978 "Listener '{}' has max_concurrent_streams={} but Pingora's H2 settings \
979 do not yet support per-listener configuration.",
980 listener.id, listener.max_concurrent_streams
981 ));
982 }
983 }
984
985 let metrics = &config.observability.metrics;
987 if metrics.address != "0.0.0.0:9090" {
988 warnings.push(format!(
989 "Metrics endpoint address='{}' is configured but a dedicated metrics HTTP server \
990 is not yet implemented. Use RUST_LOG and external scraping as a workaround.",
991 metrics.address
992 ));
993 }
994
995 let logging = &config.observability.logging;
998 if logging.file.is_some() {
999 warnings.push(
1000 "logging.file is configured but application log file routing is not yet implemented. \
1001 Logs go to stdout/stderr. Use shell redirection as a workaround."
1002 .to_string(),
1003 );
1004 }
1005 if logging.level != "info" || logging.format != "json" {
1006 warnings.push(format!(
1007 "Logging level='{}' and format='{}' are configured but the tracing subscriber uses \
1008 RUST_LOG env var and --verbose flag instead. Set RUST_LOG={} as a workaround.",
1009 logging.level, logging.format, logging.level
1010 ));
1011 }
1012}
1013
1014fn build_validation_result(errors: Vec<String>) -> Result<(), validator::ValidationError> {
1019 if errors.is_empty() {
1020 Ok(())
1021 } else {
1022 let mut err = validator::ValidationError::new("config_validation_failed");
1023 let error_summary = if errors.len() == 1 {
1024 errors[0].clone()
1025 } else {
1026 format!(
1027 "Configuration has {} issues:\n\n{}",
1028 errors.len(),
1029 errors
1030 .iter()
1031 .enumerate()
1032 .map(|(i, e)| format!("{}. {}", i + 1, e))
1033 .collect::<Vec<_>>()
1034 .join("\n\n")
1035 )
1036 };
1037 err.message = Some(std::borrow::Cow::Owned(error_summary));
1038 Err(err)
1039 }
1040}
1041
1042#[cfg(test)]
1047mod tests {
1048 use super::*;
1049 use crate::namespace::{ExportConfig, NamespaceConfig, ServiceConfig};
1050 use crate::{
1051 ConnectionPoolConfig, HttpVersionConfig, MatchCondition, RouteConfig, RoutePolicies,
1052 UpstreamConfig, UpstreamTarget, UpstreamTimeouts,
1053 };
1054 use grapsus_common::types::LoadBalancingAlgorithm;
1055
1056 fn test_upstream(id: &str) -> UpstreamConfig {
1057 UpstreamConfig {
1058 id: id.to_string(),
1059 targets: vec![UpstreamTarget {
1060 address: "127.0.0.1:8080".to_string(),
1061 weight: 1,
1062 max_requests: None,
1063 metadata: HashMap::new(),
1064 }],
1065 load_balancing: LoadBalancingAlgorithm::RoundRobin,
1066 sticky_session: None,
1067 health_check: None,
1068 connection_pool: ConnectionPoolConfig::default(),
1069 timeouts: UpstreamTimeouts::default(),
1070 tls: None,
1071 http_version: HttpVersionConfig::default(),
1072 }
1073 }
1074
1075 fn test_route(id: &str, upstream: Option<&str>) -> RouteConfig {
1076 RouteConfig {
1077 id: id.to_string(),
1078 priority: Priority::Normal,
1079 matches: vec![MatchCondition::PathPrefix("/".to_string())],
1080 upstream: upstream.map(String::from),
1081 service_type: ServiceType::Web,
1082 policies: RoutePolicies::default(),
1083 filters: vec![],
1084 builtin_handler: None,
1085 waf_enabled: false,
1086 circuit_breaker: None,
1087 retry_policy: None,
1088 static_files: None,
1089 api_schema: None,
1090 error_pages: None,
1091 websocket: false,
1092 websocket_inspection: false,
1093 inference: None,
1094 shadow: None,
1095 fallback: None,
1096 }
1097 }
1098
1099 #[test]
1100 fn test_validation_context_from_config() {
1101 let mut config = Config::default_for_testing();
1102
1103 let mut ns = NamespaceConfig::new("api");
1105 ns.upstreams
1106 .insert("ns-backend".to_string(), test_upstream("ns-backend"));
1107 ns.routes.push(test_route("ns-route", Some("ns-backend")));
1108 config.namespaces.push(ns);
1109
1110 let ctx = ValidationContext::from_config(&config);
1111
1112 assert!(ctx.can_resolve_upstream("default", &Scope::Global));
1114
1115 let ns_scope = Scope::Namespace("api".to_string());
1117 assert!(ctx.can_resolve_upstream("ns-backend", &ns_scope));
1118
1119 assert!(ctx.can_resolve_upstream("default", &ns_scope));
1121
1122 assert!(!ctx.can_resolve_upstream("ns-backend", &Scope::Global));
1124 }
1125
1126 #[test]
1127 fn test_validation_context_exports() {
1128 let mut config = Config::default_for_testing();
1129
1130 let mut ns = NamespaceConfig::new("shared");
1132 ns.upstreams.insert(
1133 "shared-backend".to_string(),
1134 test_upstream("shared-backend"),
1135 );
1136 ns.exports = ExportConfig {
1137 upstreams: vec!["shared-backend".to_string()],
1138 agents: vec![],
1139 filters: vec![],
1140 };
1141 config.namespaces.push(ns);
1142
1143 let ctx = ValidationContext::from_config(&config);
1144
1145 assert!(ctx.can_resolve_upstream("shared-backend", &Scope::Global));
1147
1148 let other_ns = Scope::Namespace("other".to_string());
1150 assert!(ctx.can_resolve_upstream("shared-backend", &other_ns));
1151 }
1152
1153 #[test]
1154 fn test_validation_context_service_scope() {
1155 let mut config = Config::default_for_testing();
1156
1157 let mut ns = NamespaceConfig::new("api");
1159 ns.upstreams
1160 .insert("ns-backend".to_string(), test_upstream("ns-backend"));
1161
1162 let mut svc = ServiceConfig::new("payments");
1163 svc.upstreams
1164 .insert("svc-backend".to_string(), test_upstream("svc-backend"));
1165 ns.services.push(svc);
1166
1167 config.namespaces.push(ns);
1168
1169 let ctx = ValidationContext::from_config(&config);
1170
1171 let svc_scope = Scope::Service {
1172 namespace: "api".to_string(),
1173 service: "payments".to_string(),
1174 };
1175
1176 assert!(ctx.can_resolve_upstream("svc-backend", &svc_scope));
1178
1179 assert!(ctx.can_resolve_upstream("ns-backend", &svc_scope));
1181
1182 assert!(ctx.can_resolve_upstream("default", &svc_scope));
1184
1185 let ns_scope = Scope::Namespace("api".to_string());
1187 assert!(!ctx.can_resolve_upstream("svc-backend", &ns_scope));
1188 }
1189
1190 #[test]
1191 fn test_validation_context_qualified_references() {
1192 let mut config = Config::default_for_testing();
1193
1194 let mut ns = NamespaceConfig::new("api");
1195 ns.upstreams
1196 .insert("backend".to_string(), test_upstream("backend"));
1197 config.namespaces.push(ns);
1198
1199 let ctx = ValidationContext::from_config(&config);
1200
1201 assert!(ctx.can_resolve_upstream("api:backend", &Scope::Global));
1203
1204 assert!(!ctx.can_resolve_upstream("other:backend", &Scope::Global));
1206 }
1207
1208 #[test]
1209 fn test_available_upstreams() {
1210 let mut config = Config::default_for_testing();
1211
1212 let mut ns = NamespaceConfig::new("api");
1213 ns.upstreams
1214 .insert("ns-backend".to_string(), test_upstream("ns-backend"));
1215 ns.exports = ExportConfig {
1216 upstreams: vec!["ns-backend".to_string()],
1217 agents: vec![],
1218 filters: vec![],
1219 };
1220 config.namespaces.push(ns);
1221
1222 let ctx = ValidationContext::from_config(&config);
1223
1224 let global_available = ctx.available_upstreams(&Scope::Global);
1226 assert!(global_available.contains("default"));
1227 assert!(global_available.contains("ns-backend"));
1228
1229 let ns_scope = Scope::Namespace("api".to_string());
1231 let ns_available = ctx.available_upstreams(&ns_scope);
1232 assert!(ns_available.contains("default"));
1233 assert!(ns_available.contains("ns-backend"));
1234 }
1235
1236 #[test]
1241 fn waf_prevention_mode_produces_hard_error() {
1242 let mut config = Config::default_for_testing();
1243 config.waf = Some(crate::WafConfig {
1244 engine: crate::WafEngine::Coraza,
1245 ruleset: crate::WafRuleset {
1246 crs_version: "4.0".to_string(),
1247 custom_rules_dir: None,
1248 paranoia_level: 1,
1249 anomaly_threshold: 5,
1250 exclusions: vec![],
1251 },
1252 mode: WafMode::Prevention,
1253 audit_log: true,
1254 body_inspection: crate::BodyInspectionPolicy::default(),
1255 });
1256
1257 let mut errors = Vec::new();
1258 let mut warnings = Vec::new();
1259 validate_implementation_status(&config, &mut errors, &mut warnings);
1260
1261 assert!(
1262 !errors.is_empty(),
1263 "WAF prevention mode should produce a hard error"
1264 );
1265 assert!(
1266 errors[0].contains("WAF mode is set to 'prevention'"),
1267 "Error should mention prevention mode, got: {}",
1268 errors[0]
1269 );
1270 }
1271
1272 #[test]
1273 fn waf_detection_mode_produces_hard_error() {
1274 let mut config = Config::default_for_testing();
1275 config.waf = Some(crate::WafConfig {
1276 engine: crate::WafEngine::Coraza,
1277 ruleset: crate::WafRuleset {
1278 crs_version: "4.0".to_string(),
1279 custom_rules_dir: None,
1280 paranoia_level: 1,
1281 anomaly_threshold: 5,
1282 exclusions: vec![],
1283 },
1284 mode: WafMode::Detection,
1285 audit_log: true,
1286 body_inspection: crate::BodyInspectionPolicy::default(),
1287 });
1288
1289 let mut errors = Vec::new();
1290 let mut warnings = Vec::new();
1291 validate_implementation_status(&config, &mut errors, &mut warnings);
1292
1293 assert!(
1294 !errors.is_empty(),
1295 "WAF detection mode should produce a hard error"
1296 );
1297 assert!(errors[0].contains("detection"));
1298 }
1299
1300 #[test]
1301 fn waf_off_mode_produces_no_error() {
1302 let mut config = Config::default_for_testing();
1303 config.waf = Some(crate::WafConfig {
1304 engine: crate::WafEngine::Coraza,
1305 ruleset: crate::WafRuleset {
1306 crs_version: "4.0".to_string(),
1307 custom_rules_dir: None,
1308 paranoia_level: 1,
1309 anomaly_threshold: 5,
1310 exclusions: vec![],
1311 },
1312 mode: WafMode::Off,
1313 audit_log: true,
1314 body_inspection: crate::BodyInspectionPolicy::default(),
1315 });
1316
1317 let mut errors = Vec::new();
1318 let mut warnings = Vec::new();
1319 validate_implementation_status(&config, &mut errors, &mut warnings);
1320
1321 assert!(errors.is_empty(), "WAF off mode should not produce errors");
1322 }
1323
1324 #[test]
1325 fn tls_cipher_suites_produce_warning() {
1326 let mut config = Config::default_for_testing();
1327 config.listeners[0].tls = Some(crate::TlsConfig {
1328 cert_file: Some("/tmp/cert.pem".into()),
1329 key_file: Some("/tmp/key.pem".into()),
1330 additional_certs: vec![],
1331 ca_file: None,
1332 min_version: TlsVersion::Tls12,
1333 max_version: None,
1334 cipher_suites: vec!["TLS_AES_256_GCM_SHA384".to_string()],
1335 client_auth: false,
1336 ocsp_stapling: true,
1337 session_resumption: true,
1338 acme: None,
1339 });
1340
1341 let mut errors = Vec::new();
1342 let mut warnings = Vec::new();
1343 validate_implementation_status(&config, &mut errors, &mut warnings);
1344
1345 assert!(
1346 errors.is_empty(),
1347 "TLS cipher_suites should not produce errors"
1348 );
1349 assert!(
1350 !warnings.is_empty(),
1351 "TLS cipher_suites should produce a warning"
1352 );
1353 assert!(warnings.iter().any(|w| w.contains("cipher_suites")));
1354 }
1355
1356 #[test]
1357 fn tls_max_version_produces_warning() {
1358 let mut config = Config::default_for_testing();
1359 config.listeners[0].tls = Some(crate::TlsConfig {
1360 cert_file: Some("/tmp/cert.pem".into()),
1361 key_file: Some("/tmp/key.pem".into()),
1362 additional_certs: vec![],
1363 ca_file: None,
1364 min_version: TlsVersion::Tls12,
1365 max_version: Some(TlsVersion::Tls12),
1366 cipher_suites: vec![],
1367 client_auth: false,
1368 ocsp_stapling: true,
1369 session_resumption: true,
1370 acme: None,
1371 });
1372
1373 let mut errors = Vec::new();
1374 let mut warnings = Vec::new();
1375 validate_implementation_status(&config, &mut errors, &mut warnings);
1376
1377 assert!(
1378 errors.is_empty(),
1379 "TLS max_version should not produce errors"
1380 );
1381 assert!(
1382 !warnings.is_empty(),
1383 "TLS max_version should produce a warning"
1384 );
1385 assert!(warnings.iter().any(|w| w.contains("max_version")));
1386 }
1387
1388 #[test]
1389 fn compress_filter_produces_no_warnings() {
1390 use crate::filters::{CompressFilter, FilterConfig};
1391
1392 let mut config = Config::default_for_testing();
1393 config.filters.insert(
1394 "gzip".to_string(),
1395 FilterConfig::new("gzip", Filter::Compress(CompressFilter::default())),
1396 );
1397 config.routes[0].filters.push("gzip".to_string());
1398
1399 let mut errors = Vec::new();
1400 let mut warnings = Vec::new();
1401 validate_implementation_status(&config, &mut errors, &mut warnings);
1402
1403 assert!(
1404 errors.is_empty(),
1405 "Compress filter should not produce errors"
1406 );
1407 assert!(
1408 !warnings.iter().any(|w| w.contains("compress")),
1409 "Compress filter is wired and should not produce warnings"
1410 );
1411 }
1412
1413 #[test]
1414 fn cors_filter_produces_no_warnings() {
1415 use crate::filters::{CorsFilter, FilterConfig};
1416
1417 let mut config = Config::default_for_testing();
1418 config.filters.insert(
1419 "cors".to_string(),
1420 FilterConfig::new("cors", Filter::Cors(CorsFilter::default())),
1421 );
1422 config.routes[0].filters.push("cors".to_string());
1423
1424 let mut errors = Vec::new();
1425 let mut warnings = Vec::new();
1426 validate_implementation_status(&config, &mut errors, &mut warnings);
1427
1428 assert!(errors.is_empty(), "CORS filter should not produce errors");
1429 assert!(
1430 !warnings.iter().any(|w| w.contains("cors")),
1431 "CORS filter is wired and should not produce warnings"
1432 );
1433 }
1434
1435 #[test]
1436 fn response_headers_policy_produces_no_warnings() {
1437 let mut config = Config::default_for_testing();
1438 config.routes[0]
1439 .policies
1440 .response_headers
1441 .set
1442 .insert("X-Custom".to_string(), "value".to_string());
1443
1444 let mut errors = Vec::new();
1445 let mut warnings = Vec::new();
1446 validate_implementation_status(&config, &mut errors, &mut warnings);
1447
1448 assert!(errors.is_empty());
1449 assert!(
1450 !warnings.iter().any(|w| w.contains("response_headers")),
1451 "response_headers is wired and should not produce warnings"
1452 );
1453 }
1454
1455 #[test]
1456 fn route_cache_enabled_produces_no_warnings() {
1457 let mut config = Config::default_for_testing();
1458 config.routes[0].policies.cache = Some(crate::RouteCacheConfig {
1459 enabled: true,
1460 ..Default::default()
1461 });
1462
1463 let mut errors = Vec::new();
1464 let mut warnings = Vec::new();
1465 validate_implementation_status(&config, &mut errors, &mut warnings);
1466
1467 assert!(errors.is_empty());
1468 assert!(
1469 !warnings.iter().any(|w| w.contains("caching")),
1470 "Per-route cache is wired and should not produce warnings"
1471 );
1472 }
1473
1474 #[test]
1475 fn server_pid_file_produces_no_warnings() {
1476 let mut config = Config::default_for_testing();
1477 config.server.pid_file = Some("/run/grapsus.pid".into());
1478
1479 let mut errors = Vec::new();
1480 let mut warnings = Vec::new();
1481 validate_implementation_status(&config, &mut errors, &mut warnings);
1482
1483 assert!(errors.is_empty());
1484 assert!(
1485 !warnings.iter().any(|w| w.contains("pid_file")),
1486 "pid_file is now wired and should not produce warnings"
1487 );
1488 }
1489
1490 #[test]
1491 fn default_config_produces_no_errors_or_warnings() {
1492 let config = Config::default_for_testing();
1493
1494 let mut errors = Vec::new();
1495 let mut warnings = Vec::new();
1496 validate_implementation_status(&config, &mut errors, &mut warnings);
1497
1498 assert!(
1499 errors.is_empty(),
1500 "Default config should produce no errors: {:?}",
1501 errors
1502 );
1503 assert!(
1504 warnings.is_empty(),
1505 "Default config should produce no warnings: {:?}",
1506 warnings
1507 );
1508 }
1509
1510 #[test]
1511 fn logging_file_produces_warning() {
1512 let mut config = Config::default_for_testing();
1513 config.observability.logging.file = Some("/var/log/grapsus/app.log".into());
1514
1515 let mut errors = Vec::new();
1516 let mut warnings = Vec::new();
1517 validate_implementation_status(&config, &mut errors, &mut warnings);
1518
1519 assert!(errors.is_empty());
1520 assert!(warnings.iter().any(|w| w.contains("logging.file")));
1521 }
1522
1523 #[test]
1524 fn non_default_listener_timeout_produces_no_warnings() {
1525 let mut config = Config::default_for_testing();
1526 config.listeners[0].request_timeout_secs = 30;
1527
1528 let mut errors = Vec::new();
1529 let mut warnings = Vec::new();
1530 validate_implementation_status(&config, &mut errors, &mut warnings);
1531
1532 assert!(errors.is_empty());
1533 assert!(
1534 !warnings.iter().any(|w| w.contains("request_timeout_secs")),
1535 "request_timeout_secs is now wired and should not produce warnings"
1536 );
1537 }
1538
1539 #[test]
1554 fn config_field_coverage_exhaustive_construction() {
1555 use crate::filters::{
1556 CompressFilter, CompressionAlgorithm, CorsFilter, FilterPhase, GlobalRateLimitConfig,
1557 HeadersFilter, LogFilter, RateLimitKey, TimeoutFilter,
1558 };
1559 use crate::observability::{LoggingConfig, MetricsConfig, ObservabilityConfig};
1560 use crate::routes::{
1561 CacheBackend, CacheStorageConfig, FailureMode, HeaderModifications, RouteCacheConfig,
1562 };
1563 use crate::server::{ListenerConfig, ListenerProtocol, ServerConfig, TlsConfig};
1564 use crate::waf::{BodyInspectionPolicy, WafConfig, WafEngine, WafRuleset};
1565 use grapsus_common::types::LoadBalancingAlgorithm;
1566
1567 let _server = ServerConfig {
1569 worker_threads: 4,
1570 max_connections: 1000,
1571 graceful_shutdown_timeout_secs: 30,
1572 daemon: false,
1573 pid_file: None,
1574 user: None,
1575 group: None,
1576 working_directory: None,
1577 trace_id_format: Default::default(),
1578 auto_reload: false,
1579 };
1580
1581 let _listener = ListenerConfig {
1583 id: "http".to_string(),
1584 address: "0.0.0.0:8080".to_string(),
1585 protocol: ListenerProtocol::Http,
1586 tls: None,
1587 default_route: Some("default".to_string()),
1588 request_timeout_secs: 60,
1589 keepalive_timeout_secs: 75,
1590 max_concurrent_streams: 100,
1591 };
1592
1593 let _tls = TlsConfig {
1595 cert_file: Some("/tmp/cert.pem".into()),
1596 key_file: Some("/tmp/key.pem".into()),
1597 additional_certs: vec![],
1598 ca_file: None,
1599 min_version: TlsVersion::Tls12,
1600 max_version: None,
1601 cipher_suites: vec![],
1602 client_auth: false,
1603 ocsp_stapling: true,
1604 session_resumption: true,
1605 acme: None,
1606 };
1607
1608 let _route = RouteConfig {
1610 id: "test".to_string(),
1611 priority: Priority::Normal,
1612 matches: vec![MatchCondition::PathPrefix("/".to_string())],
1613 upstream: Some("default".to_string()),
1614 service_type: ServiceType::Web,
1615 policies: RoutePolicies {
1616 request_headers: HeaderModifications {
1617 rename: HashMap::new(),
1618 set: HashMap::new(),
1619 add: HashMap::new(),
1620 remove: vec![],
1621 },
1622 response_headers: HeaderModifications {
1623 rename: HashMap::new(),
1624 set: HashMap::new(),
1625 add: HashMap::new(),
1626 remove: vec![],
1627 },
1628 timeout_secs: None,
1629 max_body_size: None,
1630 rate_limit: None,
1631 failure_mode: FailureMode::Closed,
1632 buffer_requests: false,
1633 buffer_responses: false,
1634 cache: None,
1635 },
1636 filters: vec![],
1637 builtin_handler: None,
1638 waf_enabled: false,
1639 circuit_breaker: None,
1640 retry_policy: None,
1641 static_files: None,
1642 api_schema: None,
1643 inference: None,
1644 error_pages: None,
1645 websocket: false,
1646 websocket_inspection: false,
1647 shadow: None,
1648 fallback: None,
1649 };
1650
1651 let _upstream = UpstreamConfig {
1653 id: "default".to_string(),
1654 targets: vec![UpstreamTarget {
1655 address: "127.0.0.1:8081".to_string(),
1656 weight: 1,
1657 max_requests: None,
1658 metadata: HashMap::new(),
1659 }],
1660 load_balancing: LoadBalancingAlgorithm::RoundRobin,
1661 sticky_session: None,
1662 health_check: None,
1663 connection_pool: ConnectionPoolConfig::default(),
1664 timeouts: UpstreamTimeouts::default(),
1665 tls: None,
1666 http_version: HttpVersionConfig::default(),
1667 };
1668
1669 let _headers = HeadersFilter {
1671 phase: FilterPhase::Request,
1672 rename: HashMap::new(),
1673 set: HashMap::new(),
1674 add: HashMap::new(),
1675 remove: vec![],
1676 };
1677
1678 let _compress = CompressFilter {
1679 algorithms: vec![CompressionAlgorithm::Gzip],
1680 min_size: 1024,
1681 content_types: vec!["text/html".to_string()],
1682 level: 6,
1683 };
1684
1685 let _cors = CorsFilter {
1686 allowed_origins: vec!["*".to_string()],
1687 allowed_methods: vec!["GET".to_string()],
1688 allowed_headers: vec![],
1689 exposed_headers: vec![],
1690 allow_credentials: false,
1691 max_age_secs: 3600,
1692 };
1693
1694 let _timeout = TimeoutFilter {
1695 request_timeout_secs: None,
1696 upstream_timeout_secs: None,
1697 connect_timeout_secs: None,
1698 };
1699
1700 let _log = LogFilter {
1701 log_request: true,
1702 log_response: true,
1703 log_body: false,
1704 max_body_log_size: 1024,
1705 fields: vec![],
1706 level: "info".to_string(),
1707 };
1708
1709 let _waf = WafConfig {
1711 engine: WafEngine::Coraza,
1712 ruleset: WafRuleset {
1713 crs_version: "4.0".to_string(),
1714 custom_rules_dir: None,
1715 paranoia_level: 1,
1716 anomaly_threshold: 5,
1717 exclusions: vec![],
1718 },
1719 mode: WafMode::Off,
1720 audit_log: true,
1721 body_inspection: BodyInspectionPolicy {
1722 inspect_request_body: true,
1723 inspect_response_body: false,
1724 max_inspection_bytes: 1024 * 1024,
1725 content_types: vec![],
1726 decompress: false,
1727 max_decompression_ratio: 100.0,
1728 },
1729 };
1730
1731 let _obs = ObservabilityConfig {
1733 metrics: MetricsConfig {
1734 enabled: true,
1735 address: "0.0.0.0:9090".to_string(),
1736 path: "/metrics".to_string(),
1737 high_cardinality: false,
1738 },
1739 logging: LoggingConfig {
1740 level: "info".to_string(),
1741 format: "json".to_string(),
1742 timestamps: true,
1743 file: None,
1744 access_log: None,
1745 error_log: None,
1746 audit_log: None,
1747 },
1748 tracing: None,
1749 };
1750
1751 let _cache = RouteCacheConfig {
1753 enabled: true,
1754 default_ttl_secs: 3600,
1755 max_size_bytes: 10 * 1024 * 1024,
1756 cache_private: false,
1757 stale_while_revalidate_secs: 60,
1758 stale_if_error_secs: 300,
1759 cacheable_methods: vec!["GET".to_string()],
1760 cacheable_status_codes: vec![200],
1761 vary_headers: vec![],
1762 ignore_query_params: vec![],
1763 exclude_extensions: vec![],
1764 exclude_paths: vec![],
1765 };
1766
1767 let _cache_storage = CacheStorageConfig {
1769 enabled: true,
1770 backend: CacheBackend::Memory,
1771 max_size_bytes: 100 * 1024 * 1024,
1772 eviction_limit_bytes: None,
1773 lock_timeout_secs: 10,
1774 disk_path: None,
1775 disk_shards: 16,
1776 disk_max_size_bytes: None,
1777 status_header: false,
1778 };
1779
1780 let _rl = GlobalRateLimitConfig {
1782 default_rps: None,
1783 default_burst: None,
1784 key: RateLimitKey::ClientIp,
1785 global: None,
1786 };
1787
1788 }
1790
1791 #[test]
1801 fn validation_warnings_snapshot() {
1802 let mut config = Config::default_for_testing();
1803
1804 config.listeners[0].tls = Some(crate::TlsConfig {
1808 cert_file: Some("/tmp/cert.pem".into()),
1809 key_file: Some("/tmp/key.pem".into()),
1810 additional_certs: vec![],
1811 ca_file: None,
1812 min_version: TlsVersion::Tls13, max_version: Some(TlsVersion::Tls13), cipher_suites: vec!["TLS_AES_256_GCM_SHA384".to_string()], client_auth: false,
1816 ocsp_stapling: true,
1817 session_resumption: true,
1818 acme: None,
1819 });
1820
1821 config.listeners[0].max_concurrent_streams = 200; config.observability.metrics.address = "0.0.0.0:9191".to_string(); config.observability.logging.file = Some("/var/log/grapsus/app.log".into());
1829
1830 config.observability.logging.level = "debug".to_string();
1832 config.observability.logging.format = "pretty".to_string();
1833
1834 let mut errors = Vec::new();
1835 let mut warnings = Vec::new();
1836 validate_implementation_status(&config, &mut errors, &mut warnings);
1837
1838 assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors);
1840
1841 let expected_warning_fragments = vec![
1845 "cipher_suites",
1846 "min_version",
1847 "max_version",
1848 "max_concurrent_streams",
1849 "Metrics endpoint address",
1850 "logging.file",
1851 "Logging level",
1852 ];
1853
1854 assert_eq!(
1855 warnings.len(),
1856 expected_warning_fragments.len(),
1857 "Warning count changed!\n\
1858 Expected {} warnings but got {}.\n\
1859 If you wired a feature, remove its expected warning.\n\
1860 If you added a new unwired feature, add its warning here.\n\
1861 Actual warnings:\n{:#?}",
1862 expected_warning_fragments.len(),
1863 warnings.len(),
1864 warnings
1865 );
1866
1867 for expected in &expected_warning_fragments {
1868 assert!(
1869 warnings.iter().any(|w| w.contains(expected)),
1870 "Expected warning containing '{}' not found.\nActual warnings:\n{:#?}",
1871 expected,
1872 warnings
1873 );
1874 }
1875 }
1876}