Skip to main content

grapsus_config/
validation.rs

1//! Configuration validation functions
2//!
3//! This module contains all validation logic for the configuration,
4//! including semantic validation and cross-reference checking.
5//!
6//! # Scoped Validation
7//!
8//! The [`ValidationContext`] provides scope-aware validation for namespaced
9//! configurations. It tracks resources by scope and validates cross-references
10//! using the resolution rules (local → parent → exported → global).
11
12use 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
20// ============================================================================
21// Field Validators
22// ============================================================================
23
24/// Validate socket address format
25pub 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// ============================================================================
39// Validation Context
40// ============================================================================
41
42/// Context for scope-aware configuration validation.
43///
44/// This struct tracks all resources by scope and enables validation of
45/// cross-references between scopes following the resolution rules.
46#[derive(Debug, Default)]
47pub struct ValidationContext {
48    /// All canonical IDs for duplicate detection (e.g., "api:payments:checkout")
49    pub all_ids: HashSet<String>,
50
51    /// Upstreams indexed by scope
52    upstreams_by_scope: HashMap<Scope, HashSet<String>>,
53
54    /// Agents indexed by scope
55    agents_by_scope: HashMap<Scope, HashSet<String>>,
56
57    /// Filters indexed by scope
58    filters_by_scope: HashMap<Scope, HashSet<String>>,
59
60    /// Routes indexed by scope
61    routes_by_scope: HashMap<Scope, HashSet<String>>,
62
63    /// Exported upstream names (local name only)
64    exported_upstreams: HashSet<String>,
65
66    /// Exported agent names (local name only)
67    exported_agents: HashSet<String>,
68
69    /// Exported filter names (local name only)
70    exported_filters: HashSet<String>,
71}
72
73impl ValidationContext {
74    /// Create a new validation context from a configuration.
75    pub fn from_config(config: &Config) -> Self {
76        let mut ctx = Self::default();
77
78        // Register global resources
79        ctx.register_global_resources(config);
80
81        // Register namespace and service resources
82        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        // Global upstreams
93        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        // Global agents
102        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        // Global filters
111        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        // Global routes
120        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        // Namespace upstreams
133        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            // Track exports
141            if ns.exports.upstreams.contains(id) {
142                self.exported_upstreams.insert(id.clone());
143            }
144        }
145
146        // Namespace agents
147        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            // Track exports
155            if ns.exports.agents.contains(&agent.id) {
156                self.exported_agents.insert(agent.id.clone());
157            }
158        }
159
160        // Namespace filters
161        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            // Track exports
169            if ns.exports.filters.contains(id) {
170                self.exported_filters.insert(id.clone());
171            }
172        }
173
174        // Namespace routes
175        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        // Register services within namespace
184        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        // Service upstreams
196        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        // Service agents
205        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        // Service filters
215        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        // Service routes
224        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    /// Check if an upstream reference can be resolved from the given scope.
235    ///
236    /// Resolution order: local scope → parent scope → exported → global
237    pub fn can_resolve_upstream(&self, reference: &str, from_scope: &Scope) -> bool {
238        // Check if it's a qualified reference (contains ':')
239        if reference.contains(':') {
240            // Qualified references must match exactly
241            return self.all_ids.contains(reference);
242        }
243
244        // Unqualified reference - search scope chain
245        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        // Check exports (for cross-namespace access)
254        if self.exported_upstreams.contains(reference) {
255            return true;
256        }
257
258        false
259    }
260
261    /// Check if an agent reference can be resolved from the given scope.
262    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    /// Check if a filter reference can be resolved from the given scope.
283    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    /// Get all upstreams available from a given scope.
304    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    /// Get all agents available from a given scope.
318    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    /// Get all filters available from a given scope.
332    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
346// ============================================================================
347// Semantic Validation
348// ============================================================================
349
350/// Comprehensive semantic validation for the entire configuration
351pub 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    // Collect IDs for cross-reference validation
365    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    // Validate routes
379    trace!("Validating routes");
380    validate_routes(config, &route_ids, &upstream_ids, &filter_ids, &mut errors);
381
382    // Validate listeners
383    trace!("Validating listeners");
384    validate_listeners(config, &route_ids, &mut errors);
385
386    // Validate filters
387    trace!("Validating filters");
388    validate_filters(config, &agent_ids, &mut errors);
389
390    // Validate upstreams
391    trace!("Validating upstreams");
392    validate_upstreams(config, &mut errors);
393
394    // Validate duplicates
395    trace!("Checking for duplicates");
396    validate_duplicates(config, &mut errors);
397
398    // Validate namespaces and services
399    trace!("Validating namespaces");
400    let ctx = ValidationContext::from_config(config);
401    validate_namespaces(config, &ctx, &mut errors);
402
403    // Warn about orphaned upstreams
404    warn_orphaned_upstreams(config, &upstream_ids);
405
406    // Validate implementation status (detect configured-but-unwired features)
407    trace!("Validating implementation status");
408    validate_implementation_status(config, &mut errors, &mut warnings);
409
410    // Emit warnings for partially-implemented features
411    for warning in &warnings {
412        warn!("Unwired feature: {}", warning);
413    }
414
415    // Build final error
416    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    // Routes needing upstreams
441    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    // Validate routes have valid upstream references
465    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    // Validate non-static routes without upstream or static-files
481    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    // Validate filter references in routes
492    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    // Validate routes have at least one match condition
507    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    // Validate static file configurations
518    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    // Duplicate route IDs
648    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    // Duplicate listener IDs
660    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    // Duplicate listener addresses
672    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
721// ============================================================================
722// Namespace Validation
723// ============================================================================
724
725fn 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    // Check for duplicate namespace IDs
732    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        // Validate IDs don't contain reserved ':' character
742        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    // Validate namespace routes reference valid upstreams/filters
762    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    // Validate exports reference existing resources
793    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    // Validate services within namespace
821    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    // Validate service routes reference valid upstreams/filters
853    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
900// ============================================================================
901// Implementation Status Validation
902// ============================================================================
903
904/// Validate that configured features are actually wired into the runtime.
905///
906/// Features that are parsed successfully but silently ignored at runtime are
907/// dangerous — especially security features. This function produces:
908///
909/// - **Hard errors** for security-critical features (WAF mode, TLS hardening)
910///   that would give a false sense of protection if ignored.
911/// - **Warnings** for convenience features (filters, policies, listener settings)
912///   that won't cause security issues but may surprise operators.
913fn validate_implementation_status(
914    config: &Config,
915    errors: &mut Vec<String>,
916    warnings: &mut Vec<String>,
917) {
918    // ========================================================================
919    // Hard errors: Security-critical features that MUST NOT be silently ignored
920    // ========================================================================
921
922    // WAF mode: Detection/Prevention configured but WAF engine not consumed
923    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    // TLS hardening: settings are validated and ready in build_server_config(),
940    // but Pingora's TlsSettings doesn't yet accept a custom ServerConfig.
941    // Warn (not error) so operators know these settings are staged but not active.
942    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    // ========================================================================
971    // Warnings: Convenience features that are configured but not yet wired
972    // ========================================================================
973
974    // Listener settings: max_concurrent_streams not yet wired to H2
975    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    // Metrics: address requires a dedicated HTTP server (not yet wired)
986    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    // Logging: level/format are controlled by RUST_LOG env var and --verbose flag,
996    // not yet by the config file. File output also not yet wired.
997    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
1014// ============================================================================
1015// Result Building
1016// ============================================================================
1017
1018fn 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// ============================================================================
1043// Tests
1044// ============================================================================
1045
1046#[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        // Add a namespace with an upstream
1104        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        // Should have global upstream
1113        assert!(ctx.can_resolve_upstream("default", &Scope::Global));
1114
1115        // Should have namespace upstream from namespace scope
1116        let ns_scope = Scope::Namespace("api".to_string());
1117        assert!(ctx.can_resolve_upstream("ns-backend", &ns_scope));
1118
1119        // Should also see global from namespace scope
1120        assert!(ctx.can_resolve_upstream("default", &ns_scope));
1121
1122        // Global scope should NOT see namespace-local upstream
1123        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        // Add a namespace with exported upstream
1131        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        // Exported upstream should be visible from global scope
1146        assert!(ctx.can_resolve_upstream("shared-backend", &Scope::Global));
1147
1148        // And from other namespaces
1149        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        // Add a namespace with a service
1158        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        // Service should see its own upstream
1177        assert!(ctx.can_resolve_upstream("svc-backend", &svc_scope));
1178
1179        // Service should see namespace upstream
1180        assert!(ctx.can_resolve_upstream("ns-backend", &svc_scope));
1181
1182        // Service should see global upstream
1183        assert!(ctx.can_resolve_upstream("default", &svc_scope));
1184
1185        // Namespace scope should NOT see service-local upstream
1186        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        // Qualified reference should work
1202        assert!(ctx.can_resolve_upstream("api:backend", &Scope::Global));
1203
1204        // Wrong qualified reference should fail
1205        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        // Global scope should see: default (global) + ns-backend (exported)
1225        let global_available = ctx.available_upstreams(&Scope::Global);
1226        assert!(global_available.contains("default"));
1227        assert!(global_available.contains("ns-backend"));
1228
1229        // Namespace scope should see: default (global) + ns-backend (local) + exported
1230        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    // ========================================================================
1237    // Implementation status validation tests
1238    // ========================================================================
1239
1240    #[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    // ========================================================================
1540    // Config field coverage tests
1541    // ========================================================================
1542
1543    /// Exhaustive construction test for config structs.
1544    ///
1545    /// This test constructs every config struct with fully explicit field
1546    /// initialization (no `..Default::default()`). When a developer adds a
1547    /// new field to any config struct, **this test fails to compile** until
1548    /// they add the field — forcing them to consider wiring.
1549    ///
1550    /// If this fails to compile, you MUST either:
1551    ///   1. Wire the field to runtime behavior AND add a test, OR
1552    ///   2. Add a warning in `validate_implementation_status()`
1553    #[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        // --- ServerConfig ---
1568        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        // --- ListenerConfig ---
1582        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        // --- TlsConfig ---
1594        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        // --- RouteConfig ---
1609        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        // --- UpstreamConfig ---
1652        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        // --- Filter types ---
1670        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        // --- WafConfig ---
1710        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        // --- ObservabilityConfig ---
1732        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        // --- RouteCacheConfig ---
1752        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        // --- CacheStorageConfig ---
1768        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        // --- GlobalRateLimitConfig ---
1781        let _rl = GlobalRateLimitConfig {
1782            default_rps: None,
1783            default_burst: None,
1784            key: RateLimitKey::ClientIp,
1785            global: None,
1786        };
1787
1788        // If this test compiles, all config struct fields are accounted for.
1789    }
1790
1791    // ========================================================================
1792    // Validation warnings snapshot test
1793    // ========================================================================
1794
1795    /// Snapshot test for validation warnings from `validate_implementation_status`.
1796    ///
1797    /// This test constructs a config with every partially-wired feature enabled
1798    /// and asserts the exact set of warnings. When someone wires a feature or
1799    /// adds a new warning, this test fails and they must update the expected list.
1800    #[test]
1801    fn validation_warnings_snapshot() {
1802        let mut config = Config::default_for_testing();
1803
1804        // Enable all features that currently produce warnings:
1805
1806        // TLS cipher_suites (unwired — Pingora doesn't expose custom cipher config)
1807        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, // non-default → produces warning
1813            max_version: Some(TlsVersion::Tls13), // set → produces warning
1814            cipher_suites: vec!["TLS_AES_256_GCM_SHA384".to_string()], // set → produces warning
1815            client_auth: false,
1816            ocsp_stapling: true,
1817            session_resumption: true,
1818            acme: None,
1819        });
1820
1821        // max_concurrent_streams (unwired — Pingora H2 per-listener config)
1822        config.listeners[0].max_concurrent_streams = 200; // non-default → produces warning
1823
1824        // Metrics address (unwired — no dedicated HTTP server yet)
1825        config.observability.metrics.address = "0.0.0.0:9191".to_string(); // non-default → warning
1826
1827        // Logging file (unwired — goes to stdout/stderr)
1828        config.observability.logging.file = Some("/var/log/grapsus/app.log".into());
1829
1830        // Logging level/format (unwired — controlled by RUST_LOG env var)
1831        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        // No errors expected (WAF is not enabled)
1839        assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors);
1840
1841        // Exact expected warnings — update when features are wired or new warnings added.
1842        //
1843        // Each entry is a substring that must appear in exactly one warning.
1844        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}