1pub mod analyze;
2pub mod expand;
3pub mod lower;
4pub mod merge;
5pub mod validate;
6
7use std::sync::Arc;
8
9use crate::error::Error;
10use crate::ir::SymbolicFlowGraph;
11use crate::metadata::{FetchMetadataProvider, MiddlewareMetadataProvider};
12
13pub use analyze::{AnalyzedRule, AnalyzedRuleSet, InspectionLevel, Posture};
14pub use expand::RawRuleSet;
15pub use merge::{MergedConfig, RawRuleFile};
16
17pub fn compile(
28 files: Vec<RawRuleFile>,
29 mw_meta: &dyn MiddlewareMetadataProvider,
30 fetch_meta: &dyn FetchMetadataProvider,
31) -> Result<Arc<SymbolicFlowGraph>, Error> {
32 let merged = merge::merge(files)?;
33 let expanded = expand::expand(merged)?;
34 let analyzed = analyze::analyze(expanded, mw_meta, fetch_meta)?;
35 let graph = lower::lower(analyzed, mw_meta, fetch_meta)?;
36 validate::validate(&graph)?;
37 Ok(Arc::new(graph))
38}
39
40#[cfg(test)]
41mod tests {
42 use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
43 use std::path::PathBuf;
44
45 use super::*;
46 use crate::fetch::{FetchKind, FetchOutputModes, FetchPhase, Terminator};
47 use crate::ir::{Node, NodeId, PredicateId};
48 use crate::metadata::{FetchMetadata, MiddlewareMetadata};
49 use crate::middleware::MiddlewareKind;
50 use crate::rule::{RawRule, TerminateSpec};
51
52 struct Providers;
53
54 #[allow(clippy::unnecessary_wraps)]
55 fn validate_ok(_: &serde_json::Value) -> Result<(), Error> {
56 Ok(())
57 }
58
59 impl MiddlewareMetadataProvider for Providers {
60 fn get(&self, name: &str) -> Option<MiddlewareMetadata> {
61 match name {
62 "forward_client_ip" => Some(MiddlewareMetadata {
63 kind: MiddlewareKind::L7Request,
64 stateless: true,
65 needs_body: false,
66 validate_args: validate_ok,
67 }),
68 "rate_limit" => Some(MiddlewareMetadata {
69 kind: MiddlewareKind::L7Request,
70 stateless: false,
71 needs_body: false,
72 validate_args: validate_ok,
73 }),
74 _ => None,
75 }
76 }
77 }
78
79 impl FetchMetadataProvider for Providers {
80 fn get(&self, kind: FetchKind) -> Option<FetchMetadata> {
81 Some(FetchMetadata {
82 kind,
83 phase: match kind {
84 FetchKind::L4Forward => FetchPhase::L4,
85 _ => FetchPhase::L7,
86 },
87 output_modes: match kind {
88 FetchKind::L4Forward => FetchOutputModes { response: false, tunnel: true },
89 FetchKind::WebSocketUpgrade => FetchOutputModes { response: true, tunnel: true },
90 _ => FetchOutputModes { response: true, tunnel: false },
91 },
92 validate_args: validate_ok,
93 })
94 }
95 }
96
97 fn parse_rule(j: serde_json::Value) -> RawRule {
98 serde_json::from_value(j).expect("parse rule")
99 }
100
101 fn rule_file(path: &str, rules: Vec<RawRule>) -> RawRuleFile {
102 RawRuleFile { path: PathBuf::from(path), order: 0, rules }
103 }
104
105 fn _unused_mentions() {
106 let _ = TerminateSpec { kind: FetchKind::HttpProxy, args: serde_json::Value::Null };
107 }
108
109 #[test]
110 fn reverse_proxy_end_to_end_compiles_with_dual_stack_entries() {
111 let r = parse_rule(serde_json::json!({
112 "name": "proxy",
113 "listen": [":443"],
114 "middleware_chain": [{ "use": "forward_client_ip" }, { "use": "rate_limit", "args": { "rate": 100 } }],
115 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
116 }));
117 let graph =
118 compile(vec![rule_file("30-proxy.json", vec![r])], &Providers, &Providers).expect("compile");
119 assert!(!graph.nodes.is_empty());
120 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 443);
122 let v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 443);
123 let e_v4 = graph.entries.get(&v4).expect("v4 entry present");
124 let e_v6 = graph.entries.get(&v6).expect("v6 entry present");
125 assert_eq!(e_v4, e_v6);
126 assert!(
129 graph.terminators.iter().any(|t| matches!(t, Terminator::WriteHttpResponse)),
130 "expected WriteHttpResponse terminator",
131 );
132 }
133
134 #[test]
135 fn predicate_hash_cons_shares_id_across_rules() {
136 let a = parse_rule(serde_json::json!({
139 "name": "a",
140 "listen": [":8443"],
141 "match": { "tls.sni": { "equals": "api" } },
142 "terminate": { "type": "http_proxy" },
143 }));
144 let b = parse_rule(serde_json::json!({
145 "name": "b",
146 "listen": [":9443"],
147 "match": { "tls.sni": { "equals": "api" } },
148 "terminate": { "type": "http_proxy" },
149 }));
150 let graph =
151 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
152 assert_eq!(graph.predicates.len(), 1, "identical predicates must hash-cons to one slot");
153 }
154
155 #[test]
156 fn stateless_middleware_hash_cons_across_rules() {
157 let a = parse_rule(serde_json::json!({
160 "name": "a",
161 "listen": [":7001"],
162 "middleware_chain": [{ "use": "forward_client_ip" }],
163 "terminate": { "type": "http_proxy" },
164 }));
165 let b = parse_rule(serde_json::json!({
166 "name": "b",
167 "listen": [":7002"],
168 "middleware_chain": [{ "use": "forward_client_ip" }],
169 "terminate": { "type": "http_proxy" },
170 }));
171 let graph =
172 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
173 let shared = graph
174 .middlewares
175 .iter()
176 .filter(|m| m.name.as_ref() == "forward_client_ip" && m.stateless)
177 .count();
178 assert_eq!(shared, 1, "stateless middleware dedups across rules");
179 }
180
181 #[test]
182 fn stateful_middleware_per_site_not_shared() {
183 let a = parse_rule(serde_json::json!({
187 "name": "a",
188 "listen": [":7003"],
189 "middleware_chain": [{ "use": "rate_limit", "args": { "rate": 100 } }],
190 "terminate": { "type": "http_proxy" },
191 }));
192 let b = parse_rule(serde_json::json!({
193 "name": "b",
194 "listen": [":7004"],
195 "middleware_chain": [{ "use": "rate_limit", "args": { "rate": 100 } }],
196 "terminate": { "type": "http_proxy" },
197 }));
198 let graph =
199 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
200 let rate_limit_count =
201 graph.middlewares.iter().filter(|m| m.name.as_ref() == "rate_limit").count();
202 assert_eq!(rate_limit_count, 2, "stateful middleware must not share ids across call sites");
203 }
204
205 #[test]
206 fn terminator_variant_derives_from_fetch_kind() {
207 let http = parse_rule(serde_json::json!({
209 "name": "http",
210 "listen": [":8080"],
211 "terminate": { "type": "http_proxy" },
212 }));
213 let tcp = parse_rule(serde_json::json!({
214 "name": "tcp",
215 "listen": [":2222"],
216 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
217 }));
218 let graph =
219 compile(vec![rule_file("a.json", vec![http, tcp])], &Providers, &Providers).expect("compile");
220 let terms: std::collections::HashSet<_> = graph.terminators.iter().copied().collect();
221 assert!(terms.contains(&Terminator::WriteHttpResponse));
222 assert!(terms.contains(&Terminator::ByteTunnel));
223 }
224
225 #[test]
226 fn l7_rule_inserts_upgrade_node() {
227 let r = parse_rule(serde_json::json!({
228 "name": "r",
229 "listen": [":443"],
230 "terminate": { "type": "http_proxy" },
231 }));
232 let graph =
233 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
234 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
235 assert!(upgrades >= 1, "L7 listener must have at least one Upgrade node");
236 }
237
238 #[test]
239 fn l4_only_rule_has_no_upgrade() {
240 let r = parse_rule(serde_json::json!({
241 "name": "r",
242 "listen": [":2222"],
243 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
244 }));
245 let graph =
246 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
247 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
248 assert_eq!(upgrades, 0);
249 }
250
251 #[test]
252 fn duplicate_rule_names_fail_at_merge_stage() {
253 let a = parse_rule(serde_json::json!({
254 "name": "same",
255 "listen": [":1000"],
256 "terminate": { "type": "http_proxy" },
257 }));
258 let b = parse_rule(serde_json::json!({
259 "name": "same",
260 "listen": [":1001"],
261 "terminate": { "type": "http_proxy" },
262 }));
263 let err = compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers)
264 .expect_err("duplicate must fail");
265 assert!(err.to_string().contains("duplicate"));
266 }
267
268 #[test]
269 fn wildcard_port_listen_spec_is_rejected() {
270 let r = parse_rule(serde_json::json!({
271 "name": "r",
272 "listen": [":0"],
273 "terminate": { "type": "http_proxy" },
274 }));
275 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
276 .expect_err("wildcard port must fail");
277 assert!(err.to_string().contains("wildcard port"));
278 }
279
280 #[test]
281 fn validate_runs_and_catches_basic_graph_integrity() {
282 let r = parse_rule(serde_json::json!({
286 "name": "r",
287 "listen": [":443"],
288 "terminate": { "type": "http_proxy" },
289 }));
290 let graph =
291 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
292 validate::validate(&graph).expect("re-validate");
294 }
295
296 #[test]
297 fn symbolic_flow_graph_round_trip_preserves_structure_and_revalidates() {
298 use crate::ir::SymbolicFlowGraph;
303 let r = parse_rule(serde_json::json!({
304 "name": "proxy",
305 "listen": [":443"],
306 "middleware_chain": [{ "use": "forward_client_ip" }, { "use": "rate_limit", "args": { "rate": 100 } }],
307 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
308 }));
309 let graph =
310 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
311
312 let encoded = serde_json::to_string(&*graph).expect("serialize graph");
313 let decoded: SymbolicFlowGraph = serde_json::from_str(&encoded).expect("deserialize graph");
314
315 validate::validate(&decoded).expect("decoded graph revalidates");
318
319 assert_eq!(decoded.nodes.len(), graph.nodes.len(), "nodes slab length");
321 assert_eq!(decoded.predicates.len(), graph.predicates.len(), "predicates slab length");
322 assert_eq!(decoded.middlewares.len(), graph.middlewares.len(), "middlewares slab length");
323 assert_eq!(decoded.fetches.len(), graph.fetches.len(), "fetches slab length");
324 assert_eq!(decoded.terminators.len(), graph.terminators.len(), "terminators slab length");
325
326 let orig_keys: std::collections::BTreeSet<_> = graph.entries.keys().copied().collect();
328 let dec_keys: std::collections::BTreeSet<_> = decoded.entries.keys().copied().collect();
329 assert_eq!(orig_keys, dec_keys, "entries key set must round-trip");
330
331 assert_eq!(decoded.predicates, graph.predicates, "predicates slab content");
334 assert_eq!(decoded.middlewares, graph.middlewares, "middlewares slab content");
335 assert_eq!(decoded.terminators, graph.terminators, "terminators slab content");
336
337 for (i, (a, b)) in graph.nodes.iter().zip(decoded.nodes.iter()).enumerate() {
342 match (a, b) {
343 (
344 Node::Check { predicate: pa, on_match: ma, on_miss: sa, collect_body_before: ca },
345 Node::Check { predicate: pb, on_match: mb, on_miss: sb, collect_body_before: cb },
346 ) => {
347 assert_eq!(pa, pb, "node[{i}] Check predicate");
348 assert_eq!(ma, mb, "node[{i}] Check on_match");
349 assert_eq!(sa, sb, "node[{i}] Check on_miss");
350 assert_eq!(ca, cb, "node[{i}] Check collect_body_before");
351 }
352 (
353 Node::Middleware { id: ia, next: na, on_error: ea, collect_body_before: ca },
354 Node::Middleware { id: ib, next: nb, on_error: eb, collect_body_before: cb },
355 ) => {
356 assert_eq!(ia, ib, "node[{i}] Middleware id");
357 assert_eq!(na, nb, "node[{i}] Middleware next");
358 assert_eq!(ea, eb, "node[{i}] Middleware on_error");
359 assert_eq!(ca, cb, "node[{i}] Middleware collect_body_before");
360 }
361 (
362 Node::Fetch { id: ia, next_response: ra, next_tunnel: ta, collect_body_before: ca },
363 Node::Fetch { id: ib, next_response: rb, next_tunnel: tb, collect_body_before: cb },
364 ) => {
365 assert_eq!(ia, ib, "node[{i}] Fetch id");
366 assert_eq!(ra, rb, "node[{i}] Fetch next_response");
367 assert_eq!(ta, tb, "node[{i}] Fetch next_tunnel");
368 assert_eq!(ca, cb, "node[{i}] Fetch collect_body_before");
369 }
370 (Node::Upgrade { next: a }, Node::Upgrade { next: b }) => {
371 assert_eq!(a, b, "node[{i}] Upgrade next");
372 }
373 (Node::Terminate(a), Node::Terminate(b)) => {
374 assert_eq!(a, b, "node[{i}] Terminate");
375 }
376 (a, b) => panic!("node[{i}] variant changed across round-trip: {a:?} -> {b:?}"),
377 }
378 }
379 }
380
381 fn check_rule(name: &str, port: u16, match_predicate: &serde_json::Value) -> RawRule {
384 parse_rule(serde_json::json!({
385 "name": name,
386 "listen": [format!(":{port}")],
387 "match": match_predicate,
388 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
389 }))
390 }
391
392 fn find_entry_check(graph: &SymbolicFlowGraph, port: u16) -> NodeId {
393 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
394 *graph.entries.get(&v4).expect("entry present")
395 }
396
397 fn unwrap_check(node: &Node) -> (PredicateId, NodeId, NodeId) {
398 match node {
399 Node::Check { predicate, on_match, on_miss, .. } => (*predicate, *on_match, *on_miss),
400 other => panic!("expected Check, got {other:?}"),
401 }
402 }
403
404 #[test]
405 fn any_of_two_checks_chains_via_on_miss_sharing_on_match() {
406 let r = check_rule(
407 "r",
408 7100,
409 &serde_json::json!({
410 "any_of": [
411 { "tls.sni": { "equals": "a" } },
412 { "tls.sni": { "equals": "b" } },
413 ],
414 }),
415 );
416 let graph =
417 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
418
419 let entry = find_entry_check(&graph, 7100);
420 let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
421 let (_, match_b, _miss_b) = unwrap_check(&graph[miss_a]);
422 assert_eq!(match_a, match_b, "both any_of branches share on_match");
423 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
424 assert_eq!(check_count, 2);
425 assert_eq!(graph.predicates.len(), 2, "tls.sni=\"a\" and tls.sni=\"b\" are distinct");
426 }
427
428 #[test]
429 fn any_of_three_checks_chains_right_to_left() {
430 let r = check_rule(
431 "r",
432 7101,
433 &serde_json::json!({
434 "any_of": [
435 { "tls.sni": { "equals": "a" } },
436 { "tls.sni": { "equals": "b" } },
437 { "tls.sni": { "equals": "c" } },
438 ],
439 }),
440 );
441 let graph =
442 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
443
444 let c0 = find_entry_check(&graph, 7101);
445 let (_, m0, miss0) = unwrap_check(&graph[c0]);
446 let (_, m1, miss1) = unwrap_check(&graph[miss0]);
447 let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
448 assert_eq!(m0, m1);
449 assert_eq!(m1, m2, "all three any_of branches share on_match");
450 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
451 }
452
453 #[test]
454 fn not_wrapping_a_check_swaps_on_match_and_on_miss() {
455 let r =
456 check_rule("r", 7102, &serde_json::json!({ "not": { "tls.sni": { "equals": "internal" } } }));
457 let graph =
458 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
459
460 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
462 assert_eq!(check_count, 1);
463 let entry = find_entry_check(&graph, 7102);
464 let (_, on_match, on_miss) = unwrap_check(&graph[entry]);
465 assert_ne!(on_match, on_miss);
471 }
475
476 #[test]
477 fn not_wrapping_any_of_swaps_edges_and_produces_two_checks() {
478 let r = check_rule(
479 "r",
480 7103,
481 &serde_json::json!({
482 "not": {
483 "any_of": [
484 { "tls.sni": { "equals": "a" } },
485 { "tls.sni": { "equals": "b" } },
486 ],
487 },
488 }),
489 );
490 let graph =
491 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
492
493 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
494 let c0 = find_entry_check(&graph, 7103);
495 let (_, m0, miss0) = unwrap_check(&graph[c0]);
496 let (_, m1, _miss1) = unwrap_check(&graph[miss0]);
497 assert_eq!(m0, m1);
501 }
502
503 #[test]
504 fn any_of_nested_inside_any_of_produces_three_checks_with_shared_on_match() {
505 let r = check_rule(
506 "r",
507 7104,
508 &serde_json::json!({
509 "any_of": [
510 { "tls.sni": { "equals": "a" } },
511 {
512 "any_of": [
513 { "tls.sni": { "equals": "b" } },
514 { "tls.sni": { "equals": "c" } },
515 ],
516 },
517 ],
518 }),
519 );
520 let graph =
521 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
522
523 let c0 = find_entry_check(&graph, 7104);
524 let (_, m0, miss0) = unwrap_check(&graph[c0]);
525 let (_, m1, miss1) = unwrap_check(&graph[miss0]);
526 let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
527 assert_eq!(m0, m1);
528 assert_eq!(m1, m2);
529 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
530 }
531
532 #[test]
533 fn empty_any_of_short_circuits_to_on_miss() {
534 let r = check_rule("r", 7105, &serde_json::json!({ "any_of": [] }));
535 let graph =
539 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
540 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
541 assert_eq!(check_count, 0, "empty any_of must not emit a Check node");
542 }
543
544 #[test]
545 fn any_of_hash_cons_shares_predicate_slot_across_rules() {
546 let a = check_rule(
551 "a",
552 7106,
553 &serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
554 );
555 let b = check_rule(
556 "b",
557 7107,
558 &serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
559 );
560 let graph =
561 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
562 assert_eq!(graph.predicates.len(), 1);
563 }
564
565 fn node_successors(n: &Node) -> Vec<NodeId> {
568 match n {
569 Node::Check { on_match, on_miss, .. } => vec![*on_match, *on_miss],
570 Node::Middleware { next, on_error, .. } => {
571 let mut v = vec![*next];
572 if let Some(e) = on_error {
573 v.push(*e);
574 }
575 v
576 }
577 Node::Fetch { next_response, next_tunnel, .. } => {
578 let mut v = Vec::new();
579 if let Some(r) = next_response {
580 v.push(*r);
581 }
582 if let Some(t) = next_tunnel {
583 v.push(*t);
584 }
585 v
586 }
587 Node::Upgrade { next } => vec![*next],
588 Node::Terminate(_) => Vec::new(),
589 }
590 }
591
592 fn walk_reachable(graph: &SymbolicFlowGraph, from: NodeId) -> std::collections::HashSet<NodeId> {
593 let mut seen = std::collections::HashSet::new();
594 let mut stack = vec![from];
595 while let Some(id) = stack.pop() {
596 if !seen.insert(id) {
597 continue;
598 }
599 for s in node_successors(&graph[id]) {
600 stack.push(s);
601 }
602 }
603 seen
604 }
605
606 #[test]
607 fn l4_predicate_on_l7_rule_sits_before_upgrade() {
608 let r = check_rule("r", 7300, &serde_json::json!({ "tls.sni": { "equals": "a" } }));
612 let graph =
613 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
614 let entry = find_entry_check(&graph, 7300);
615 assert!(matches!(&graph[entry], Node::Check { .. }));
617 let (_, on_match, _) = unwrap_check(&graph[entry]);
619 let reached = walk_reachable(&graph, on_match);
620 let upgrade_reached = reached.iter().any(|id| matches!(&graph[*id], Node::Upgrade { .. }));
621 assert!(upgrade_reached, "Upgrade must sit below the L4-level Check");
622 }
623
624 #[test]
625 fn l7_predicate_on_l7_rule_sits_after_upgrade() {
626 let r = check_rule(
629 "r",
630 7301,
631 &serde_json::json!({ "http.header.host": { "equals": "api.example.com" } }),
632 );
633 let graph =
634 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
635 let entry = find_entry_check(&graph, 7301);
636 assert!(
638 matches!(&graph[entry], Node::Upgrade { .. }),
639 "L7-level check must sit below Upgrade, so listener entry is the Upgrade itself",
640 );
641 let Node::Upgrade { next } = &graph[entry] else {
643 panic!("expected Upgrade");
644 };
645 assert!(matches!(&graph[*next], Node::Check { .. }));
646 }
647
648 #[test]
649 fn pure_l4_rule_with_predicate_synthesises_close_miss() {
650 let r = parse_rule(serde_json::json!({
651 "name": "r",
652 "listen": [":7302"],
653 "match": { "remote.ip": { "cidr": "10.0.0.0/8" } },
654 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
655 }));
656 let graph =
657 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
658 let entry = find_entry_check(&graph, 7302);
659 assert!(matches!(&graph[entry], Node::Check { .. }));
660 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
661 assert_eq!(upgrades, 0, "L4 posture never Upgrades");
662 assert!(
663 graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
664 "default-miss must synthesise a Close terminator",
665 );
666 }
667
668 #[test]
669 fn l7_rule_with_predicate_uses_close_not_500_for_default_miss() {
670 let r = check_rule("r", 7400, &serde_json::json!({ "tls.sni": { "equals": "api" } }));
674 let graph =
675 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
676 assert!(
677 graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
678 "default-miss must be Close",
679 );
680 let synth_fetches =
681 graph.fetches.iter().filter(|f| f.kind == FetchKind::HttpSynthesize).count();
682 assert_eq!(synth_fetches, 0, "no 500 synth for unmatched L7 traffic — just Close");
683 }
684
685 #[test]
686 fn catch_all_rule_set_omits_close_fallback() {
687 let r = parse_rule(serde_json::json!({
690 "name": "r",
691 "listen": [":7401"],
692 "terminate": { "type": "http_proxy" },
693 }));
694 let graph =
695 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
696 let close_count = graph.terminators.iter().filter(|t| matches!(t, Terminator::Close)).count();
697 assert_eq!(close_count, 0, "no predicate means no miss path means no Close");
698 }
699
700 #[test]
701 fn close_terminator_serde_round_trip() {
702 let t = Terminator::Close;
704 let encoded = serde_json::to_string(&t).expect("serialize");
705 let decoded: Terminator = serde_json::from_str(&encoded).expect("deserialize");
706 assert_eq!(decoded, t);
707 }
708
709 #[test]
710 fn l7_rule_without_predicate_has_upgrade_as_entry() {
711 let r = parse_rule(serde_json::json!({
712 "name": "r",
713 "listen": [":7303"],
714 "terminate": { "type": "http_proxy" },
715 }));
716 let graph =
717 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
718 let entry = find_entry_check(&graph, 7303);
719 assert!(matches!(&graph[entry], Node::Upgrade { .. }));
720 }
721
722 #[test]
723 fn cross_level_any_of_is_rejected() {
724 let r = check_rule(
725 "r",
726 7304,
727 &serde_json::json!({
728 "any_of": [
729 { "tls.sni": { "equals": "a" } },
730 { "http.method": { "equals": "GET" } },
731 ],
732 }),
733 );
734 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
735 .expect_err("cross-level any_of must fail");
736 assert!(err.to_string().contains("cross-level"), "error message names the constraint: {err}");
737 }
738
739 #[test]
740 fn cross_level_not_is_rejected() {
741 let r = check_rule(
742 "r",
743 7305,
744 &serde_json::json!({
745 "not": {
746 "any_of": [
747 { "tls.sni": { "equals": "a" } },
748 { "http.method": { "equals": "GET" } },
749 ],
750 },
751 }),
752 );
753 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
754 .expect_err("cross-level not(any_of) must fail");
755 assert!(err.to_string().contains("cross-level"));
756 }
757
758 #[test]
759 fn same_level_any_of_compiles_at_one_side_of_upgrade() {
760 let r = check_rule(
762 "r",
763 7306,
764 &serde_json::json!({
765 "any_of": [
766 { "tls.sni": { "equals": "a" } },
767 { "tls.sni": { "equals": "b" } },
768 ],
769 }),
770 );
771 let graph =
772 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
773 let entry = find_entry_check(&graph, 7306);
774 assert!(matches!(&graph[entry], Node::Check { .. }));
776 }
777
778 #[test]
779 fn validate_stays_green_for_all_combinator_shapes() {
780 let shapes = [
781 serde_json::json!({ "tls.sni": { "equals": "x" } }),
782 serde_json::json!({
783 "any_of": [
784 { "tls.sni": { "equals": "a" } },
785 { "tls.sni": { "equals": "b" } },
786 ],
787 }),
788 serde_json::json!({ "not": { "tls.sni": { "equals": "y" } } }),
789 serde_json::json!({
790 "not": {
791 "any_of": [
792 { "tls.sni": { "equals": "a" } },
793 { "tls.sni": { "equals": "b" } },
794 ],
795 },
796 }),
797 ];
798 for (i, m) in shapes.iter().enumerate() {
799 let port = 7200 + u16::try_from(i).expect("fits u16");
800 let r = check_rule(&format!("r{i}"), port, m);
801 let graph =
802 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
803 validate::validate(&graph).expect("validate");
804 }
805 }
806}