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 {
423 parse_rule(serde_json::json!({
424 "name": name,
425 "listen": [format!(":{port}")],
426 "match": match_predicate,
427 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
428 }))
429 }
430
431 fn find_entry_check(graph: &SymbolicFlowGraph, port: u16) -> NodeId {
432 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
433 let entry = *graph.entries.get(&v4).expect("entry present");
434 match &graph[entry] {
438 Node::Upgrade { next } => *next,
439 _ => entry,
440 }
441 }
442
443 fn unwrap_check(node: &Node) -> (PredicateId, NodeId, NodeId) {
444 match node {
445 Node::Check { predicate, on_match, on_miss, .. } => (*predicate, *on_match, *on_miss),
446 other => panic!("expected Check, got {other:?}"),
447 }
448 }
449
450 #[test]
451 fn any_of_two_checks_chains_via_on_miss_sharing_on_match() {
452 let r = check_rule(
453 "r",
454 7100,
455 &serde_json::json!({
456 "any_of": [
457 { "tls.sni": { "equals": "a" } },
458 { "tls.sni": { "equals": "b" } },
459 ],
460 }),
461 );
462 let graph =
463 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
464
465 let entry = find_entry_check(&graph, 7100);
466 let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
467 let (_, match_b, _miss_b) = unwrap_check(&graph[miss_a]);
468 assert_eq!(match_a, match_b, "both any_of branches share on_match");
469 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
470 assert_eq!(check_count, 2);
471 assert_eq!(graph.predicates.len(), 2, "tls.sni=\"a\" and tls.sni=\"b\" are distinct");
472 }
473
474 #[test]
475 fn any_of_three_checks_chains_right_to_left() {
476 let r = check_rule(
477 "r",
478 7101,
479 &serde_json::json!({
480 "any_of": [
481 { "tls.sni": { "equals": "a" } },
482 { "tls.sni": { "equals": "b" } },
483 { "tls.sni": { "equals": "c" } },
484 ],
485 }),
486 );
487 let graph =
488 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
489
490 let c0 = find_entry_check(&graph, 7101);
491 let (_, m0, miss0) = unwrap_check(&graph[c0]);
492 let (_, m1, miss1) = unwrap_check(&graph[miss0]);
493 let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
494 assert_eq!(m0, m1);
495 assert_eq!(m1, m2, "all three any_of branches share on_match");
496 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
497 }
498
499 #[test]
500 fn not_wrapping_a_check_swaps_on_match_and_on_miss() {
501 let r =
502 check_rule("r", 7102, &serde_json::json!({ "not": { "tls.sni": { "equals": "internal" } } }));
503 let graph =
504 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
505
506 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
508 assert_eq!(check_count, 1);
509 let entry = find_entry_check(&graph, 7102);
510 let (_, on_match, on_miss) = unwrap_check(&graph[entry]);
511 assert_ne!(on_match, on_miss);
517 }
521
522 #[test]
523 fn not_wrapping_any_of_swaps_edges_and_produces_two_checks() {
524 let r = check_rule(
525 "r",
526 7103,
527 &serde_json::json!({
528 "not": {
529 "any_of": [
530 { "tls.sni": { "equals": "a" } },
531 { "tls.sni": { "equals": "b" } },
532 ],
533 },
534 }),
535 );
536 let graph =
537 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
538
539 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
540 let c0 = find_entry_check(&graph, 7103);
541 let (_, m0, miss0) = unwrap_check(&graph[c0]);
542 let (_, m1, _miss1) = unwrap_check(&graph[miss0]);
543 assert_eq!(m0, m1);
547 }
548
549 #[test]
550 fn any_of_nested_inside_any_of_produces_three_checks_with_shared_on_match() {
551 let r = check_rule(
552 "r",
553 7104,
554 &serde_json::json!({
555 "any_of": [
556 { "tls.sni": { "equals": "a" } },
557 {
558 "any_of": [
559 { "tls.sni": { "equals": "b" } },
560 { "tls.sni": { "equals": "c" } },
561 ],
562 },
563 ],
564 }),
565 );
566 let graph =
567 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
568
569 let c0 = find_entry_check(&graph, 7104);
570 let (_, m0, miss0) = unwrap_check(&graph[c0]);
571 let (_, m1, miss1) = unwrap_check(&graph[miss0]);
572 let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
573 assert_eq!(m0, m1);
574 assert_eq!(m1, m2);
575 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
576 }
577
578 #[test]
579 fn empty_any_of_short_circuits_to_on_miss() {
580 let r = check_rule("r", 7105, &serde_json::json!({ "any_of": [] }));
581 let graph =
585 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
586 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
587 assert_eq!(check_count, 0, "empty any_of must not emit a Check node");
588 }
589
590 #[test]
591 fn any_of_hash_cons_shares_predicate_slot_across_rules() {
592 let a = check_rule(
597 "a",
598 7106,
599 &serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
600 );
601 let b = check_rule(
602 "b",
603 7107,
604 &serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
605 );
606 let graph =
607 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
608 assert_eq!(graph.predicates.len(), 1);
609 }
610
611 #[test]
612 fn l4_predicate_on_l7_rule_sits_post_upgrade() {
613 let r = check_rule("r", 7300, &serde_json::json!({ "tls.sni": { "equals": "a" } }));
618 let graph =
619 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
620 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7300);
622 let listener_entry = *graph.entries.get(&v4).expect("entry present");
623 assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
624 let check_below = find_entry_check(&graph, 7300);
626 assert!(matches!(&graph[check_below], Node::Check { .. }));
627 }
628
629 #[test]
630 fn l7_predicate_on_l7_rule_sits_after_upgrade() {
631 let r = check_rule(
634 "r",
635 7301,
636 &serde_json::json!({ "http.header.host": { "equals": "api.example.com" } }),
637 );
638 let graph =
639 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
640 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7301);
641 let listener_entry = *graph.entries.get(&v4).expect("entry present");
642 assert!(
643 matches!(&graph[listener_entry], Node::Upgrade { .. }),
644 "L7 listener entry is the shared Upgrade",
645 );
646 let Node::Upgrade { next } = &graph[listener_entry] else {
647 panic!("expected Upgrade");
648 };
649 assert!(matches!(&graph[*next], Node::Check { .. }));
650 }
651
652 #[test]
653 fn pure_l4_rule_with_predicate_synthesises_close_miss() {
654 let r = parse_rule(serde_json::json!({
655 "name": "r",
656 "listen": [":7302"],
657 "match": { "remote.ip": { "cidr": "10.0.0.0/8" } },
658 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
659 }));
660 let graph =
661 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
662 let entry = find_entry_check(&graph, 7302);
663 assert!(matches!(&graph[entry], Node::Check { .. }));
664 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
665 assert_eq!(upgrades, 0, "L4 posture never Upgrades");
666 assert!(
667 graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
668 "default-miss must synthesise a Close terminator",
669 );
670 }
671
672 #[test]
673 fn l7_rule_with_predicate_uses_close_not_500_for_default_miss() {
674 let r = check_rule("r", 7400, &serde_json::json!({ "tls.sni": { "equals": "api" } }));
678 let graph =
679 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
680 assert!(
681 graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
682 "default-miss must be Close",
683 );
684 let synth_fetches =
685 graph.fetches.iter().filter(|f| f.kind == FetchKind::HttpSynthesize).count();
686 assert_eq!(synth_fetches, 0, "no 500 synth for unmatched L7 traffic — just Close");
687 }
688
689 #[test]
690 fn catch_all_rule_set_omits_close_fallback() {
691 let r = parse_rule(serde_json::json!({
694 "name": "r",
695 "listen": [":7401"],
696 "terminate": { "type": "http_proxy" },
697 }));
698 let graph =
699 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
700 let close_count = graph.terminators.iter().filter(|t| matches!(t, Terminator::Close)).count();
701 assert_eq!(close_count, 0, "no predicate means no miss path means no Close");
702 }
703
704 #[test]
705 fn close_terminator_serde_round_trip() {
706 let t = Terminator::Close;
708 let encoded = serde_json::to_string(&t).expect("serialize");
709 let decoded: Terminator = serde_json::from_str(&encoded).expect("deserialize");
710 assert_eq!(decoded, t);
711 }
712
713 #[test]
714 fn l7_rule_without_predicate_has_upgrade_as_entry() {
715 let r = parse_rule(serde_json::json!({
716 "name": "r",
717 "listen": [":7303"],
718 "terminate": { "type": "http_proxy" },
719 }));
720 let graph =
721 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
722 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7303);
723 let listener_entry = *graph.entries.get(&v4).expect("entry present");
724 assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
725 }
726
727 #[test]
728 fn cross_level_any_of_is_rejected() {
729 let r = check_rule(
730 "r",
731 7304,
732 &serde_json::json!({
733 "any_of": [
734 { "tls.sni": { "equals": "a" } },
735 { "http.method": { "equals": "GET" } },
736 ],
737 }),
738 );
739 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
740 .expect_err("cross-level any_of must fail");
741 assert!(err.to_string().contains("cross-level"), "error message names the constraint: {err}");
742 }
743
744 #[test]
745 fn cross_level_not_is_rejected() {
746 let r = check_rule(
747 "r",
748 7305,
749 &serde_json::json!({
750 "not": {
751 "any_of": [
752 { "tls.sni": { "equals": "a" } },
753 { "http.method": { "equals": "GET" } },
754 ],
755 },
756 }),
757 );
758 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
759 .expect_err("cross-level not(any_of) must fail");
760 assert!(err.to_string().contains("cross-level"));
761 }
762
763 #[test]
764 fn same_level_any_of_compiles_at_one_side_of_upgrade() {
765 let r = check_rule(
767 "r",
768 7306,
769 &serde_json::json!({
770 "any_of": [
771 { "tls.sni": { "equals": "a" } },
772 { "tls.sni": { "equals": "b" } },
773 ],
774 }),
775 );
776 let graph =
777 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
778 let entry = find_entry_check(&graph, 7306);
779 assert!(matches!(&graph[entry], Node::Check { .. }));
781 }
782
783 #[test]
784 fn validate_stays_green_for_all_combinator_shapes() {
785 let shapes = [
786 serde_json::json!({ "tls.sni": { "equals": "x" } }),
787 serde_json::json!({
788 "any_of": [
789 { "tls.sni": { "equals": "a" } },
790 { "tls.sni": { "equals": "b" } },
791 ],
792 }),
793 serde_json::json!({ "not": { "tls.sni": { "equals": "y" } } }),
794 serde_json::json!({
795 "not": {
796 "any_of": [
797 { "tls.sni": { "equals": "a" } },
798 { "tls.sni": { "equals": "b" } },
799 ],
800 },
801 }),
802 serde_json::json!({
803 "all_of": [
804 { "tls.sni": { "equals": "a" } },
805 { "tls.sni": { "equals": "b" } },
806 ],
807 }),
808 serde_json::json!({
809 "not": {
810 "all_of": [
811 { "tls.sni": { "equals": "a" } },
812 { "tls.sni": { "equals": "b" } },
813 ],
814 },
815 }),
816 ];
817 for (i, m) in shapes.iter().enumerate() {
818 let port = 7200 + u16::try_from(i).expect("fits u16");
819 let r = check_rule(&format!("r{i}"), port, m);
820 let graph =
821 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
822 validate::validate(&graph).expect("validate");
823 }
824 }
825
826 #[test]
827 fn all_of_two_checks_chains_left_match_to_right_entry() {
828 let r = check_rule(
833 "r",
834 7500,
835 &serde_json::json!({
836 "all_of": [
837 { "tls.sni": { "equals": "a" } },
838 { "tls.sni": { "equals": "b" } },
839 ],
840 }),
841 );
842 let graph =
843 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
844 let entry = find_entry_check(&graph, 7500);
845 let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
846 let (_, _match_b, miss_b) = unwrap_check(&graph[match_a]);
847 assert_eq!(miss_a, miss_b, "both all_of branches share on_miss");
848 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
849 }
850
851 #[test]
852 fn all_of_empty_array_short_circuits_to_on_match() {
853 let r = check_rule("r", 7501, &serde_json::json!({ "all_of": [] }));
856 let graph =
857 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
858 let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
859 assert_eq!(check_count, 0, "empty all_of must not emit a Check node");
860 }
861
862 #[test]
863 fn all_of_cross_level_combinator_is_rejected() {
864 let r = check_rule(
866 "r",
867 7502,
868 &serde_json::json!({
869 "all_of": [
870 { "tls.sni": { "equals": "a" } },
871 { "http.method": { "equals": "GET" } },
872 ],
873 }),
874 );
875 let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
876 .expect_err("cross-level all_of must fail");
877 let msg = err.to_string();
878 assert!(msg.contains("cross-level"), "error names the constraint: {msg}");
879 assert!(msg.contains("all_of"), "error mentions all_of: {msg}");
880 }
881
882 #[test]
883 fn all_of_nested_inside_any_of_works() {
884 let r = check_rule(
885 "r",
886 7503,
887 &serde_json::json!({
888 "any_of": [
889 { "all_of": [
890 { "http.header.upgrade": { "equals": "websocket" } },
891 { "http.uri.path": { "prefix": "/ws" } },
892 ]},
893 { "all_of": [
894 { "http.header.upgrade": { "equals": "websocket" } },
895 { "http.uri.path": { "prefix": "/api/stream" } },
896 ]},
897 ],
898 }),
899 );
900 let graph =
901 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
902 assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 4);
903 }
904
905 #[test]
906 fn l7_listener_emits_single_upgrade_at_top_for_two_rules() {
907 let a = parse_rule(serde_json::json!({
911 "name": "a",
912 "listen": [":7600"],
913 "match": { "http.header.host": { "equals": "a.example.com" } },
914 "terminate": { "type": "http_proxy" },
915 }));
916 let b = parse_rule(serde_json::json!({
917 "name": "b",
918 "listen": [":7600"],
919 "match": { "http.header.host": { "equals": "b.example.com" } },
920 "terminate": { "type": "http_proxy" },
921 }));
922 let graph =
923 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
924 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
925 assert_eq!(upgrades, 1, "exactly one Upgrade per L7 listener regardless of rule count");
926 }
927
928 #[test]
929 fn l4_listener_has_no_upgrade() {
930 let r = parse_rule(serde_json::json!({
932 "name": "fwd",
933 "listen": [":7601"],
934 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
935 }));
936 let graph =
937 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
938 let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
939 assert_eq!(upgrades, 0);
940 }
941
942 #[test]
943 fn websocket_upgrade_emits_two_distinct_terminators() {
944 let r = parse_rule(serde_json::json!({
948 "name": "ws",
949 "listen": [":7602"],
950 "match": { "http.header.upgrade": { "equals": "websocket" } },
951 "terminate": { "type": "websocket", "upstream": "127.0.0.1:8080" },
952 }));
953 let graph =
954 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
955 let terms: std::collections::HashSet<_> = graph.terminators.iter().copied().collect();
956 assert!(terms.contains(&Terminator::WriteHttpResponse), "response branch terminator");
957 assert!(terms.contains(&Terminator::ByteTunnel), "tunnel branch terminator");
958 }
959
960 #[test]
962 fn lower_l7_listener_synthesizes_short_circuit_response_target() {
963 let r = parse_rule(serde_json::json!({
968 "name": "r",
969 "listen": [":7700"],
970 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
971 }));
972 let graph =
973 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
974 let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
979 assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
980 for synth in graph.meta.short_circuit_response_entry.values() {
982 let Node::Terminate(tid) = &graph[*synth] else {
983 panic!("synth node is not a Terminate: {:?}", &graph[*synth]);
984 };
985 assert_eq!(graph.terminators[tid.get() as usize], Terminator::WriteHttpResponse);
986 }
987 }
988
989 #[test]
990 fn lower_l4_listener_has_no_short_circuit_response_target() {
991 let r = parse_rule(serde_json::json!({
994 "name": "r",
995 "listen": [":7701"],
996 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
997 }));
998 let graph =
999 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1000 assert!(graph.meta.short_circuit_response_entry.is_empty());
1001 }
1002
1003 #[test]
1004 fn lower_derives_raw_when_only_l4_forward_terminator() {
1005 let r = parse_rule(serde_json::json!({
1009 "name": "r",
1010 "listen": [":7800"],
1011 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1012 }));
1013 let graph =
1014 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1015 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7800);
1016 assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Raw));
1017 }
1018
1019 #[test]
1020 fn lower_derives_http_when_only_l7_terminators() {
1021 let r = parse_rule(serde_json::json!({
1022 "name": "r",
1023 "listen": [":7801"],
1024 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1025 }));
1026 let graph =
1027 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1028 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7801);
1029 assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Http));
1030 }
1031
1032 #[test]
1033 fn lower_derives_http_for_udp_prefix_listener() {
1034 let r = parse_rule(serde_json::json!({
1040 "name": "h3",
1041 "listen": ["udp:7802"],
1042 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1043 }));
1044 let graph =
1045 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1046 let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7802);
1047 assert_eq!(
1048 graph.meta.listener_kinds.get(&v4),
1049 Some(&crate::ir::ListenerKind::Http),
1050 "udp listener with http_proxy terminator must derive ListenerKind::Http",
1051 );
1052 assert_eq!(
1053 graph.meta.listener_transports.get(&v4),
1054 Some(&crate::conn_context::Transport::Udp),
1055 "udp: prefix must populate listener_transports as Udp",
1056 );
1057 }
1058
1059 #[test]
1060 fn lower_derives_auto_when_l4_and_l7_share_listener() {
1061 use crate::compile::lower::test_only::derive_listener_kind_for_test;
1069 use crate::fetch::{FetchKind, SymbolicFetchRef};
1070 use crate::ir::{FetchId, ListenerKind, Node, NodeId, TerminatorId};
1071
1072 let nodes = vec![
1073 Node::Check {
1074 predicate: PredicateId::new(0),
1075 on_match: NodeId::new(1),
1076 on_miss: NodeId::new(2),
1077 collect_body_before: None,
1078 body_limit: 0,
1079 },
1080 Node::Fetch {
1081 id: FetchId::new(0),
1082 next_response: None,
1083 next_tunnel: Some(NodeId::new(3)),
1084 collect_body_before: None,
1085 body_limit: 0,
1086 },
1087 Node::Fetch {
1088 id: FetchId::new(1),
1089 next_response: Some(NodeId::new(3)),
1090 next_tunnel: None,
1091 collect_body_before: None,
1092 body_limit: 0,
1093 },
1094 Node::Terminate(TerminatorId::new(0)),
1095 ];
1096 let fetches = vec![
1097 SymbolicFetchRef {
1098 kind: FetchKind::L4Forward,
1099 args: serde_json::Value::Null,
1100 retry_buffer_required: false,
1101 allow_zero_rtt: None,
1102 },
1103 SymbolicFetchRef {
1104 kind: FetchKind::HttpProxy,
1105 args: serde_json::Value::Null,
1106 retry_buffer_required: false,
1107 allow_zero_rtt: None,
1108 },
1109 ];
1110 assert_eq!(derive_listener_kind_for_test(&nodes, &fetches, NodeId::new(0)), ListenerKind::Auto);
1111 }
1112
1113 #[test]
1114 fn listener_kinds_round_trip_through_dry_run_json() {
1115 let l4 = parse_rule(serde_json::json!({
1116 "name": "tcp",
1117 "listen": [":7803"],
1118 "terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1119 }));
1120 let l7 = parse_rule(serde_json::json!({
1121 "name": "http",
1122 "listen": [":7804"],
1123 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1124 }));
1125 let graph =
1126 compile(vec![rule_file("a.json", vec![l4, l7])], &Providers, &Providers).expect("compile");
1127 let encoded = serde_json::to_string(&*graph).expect("serialize graph");
1128 let decoded: crate::ir::SymbolicFlowGraph =
1129 serde_json::from_str(&encoded).expect("deserialize graph");
1130 let v4_raw = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7803);
1131 let v4_http = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7804);
1132 assert_eq!(decoded.meta.listener_kinds.get(&v4_raw), Some(&crate::ir::ListenerKind::Raw));
1133 assert_eq!(decoded.meta.listener_kinds.get(&v4_http), Some(&crate::ir::ListenerKind::Http));
1134 }
1135
1136 #[test]
1137 fn lower_force_buffering_triggers_collect_body_before_request() {
1138 let r = parse_rule(serde_json::json!({
1144 "name": "r",
1145 "listen": [":7900"],
1146 "terminate": {
1147 "type": "http_proxy",
1148 "upstream": "127.0.0.1:8080",
1149 "retry": { "max_attempts": 3, "buffering": "force" },
1150 },
1151 }));
1152 let graph =
1153 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1154 let fetch_with_collect = graph.nodes.iter().find_map(|n| match n {
1155 crate::ir::Node::Fetch {
1156 collect_body_before: Some(crate::ir::BodySide::Request), ..
1157 } => Some(()),
1158 _ => None,
1159 });
1160 assert!(
1161 fetch_with_collect.is_some(),
1162 "force buffering must flag fetch with collect_body_before"
1163 );
1164 }
1165
1166 #[test]
1167 fn lower_opportunistic_buffering_does_not_trigger_collect() {
1168 let r = parse_rule(serde_json::json!({
1169 "name": "r",
1170 "listen": [":7901"],
1171 "terminate": {
1172 "type": "http_proxy",
1173 "upstream": "127.0.0.1:8080",
1174 "retry": { "max_attempts": 3, "buffering": "opportunistic" },
1175 },
1176 }));
1177 let graph =
1178 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1179 let any_fetch_collects = graph.nodes.iter().any(|n| {
1180 matches!(
1181 n,
1182 crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1183 )
1184 });
1185 assert!(
1186 !any_fetch_collects,
1187 "opportunistic buffering must NOT flag fetch with collect_body_before",
1188 );
1189 }
1190
1191 #[test]
1192 fn lower_max_attempts_one_with_force_does_not_trigger_collect() {
1193 let r = parse_rule(serde_json::json!({
1197 "name": "r",
1198 "listen": [":7902"],
1199 "terminate": {
1200 "type": "http_proxy",
1201 "upstream": "127.0.0.1:8080",
1202 "retry": { "max_attempts": 1, "buffering": "force" },
1203 },
1204 }));
1205 let graph =
1206 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1207 let any_fetch_collects = graph.nodes.iter().any(|n| {
1208 matches!(
1209 n,
1210 crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1211 )
1212 });
1213 assert!(!any_fetch_collects, "max_attempts=1 disables the force-buffering trigger");
1214 }
1215
1216 #[test]
1217 fn lower_two_l7_listeners_have_independent_synth_entries() {
1218 let a = parse_rule(serde_json::json!({
1223 "name": "a",
1224 "listen": [":7702"],
1225 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1226 }));
1227 let b = parse_rule(serde_json::json!({
1228 "name": "b",
1229 "listen": [":7703"],
1230 "terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8081" },
1231 }));
1232 let graph =
1233 compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
1234 let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
1238 assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
1239 assert!(
1240 graph.meta.short_circuit_response_entry.len() >= 2,
1241 "two listeners → at least two synth entries"
1242 );
1243 }
1244
1245 #[test]
1246 fn http_body_check_node_sets_collect_body_before_request() {
1247 let r = parse_rule(serde_json::json!({
1254 "name": "r",
1255 "listen": [":7910"],
1256 "match": { "http.body": { "contains": "aGVsbG8=" } },
1257 "terminate": { "type": "http_proxy" },
1258 }));
1259 let graph =
1260 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1261 let has_collecting_check = graph.nodes.iter().any(|n| {
1262 matches!(
1263 n,
1264 crate::ir::Node::Check { collect_body_before: Some(crate::ir::BodySide::Request), .. }
1265 )
1266 });
1267 assert!(has_collecting_check, "http.body Check must carry collect_body_before = Some(Request)");
1268 }
1269
1270 #[test]
1271 fn rule_without_http_body_predicate_has_no_request_collect_on_check() {
1272 let r = parse_rule(serde_json::json!({
1277 "name": "r",
1278 "listen": [":7911"],
1279 "match": { "http.method": { "equals": "GET" } },
1280 "terminate": { "type": "http_proxy" },
1281 }));
1282 let graph =
1283 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1284 let any_check_collects = graph.nodes.iter().any(|n| {
1285 matches!(
1286 n,
1287 crate::ir::Node::Check { collect_body_before: Some(crate::ir::BodySide::Request), .. }
1288 )
1289 });
1290 assert!(
1291 !any_check_collects,
1292 "non-body predicate must not set collect_body_before on any Check node"
1293 );
1294 }
1295
1296 #[test]
1297 fn malformed_base64_in_http_body_predicate_fails_compile() {
1298 let r = parse_rule(serde_json::json!({
1304 "name": "r",
1305 "listen": [":7912"],
1306 "match": { "http.body": { "contains": "not-valid-base64!!!" } },
1307 "terminate": { "type": "http_proxy" },
1308 }));
1309 let err =
1310 compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect_err("must fail");
1311 let msg = err.to_string();
1312 assert!(
1313 msg.contains("base64") || msg.contains("base 64"),
1314 "error must mention base64 decoding failure, got: {msg}"
1315 );
1316 }
1317}