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 let entries = rules.into_iter().map(crate::preset::RuleEntry::Raw).collect();
103 RawRuleFile { path: PathBuf::from(path), order: 0, rules: entries }
104 }
105
106 fn _unused_mentions() {
107 let _ = TerminateSpec { kind: FetchKind::HttpProxy, args: serde_json::Value::Null };
108 }
109
110 #[test]
111 fn reverse_proxy_end_to_end_compiles_with_dual_stack_entries() {
112 let r = parse_rule(serde_json::json!({
113 "name": "proxy",
114 "listen": [":443"],
115 "middleware_chain": [{ "use": "forward_client_ip" }, { "use": "rate_limit", "args": { "rate": 100 } }],
116 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
117 }));
118 let graph =
119 compile(vec![rule_file("30-proxy.json", vec![r])], &Providers, &Providers).expect("compile");
120 assert!(!graph.nodes.is_empty());
121 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 443);
123 let v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 443);
124 let e_v4 = graph.entries.get(&v4).expect("v4 entry present");
125 let e_v6 = graph.entries.get(&v6).expect("v6 entry present");
126 assert_eq!(e_v4, e_v6);
127 assert!(
130 graph.terminators.iter().any(|t| matches!(t, Terminator::WriteHttpResponse)),
131 "expected WriteHttpResponse terminator",
132 );
133 }
134
135 #[test]
136 fn predicate_hash_cons_shares_id_across_rules() {
137 let a = parse_rule(serde_json::json!({
140 "name": "a",
141 "listen": [":8443"],
142 "match": { "tls.sni": { "equals": "api" } },
143 "terminate": { "type": "http_proxy" },
144 }));
145 let b = parse_rule(serde_json::json!({
146 "name": "b",
147 "listen": [":9443"],
148 "match": { "tls.sni": { "equals": "api" } },
149 "terminate": { "type": "http_proxy" },
150 }));
151 let graph =
152 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
153 assert_eq!(graph.predicates.len(), 1, "identical predicates must hash-cons to one slot");
154 }
155
156 #[test]
157 fn stateless_middleware_hash_cons_across_rules() {
158 let a = parse_rule(serde_json::json!({
161 "name": "a",
162 "listen": [":7001"],
163 "middleware_chain": [{ "use": "forward_client_ip" }],
164 "terminate": { "type": "http_proxy" },
165 }));
166 let b = parse_rule(serde_json::json!({
167 "name": "b",
168 "listen": [":7002"],
169 "middleware_chain": [{ "use": "forward_client_ip" }],
170 "terminate": { "type": "http_proxy" },
171 }));
172 let graph =
173 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
174 let shared = graph
175 .middlewares
176 .iter()
177 .filter(|m| m.name.as_ref() == "forward_client_ip" && m.stateless)
178 .count();
179 assert_eq!(shared, 1, "stateless middleware dedups across rules");
180 }
181
182 #[test]
183 fn stateful_middleware_per_site_not_shared() {
184 let a = parse_rule(serde_json::json!({
188 "name": "a",
189 "listen": [":7003"],
190 "middleware_chain": [{ "use": "rate_limit", "args": { "rate": 100 } }],
191 "terminate": { "type": "http_proxy" },
192 }));
193 let b = parse_rule(serde_json::json!({
194 "name": "b",
195 "listen": [":7004"],
196 "middleware_chain": [{ "use": "rate_limit", "args": { "rate": 100 } }],
197 "terminate": { "type": "http_proxy" },
198 }));
199 let graph =
200 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
201 let rate_limit_count =
202 graph.middlewares.iter().filter(|m| m.name.as_ref() == "rate_limit").count();
203 assert_eq!(rate_limit_count, 2, "stateful middleware must not share ids across call sites");
204 }
205
206 #[test]
207 fn terminator_variant_derives_from_fetch_kind() {
208 let http = parse_rule(serde_json::json!({
210 "name": "http",
211 "listen": [":8080"],
212 "terminate": { "type": "http_proxy" },
213 }));
214 let tcp = parse_rule(serde_json::json!({
215 "name": "tcp",
216 "listen": [":2222"],
217 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
218 }));
219 let graph =
220 compile(vec![rule_file("a.json", vec![http, tcp])], &Providers, &Providers).expect("compile");
221 let terms: std::collections::HashSet<_> = graph.terminators.iter().copied().collect();
222 assert!(terms.contains(&Terminator::WriteHttpResponse));
223 assert!(terms.contains(&Terminator::ByteTunnel));
224 }
225
226 #[test]
227 fn l7_rule_inserts_upgrade_node() {
228 let r = parse_rule(serde_json::json!({
229 "name": "r",
230 "listen": [":443"],
231 "terminate": { "type": "http_proxy" },
232 }));
233 let graph =
234 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
235 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
236 assert!(upgrades >= 1, "L7 listener must have at least one Upgrade node");
237 }
238
239 #[test]
240 fn l4_only_rule_has_no_upgrade() {
241 let r = parse_rule(serde_json::json!({
242 "name": "r",
243 "listen": [":2222"],
244 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
245 }));
246 let graph =
247 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
248 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
249 assert_eq!(upgrades, 0);
250 }
251
252 #[test]
253 fn duplicate_rule_names_fail_at_merge_stage() {
254 let a = parse_rule(serde_json::json!({
255 "name": "same",
256 "listen": [":1000"],
257 "terminate": { "type": "http_proxy" },
258 }));
259 let b = parse_rule(serde_json::json!({
260 "name": "same",
261 "listen": [":1001"],
262 "terminate": { "type": "http_proxy" },
263 }));
264 let err = compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers)
265 .expect_err("duplicate must fail");
266 assert!(err.to_string().contains("duplicate"));
267 }
268
269 #[test]
270 fn wildcard_port_listen_spec_is_rejected() {
271 let r = parse_rule(serde_json::json!({
272 "name": "r",
273 "listen": [":0"],
274 "terminate": { "type": "http_proxy" },
275 }));
276 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
277 .expect_err("wildcard port must fail");
278 assert!(err.to_string().contains("wildcard port"));
279 }
280
281 #[test]
282 fn validate_runs_and_catches_basic_graph_integrity() {
283 let r = parse_rule(serde_json::json!({
287 "name": "r",
288 "listen": [":443"],
289 "terminate": { "type": "http_proxy" },
290 }));
291 let graph =
292 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
293 validate::validate(&graph).expect("re-validate");
295 }
296
297 #[test]
298 fn symbolic_flow_graph_round_trip_preserves_structure_and_revalidates() {
299 use crate::ir::SymbolicFlowGraph;
304 let r = parse_rule(serde_json::json!({
305 "name": "proxy",
306 "listen": [":443"],
307 "middleware_chain": [{ "use": "forward_client_ip" }, { "use": "rate_limit", "args": { "rate": 100 } }],
308 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
309 }));
310 let graph =
311 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
312
313 let encoded = serde_json::to_string(&*graph).expect("serialize graph");
314 let decoded: SymbolicFlowGraph = serde_json::from_str(&encoded).expect("deserialize graph");
315
316 validate::validate(&decoded).expect("decoded graph revalidates");
319
320 assert_eq!(decoded.nodes.len(), graph.nodes.len(), "nodes slab length");
322 assert_eq!(decoded.predicates.len(), graph.predicates.len(), "predicates slab length");
323 assert_eq!(decoded.middlewares.len(), graph.middlewares.len(), "middlewares slab length");
324 assert_eq!(decoded.fetches.len(), graph.fetches.len(), "fetches slab length");
325 assert_eq!(decoded.terminators.len(), graph.terminators.len(), "terminators slab length");
326
327 let orig_keys: std::collections::BTreeSet<_> = graph.entries.keys().copied().collect();
329 let dec_keys: std::collections::BTreeSet<_> = decoded.entries.keys().copied().collect();
330 assert_eq!(orig_keys, dec_keys, "entries key set must round-trip");
331
332 assert_eq!(decoded.predicates, graph.predicates, "predicates slab content");
335 assert_eq!(decoded.middlewares, graph.middlewares, "middlewares slab content");
336 assert_eq!(decoded.terminators, graph.terminators, "terminators slab content");
337
338 for (i, (a, b)) in graph.nodes.iter().zip(decoded.nodes.iter()).enumerate() {
343 match (a, b) {
344 (
345 Node::Check {
346 predicate: pa,
347 on_match: ma,
348 on_miss: sa,
349 collect_body_before: ca,
350 body_limit: la,
351 },
352 Node::Check {
353 predicate: pb,
354 on_match: mb,
355 on_miss: sb,
356 collect_body_before: cb,
357 body_limit: lb,
358 },
359 ) => {
360 assert_eq!(pa, pb, "node[{i}] Check predicate");
361 assert_eq!(ma, mb, "node[{i}] Check on_match");
362 assert_eq!(sa, sb, "node[{i}] Check on_miss");
363 assert_eq!(ca, cb, "node[{i}] Check collect_body_before");
364 assert_eq!(la, lb, "node[{i}] Check body_limit");
365 }
366 (
367 Node::Middleware {
368 id: ia,
369 next: na,
370 on_error: ea,
371 collect_body_before: ca,
372 body_limit: la,
373 },
374 Node::Middleware {
375 id: ib,
376 next: nb,
377 on_error: eb,
378 collect_body_before: cb,
379 body_limit: lb,
380 },
381 ) => {
382 assert_eq!(ia, ib, "node[{i}] Middleware id");
383 assert_eq!(na, nb, "node[{i}] Middleware next");
384 assert_eq!(ea, eb, "node[{i}] Middleware on_error");
385 assert_eq!(ca, cb, "node[{i}] Middleware collect_body_before");
386 assert_eq!(la, lb, "node[{i}] Middleware body_limit");
387 }
388 (
389 Node::Fetch {
390 id: ia,
391 next_response: ra,
392 next_tunnel: ta,
393 collect_body_before: ca,
394 body_limit: la,
395 },
396 Node::Fetch {
397 id: ib,
398 next_response: rb,
399 next_tunnel: tb,
400 collect_body_before: cb,
401 body_limit: lb,
402 },
403 ) => {
404 assert_eq!(ia, ib, "node[{i}] Fetch id");
405 assert_eq!(ra, rb, "node[{i}] Fetch next_response");
406 assert_eq!(ta, tb, "node[{i}] Fetch next_tunnel");
407 assert_eq!(ca, cb, "node[{i}] Fetch collect_body_before");
408 assert_eq!(la, lb, "node[{i}] Fetch body_limit");
409 }
410 (Node::Upgrade { next: a }, Node::Upgrade { next: b }) => {
411 assert_eq!(a, b, "node[{i}] Upgrade next");
412 }
413 (Node::Terminate(a), Node::Terminate(b)) => {
414 assert_eq!(a, b, "node[{i}] Terminate");
415 }
416 (a, b) => panic!("node[{i}] variant changed across round-trip: {a:?} -> {b:?}"),
417 }
418 }
419 }
420
421 fn check_rule(name: &str, port: u16, match_predicate: &serde_json::Value) -> RawRule {
424 parse_rule(serde_json::json!({
425 "name": name,
426 "listen": [format!(":{port}")],
427 "match": match_predicate,
428 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
429 }))
430 }
431
432 fn find_entry_check(graph: &SymbolicFlowGraph, port: u16) -> NodeId {
433 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
434 let entry = *graph.entries.get(&v4).expect("entry present");
435 match &graph[entry] {
439 Node::Upgrade { next } => *next,
440 _ => entry,
441 }
442 }
443
444 fn unwrap_check(node: &Node) -> (PredicateId, NodeId, NodeId) {
445 match node {
446 Node::Check { predicate, on_match, on_miss, .. } => (*predicate, *on_match, *on_miss),
447 other => panic!("expected Check, got {other:?}"),
448 }
449 }
450
451 #[test]
452 fn any_of_two_checks_chains_via_on_miss_sharing_on_match() {
453 let r = check_rule(
454 "r",
455 7100,
456 &serde_json::json!({
457 "any_of": [
458 { "tls.sni": { "equals": "a" } },
459 { "tls.sni": { "equals": "b" } },
460 ],
461 }),
462 );
463 let graph =
464 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
465
466 let entry = find_entry_check(&graph, 7100);
467 let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
468 let (_, match_b, _miss_b) = unwrap_check(&graph[miss_a]);
469 assert_eq!(match_a, match_b, "both any_of branches share on_match");
470 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
471 assert_eq!(check_count, 2);
472 assert_eq!(graph.predicates.len(), 2, "tls.sni=\"a\" and tls.sni=\"b\" are distinct");
473 }
474
475 #[test]
476 fn any_of_three_checks_chains_right_to_left() {
477 let r = check_rule(
478 "r",
479 7101,
480 &serde_json::json!({
481 "any_of": [
482 { "tls.sni": { "equals": "a" } },
483 { "tls.sni": { "equals": "b" } },
484 { "tls.sni": { "equals": "c" } },
485 ],
486 }),
487 );
488 let graph =
489 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
490
491 let c0 = find_entry_check(&graph, 7101);
492 let (_, m0, miss0) = unwrap_check(&graph[c0]);
493 let (_, m1, miss1) = unwrap_check(&graph[miss0]);
494 let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
495 assert_eq!(m0, m1);
496 assert_eq!(m1, m2, "all three any_of branches share on_match");
497 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
498 }
499
500 #[test]
501 fn not_wrapping_a_check_swaps_on_match_and_on_miss() {
502 let r =
503 check_rule("r", 7102, &serde_json::json!({ "not": { "tls.sni": { "equals": "internal" } } }));
504 let graph =
505 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
506
507 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
509 assert_eq!(check_count, 1);
510 let entry = find_entry_check(&graph, 7102);
511 let (_, on_match, on_miss) = unwrap_check(&graph[entry]);
512 assert_ne!(on_match, on_miss);
518 }
522
523 #[test]
524 fn not_wrapping_any_of_swaps_edges_and_produces_two_checks() {
525 let r = check_rule(
526 "r",
527 7103,
528 &serde_json::json!({
529 "not": {
530 "any_of": [
531 { "tls.sni": { "equals": "a" } },
532 { "tls.sni": { "equals": "b" } },
533 ],
534 },
535 }),
536 );
537 let graph =
538 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
539
540 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
541 let c0 = find_entry_check(&graph, 7103);
542 let (_, m0, miss0) = unwrap_check(&graph[c0]);
543 let (_, m1, _miss1) = unwrap_check(&graph[miss0]);
544 assert_eq!(m0, m1);
548 }
549
550 #[test]
551 fn any_of_nested_inside_any_of_produces_three_checks_with_shared_on_match() {
552 let r = check_rule(
553 "r",
554 7104,
555 &serde_json::json!({
556 "any_of": [
557 { "tls.sni": { "equals": "a" } },
558 {
559 "any_of": [
560 { "tls.sni": { "equals": "b" } },
561 { "tls.sni": { "equals": "c" } },
562 ],
563 },
564 ],
565 }),
566 );
567 let graph =
568 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
569
570 let c0 = find_entry_check(&graph, 7104);
571 let (_, m0, miss0) = unwrap_check(&graph[c0]);
572 let (_, m1, miss1) = unwrap_check(&graph[miss0]);
573 let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
574 assert_eq!(m0, m1);
575 assert_eq!(m1, m2);
576 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
577 }
578
579 #[test]
580 fn empty_any_of_short_circuits_to_on_miss() {
581 let r = check_rule("r", 7105, &serde_json::json!({ "any_of": [] }));
582 let graph =
586 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
587 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
588 assert_eq!(check_count, 0, "empty any_of must not emit a Check node");
589 }
590
591 #[test]
592 fn any_of_hash_cons_shares_predicate_slot_across_rules() {
593 let a = check_rule(
598 "a",
599 7106,
600 &serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
601 );
602 let b = check_rule(
603 "b",
604 7107,
605 &serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
606 );
607 let graph =
608 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
609 assert_eq!(graph.predicates.len(), 1);
610 }
611
612 #[test]
615 fn l4_predicate_on_l7_rule_sits_post_upgrade() {
616 let r = check_rule("r", 7300, &serde_json::json!({ "tls.sni": { "equals": "a" } }));
623 let graph =
624 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
625 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7300);
627 let listener_entry = *graph.entries.get(&v4).expect("entry present");
628 assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
629 let check_below = find_entry_check(&graph, 7300);
631 assert!(matches!(&graph[check_below], Node::Check { .. }));
632 }
633
634 #[test]
635 fn l7_predicate_on_l7_rule_sits_after_upgrade() {
636 let r = check_rule(
640 "r",
641 7301,
642 &serde_json::json!({ "http.header.host": { "equals": "api.example.com" } }),
643 );
644 let graph =
645 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
646 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7301);
647 let listener_entry = *graph.entries.get(&v4).expect("entry present");
648 assert!(
649 matches!(&graph[listener_entry], Node::Upgrade { .. }),
650 "L7 listener entry is the shared Upgrade",
651 );
652 let Node::Upgrade { next } = &graph[listener_entry] else {
653 panic!("expected Upgrade");
654 };
655 assert!(matches!(&graph[*next], Node::Check { .. }));
656 }
657
658 #[test]
659 fn pure_l4_rule_with_predicate_synthesises_close_miss() {
660 let r = parse_rule(serde_json::json!({
661 "name": "r",
662 "listen": [":7302"],
663 "match": { "remote.ip": { "cidr": "10.0.0.0/8" } },
664 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
665 }));
666 let graph =
667 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
668 let entry = find_entry_check(&graph, 7302);
669 assert!(matches!(&graph[entry], Node::Check { .. }));
670 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
671 assert_eq!(upgrades, 0, "L4 posture never Upgrades");
672 assert!(
673 graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
674 "default-miss must synthesise a Close terminator",
675 );
676 }
677
678 #[test]
679 fn l7_rule_with_predicate_uses_close_not_500_for_default_miss() {
680 let r = check_rule("r", 7400, &serde_json::json!({ "tls.sni": { "equals": "api" } }));
684 let graph =
685 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
686 assert!(
687 graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
688 "default-miss must be Close",
689 );
690 let synth_fetches =
691 graph.fetches.iter().filter(|f| f.kind == FetchKind::HttpSynthesize).count();
692 assert_eq!(synth_fetches, 0, "no 500 synth for unmatched L7 traffic — just Close");
693 }
694
695 #[test]
696 fn catch_all_rule_set_omits_close_fallback() {
697 let r = parse_rule(serde_json::json!({
700 "name": "r",
701 "listen": [":7401"],
702 "terminate": { "type": "http_proxy" },
703 }));
704 let graph =
705 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
706 let close_count = graph.terminators.iter().filter(|t| matches!(t, Terminator::Close)).count();
707 assert_eq!(close_count, 0, "no predicate means no miss path means no Close");
708 }
709
710 #[test]
711 fn close_terminator_serde_round_trip() {
712 let t = Terminator::Close;
714 let encoded = serde_json::to_string(&t).expect("serialize");
715 let decoded: Terminator = serde_json::from_str(&encoded).expect("deserialize");
716 assert_eq!(decoded, t);
717 }
718
719 #[test]
720 fn l7_rule_without_predicate_has_upgrade_as_entry() {
721 let r = parse_rule(serde_json::json!({
722 "name": "r",
723 "listen": [":7303"],
724 "terminate": { "type": "http_proxy" },
725 }));
726 let graph =
727 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
728 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7303);
729 let listener_entry = *graph.entries.get(&v4).expect("entry present");
730 assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
731 }
732
733 #[test]
734 fn cross_level_any_of_is_rejected() {
735 let r = check_rule(
736 "r",
737 7304,
738 &serde_json::json!({
739 "any_of": [
740 { "tls.sni": { "equals": "a" } },
741 { "http.method": { "equals": "GET" } },
742 ],
743 }),
744 );
745 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
746 .expect_err("cross-level any_of must fail");
747 assert!(err.to_string().contains("cross-level"), "error message names the constraint: {err}");
748 }
749
750 #[test]
751 fn cross_level_not_is_rejected() {
752 let r = check_rule(
753 "r",
754 7305,
755 &serde_json::json!({
756 "not": {
757 "any_of": [
758 { "tls.sni": { "equals": "a" } },
759 { "http.method": { "equals": "GET" } },
760 ],
761 },
762 }),
763 );
764 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
765 .expect_err("cross-level not(any_of) must fail");
766 assert!(err.to_string().contains("cross-level"));
767 }
768
769 #[test]
770 fn same_level_any_of_compiles_at_one_side_of_upgrade() {
771 let r = check_rule(
773 "r",
774 7306,
775 &serde_json::json!({
776 "any_of": [
777 { "tls.sni": { "equals": "a" } },
778 { "tls.sni": { "equals": "b" } },
779 ],
780 }),
781 );
782 let graph =
783 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
784 let entry = find_entry_check(&graph, 7306);
785 assert!(matches!(&graph[entry], Node::Check { .. }));
787 }
788
789 #[test]
790 fn validate_stays_green_for_all_combinator_shapes() {
791 let shapes = [
792 serde_json::json!({ "tls.sni": { "equals": "x" } }),
793 serde_json::json!({
794 "any_of": [
795 { "tls.sni": { "equals": "a" } },
796 { "tls.sni": { "equals": "b" } },
797 ],
798 }),
799 serde_json::json!({ "not": { "tls.sni": { "equals": "y" } } }),
800 serde_json::json!({
801 "not": {
802 "any_of": [
803 { "tls.sni": { "equals": "a" } },
804 { "tls.sni": { "equals": "b" } },
805 ],
806 },
807 }),
808 serde_json::json!({
809 "all_of": [
810 { "tls.sni": { "equals": "a" } },
811 { "tls.sni": { "equals": "b" } },
812 ],
813 }),
814 serde_json::json!({
815 "not": {
816 "all_of": [
817 { "tls.sni": { "equals": "a" } },
818 { "tls.sni": { "equals": "b" } },
819 ],
820 },
821 }),
822 ];
823 for (i, m) in shapes.iter().enumerate() {
824 let port = 7200 + u16::try_from(i).expect("fits u16");
825 let r = check_rule(&format!("r{i}"), port, m);
826 let graph =
827 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
828 validate::validate(&graph).expect("validate");
829 }
830 }
831
832 #[test]
835 fn all_of_two_checks_chains_left_match_to_right_entry() {
836 let r = check_rule(
841 "r",
842 7500,
843 &serde_json::json!({
844 "all_of": [
845 { "tls.sni": { "equals": "a" } },
846 { "tls.sni": { "equals": "b" } },
847 ],
848 }),
849 );
850 let graph =
851 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
852 let entry = find_entry_check(&graph, 7500);
853 let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
854 let (_, _match_b, miss_b) = unwrap_check(&graph[match_a]);
855 assert_eq!(miss_a, miss_b, "both all_of branches share on_miss");
856 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
857 }
858
859 #[test]
860 fn all_of_empty_array_short_circuits_to_on_match() {
861 let r = check_rule("r", 7501, &serde_json::json!({ "all_of": [] }));
864 let graph =
865 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
866 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
867 assert_eq!(check_count, 0, "empty all_of must not emit a Check node");
868 }
869
870 #[test]
871 fn all_of_cross_level_combinator_is_rejected() {
872 let r = check_rule(
874 "r",
875 7502,
876 &serde_json::json!({
877 "all_of": [
878 { "tls.sni": { "equals": "a" } },
879 { "http.method": { "equals": "GET" } },
880 ],
881 }),
882 );
883 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
884 .expect_err("cross-level all_of must fail");
885 let msg = err.to_string();
886 assert!(msg.contains("cross-level"), "error names the constraint: {msg}");
887 assert!(msg.contains("all_of"), "error mentions all_of: {msg}");
888 }
889
890 #[test]
891 fn all_of_nested_inside_any_of_works() {
892 let r = check_rule(
893 "r",
894 7503,
895 &serde_json::json!({
896 "any_of": [
897 { "all_of": [
898 { "http.header.upgrade": { "equals": "websocket" } },
899 { "http.uri.path": { "prefix": "/ws" } },
900 ]},
901 { "all_of": [
902 { "http.header.upgrade": { "equals": "websocket" } },
903 { "http.uri.path": { "prefix": "/api/stream" } },
904 ]},
905 ],
906 }),
907 );
908 let graph =
909 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
910 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 4);
911 }
912
913 #[test]
914 fn l7_listener_emits_single_upgrade_at_top_for_two_rules() {
915 let a = parse_rule(serde_json::json!({
919 "name": "a",
920 "listen": [":7600"],
921 "match": { "http.header.host": { "equals": "a.example.com" } },
922 "terminate": { "type": "http_proxy" },
923 }));
924 let b = parse_rule(serde_json::json!({
925 "name": "b",
926 "listen": [":7600"],
927 "match": { "http.header.host": { "equals": "b.example.com" } },
928 "terminate": { "type": "http_proxy" },
929 }));
930 let graph =
931 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
932 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
933 assert_eq!(upgrades, 1, "exactly one Upgrade per L7 listener regardless of rule count");
934 }
935
936 #[test]
937 fn l4_listener_has_no_upgrade() {
938 let r = parse_rule(serde_json::json!({
940 "name": "fwd",
941 "listen": [":7601"],
942 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
943 }));
944 let graph =
945 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
946 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
947 assert_eq!(upgrades, 0);
948 }
949
950 #[test]
951 fn websocket_upgrade_emits_two_distinct_terminators() {
952 let r = parse_rule(serde_json::json!({
956 "name": "ws",
957 "listen": [":7602"],
958 "match": { "http.header.upgrade": { "equals": "websocket" } },
959 "terminate": { "type": "websocket", "upstream": "127.0.0.1:8080" },
960 }));
961 let graph =
962 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
963 let terms: std::collections::HashSet<_> = graph.terminators.iter().copied().collect();
964 assert!(terms.contains(&Terminator::WriteHttpResponse), "response branch terminator");
965 assert!(terms.contains(&Terminator::ByteTunnel), "tunnel branch terminator");
966 }
967
968 #[test]
971 fn lower_l7_listener_synthesizes_short_circuit_response_target() {
972 let r = parse_rule(serde_json::json!({
977 "name": "r",
978 "listen": [":7700"],
979 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
980 }));
981 let graph =
982 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
983 let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
988 assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
989 for synth in graph.meta.short_circuit_response_entry.values() {
991 let Node::Terminate(tid) = &graph[*synth] else {
992 panic!("synth node is not a Terminate: {:?}", &graph[*synth]);
993 };
994 assert_eq!(graph.terminators[tid.get() as usize], Terminator::WriteHttpResponse);
995 }
996 }
997
998 #[test]
999 fn lower_l4_listener_has_no_short_circuit_response_target() {
1000 let r = parse_rule(serde_json::json!({
1003 "name": "r",
1004 "listen": [":7701"],
1005 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1006 }));
1007 let graph =
1008 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1009 assert!(graph.meta.short_circuit_response_entry.is_empty());
1010 }
1011
1012 #[test]
1013 fn lower_derives_raw_when_only_l4_forward_terminator() {
1014 let r = parse_rule(serde_json::json!({
1018 "name": "r",
1019 "listen": [":7800"],
1020 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1021 }));
1022 let graph =
1023 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1024 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7800);
1025 assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Raw));
1026 }
1027
1028 #[test]
1029 fn lower_derives_http_when_only_l7_terminators() {
1030 let r = parse_rule(serde_json::json!({
1031 "name": "r",
1032 "listen": [":7801"],
1033 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1034 }));
1035 let graph =
1036 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1037 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7801);
1038 assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Http));
1039 }
1040
1041 #[test]
1042 fn lower_derives_auto_when_l4_and_l7_share_listener() {
1043 use crate::compile::lower::test_only::derive_listener_kind_for_test;
1051 use crate::fetch::{FetchKind, SymbolicFetchRef};
1052 use crate::ir::{FetchId, ListenerKind, Node, NodeId, TerminatorId};
1053
1054 let nodes = vec![
1055 Node::Check {
1056 predicate: PredicateId::new(0),
1057 on_match: NodeId::new(1),
1058 on_miss: NodeId::new(2),
1059 collect_body_before: None,
1060 body_limit: 0,
1061 },
1062 Node::Fetch {
1063 id: FetchId::new(0),
1064 next_response: None,
1065 next_tunnel: Some(NodeId::new(3)),
1066 collect_body_before: None,
1067 body_limit: 0,
1068 },
1069 Node::Fetch {
1070 id: FetchId::new(1),
1071 next_response: Some(NodeId::new(3)),
1072 next_tunnel: None,
1073 collect_body_before: None,
1074 body_limit: 0,
1075 },
1076 Node::Terminate(TerminatorId::new(0)),
1077 ];
1078 let fetches = vec![
1079 SymbolicFetchRef {
1080 kind: FetchKind::L4Forward,
1081 args: serde_json::Value::Null,
1082 retry_buffer_required: false,
1083 },
1084 SymbolicFetchRef {
1085 kind: FetchKind::HttpProxy,
1086 args: serde_json::Value::Null,
1087 retry_buffer_required: false,
1088 },
1089 ];
1090 assert_eq!(derive_listener_kind_for_test(&nodes, &fetches, NodeId::new(0)), ListenerKind::Auto);
1091 }
1092
1093 #[test]
1094 fn listener_kinds_round_trip_through_dry_run_json() {
1095 let l4 = parse_rule(serde_json::json!({
1096 "name": "tcp",
1097 "listen": [":7803"],
1098 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1099 }));
1100 let l7 = parse_rule(serde_json::json!({
1101 "name": "http",
1102 "listen": [":7804"],
1103 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1104 }));
1105 let graph =
1106 compile(vec![rule_file("a.json", vec![l4, l7])], &Providers, &Providers).expect("compile");
1107 let encoded = serde_json::to_string(&*graph).expect("serialize graph");
1108 let decoded: crate::ir::SymbolicFlowGraph =
1109 serde_json::from_str(&encoded).expect("deserialize graph");
1110 let v4_raw = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7803);
1111 let v4_http = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7804);
1112 assert_eq!(decoded.meta.listener_kinds.get(&v4_raw), Some(&crate::ir::ListenerKind::Raw));
1113 assert_eq!(decoded.meta.listener_kinds.get(&v4_http), Some(&crate::ir::ListenerKind::Http));
1114 }
1115
1116 #[test]
1117 fn lower_force_buffering_triggers_collect_body_before_request() {
1118 let r = parse_rule(serde_json::json!({
1124 "name": "r",
1125 "listen": [":7900"],
1126 "terminate": {
1127 "type": "http_proxy",
1128 "upstream": "127.0.0.1:8080",
1129 "retry": { "max_attempts": 3, "buffering": "force" },
1130 },
1131 }));
1132 let graph =
1133 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1134 let fetch_with_collect = graph.nodes.iter().find_map(|n| match n {
1135 crate::ir::Node::Fetch {
1136 collect_body_before: Some(crate::ir::BodySide::Request), ..
1137 } => Some(()),
1138 _ => None,
1139 });
1140 assert!(
1141 fetch_with_collect.is_some(),
1142 "force buffering must flag fetch with collect_body_before"
1143 );
1144 }
1145
1146 #[test]
1147 fn lower_opportunistic_buffering_does_not_trigger_collect() {
1148 let r = parse_rule(serde_json::json!({
1149 "name": "r",
1150 "listen": [":7901"],
1151 "terminate": {
1152 "type": "http_proxy",
1153 "upstream": "127.0.0.1:8080",
1154 "retry": { "max_attempts": 3, "buffering": "opportunistic" },
1155 },
1156 }));
1157 let graph =
1158 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1159 let any_fetch_collects = graph.nodes.iter().any(|n| {
1160 matches!(
1161 n,
1162 crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1163 )
1164 });
1165 assert!(
1166 !any_fetch_collects,
1167 "opportunistic buffering must NOT flag fetch with collect_body_before",
1168 );
1169 }
1170
1171 #[test]
1172 fn lower_max_attempts_one_with_force_does_not_trigger_collect() {
1173 let r = parse_rule(serde_json::json!({
1177 "name": "r",
1178 "listen": [":7902"],
1179 "terminate": {
1180 "type": "http_proxy",
1181 "upstream": "127.0.0.1:8080",
1182 "retry": { "max_attempts": 1, "buffering": "force" },
1183 },
1184 }));
1185 let graph =
1186 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1187 let any_fetch_collects = graph.nodes.iter().any(|n| {
1188 matches!(
1189 n,
1190 crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1191 )
1192 });
1193 assert!(!any_fetch_collects, "max_attempts=1 disables the force-buffering trigger");
1194 }
1195
1196 #[test]
1197 fn lower_two_l7_listeners_have_independent_synth_entries() {
1198 let a = parse_rule(serde_json::json!({
1203 "name": "a",
1204 "listen": [":7702"],
1205 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1206 }));
1207 let b = parse_rule(serde_json::json!({
1208 "name": "b",
1209 "listen": [":7703"],
1210 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8081" },
1211 }));
1212 let graph =
1213 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
1214 let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
1218 assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
1219 assert!(
1220 graph.meta.short_circuit_response_entry.len() >= 2,
1221 "two listeners → at least two synth entries"
1222 );
1223 }
1224
1225 #[test]
1226 fn http_body_check_node_sets_collect_body_before_request() {
1227 let r = parse_rule(serde_json::json!({
1234 "name": "r",
1235 "listen": [":7910"],
1236 "match": { "http.body": { "contains": "aGVsbG8=" } },
1237 "terminate": { "type": "http_proxy" },
1238 }));
1239 let graph =
1240 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1241 let has_collecting_check = graph.nodes.iter().any(|n| {
1242 matches!(
1243 n,
1244 crate::ir::Node::Check { collect_body_before: Some(crate::ir::BodySide::Request), .. }
1245 )
1246 });
1247 assert!(has_collecting_check, "http.body Check must carry collect_body_before = Some(Request)");
1248 }
1249
1250 #[test]
1251 fn rule_without_http_body_predicate_has_no_request_collect_on_check() {
1252 let r = parse_rule(serde_json::json!({
1257 "name": "r",
1258 "listen": [":7911"],
1259 "match": { "http.method": { "equals": "GET" } },
1260 "terminate": { "type": "http_proxy" },
1261 }));
1262 let graph =
1263 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1264 let any_check_collects = graph.nodes.iter().any(|n| {
1265 matches!(
1266 n,
1267 crate::ir::Node::Check { collect_body_before: Some(crate::ir::BodySide::Request), .. }
1268 )
1269 });
1270 assert!(
1271 !any_check_collects,
1272 "non-body predicate must not set collect_body_before on any Check node"
1273 );
1274 }
1275
1276 #[test]
1277 fn malformed_base64_in_http_body_predicate_fails_compile() {
1278 let r = parse_rule(serde_json::json!({
1284 "name": "r",
1285 "listen": [":7912"],
1286 "match": { "http.body": { "contains": "not-valid-base64!!!" } },
1287 "terminate": { "type": "http_proxy" },
1288 }));
1289 let err =
1290 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect_err("must fail");
1291 let msg = err.to_string();
1292 assert!(
1293 msg.contains("base64") || msg.contains("base 64"),
1294 "error must mention base64 decoding failure, got: {msg}"
1295 );
1296 }
1297}