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 fn validate_ok(_: &serde_json::Value) -> Result<(), Error> {
55 Ok(())
56 }
57
58 impl MiddlewareMetadataProvider for Providers {
59 fn get(&self, name: &str) -> Option<MiddlewareMetadata> {
60 match name {
61 "forward_client_ip" => Some(MiddlewareMetadata {
62 kind: MiddlewareKind::L7Request,
63 stateless: true,
64 needs_body: false,
65 validate_args: validate_ok,
66 }),
67 "rate_limit" => Some(MiddlewareMetadata {
68 kind: MiddlewareKind::L7Request,
69 stateless: false,
70 needs_body: false,
71 validate_args: validate_ok,
72 }),
73 _ => None,
74 }
75 }
76 }
77
78 impl FetchMetadataProvider for Providers {
79 fn get(&self, kind: FetchKind) -> Option<FetchMetadata> {
80 Some(FetchMetadata {
81 kind,
82 phase: match kind {
83 FetchKind::L4Forward => FetchPhase::L4,
84 _ => FetchPhase::L7,
85 },
86 output_modes: match kind {
87 FetchKind::L4Forward => FetchOutputModes { response: false, tunnel: true },
88 FetchKind::WebSocketUpgrade => FetchOutputModes { response: true, tunnel: true },
89 _ => FetchOutputModes { response: true, tunnel: false },
90 },
91 validate_args: validate_ok,
92 })
93 }
94 }
95
96 fn parse_rule(j: serde_json::Value) -> RawRule {
97 serde_json::from_value(j).expect("parse rule")
98 }
99
100 fn rule_file(path: &str, rules: Vec<RawRule>) -> RawRuleFile {
101 let entries = rules.into_iter().map(crate::preset::RuleEntry::Raw).collect();
102 RawRuleFile { path: PathBuf::from(path), order: 0, rules: entries }
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 #[allow(
298 clippy::cognitive_complexity,
299 reason = "exhaustive Node-variant round-trip assertion: complexity grows with the number of variants, not with logic. Splitting to a per-variant helper just renames the variant-tag dispatch"
300 )]
301 fn symbolic_flow_graph_round_trip_preserves_structure_and_revalidates() {
302 use crate::ir::SymbolicFlowGraph;
307 let r = parse_rule(serde_json::json!({
308 "name": "proxy",
309 "listen": [":443"],
310 "middleware_chain": [{ "use": "forward_client_ip" }, { "use": "rate_limit", "args": { "rate": 100 } }],
311 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
312 }));
313 let graph =
314 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
315
316 let encoded = serde_json::to_string(&*graph).expect("serialize graph");
317 let decoded: SymbolicFlowGraph = serde_json::from_str(&encoded).expect("deserialize graph");
318
319 validate::validate(&decoded).expect("decoded graph revalidates");
322
323 assert_eq!(decoded.nodes.len(), graph.nodes.len(), "nodes slab length");
325 assert_eq!(decoded.predicates.len(), graph.predicates.len(), "predicates slab length");
326 assert_eq!(decoded.middlewares.len(), graph.middlewares.len(), "middlewares slab length");
327 assert_eq!(decoded.fetches.len(), graph.fetches.len(), "fetches slab length");
328 assert_eq!(decoded.terminators.len(), graph.terminators.len(), "terminators slab length");
329
330 let orig_keys: std::collections::BTreeSet<_> = graph.entries.keys().copied().collect();
332 let dec_keys: std::collections::BTreeSet<_> = decoded.entries.keys().copied().collect();
333 assert_eq!(orig_keys, dec_keys, "entries key set must round-trip");
334
335 assert_eq!(decoded.predicates, graph.predicates, "predicates slab content");
338 assert_eq!(decoded.middlewares, graph.middlewares, "middlewares slab content");
339 assert_eq!(decoded.terminators, graph.terminators, "terminators slab content");
340
341 for (i, (a, b)) in graph.nodes.iter().zip(decoded.nodes.iter()).enumerate() {
346 match (a, b) {
347 (
348 Node::Check {
349 predicate: pa,
350 on_match: ma,
351 on_miss: sa,
352 collect_body_before: ca,
353 body_limit: la,
354 },
355 Node::Check {
356 predicate: pb,
357 on_match: mb,
358 on_miss: sb,
359 collect_body_before: cb,
360 body_limit: lb,
361 },
362 ) => {
363 assert_eq!(pa, pb, "node[{i}] Check predicate");
364 assert_eq!(ma, mb, "node[{i}] Check on_match");
365 assert_eq!(sa, sb, "node[{i}] Check on_miss");
366 assert_eq!(ca, cb, "node[{i}] Check collect_body_before");
367 assert_eq!(la, lb, "node[{i}] Check body_limit");
368 }
369 (
370 Node::Middleware {
371 id: ia,
372 next: na,
373 on_error: ea,
374 collect_body_before: ca,
375 body_limit: la,
376 },
377 Node::Middleware {
378 id: ib,
379 next: nb,
380 on_error: eb,
381 collect_body_before: cb,
382 body_limit: lb,
383 },
384 ) => {
385 assert_eq!(ia, ib, "node[{i}] Middleware id");
386 assert_eq!(na, nb, "node[{i}] Middleware next");
387 assert_eq!(ea, eb, "node[{i}] Middleware on_error");
388 assert_eq!(ca, cb, "node[{i}] Middleware collect_body_before");
389 assert_eq!(la, lb, "node[{i}] Middleware body_limit");
390 }
391 (
392 Node::Fetch {
393 id: ia,
394 next_response: ra,
395 next_tunnel: ta,
396 collect_body_before: ca,
397 body_limit: la,
398 },
399 Node::Fetch {
400 id: ib,
401 next_response: rb,
402 next_tunnel: tb,
403 collect_body_before: cb,
404 body_limit: lb,
405 },
406 ) => {
407 assert_eq!(ia, ib, "node[{i}] Fetch id");
408 assert_eq!(ra, rb, "node[{i}] Fetch next_response");
409 assert_eq!(ta, tb, "node[{i}] Fetch next_tunnel");
410 assert_eq!(ca, cb, "node[{i}] Fetch collect_body_before");
411 assert_eq!(la, lb, "node[{i}] Fetch body_limit");
412 }
413 (Node::Upgrade { next: a }, Node::Upgrade { next: b }) => {
414 assert_eq!(a, b, "node[{i}] Upgrade next");
415 }
416 (Node::Terminate(a), Node::Terminate(b)) => {
417 assert_eq!(a, b, "node[{i}] Terminate");
418 }
419 (a, b) => panic!("node[{i}] variant changed across round-trip: {a:?} -> {b:?}"),
420 }
421 }
422 }
423
424 fn check_rule(name: &str, port: u16, match_predicate: &serde_json::Value) -> RawRule {
426 parse_rule(serde_json::json!({
427 "name": name,
428 "listen": [format!(":{port}")],
429 "match": match_predicate,
430 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
431 }))
432 }
433
434 fn find_entry_check(graph: &SymbolicFlowGraph, port: u16) -> NodeId {
435 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
436 let entry = *graph.entries.get(&v4).expect("entry present");
437 match &graph[entry] {
441 Node::Upgrade { next } => *next,
442 _ => entry,
443 }
444 }
445
446 fn unwrap_check(node: &Node) -> (PredicateId, NodeId, NodeId) {
447 match node {
448 Node::Check { predicate, on_match, on_miss, .. } => (*predicate, *on_match, *on_miss),
449 other => panic!("expected Check, got {other:?}"),
450 }
451 }
452
453 #[test]
454 fn any_of_two_checks_chains_via_on_miss_sharing_on_match() {
455 let r = check_rule(
456 "r",
457 7100,
458 &serde_json::json!({
459 "any_of": [
460 { "tls.sni": { "equals": "a" } },
461 { "tls.sni": { "equals": "b" } },
462 ],
463 }),
464 );
465 let graph =
466 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
467
468 let entry = find_entry_check(&graph, 7100);
469 let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
470 let (_, match_b, _miss_b) = unwrap_check(&graph[miss_a]);
471 assert_eq!(match_a, match_b, "both any_of branches share on_match");
472 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
473 assert_eq!(check_count, 2);
474 assert_eq!(graph.predicates.len(), 2, "tls.sni=\"a\" and tls.sni=\"b\" are distinct");
475 }
476
477 #[test]
478 fn any_of_three_checks_chains_right_to_left() {
479 let r = check_rule(
480 "r",
481 7101,
482 &serde_json::json!({
483 "any_of": [
484 { "tls.sni": { "equals": "a" } },
485 { "tls.sni": { "equals": "b" } },
486 { "tls.sni": { "equals": "c" } },
487 ],
488 }),
489 );
490 let graph =
491 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
492
493 let c0 = find_entry_check(&graph, 7101);
494 let (_, m0, miss0) = unwrap_check(&graph[c0]);
495 let (_, m1, miss1) = unwrap_check(&graph[miss0]);
496 let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
497 assert_eq!(m0, m1);
498 assert_eq!(m1, m2, "all three any_of branches share on_match");
499 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
500 }
501
502 #[test]
503 fn not_wrapping_a_check_swaps_on_match_and_on_miss() {
504 let r =
505 check_rule("r", 7102, &serde_json::json!({ "not": { "tls.sni": { "equals": "internal" } } }));
506 let graph =
507 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
508
509 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
511 assert_eq!(check_count, 1);
512 let entry = find_entry_check(&graph, 7102);
513 let (_, on_match, on_miss) = unwrap_check(&graph[entry]);
514 assert_ne!(on_match, on_miss);
520 }
524
525 #[test]
526 fn not_wrapping_any_of_swaps_edges_and_produces_two_checks() {
527 let r = check_rule(
528 "r",
529 7103,
530 &serde_json::json!({
531 "not": {
532 "any_of": [
533 { "tls.sni": { "equals": "a" } },
534 { "tls.sni": { "equals": "b" } },
535 ],
536 },
537 }),
538 );
539 let graph =
540 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
541
542 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
543 let c0 = find_entry_check(&graph, 7103);
544 let (_, m0, miss0) = unwrap_check(&graph[c0]);
545 let (_, m1, _miss1) = unwrap_check(&graph[miss0]);
546 assert_eq!(m0, m1);
550 }
551
552 #[test]
553 fn any_of_nested_inside_any_of_produces_three_checks_with_shared_on_match() {
554 let r = check_rule(
555 "r",
556 7104,
557 &serde_json::json!({
558 "any_of": [
559 { "tls.sni": { "equals": "a" } },
560 {
561 "any_of": [
562 { "tls.sni": { "equals": "b" } },
563 { "tls.sni": { "equals": "c" } },
564 ],
565 },
566 ],
567 }),
568 );
569 let graph =
570 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
571
572 let c0 = find_entry_check(&graph, 7104);
573 let (_, m0, miss0) = unwrap_check(&graph[c0]);
574 let (_, m1, miss1) = unwrap_check(&graph[miss0]);
575 let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
576 assert_eq!(m0, m1);
577 assert_eq!(m1, m2);
578 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
579 }
580
581 #[test]
582 fn empty_any_of_short_circuits_to_on_miss() {
583 let r = check_rule("r", 7105, &serde_json::json!({ "any_of": [] }));
584 let graph =
588 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
589 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
590 assert_eq!(check_count, 0, "empty any_of must not emit a Check node");
591 }
592
593 #[test]
594 fn any_of_hash_cons_shares_predicate_slot_across_rules() {
595 let a = check_rule(
600 "a",
601 7106,
602 &serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
603 );
604 let b = check_rule(
605 "b",
606 7107,
607 &serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
608 );
609 let graph =
610 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
611 assert_eq!(graph.predicates.len(), 1);
612 }
613
614 #[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" } }));
621 let graph =
622 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
623 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7300);
625 let listener_entry = *graph.entries.get(&v4).expect("entry present");
626 assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
627 let check_below = find_entry_check(&graph, 7300);
629 assert!(matches!(&graph[check_below], Node::Check { .. }));
630 }
631
632 #[test]
633 fn l7_predicate_on_l7_rule_sits_after_upgrade() {
634 let r = check_rule(
637 "r",
638 7301,
639 &serde_json::json!({ "http.header.host": { "equals": "api.example.com" } }),
640 );
641 let graph =
642 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
643 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7301);
644 let listener_entry = *graph.entries.get(&v4).expect("entry present");
645 assert!(
646 matches!(&graph[listener_entry], Node::Upgrade { .. }),
647 "L7 listener entry is the shared Upgrade",
648 );
649 let Node::Upgrade { next } = &graph[listener_entry] else {
650 panic!("expected Upgrade");
651 };
652 assert!(matches!(&graph[*next], Node::Check { .. }));
653 }
654
655 #[test]
656 fn pure_l4_rule_with_predicate_synthesises_close_miss() {
657 let r = parse_rule(serde_json::json!({
658 "name": "r",
659 "listen": [":7302"],
660 "match": { "remote.ip": { "cidr": "10.0.0.0/8" } },
661 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
662 }));
663 let graph =
664 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
665 let entry = find_entry_check(&graph, 7302);
666 assert!(matches!(&graph[entry], Node::Check { .. }));
667 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
668 assert_eq!(upgrades, 0, "L4 posture never Upgrades");
669 assert!(
670 graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
671 "default-miss must synthesise a Close terminator",
672 );
673 }
674
675 #[test]
676 fn l7_rule_with_predicate_uses_close_not_500_for_default_miss() {
677 let r = check_rule("r", 7400, &serde_json::json!({ "tls.sni": { "equals": "api" } }));
681 let graph =
682 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
683 assert!(
684 graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
685 "default-miss must be Close",
686 );
687 let synth_fetches =
688 graph.fetches.iter().filter(|f| f.kind == FetchKind::HttpSynthesize).count();
689 assert_eq!(synth_fetches, 0, "no 500 synth for unmatched L7 traffic — just Close");
690 }
691
692 #[test]
693 fn catch_all_rule_set_omits_close_fallback() {
694 let r = parse_rule(serde_json::json!({
697 "name": "r",
698 "listen": [":7401"],
699 "terminate": { "type": "http_proxy" },
700 }));
701 let graph =
702 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
703 let close_count = graph.terminators.iter().filter(|t| matches!(t, Terminator::Close)).count();
704 assert_eq!(close_count, 0, "no predicate means no miss path means no Close");
705 }
706
707 #[test]
708 fn close_terminator_serde_round_trip() {
709 let t = Terminator::Close;
711 let encoded = serde_json::to_string(&t).expect("serialize");
712 let decoded: Terminator = serde_json::from_str(&encoded).expect("deserialize");
713 assert_eq!(decoded, t);
714 }
715
716 #[test]
717 fn l7_rule_without_predicate_has_upgrade_as_entry() {
718 let r = parse_rule(serde_json::json!({
719 "name": "r",
720 "listen": [":7303"],
721 "terminate": { "type": "http_proxy" },
722 }));
723 let graph =
724 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
725 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7303);
726 let listener_entry = *graph.entries.get(&v4).expect("entry present");
727 assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
728 }
729
730 #[test]
731 fn cross_level_any_of_is_rejected() {
732 let r = check_rule(
733 "r",
734 7304,
735 &serde_json::json!({
736 "any_of": [
737 { "tls.sni": { "equals": "a" } },
738 { "http.method": { "equals": "GET" } },
739 ],
740 }),
741 );
742 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
743 .expect_err("cross-level any_of must fail");
744 assert!(err.to_string().contains("cross-level"), "error message names the constraint: {err}");
745 }
746
747 #[test]
748 fn cross_level_not_is_rejected() {
749 let r = check_rule(
750 "r",
751 7305,
752 &serde_json::json!({
753 "not": {
754 "any_of": [
755 { "tls.sni": { "equals": "a" } },
756 { "http.method": { "equals": "GET" } },
757 ],
758 },
759 }),
760 );
761 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
762 .expect_err("cross-level not(any_of) must fail");
763 assert!(err.to_string().contains("cross-level"));
764 }
765
766 #[test]
767 fn same_level_any_of_compiles_at_one_side_of_upgrade() {
768 let r = check_rule(
770 "r",
771 7306,
772 &serde_json::json!({
773 "any_of": [
774 { "tls.sni": { "equals": "a" } },
775 { "tls.sni": { "equals": "b" } },
776 ],
777 }),
778 );
779 let graph =
780 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
781 let entry = find_entry_check(&graph, 7306);
782 assert!(matches!(&graph[entry], Node::Check { .. }));
784 }
785
786 #[test]
787 fn validate_stays_green_for_all_combinator_shapes() {
788 let shapes = [
789 serde_json::json!({ "tls.sni": { "equals": "x" } }),
790 serde_json::json!({
791 "any_of": [
792 { "tls.sni": { "equals": "a" } },
793 { "tls.sni": { "equals": "b" } },
794 ],
795 }),
796 serde_json::json!({ "not": { "tls.sni": { "equals": "y" } } }),
797 serde_json::json!({
798 "not": {
799 "any_of": [
800 { "tls.sni": { "equals": "a" } },
801 { "tls.sni": { "equals": "b" } },
802 ],
803 },
804 }),
805 serde_json::json!({
806 "all_of": [
807 { "tls.sni": { "equals": "a" } },
808 { "tls.sni": { "equals": "b" } },
809 ],
810 }),
811 serde_json::json!({
812 "not": {
813 "all_of": [
814 { "tls.sni": { "equals": "a" } },
815 { "tls.sni": { "equals": "b" } },
816 ],
817 },
818 }),
819 ];
820 for (i, m) in shapes.iter().enumerate() {
821 let port = 7200 + u16::try_from(i).expect("fits u16");
822 let r = check_rule(&format!("r{i}"), port, m);
823 let graph =
824 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
825 validate::validate(&graph).expect("validate");
826 }
827 }
828
829 #[test]
830 fn all_of_two_checks_chains_left_match_to_right_entry() {
831 let r = check_rule(
836 "r",
837 7500,
838 &serde_json::json!({
839 "all_of": [
840 { "tls.sni": { "equals": "a" } },
841 { "tls.sni": { "equals": "b" } },
842 ],
843 }),
844 );
845 let graph =
846 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
847 let entry = find_entry_check(&graph, 7500);
848 let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
849 let (_, _match_b, miss_b) = unwrap_check(&graph[match_a]);
850 assert_eq!(miss_a, miss_b, "both all_of branches share on_miss");
851 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
852 }
853
854 #[test]
855 fn all_of_empty_array_short_circuits_to_on_match() {
856 let r = check_rule("r", 7501, &serde_json::json!({ "all_of": [] }));
859 let graph =
860 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
861 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
862 assert_eq!(check_count, 0, "empty all_of must not emit a Check node");
863 }
864
865 #[test]
866 fn all_of_cross_level_combinator_is_rejected() {
867 let r = check_rule(
869 "r",
870 7502,
871 &serde_json::json!({
872 "all_of": [
873 { "tls.sni": { "equals": "a" } },
874 { "http.method": { "equals": "GET" } },
875 ],
876 }),
877 );
878 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
879 .expect_err("cross-level all_of must fail");
880 let msg = err.to_string();
881 assert!(msg.contains("cross-level"), "error names the constraint: {msg}");
882 assert!(msg.contains("all_of"), "error mentions all_of: {msg}");
883 }
884
885 #[test]
886 fn all_of_nested_inside_any_of_works() {
887 let r = check_rule(
888 "r",
889 7503,
890 &serde_json::json!({
891 "any_of": [
892 { "all_of": [
893 { "http.header.upgrade": { "equals": "websocket" } },
894 { "http.uri.path": { "prefix": "/ws" } },
895 ]},
896 { "all_of": [
897 { "http.header.upgrade": { "equals": "websocket" } },
898 { "http.uri.path": { "prefix": "/api/stream" } },
899 ]},
900 ],
901 }),
902 );
903 let graph =
904 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
905 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 4);
906 }
907
908 #[test]
909 fn l7_listener_emits_single_upgrade_at_top_for_two_rules() {
910 let a = parse_rule(serde_json::json!({
914 "name": "a",
915 "listen": [":7600"],
916 "match": { "http.header.host": { "equals": "a.example.com" } },
917 "terminate": { "type": "http_proxy" },
918 }));
919 let b = parse_rule(serde_json::json!({
920 "name": "b",
921 "listen": [":7600"],
922 "match": { "http.header.host": { "equals": "b.example.com" } },
923 "terminate": { "type": "http_proxy" },
924 }));
925 let graph =
926 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
927 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
928 assert_eq!(upgrades, 1, "exactly one Upgrade per L7 listener regardless of rule count");
929 }
930
931 #[test]
932 fn l4_listener_has_no_upgrade() {
933 let r = parse_rule(serde_json::json!({
935 "name": "fwd",
936 "listen": [":7601"],
937 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
938 }));
939 let graph =
940 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
941 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
942 assert_eq!(upgrades, 0);
943 }
944
945 #[test]
946 fn websocket_upgrade_emits_two_distinct_terminators() {
947 let r = parse_rule(serde_json::json!({
951 "name": "ws",
952 "listen": [":7602"],
953 "match": { "http.header.upgrade": { "equals": "websocket" } },
954 "terminate": { "type": "websocket", "upstream": "127.0.0.1:8080" },
955 }));
956 let graph =
957 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
958 let terms: std::collections::HashSet<_> = graph.terminators.iter().copied().collect();
959 assert!(terms.contains(&Terminator::WriteHttpResponse), "response branch terminator");
960 assert!(terms.contains(&Terminator::ByteTunnel), "tunnel branch terminator");
961 }
962
963 #[test]
965 fn lower_l7_listener_synthesizes_short_circuit_response_target() {
966 let r = parse_rule(serde_json::json!({
971 "name": "r",
972 "listen": [":7700"],
973 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
974 }));
975 let graph =
976 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
977 let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
982 assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
983 for synth in graph.meta.short_circuit_response_entry.values() {
985 let Node::Terminate(tid) = &graph[*synth] else {
986 panic!("synth node is not a Terminate: {:?}", &graph[*synth]);
987 };
988 assert_eq!(graph.terminators[tid.get() as usize], Terminator::WriteHttpResponse);
989 }
990 }
991
992 #[test]
993 fn lower_l4_listener_has_no_short_circuit_response_target() {
994 let r = parse_rule(serde_json::json!({
997 "name": "r",
998 "listen": [":7701"],
999 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1000 }));
1001 let graph =
1002 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1003 assert!(graph.meta.short_circuit_response_entry.is_empty());
1004 }
1005
1006 #[test]
1007 fn lower_derives_raw_when_only_l4_forward_terminator() {
1008 let r = parse_rule(serde_json::json!({
1012 "name": "r",
1013 "listen": [":7800"],
1014 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1015 }));
1016 let graph =
1017 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1018 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7800);
1019 assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Raw));
1020 }
1021
1022 #[test]
1023 fn lower_derives_http_when_only_l7_terminators() {
1024 let r = parse_rule(serde_json::json!({
1025 "name": "r",
1026 "listen": [":7801"],
1027 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1028 }));
1029 let graph =
1030 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1031 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7801);
1032 assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Http));
1033 }
1034
1035 #[test]
1036 fn lower_derives_http_for_udp_prefix_listener() {
1037 let r = parse_rule(serde_json::json!({
1043 "name": "h3",
1044 "listen": ["udp:7802"],
1045 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1046 }));
1047 let graph =
1048 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1049 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7802);
1050 assert_eq!(
1051 graph.meta.listener_kinds.get(&v4),
1052 Some(&crate::ir::ListenerKind::Http),
1053 "udp listener with http_proxy terminator must derive ListenerKind::Http",
1054 );
1055 assert_eq!(
1056 graph.meta.listener_transports.get(&v4),
1057 Some(&crate::conn_context::Transport::Udp),
1058 "udp: prefix must populate listener_transports as Udp",
1059 );
1060 }
1061
1062 #[test]
1063 fn lower_derives_auto_when_l4_and_l7_share_listener() {
1064 use crate::compile::lower::test_only::derive_listener_kind_for_test;
1072 use crate::fetch::{FetchKind, SymbolicFetchRef};
1073 use crate::ir::{FetchId, ListenerKind, Node, NodeId, TerminatorId};
1074
1075 let nodes = vec![
1076 Node::Check {
1077 predicate: PredicateId::new(0),
1078 on_match: NodeId::new(1),
1079 on_miss: NodeId::new(2),
1080 collect_body_before: None,
1081 body_limit: 0,
1082 },
1083 Node::Fetch {
1084 id: FetchId::new(0),
1085 next_response: None,
1086 next_tunnel: Some(NodeId::new(3)),
1087 collect_body_before: None,
1088 body_limit: 0,
1089 },
1090 Node::Fetch {
1091 id: FetchId::new(1),
1092 next_response: Some(NodeId::new(3)),
1093 next_tunnel: None,
1094 collect_body_before: None,
1095 body_limit: 0,
1096 },
1097 Node::Terminate(TerminatorId::new(0)),
1098 ];
1099 let fetches = vec![
1100 SymbolicFetchRef {
1101 kind: FetchKind::L4Forward,
1102 args: serde_json::Value::Null,
1103 retry_buffer_required: false,
1104 allow_zero_rtt: None,
1105 },
1106 SymbolicFetchRef {
1107 kind: FetchKind::HttpProxy,
1108 args: serde_json::Value::Null,
1109 retry_buffer_required: false,
1110 allow_zero_rtt: None,
1111 },
1112 ];
1113 assert_eq!(derive_listener_kind_for_test(&nodes, &fetches, NodeId::new(0)), ListenerKind::Auto);
1114 }
1115
1116 #[test]
1117 fn listener_kinds_round_trip_through_dry_run_json() {
1118 let l4 = parse_rule(serde_json::json!({
1119 "name": "tcp",
1120 "listen": [":7803"],
1121 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1122 }));
1123 let l7 = parse_rule(serde_json::json!({
1124 "name": "http",
1125 "listen": [":7804"],
1126 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1127 }));
1128 let graph =
1129 compile(vec![rule_file("a.json", vec![l4, l7])], &Providers, &Providers).expect("compile");
1130 let encoded = serde_json::to_string(&*graph).expect("serialize graph");
1131 let decoded: crate::ir::SymbolicFlowGraph =
1132 serde_json::from_str(&encoded).expect("deserialize graph");
1133 let v4_raw = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7803);
1134 let v4_http = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7804);
1135 assert_eq!(decoded.meta.listener_kinds.get(&v4_raw), Some(&crate::ir::ListenerKind::Raw));
1136 assert_eq!(decoded.meta.listener_kinds.get(&v4_http), Some(&crate::ir::ListenerKind::Http));
1137 }
1138
1139 #[test]
1140 fn lower_force_buffering_triggers_collect_body_before_request() {
1141 let r = parse_rule(serde_json::json!({
1147 "name": "r",
1148 "listen": [":7900"],
1149 "terminate": {
1150 "type": "http_proxy",
1151 "upstream": "127.0.0.1:8080",
1152 "retry": { "max_attempts": 3, "buffering": "force" },
1153 },
1154 }));
1155 let graph =
1156 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1157 let fetch_with_collect = graph.nodes.iter().find_map(|n| match n {
1158 crate::ir::Node::Fetch {
1159 collect_body_before: Some(crate::ir::BodySide::Request), ..
1160 } => Some(()),
1161 _ => None,
1162 });
1163 assert!(
1164 fetch_with_collect.is_some(),
1165 "force buffering must flag fetch with collect_body_before"
1166 );
1167 }
1168
1169 #[test]
1170 fn lower_opportunistic_buffering_does_not_trigger_collect() {
1171 let r = parse_rule(serde_json::json!({
1172 "name": "r",
1173 "listen": [":7901"],
1174 "terminate": {
1175 "type": "http_proxy",
1176 "upstream": "127.0.0.1:8080",
1177 "retry": { "max_attempts": 3, "buffering": "opportunistic" },
1178 },
1179 }));
1180 let graph =
1181 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1182 let any_fetch_collects = graph.nodes.iter().any(|n| {
1183 matches!(
1184 n,
1185 crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1186 )
1187 });
1188 assert!(
1189 !any_fetch_collects,
1190 "opportunistic buffering must NOT flag fetch with collect_body_before",
1191 );
1192 }
1193
1194 #[test]
1195 fn lower_max_attempts_one_with_force_does_not_trigger_collect() {
1196 let r = parse_rule(serde_json::json!({
1200 "name": "r",
1201 "listen": [":7902"],
1202 "terminate": {
1203 "type": "http_proxy",
1204 "upstream": "127.0.0.1:8080",
1205 "retry": { "max_attempts": 1, "buffering": "force" },
1206 },
1207 }));
1208 let graph =
1209 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1210 let any_fetch_collects = graph.nodes.iter().any(|n| {
1211 matches!(
1212 n,
1213 crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1214 )
1215 });
1216 assert!(!any_fetch_collects, "max_attempts=1 disables the force-buffering trigger");
1217 }
1218
1219 #[test]
1220 fn lower_two_l7_listeners_have_independent_synth_entries() {
1221 let a = parse_rule(serde_json::json!({
1226 "name": "a",
1227 "listen": [":7702"],
1228 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1229 }));
1230 let b = parse_rule(serde_json::json!({
1231 "name": "b",
1232 "listen": [":7703"],
1233 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8081" },
1234 }));
1235 let graph =
1236 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
1237 let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
1241 assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
1242 assert!(
1243 graph.meta.short_circuit_response_entry.len() >= 2,
1244 "two listeners → at least two synth entries"
1245 );
1246 }
1247
1248 #[test]
1249 fn http_body_check_node_sets_collect_body_before_request() {
1250 let r = parse_rule(serde_json::json!({
1257 "name": "r",
1258 "listen": [":7910"],
1259 "match": { "http.body": { "contains": "aGVsbG8=" } },
1260 "terminate": { "type": "http_proxy" },
1261 }));
1262 let graph =
1263 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1264 let has_collecting_check = 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!(has_collecting_check, "http.body Check must carry collect_body_before = Some(Request)");
1271 }
1272
1273 #[test]
1274 fn rule_without_http_body_predicate_has_no_request_collect_on_check() {
1275 let r = parse_rule(serde_json::json!({
1280 "name": "r",
1281 "listen": [":7911"],
1282 "match": { "http.method": { "equals": "GET" } },
1283 "terminate": { "type": "http_proxy" },
1284 }));
1285 let graph =
1286 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1287 let any_check_collects = graph.nodes.iter().any(|n| {
1288 matches!(
1289 n,
1290 crate::ir::Node::Check { collect_body_before: Some(crate::ir::BodySide::Request), .. }
1291 )
1292 });
1293 assert!(
1294 !any_check_collects,
1295 "non-body predicate must not set collect_body_before on any Check node"
1296 );
1297 }
1298
1299 #[test]
1300 fn malformed_base64_in_http_body_predicate_fails_compile() {
1301 let r = parse_rule(serde_json::json!({
1307 "name": "r",
1308 "listen": [":7912"],
1309 "match": { "http.body": { "contains": "not-valid-base64!!!" } },
1310 "terminate": { "type": "http_proxy" },
1311 }));
1312 let err =
1313 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect_err("must fail");
1314 let msg = err.to_string();
1315 assert!(
1316 msg.contains("base64") || msg.contains("base 64"),
1317 "error must mention base64 decoding failure, got: {msg}"
1318 );
1319 }
1320}