Skip to main content

featherbit/graph/
validation.rs

1//! Structural validation of policy node graphs, run before compilation
2//! (e.g. at config load and on Admin API writes) so malformed policies are
3//! rejected with actionable messages instead of failing at request time.
4
5use std::collections::HashSet;
6
7use crate::config::PolicyConfig;
8
9/// Validates a policy's node graph structure, collecting all violations.
10///
11/// Enforced rules:
12/// - the policy has a `listener` node (entry) and a `client` node (exit);
13/// - every edge endpoint references an existing node;
14/// - each input port has at most one incoming edge, except inputs of
15///   `client` and `error-handler` nodes, which accept multiple;
16/// - no orphan nodes (a node with neither incoming nor outgoing edges;
17///   being named as the policy-level `error_handler` counts as connected);
18/// - `error_handler`, if set, references an existing node.
19///
20/// Returns `Ok(())` when valid, otherwise `Err` with one message per
21/// violation (validation does not stop at the first error).
22pub fn validate_policy(policy: &PolicyConfig) -> Result<(), Vec<String>> {
23    let mut errors = Vec::new();
24
25    let node_ids: HashSet<&str> = policy.nodes.iter().map(|n| n.id.as_str()).collect();
26
27    // Must have a listener node
28    let has_listener = policy.nodes.iter().any(|n| n.node_type == "listener");
29    if !has_listener {
30        errors.push("Policy must have a 'listener' node".to_string());
31    }
32
33    // Must have a client node
34    let has_client = policy.nodes.iter().any(|n| n.node_type == "client");
35    if !has_client {
36        errors.push("Policy must have a 'client' node".to_string());
37    }
38
39    // Validate edges reference existing nodes
40    for edge in &policy.edges {
41        let from_node = edge.from.split('.').next().unwrap_or("");
42        let to_node = edge.to.split('.').next().unwrap_or("");
43
44        if !node_ids.contains(from_node) {
45            errors.push(format!(
46                "Edge references unknown source node: '{}'",
47                from_node
48            ));
49        }
50        if !node_ids.contains(to_node) {
51            errors.push(format!(
52                "Edge references unknown target node: '{}'",
53                to_node
54            ));
55        }
56    }
57
58    // Check for multiple edges into the same input port.
59    // Exceptions: client nodes (multiple paths can deliver the response) and
60    // error-handler nodes (can receive errors from multiple nodes).
61    let client_ids: HashSet<&str> = policy
62        .nodes
63        .iter()
64        .filter(|n| n.node_type == "client")
65        .map(|n| n.id.as_str())
66        .collect();
67    let error_handler_ids: HashSet<&str> = policy
68        .nodes
69        .iter()
70        .filter(|n| n.node_type == "error-handler")
71        .map(|n| n.id.as_str())
72        .collect();
73
74    let mut input_targets: HashSet<String> = HashSet::new();
75    for edge in &policy.edges {
76        let target = &edge.to;
77        let to_node = target.split('.').next().unwrap_or("");
78
79        let is_client = client_ids.contains(to_node);
80        let is_error_handler = error_handler_ids.contains(to_node);
81
82        if !is_client && !is_error_handler && !input_targets.insert(target.clone()) {
83            errors.push(format!(
84                "Node '{}' input '{}' has multiple incoming edges — each input accepts only one edge",
85                to_node, target
86            ));
87        }
88    }
89
90    // Check for orphan nodes (no incoming or outgoing edges)
91    let mut connected_nodes: HashSet<&str> = HashSet::new();
92    for edge in &policy.edges {
93        let from_node = edge.from.split('.').next().unwrap_or("");
94        let to_node = edge.to.split('.').next().unwrap_or("");
95        connected_nodes.insert(from_node);
96        connected_nodes.insert(to_node);
97    }
98
99    // Also include the catch-all handler if specified
100    if let Some(ref handler) = policy.error_handler {
101        connected_nodes.insert(handler.as_str());
102    }
103
104    for node in &policy.nodes {
105        if !connected_nodes.contains(node.id.as_str()) {
106            errors.push(format!("Orphan node '{}' has no connections", node.id));
107        }
108    }
109
110    // Validate error_handler references an existing node
111    if let Some(ref handler) = policy.error_handler {
112        if !node_ids.contains(handler.as_str()) {
113            errors.push(format!(
114                "Policy error_handler references unknown node: '{}'",
115                handler
116            ));
117        }
118    }
119
120    if errors.is_empty() {
121        Ok(())
122    } else {
123        Err(errors)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::config::{EdgeConfig, NodeConfig, PolicyConfig};
131    use std::collections::HashMap;
132
133    fn listener_node() -> NodeConfig {
134        NodeConfig {
135            id: "listener".to_string(),
136            node_type: "listener".to_string(),
137            config: HashMap::new(),
138            position: None,
139        }
140    }
141
142    fn client_node() -> NodeConfig {
143        NodeConfig {
144            id: "client".to_string(),
145            node_type: "client".to_string(),
146            config: HashMap::new(),
147            position: None,
148        }
149    }
150
151    fn upstream_node() -> NodeConfig {
152        NodeConfig {
153            id: "backend".to_string(),
154            node_type: "upstream".to_string(),
155            config: HashMap::new(),
156            position: None,
157        }
158    }
159
160    #[test]
161    fn test_valid_simple_policy() {
162        let policy = PolicyConfig {
163            name: "test".to_string(),
164            error_handler: None,
165            nodes: vec![listener_node(), upstream_node(), client_node()],
166            edges: vec![
167                EdgeConfig {
168                    from: "listener.out".to_string(),
169                    to: "backend.in".to_string(),
170                },
171                EdgeConfig {
172                    from: "backend.success".to_string(),
173                    to: "client.in".to_string(),
174                },
175            ],
176        };
177        assert!(validate_policy(&policy).is_ok());
178    }
179
180    #[test]
181    fn test_missing_listener() {
182        let policy = PolicyConfig {
183            name: "test".to_string(),
184            error_handler: None,
185            nodes: vec![upstream_node(), client_node()],
186            edges: vec![],
187        };
188        let errors = validate_policy(&policy).unwrap_err();
189        assert!(errors.iter().any(|e| e.contains("listener")));
190    }
191
192    #[test]
193    fn test_missing_client() {
194        let policy = PolicyConfig {
195            name: "test".to_string(),
196            error_handler: None,
197            nodes: vec![listener_node(), upstream_node()],
198            edges: vec![EdgeConfig {
199                from: "listener.out".to_string(),
200                to: "backend.in".to_string(),
201            }],
202        };
203        let errors = validate_policy(&policy).unwrap_err();
204        assert!(errors.iter().any(|e| e.contains("client")));
205    }
206
207    #[test]
208    fn test_unknown_edge_reference() {
209        let policy = PolicyConfig {
210            name: "test".to_string(),
211            error_handler: None,
212            nodes: vec![listener_node(), client_node()],
213            edges: vec![EdgeConfig {
214                from: "listener.out".to_string(),
215                to: "nonexistent.in".to_string(),
216            }],
217        };
218        let errors = validate_policy(&policy).unwrap_err();
219        assert!(errors.iter().any(|e| e.contains("nonexistent")));
220    }
221
222    #[test]
223    fn test_client_allows_multiple_inputs() {
224        let policy = PolicyConfig {
225            name: "test".to_string(),
226            error_handler: None,
227            nodes: vec![listener_node(), upstream_node(), client_node()],
228            edges: vec![
229                EdgeConfig {
230                    from: "listener.out".to_string(),
231                    to: "backend.in".to_string(),
232                },
233                EdgeConfig {
234                    from: "backend.success".to_string(),
235                    to: "client.in".to_string(),
236                },
237                EdgeConfig {
238                    from: "backend.error".to_string(),
239                    to: "client.in".to_string(),
240                },
241            ],
242        };
243        assert!(validate_policy(&policy).is_ok());
244    }
245}