Skip to main content

vane_core/
compile.rs

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
17/// Facade for the core compile pipeline.
18///
19/// Runs `merge → expand → analyze → lower → validate` and returns an
20/// `Arc<SymbolicFlowGraph>` ready for `vane-engine::FlowGraph::link`.
21///
22/// # Errors
23/// Returns [`Error::compile`] on duplicate rule names, unknown middleware
24/// or fetch names referenced by rules, bad `ListenSpec` strings, predicate
25/// type mismatches, or graph-level validation failures (dangling IDs,
26/// cycles, phase mismatches).
27pub 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		// Dual-stack `:443` expands to both v4 and v6 SocketAddrs sharing one entry NodeId.
121		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		// The terminator set contains WriteHttpResponse (both the rule terminator
127		// and the synthesised default-miss write it).
128		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		// Two rules on different listeners both match `tls.sni == "api"`.
137		// Spec spec/flow-model.md § _Hash-consing_: predicates always dedup.
138		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		// Two rules sharing an identical `forward_client_ip` (stateless, no args)
158		// must share one MiddlewareId.
159		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		// Two rules both use `rate_limit` (stateful). Each call site must get
184		// its own MiddlewareId per `spec/flow-model.md` § _Hash-consing_ — sharing buckets
185		// would silently halve the effective rate.
186		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		// HttpProxy / HttpSynthesize → WriteHttpResponse; L4Forward → ByteTunnel.
208		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		// End-to-end: `compile` runs `validate` inside. A clean reverse_proxy
283		// graph must pass — this is an end-to-end sanity check that validate
284		// is wired into the pipeline and doesn't falsely reject good graphs.
285		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		// Running validate again on the returned graph must still succeed.
293		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		// Dry-run JSON contract (spec/flow-model.md § _The compiled form_): a compiled
303		// SymbolicFlowGraph serializes to JSON and the result deserializes
304		// back to an equivalent graph that re-`validate()`s green. Slab
305		// contents and `entries` map key set must survive the round-trip.
306		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		// Re-validate the decoded graph: the contract is that dry-run JSON
320		// is a ground-truth snapshot that the engine could rehydrate.
321		validate::validate(&decoded).expect("decoded graph revalidates");
322
323		// Slab lengths survive.
324		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		// `entries` key set (SocketAddr → NodeId) survives.
331		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		// PredicateInst / SymbolicMiddlewareRef / Terminator implement
336		// PartialEq; compare their slabs directly.
337		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		// `Node` does not implement PartialEq (by design — the enum holds
342		// id newtypes and Option<NodeId>s only). Compare node-by-node via
343		// variant destructuring to pin that the control-flow structure
344		// survived the round-trip.
345		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	// AnyOf / Not lowering tests
425	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		// Per spec/flow-model.md § _The compiled form_, every L7
438		// listener carries one shared Upgrade above the rule chains. Tests
439		// that probe Check structure below the Upgrade skip past it here.
440		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		// Not adds no node — exactly one Check.
510		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		// Per the equivalence `not P match=>X miss=>Y` ≡ lower(P, match=>Y, miss=>X),
515		// the emitted Check has swapped edges: its on_match is the outer on_miss
516		// (the default-miss fallback) and its on_miss is the rule body entry.
517		// Assert they're distinct — before-task-2 code had them both pointing
518		// at the body entry.
519		assert_ne!(on_match, on_miss);
520		// Walking `on_miss` should land at something reachable; walking
521		// `on_match` should land at a node that cannot reach the rule's Fetch.
522		// Minimal structural check: the two targets differ.
523	}
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		// `not (any_of [A, B])` = lower(any_of, match=>Y, miss=>X) =
547		//   Check(A) match=>Y miss=>Check(B) match=>Y miss=>X.
548		// Both Checks share on_match (== outer on_miss, i.e. the default-miss).
549		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		// Empty any_of ≡ never matches. The rule's chain entry equals the
585		// on_miss target (default-miss); no Check node is emitted for the
586		// empty any_of itself.
587		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		// Two rules on different listeners both use the same `tls.sni ==
596		// "shared"` predicate inside any_of. Per spec/flow-model.md § _Hash-consing_,
597		// predicates dedup transparently regardless of the combinator tree
598		// they're nested inside.
599		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		// Every L7 listener carries one shared Upgrade above the rule
617		// chains, so L4-level Check leaves on L7 rules sit AFTER the
618		// Upgrade. `PredicateView::L7Req` carries `conn`, so reads of
619		// `tls.sni` / `remote.ip` remain functionally correct.
620		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		// Listener entry is the shared Upgrade.
624		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		// One step below the Upgrade is the Check.
628		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		// Upgrade is listener-level rather than per-rule; L7-level Checks
635		// still descend from it.
636		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		// Pre-task-4 the L7 default-miss was a synthesised 500. Task 4 unified
678		// both postures on `Terminator::Close`, so no HttpSynthesize fetch
679		// should appear in the graph for a rule whose terminator is http_proxy.
680		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		// A predicate-less rule is always matched; the default-miss is dead
695		// code and must not appear in the graph.
696		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		// Pin the wire form of the new variant for dry-run JSON.
710		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		// Two L4Peek checks: Upgrade sits BELOW both Checks.
769		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		// Entry is a Check; walking any_of's shared on_match must reach an Upgrade.
783		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		// all_of [A, B] match=>X miss=>Y  ≡
832		//   Check(A) match=>Check(B) match=>X miss=>Y, miss=>Y
833		// Both Checks share on_miss; the first Check's on_match points at
834		// the second Check's entry, not at X directly.
835		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		// `all_of: []` is vacuously true → no Check emitted, the rule's
857		// chain entry equals on_match (the rule body).
858		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		// Same uniform-level rule as any_of: AllOf can't mix L4 and L7 leaves.
868		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		// spec/flow-model.md § _The compiled form_: two L7 rules on
911		// the same listener share one Upgrade — the entire graph must
912		// contain exactly one Upgrade node, not two.
913		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		// L4 posture stays as-is — no Upgrade emitted.
934		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		// `WebSocketUpgrade` is dual-output — the response branch lands at
948		// `WriteHttpResponse` (rejection); the tunnel branch lands at
949		// `ByteTunnel` (101 Switching handoff).
950		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	// Short(Response) synth target
964	#[test]
965	fn lower_l7_listener_synthesizes_short_circuit_response_target() {
966		// Every L7 listener gets a synth `Terminate(WriteHttpResponse)` that
967		// `meta.short_circuit_response_entry` maps the listener entry to.
968		// Without this synth, an L7 request middleware returning
969		// `Decision::Short(ShortCircuit::Response(_))` has nowhere to land.
970		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		// L7 listener → exactly one synth target per *unique* entry NodeId.
978		// Dual-stack `:N` shorthand maps both v4 and v6 SocketAddrs to the
979		// same listener NodeId, so the synth map keys by NodeId and is
980		// smaller than `entries.len()` for the dual-stack case.
981		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		// Every value points at a `Terminate(WriteHttpResponse)`.
984		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		// Pure L4 (port_forward) listeners never go through Upgrade — no
995		// L7Request middleware can run, so no synth target is needed.
996		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		// `port_forward`-shaped rule: single `tcp_forward` upstream on a
1009		// listener with no L7 path. Per `spec/crates/core.md` § _Listener kind
1010		// derivation_, every reachable terminator is L4 → `Raw`.
1011		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		// `spec/crates/core.md` § _Listener kind derivation_ is graph-shape-only —
1038		// transport doesn't enter the rule. The `udp:` prefix flows
1039		// through `parse_listen` to `listener_transports`, while the
1040		// L7 terminator independently picks `Http`. The combination is
1041		// what the engine needs to spawn the per-listener H3 stack.
1042		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		// `analyze` currently rejects a single rule set with mixed L4 + L7
1065		// postures on the same listener (the protocol-detect frontend
1066		// that legitimises that combination is a separate feature). We
1067		// exercise the kind-derivation rule directly by hand-building a
1068		// graph whose entry can reach both an `L4Forward` fetch and an
1069		// `HttpProxy` fetch — the union of the two paths is what the
1070		// spec says yields `Auto`.
1071		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		// `retry: { max_attempts: 3, buffering: "force" }` flags the
1142		// fetch node itself with `collect_body_before:
1143		// Some(BodySide::Request)` so the executor buffers the body
1144		// before the fetch runs. See `spec/crates/engine.md`
1145		// § _Retry_.
1146		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		// `force` only means anything together with `max_attempts > 1`.
1197		// Pinning `max_attempts: 1` keeps retry off and the buffering
1198		// trigger inactive even when the JSON spells `force`.
1199		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		// Two L7 listeners on distinct ports each get their own synth
1222		// target keyed by their listener entry. (Whether the synth nodes
1223		// collapse via terminator-id hash-cons is an internal detail; the
1224		// public contract is one map entry per listener entry.)
1225		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		// One synth entry per listener entry — the dual-stack `:7702`
1238		// expands to v4+v6 sharing one entry NodeId, so map size matches
1239		// the number of *unique* entry NodeIds in the graph.
1240		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		// A rule whose predicate reads http.body must produce a Check node
1251		// with collect_body_before = Some(BodySide::Request). The executor
1252		// uses this flag to materialise the request body before running the
1253		// predicate test — without it, as_static() would panic.
1254		//
1255		// "aGVsbG8=" is standard base64 for the ASCII bytes "hello".
1256		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		// A rule whose predicate does not read http.body must not trigger
1276		// request-side buffering. The Check node produced for an
1277		// http.method predicate must have collect_body_before = None so the
1278		// pay-as-you-go invariant holds.
1279		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		// The base64 literal in a Bytes-typed field predicate is decoded at
1302		// lower time, not at predicate-test time. A syntactically invalid
1303		// base64 string must produce a compile-time error so the rule never
1304		// reaches the runtime graph. "not-valid-base64!!!" contains '!'
1305		// which is not in the standard base64 alphabet.
1306		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}