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	#[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		// Dual-stack `:443` expands to both v4 and v6 SocketAddrs sharing one entry NodeId.
122		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		// The terminator set contains WriteHttpResponse (both the rule terminator
128		// and the synthesised default-miss write it).
129		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		// Two rules on different listeners both match `tls.sni == "api"`.
138		// Spec 02-flow.md § _Hash-consing_: predicates always dedup.
139		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		// Two rules sharing an identical `forward_client_ip` (stateless, no args)
159		// must share one MiddlewareId.
160		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		// Two rules both use `rate_limit` (stateful). Each call site must get
185		// its own MiddlewareId per spec § _Hash-consing_ — sharing buckets
186		// would silently halve the effective rate.
187		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		// HttpProxy / HttpSynthesize → WriteHttpResponse; L4Forward → ByteTunnel.
209		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		// End-to-end: `compile` runs `validate` inside. A clean reverse_proxy
284		// graph must pass — this is an end-to-end sanity check that validate
285		// is wired into the pipeline and doesn't falsely reject good graphs.
286		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		// Running validate again on the returned graph must still succeed.
294		validate::validate(&graph).expect("re-validate");
295	}
296
297	#[test]
298	fn symbolic_flow_graph_round_trip_preserves_structure_and_revalidates() {
299		// Dry-run JSON contract (02-flow.md § _The compiled form_): a compiled
300		// SymbolicFlowGraph serializes to JSON and the result deserializes
301		// back to an equivalent graph that re-`validate()`s green. Slab
302		// contents and `entries` map key set must survive the round-trip.
303		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		// Re-validate the decoded graph: the contract is that dry-run JSON
317		// is a ground-truth snapshot that the engine could rehydrate.
318		validate::validate(&decoded).expect("decoded graph revalidates");
319
320		// Slab lengths survive.
321		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		// `entries` key set (SocketAddr → NodeId) survives.
328		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		// PredicateInst / SymbolicMiddlewareRef / Terminator implement
333		// PartialEq; compare their slabs directly.
334		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		// `Node` does not implement PartialEq (by design — the enum holds
339		// id newtypes and Option<NodeId>s only). Compare node-by-node via
340		// variant destructuring to pin that the control-flow structure
341		// survived the round-trip.
342		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	// --- AnyOf / Not lowering tests -----------------------------------------
422
423	fn check_rule(name: &str, port: u16, match_predicate: &serde_json::Value) -> RawRule {
424		parse_rule(serde_json::json!({
425			"name": name,
426			"listen": [format!(":{port}")],
427			"match": match_predicate,
428			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
429		}))
430	}
431
432	fn find_entry_check(graph: &SymbolicFlowGraph, port: u16) -> NodeId {
433		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
434		let entry = *graph.entries.get(&v4).expect("entry present");
435		// Per 02-flow.md § _Listener-level Upgrade placement_, every L7
436		// listener carries one shared Upgrade above the rule chains. Tests
437		// that probe Check structure below the Upgrade skip past it here.
438		match &graph[entry] {
439			Node::Upgrade { next } => *next,
440			_ => entry,
441		}
442	}
443
444	fn unwrap_check(node: &Node) -> (PredicateId, NodeId, NodeId) {
445		match node {
446			Node::Check { predicate, on_match, on_miss, .. } => (*predicate, *on_match, *on_miss),
447			other => panic!("expected Check, got {other:?}"),
448		}
449	}
450
451	#[test]
452	fn any_of_two_checks_chains_via_on_miss_sharing_on_match() {
453		let r = check_rule(
454			"r",
455			7100,
456			&serde_json::json!({
457				"any_of": [
458					{ "tls.sni": { "equals": "a" } },
459					{ "tls.sni": { "equals": "b" } },
460				],
461			}),
462		);
463		let graph =
464			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
465
466		let entry = find_entry_check(&graph, 7100);
467		let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
468		let (_, match_b, _miss_b) = unwrap_check(&graph[miss_a]);
469		assert_eq!(match_a, match_b, "both any_of branches share on_match");
470		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
471		assert_eq!(check_count, 2);
472		assert_eq!(graph.predicates.len(), 2, "tls.sni=\"a\" and tls.sni=\"b\" are distinct");
473	}
474
475	#[test]
476	fn any_of_three_checks_chains_right_to_left() {
477		let r = check_rule(
478			"r",
479			7101,
480			&serde_json::json!({
481				"any_of": [
482					{ "tls.sni": { "equals": "a" } },
483					{ "tls.sni": { "equals": "b" } },
484					{ "tls.sni": { "equals": "c" } },
485				],
486			}),
487		);
488		let graph =
489			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
490
491		let c0 = find_entry_check(&graph, 7101);
492		let (_, m0, miss0) = unwrap_check(&graph[c0]);
493		let (_, m1, miss1) = unwrap_check(&graph[miss0]);
494		let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
495		assert_eq!(m0, m1);
496		assert_eq!(m1, m2, "all three any_of branches share on_match");
497		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
498	}
499
500	#[test]
501	fn not_wrapping_a_check_swaps_on_match_and_on_miss() {
502		let r =
503			check_rule("r", 7102, &serde_json::json!({ "not": { "tls.sni": { "equals": "internal" } } }));
504		let graph =
505			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
506
507		// Not adds no node — exactly one Check.
508		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
509		assert_eq!(check_count, 1);
510		let entry = find_entry_check(&graph, 7102);
511		let (_, on_match, on_miss) = unwrap_check(&graph[entry]);
512		// Per the equivalence `not P match=>X miss=>Y` ≡ lower(P, match=>Y, miss=>X),
513		// the emitted Check has swapped edges: its on_match is the outer on_miss
514		// (the default-miss fallback) and its on_miss is the rule body entry.
515		// Assert they're distinct — before-task-2 code had them both pointing
516		// at the body entry.
517		assert_ne!(on_match, on_miss);
518		// Walking `on_miss` should land at something reachable; walking
519		// `on_match` should land at a node that cannot reach the rule's Fetch.
520		// Minimal structural check: the two targets differ.
521	}
522
523	#[test]
524	fn not_wrapping_any_of_swaps_edges_and_produces_two_checks() {
525		let r = check_rule(
526			"r",
527			7103,
528			&serde_json::json!({
529				"not": {
530					"any_of": [
531						{ "tls.sni": { "equals": "a" } },
532						{ "tls.sni": { "equals": "b" } },
533					],
534				},
535			}),
536		);
537		let graph =
538			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
539
540		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
541		let c0 = find_entry_check(&graph, 7103);
542		let (_, m0, miss0) = unwrap_check(&graph[c0]);
543		let (_, m1, _miss1) = unwrap_check(&graph[miss0]);
544		// `not (any_of [A, B])` = lower(any_of, match=>Y, miss=>X) =
545		//   Check(A) match=>Y miss=>Check(B) match=>Y miss=>X.
546		// Both Checks share on_match (== outer on_miss, i.e. the default-miss).
547		assert_eq!(m0, m1);
548	}
549
550	#[test]
551	fn any_of_nested_inside_any_of_produces_three_checks_with_shared_on_match() {
552		let r = check_rule(
553			"r",
554			7104,
555			&serde_json::json!({
556				"any_of": [
557					{ "tls.sni": { "equals": "a" } },
558					{
559						"any_of": [
560							{ "tls.sni": { "equals": "b" } },
561							{ "tls.sni": { "equals": "c" } },
562						],
563					},
564				],
565			}),
566		);
567		let graph =
568			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
569
570		let c0 = find_entry_check(&graph, 7104);
571		let (_, m0, miss0) = unwrap_check(&graph[c0]);
572		let (_, m1, miss1) = unwrap_check(&graph[miss0]);
573		let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
574		assert_eq!(m0, m1);
575		assert_eq!(m1, m2);
576		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
577	}
578
579	#[test]
580	fn empty_any_of_short_circuits_to_on_miss() {
581		let r = check_rule("r", 7105, &serde_json::json!({ "any_of": [] }));
582		// Empty any_of ≡ never matches. The rule's chain entry equals the
583		// on_miss target (default-miss); no Check node is emitted for the
584		// empty any_of itself.
585		let graph =
586			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
587		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
588		assert_eq!(check_count, 0, "empty any_of must not emit a Check node");
589	}
590
591	#[test]
592	fn any_of_hash_cons_shares_predicate_slot_across_rules() {
593		// Two rules on different listeners both use the same `tls.sni ==
594		// "shared"` predicate inside any_of. Per 02-flow.md § _Hash-consing_,
595		// predicates dedup transparently regardless of the combinator tree
596		// they're nested inside.
597		let a = check_rule(
598			"a",
599			7106,
600			&serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
601		);
602		let b = check_rule(
603			"b",
604			7107,
605			&serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
606		);
607		let graph =
608			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
609		assert_eq!(graph.predicates.len(), 1);
610	}
611
612	// --- Phase-split Check placement (C13.5: single shared Upgrade) --------
613
614	#[test]
615	fn l4_predicate_on_l7_rule_sits_post_upgrade() {
616		// 02-flow.md § _Listener-level Upgrade placement_ (C13.5): the
617		// C5.5-era "L4-level Check fails fast before HTTP decode" optimisation
618		// is gone. Every L7 listener carries one shared Upgrade above the
619		// rule chains, so L4-level Check leaves on L7 rules now sit AFTER
620		// the Upgrade. PredicateView's `L7Req` variant still carries `conn`,
621		// so reads of `tls.sni` / `remote.ip` remain functionally correct.
622		let r = check_rule("r", 7300, &serde_json::json!({ "tls.sni": { "equals": "a" } }));
623		let graph =
624			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
625		// Listener entry is the shared Upgrade.
626		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7300);
627		let listener_entry = *graph.entries.get(&v4).expect("entry present");
628		assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
629		// One step below the Upgrade is the Check.
630		let check_below = find_entry_check(&graph, 7300);
631		assert!(matches!(&graph[check_below], Node::Check { .. }));
632	}
633
634	#[test]
635	fn l7_predicate_on_l7_rule_sits_after_upgrade() {
636		// L7-level checks have always sat after Upgrade. The C13.5 refactor
637		// preserves that — Upgrade is now listener-level rather than
638		// per-rule, but Checks still descend from it.
639		let r = check_rule(
640			"r",
641			7301,
642			&serde_json::json!({ "http.header.host": { "equals": "api.example.com" } }),
643		);
644		let graph =
645			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
646		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7301);
647		let listener_entry = *graph.entries.get(&v4).expect("entry present");
648		assert!(
649			matches!(&graph[listener_entry], Node::Upgrade { .. }),
650			"L7 listener entry is the shared Upgrade",
651		);
652		let Node::Upgrade { next } = &graph[listener_entry] else {
653			panic!("expected Upgrade");
654		};
655		assert!(matches!(&graph[*next], Node::Check { .. }));
656	}
657
658	#[test]
659	fn pure_l4_rule_with_predicate_synthesises_close_miss() {
660		let r = parse_rule(serde_json::json!({
661			"name": "r",
662			"listen": [":7302"],
663			"match": { "remote.ip": { "cidr": "10.0.0.0/8" } },
664			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
665		}));
666		let graph =
667			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
668		let entry = find_entry_check(&graph, 7302);
669		assert!(matches!(&graph[entry], Node::Check { .. }));
670		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
671		assert_eq!(upgrades, 0, "L4 posture never Upgrades");
672		assert!(
673			graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
674			"default-miss must synthesise a Close terminator",
675		);
676	}
677
678	#[test]
679	fn l7_rule_with_predicate_uses_close_not_500_for_default_miss() {
680		// Pre-task-4 the L7 default-miss was a synthesised 500. Task 4 unified
681		// both postures on `Terminator::Close`, so no HttpSynthesize fetch
682		// should appear in the graph for a rule whose terminator is http_proxy.
683		let r = check_rule("r", 7400, &serde_json::json!({ "tls.sni": { "equals": "api" } }));
684		let graph =
685			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
686		assert!(
687			graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
688			"default-miss must be Close",
689		);
690		let synth_fetches =
691			graph.fetches.iter().filter(|f| f.kind == FetchKind::HttpSynthesize).count();
692		assert_eq!(synth_fetches, 0, "no 500 synth for unmatched L7 traffic — just Close");
693	}
694
695	#[test]
696	fn catch_all_rule_set_omits_close_fallback() {
697		// A predicate-less rule is always matched; the default-miss is dead
698		// code and must not appear in the graph.
699		let r = parse_rule(serde_json::json!({
700			"name": "r",
701			"listen": [":7401"],
702			"terminate": { "type": "http_proxy" },
703		}));
704		let graph =
705			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
706		let close_count = graph.terminators.iter().filter(|t| matches!(t, Terminator::Close)).count();
707		assert_eq!(close_count, 0, "no predicate means no miss path means no Close");
708	}
709
710	#[test]
711	fn close_terminator_serde_round_trip() {
712		// Pin the wire form of the new variant for dry-run JSON.
713		let t = Terminator::Close;
714		let encoded = serde_json::to_string(&t).expect("serialize");
715		let decoded: Terminator = serde_json::from_str(&encoded).expect("deserialize");
716		assert_eq!(decoded, t);
717	}
718
719	#[test]
720	fn l7_rule_without_predicate_has_upgrade_as_entry() {
721		let r = parse_rule(serde_json::json!({
722			"name": "r",
723			"listen": [":7303"],
724			"terminate": { "type": "http_proxy" },
725		}));
726		let graph =
727			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
728		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7303);
729		let listener_entry = *graph.entries.get(&v4).expect("entry present");
730		assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
731	}
732
733	#[test]
734	fn cross_level_any_of_is_rejected() {
735		let r = check_rule(
736			"r",
737			7304,
738			&serde_json::json!({
739				"any_of": [
740					{ "tls.sni": { "equals": "a" } },
741					{ "http.method": { "equals": "GET" } },
742				],
743			}),
744		);
745		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
746			.expect_err("cross-level any_of must fail");
747		assert!(err.to_string().contains("cross-level"), "error message names the constraint: {err}");
748	}
749
750	#[test]
751	fn cross_level_not_is_rejected() {
752		let r = check_rule(
753			"r",
754			7305,
755			&serde_json::json!({
756				"not": {
757					"any_of": [
758						{ "tls.sni": { "equals": "a" } },
759						{ "http.method": { "equals": "GET" } },
760					],
761				},
762			}),
763		);
764		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
765			.expect_err("cross-level not(any_of) must fail");
766		assert!(err.to_string().contains("cross-level"));
767	}
768
769	#[test]
770	fn same_level_any_of_compiles_at_one_side_of_upgrade() {
771		// Two L4Peek checks: Upgrade sits BELOW both Checks.
772		let r = check_rule(
773			"r",
774			7306,
775			&serde_json::json!({
776				"any_of": [
777					{ "tls.sni": { "equals": "a" } },
778					{ "tls.sni": { "equals": "b" } },
779				],
780			}),
781		);
782		let graph =
783			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
784		let entry = find_entry_check(&graph, 7306);
785		// Entry is a Check; walking any_of's shared on_match must reach an Upgrade.
786		assert!(matches!(&graph[entry], Node::Check { .. }));
787	}
788
789	#[test]
790	fn validate_stays_green_for_all_combinator_shapes() {
791		let shapes = [
792			serde_json::json!({ "tls.sni": { "equals": "x" } }),
793			serde_json::json!({
794				"any_of": [
795					{ "tls.sni": { "equals": "a" } },
796					{ "tls.sni": { "equals": "b" } },
797				],
798			}),
799			serde_json::json!({ "not": { "tls.sni": { "equals": "y" } } }),
800			serde_json::json!({
801				"not": {
802					"any_of": [
803						{ "tls.sni": { "equals": "a" } },
804						{ "tls.sni": { "equals": "b" } },
805					],
806				},
807			}),
808			serde_json::json!({
809				"all_of": [
810					{ "tls.sni": { "equals": "a" } },
811					{ "tls.sni": { "equals": "b" } },
812				],
813			}),
814			serde_json::json!({
815				"not": {
816					"all_of": [
817						{ "tls.sni": { "equals": "a" } },
818						{ "tls.sni": { "equals": "b" } },
819					],
820				},
821			}),
822		];
823		for (i, m) in shapes.iter().enumerate() {
824			let port = 7200 + u16::try_from(i).expect("fits u16");
825			let r = check_rule(&format!("r{i}"), port, m);
826			let graph =
827				compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
828			validate::validate(&graph).expect("validate");
829		}
830	}
831
832	// --- C13.5: AllOf lowering + listener-level shared Upgrade -----------
833
834	#[test]
835	fn all_of_two_checks_chains_left_match_to_right_entry() {
836		// all_of [A, B] match=>X miss=>Y  ≡
837		//   Check(A) match=>Check(B) match=>X miss=>Y, miss=>Y
838		// Both Checks share on_miss; the first Check's on_match points at
839		// the second Check's entry, not at X directly.
840		let r = check_rule(
841			"r",
842			7500,
843			&serde_json::json!({
844				"all_of": [
845					{ "tls.sni": { "equals": "a" } },
846					{ "tls.sni": { "equals": "b" } },
847				],
848			}),
849		);
850		let graph =
851			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
852		let entry = find_entry_check(&graph, 7500);
853		let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
854		let (_, _match_b, miss_b) = unwrap_check(&graph[match_a]);
855		assert_eq!(miss_a, miss_b, "both all_of branches share on_miss");
856		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
857	}
858
859	#[test]
860	fn all_of_empty_array_short_circuits_to_on_match() {
861		// `all_of: []` is vacuously true → no Check emitted, the rule's
862		// chain entry equals on_match (the rule body).
863		let r = check_rule("r", 7501, &serde_json::json!({ "all_of": [] }));
864		let graph =
865			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
866		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
867		assert_eq!(check_count, 0, "empty all_of must not emit a Check node");
868	}
869
870	#[test]
871	fn all_of_cross_level_combinator_is_rejected() {
872		// Same uniform-level rule as any_of: AllOf can't mix L4 and L7 leaves.
873		let r = check_rule(
874			"r",
875			7502,
876			&serde_json::json!({
877				"all_of": [
878					{ "tls.sni": { "equals": "a" } },
879					{ "http.method": { "equals": "GET" } },
880				],
881			}),
882		);
883		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
884			.expect_err("cross-level all_of must fail");
885		let msg = err.to_string();
886		assert!(msg.contains("cross-level"), "error names the constraint: {msg}");
887		assert!(msg.contains("all_of"), "error mentions all_of: {msg}");
888	}
889
890	#[test]
891	fn all_of_nested_inside_any_of_works() {
892		let r = check_rule(
893			"r",
894			7503,
895			&serde_json::json!({
896				"any_of": [
897					{ "all_of": [
898						{ "http.header.upgrade": { "equals": "websocket" } },
899						{ "http.uri.path": { "prefix": "/ws" } },
900					]},
901					{ "all_of": [
902						{ "http.header.upgrade": { "equals": "websocket" } },
903						{ "http.uri.path": { "prefix": "/api/stream" } },
904					]},
905				],
906			}),
907		);
908		let graph =
909			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
910		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 4);
911	}
912
913	#[test]
914	fn l7_listener_emits_single_upgrade_at_top_for_two_rules() {
915		// 02-flow.md § _Listener-level Upgrade placement_: two L7 rules on
916		// the same listener share one Upgrade — the entire graph must
917		// contain exactly one Upgrade node, not two.
918		let a = parse_rule(serde_json::json!({
919			"name": "a",
920			"listen": [":7600"],
921			"match": { "http.header.host": { "equals": "a.example.com" } },
922			"terminate": { "type": "http_proxy" },
923		}));
924		let b = parse_rule(serde_json::json!({
925			"name": "b",
926			"listen": [":7600"],
927			"match": { "http.header.host": { "equals": "b.example.com" } },
928			"terminate": { "type": "http_proxy" },
929		}));
930		let graph =
931			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
932		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
933		assert_eq!(upgrades, 1, "exactly one Upgrade per L7 listener regardless of rule count");
934	}
935
936	#[test]
937	fn l4_listener_has_no_upgrade() {
938		// L4 posture stays as-is — no Upgrade emitted.
939		let r = parse_rule(serde_json::json!({
940			"name": "fwd",
941			"listen": [":7601"],
942			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
943		}));
944		let graph =
945			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
946		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
947		assert_eq!(upgrades, 0);
948	}
949
950	#[test]
951	fn websocket_upgrade_emits_two_distinct_terminators() {
952		// C13.5 fix: WebSocketUpgrade is dual-output — response branch goes
953		// through WriteHttpResponse (rejection), tunnel branch through
954		// ByteTunnel (101-Switching handoff).
955		let r = parse_rule(serde_json::json!({
956			"name": "ws",
957			"listen": [":7602"],
958			"match": { "http.header.upgrade": { "equals": "websocket" } },
959			"terminate": { "type": "websocket", "upstream": "127.0.0.1:8080" },
960		}));
961		let graph =
962			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
963		let terms: std::collections::HashSet<_> = graph.terminators.iter().copied().collect();
964		assert!(terms.contains(&Terminator::WriteHttpResponse), "response branch terminator");
965		assert!(terms.contains(&Terminator::ByteTunnel), "tunnel branch terminator");
966	}
967
968	// --- Short(Response) synth target -------------------------------------
969
970	#[test]
971	fn lower_l7_listener_synthesizes_short_circuit_response_target() {
972		// Every L7 listener gets a synth `Terminate(WriteHttpResponse)` that
973		// `meta.short_circuit_response_entry` maps the listener entry to.
974		// Without this synth, an L7 request middleware returning
975		// `Decision::Short(ShortCircuit::Response(_))` has nowhere to land.
976		let r = parse_rule(serde_json::json!({
977			"name": "r",
978			"listen": [":7700"],
979			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
980		}));
981		let graph =
982			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
983		// L7 listener → exactly one synth target per *unique* entry NodeId.
984		// Dual-stack `:N` shorthand maps both v4 and v6 SocketAddrs to the
985		// same listener NodeId, so the synth map keys by NodeId and is
986		// smaller than `entries.len()` for the dual-stack case.
987		let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
988		assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
989		// Every value points at a `Terminate(WriteHttpResponse)`.
990		for synth in graph.meta.short_circuit_response_entry.values() {
991			let Node::Terminate(tid) = &graph[*synth] else {
992				panic!("synth node is not a Terminate: {:?}", &graph[*synth]);
993			};
994			assert_eq!(graph.terminators[tid.get() as usize], Terminator::WriteHttpResponse);
995		}
996	}
997
998	#[test]
999	fn lower_l4_listener_has_no_short_circuit_response_target() {
1000		// Pure L4 (port_forward) listeners never go through Upgrade — no
1001		// L7Request middleware can run, so no synth target is needed.
1002		let r = parse_rule(serde_json::json!({
1003			"name": "r",
1004			"listen": [":7701"],
1005			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1006		}));
1007		let graph =
1008			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1009		assert!(graph.meta.short_circuit_response_entry.is_empty());
1010	}
1011
1012	#[test]
1013	fn lower_derives_raw_when_only_l4_forward_terminator() {
1014		// `port_forward`-shaped rule: single `tcp_forward` upstream on a
1015		// listener with no L7 path. Per `06-l4.md` § _Listener kind
1016		// derivation_, every reachable terminator is L4 → `Raw`.
1017		let r = parse_rule(serde_json::json!({
1018			"name": "r",
1019			"listen": [":7800"],
1020			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1021		}));
1022		let graph =
1023			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1024		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7800);
1025		assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Raw));
1026	}
1027
1028	#[test]
1029	fn lower_derives_http_when_only_l7_terminators() {
1030		let r = parse_rule(serde_json::json!({
1031			"name": "r",
1032			"listen": [":7801"],
1033			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1034		}));
1035		let graph =
1036			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1037		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7801);
1038		assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Http));
1039	}
1040
1041	#[test]
1042	fn lower_derives_auto_when_l4_and_l7_share_listener() {
1043		// `analyze` currently rejects a single rule set with mixed L4 + L7
1044		// postures on the same listener (the protocol-detect frontend
1045		// that legitimises that combination is a separate feature). We
1046		// exercise the kind-derivation rule directly by hand-building a
1047		// graph whose entry can reach both an `L4Forward` fetch and an
1048		// `HttpProxy` fetch — the union of the two paths is what the
1049		// spec says yields `Auto`.
1050		use crate::compile::lower::test_only::derive_listener_kind_for_test;
1051		use crate::fetch::{FetchKind, SymbolicFetchRef};
1052		use crate::ir::{FetchId, ListenerKind, Node, NodeId, TerminatorId};
1053
1054		let nodes = vec![
1055			Node::Check {
1056				predicate: PredicateId::new(0),
1057				on_match: NodeId::new(1),
1058				on_miss: NodeId::new(2),
1059				collect_body_before: None,
1060				body_limit: 0,
1061			},
1062			Node::Fetch {
1063				id: FetchId::new(0),
1064				next_response: None,
1065				next_tunnel: Some(NodeId::new(3)),
1066				collect_body_before: None,
1067				body_limit: 0,
1068			},
1069			Node::Fetch {
1070				id: FetchId::new(1),
1071				next_response: Some(NodeId::new(3)),
1072				next_tunnel: None,
1073				collect_body_before: None,
1074				body_limit: 0,
1075			},
1076			Node::Terminate(TerminatorId::new(0)),
1077		];
1078		let fetches = vec![
1079			SymbolicFetchRef {
1080				kind: FetchKind::L4Forward,
1081				args: serde_json::Value::Null,
1082				retry_buffer_required: false,
1083			},
1084			SymbolicFetchRef {
1085				kind: FetchKind::HttpProxy,
1086				args: serde_json::Value::Null,
1087				retry_buffer_required: false,
1088			},
1089		];
1090		assert_eq!(derive_listener_kind_for_test(&nodes, &fetches, NodeId::new(0)), ListenerKind::Auto);
1091	}
1092
1093	#[test]
1094	fn listener_kinds_round_trip_through_dry_run_json() {
1095		let l4 = parse_rule(serde_json::json!({
1096			"name": "tcp",
1097			"listen": [":7803"],
1098			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1099		}));
1100		let l7 = parse_rule(serde_json::json!({
1101			"name": "http",
1102			"listen": [":7804"],
1103			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1104		}));
1105		let graph =
1106			compile(vec![rule_file("a.json", vec![l4, l7])], &Providers, &Providers).expect("compile");
1107		let encoded = serde_json::to_string(&*graph).expect("serialize graph");
1108		let decoded: crate::ir::SymbolicFlowGraph =
1109			serde_json::from_str(&encoded).expect("deserialize graph");
1110		let v4_raw = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7803);
1111		let v4_http = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7804);
1112		assert_eq!(decoded.meta.listener_kinds.get(&v4_raw), Some(&crate::ir::ListenerKind::Raw));
1113		assert_eq!(decoded.meta.listener_kinds.get(&v4_http), Some(&crate::ir::ListenerKind::Http));
1114	}
1115
1116	#[test]
1117	fn lower_force_buffering_triggers_collect_body_before_request() {
1118		// `retry: { max_attempts: 3, buffering: "force" }` flags the
1119		// fetch node itself with `collect_body_before:
1120		// Some(BodySide::Request)` so the executor buffers the body
1121		// before the fetch runs. See `spec/architecture/05-terminator.md`
1122		// § _Retry buffering_.
1123		let r = parse_rule(serde_json::json!({
1124			"name": "r",
1125			"listen": [":7900"],
1126			"terminate": {
1127				"type": "http_proxy",
1128				"upstream": "127.0.0.1:8080",
1129				"retry": { "max_attempts": 3, "buffering": "force" },
1130			},
1131		}));
1132		let graph =
1133			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1134		let fetch_with_collect = graph.nodes.iter().find_map(|n| match n {
1135			crate::ir::Node::Fetch {
1136				collect_body_before: Some(crate::ir::BodySide::Request), ..
1137			} => Some(()),
1138			_ => None,
1139		});
1140		assert!(
1141			fetch_with_collect.is_some(),
1142			"force buffering must flag fetch with collect_body_before"
1143		);
1144	}
1145
1146	#[test]
1147	fn lower_opportunistic_buffering_does_not_trigger_collect() {
1148		let r = parse_rule(serde_json::json!({
1149			"name": "r",
1150			"listen": [":7901"],
1151			"terminate": {
1152				"type": "http_proxy",
1153				"upstream": "127.0.0.1:8080",
1154				"retry": { "max_attempts": 3, "buffering": "opportunistic" },
1155			},
1156		}));
1157		let graph =
1158			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1159		let any_fetch_collects = graph.nodes.iter().any(|n| {
1160			matches!(
1161				n,
1162				crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1163			)
1164		});
1165		assert!(
1166			!any_fetch_collects,
1167			"opportunistic buffering must NOT flag fetch with collect_body_before",
1168		);
1169	}
1170
1171	#[test]
1172	fn lower_max_attempts_one_with_force_does_not_trigger_collect() {
1173		// `force` only means anything together with `max_attempts > 1`.
1174		// Pinning `max_attempts: 1` keeps retry off and the buffering
1175		// trigger inactive even when the JSON spells `force`.
1176		let r = parse_rule(serde_json::json!({
1177			"name": "r",
1178			"listen": [":7902"],
1179			"terminate": {
1180				"type": "http_proxy",
1181				"upstream": "127.0.0.1:8080",
1182				"retry": { "max_attempts": 1, "buffering": "force" },
1183			},
1184		}));
1185		let graph =
1186			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1187		let any_fetch_collects = graph.nodes.iter().any(|n| {
1188			matches!(
1189				n,
1190				crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1191			)
1192		});
1193		assert!(!any_fetch_collects, "max_attempts=1 disables the force-buffering trigger");
1194	}
1195
1196	#[test]
1197	fn lower_two_l7_listeners_have_independent_synth_entries() {
1198		// Two L7 listeners on distinct ports each get their own synth
1199		// target keyed by their listener entry. (Whether the synth nodes
1200		// collapse via terminator-id hash-cons is an internal detail; the
1201		// public contract is one map entry per listener entry.)
1202		let a = parse_rule(serde_json::json!({
1203			"name": "a",
1204			"listen": [":7702"],
1205			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1206		}));
1207		let b = parse_rule(serde_json::json!({
1208			"name": "b",
1209			"listen": [":7703"],
1210			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8081" },
1211		}));
1212		let graph =
1213			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
1214		// One synth entry per listener entry — the dual-stack `:7702`
1215		// expands to v4+v6 sharing one entry NodeId, so map size matches
1216		// the number of *unique* entry NodeIds in the graph.
1217		let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
1218		assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
1219		assert!(
1220			graph.meta.short_circuit_response_entry.len() >= 2,
1221			"two listeners → at least two synth entries"
1222		);
1223	}
1224
1225	#[test]
1226	fn http_body_check_node_sets_collect_body_before_request() {
1227		// A rule whose predicate reads http.body must produce a Check node
1228		// with collect_body_before = Some(BodySide::Request). The executor
1229		// uses this flag to materialise the request body before running the
1230		// predicate test — without it, as_static() would panic.
1231		//
1232		// "aGVsbG8=" is standard base64 for the ASCII bytes "hello".
1233		let r = parse_rule(serde_json::json!({
1234			"name": "r",
1235			"listen": [":7910"],
1236			"match": { "http.body": { "contains": "aGVsbG8=" } },
1237			"terminate": { "type": "http_proxy" },
1238		}));
1239		let graph =
1240			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1241		let has_collecting_check = graph.nodes.iter().any(|n| {
1242			matches!(
1243				n,
1244				crate::ir::Node::Check { collect_body_before: Some(crate::ir::BodySide::Request), .. }
1245			)
1246		});
1247		assert!(has_collecting_check, "http.body Check must carry collect_body_before = Some(Request)");
1248	}
1249
1250	#[test]
1251	fn rule_without_http_body_predicate_has_no_request_collect_on_check() {
1252		// A rule whose predicate does not read http.body must not trigger
1253		// request-side buffering. The Check node produced for an
1254		// http.method predicate must have collect_body_before = None so the
1255		// pay-as-you-go invariant holds.
1256		let r = parse_rule(serde_json::json!({
1257			"name": "r",
1258			"listen": [":7911"],
1259			"match": { "http.method": { "equals": "GET" } },
1260			"terminate": { "type": "http_proxy" },
1261		}));
1262		let graph =
1263			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1264		let any_check_collects = graph.nodes.iter().any(|n| {
1265			matches!(
1266				n,
1267				crate::ir::Node::Check { collect_body_before: Some(crate::ir::BodySide::Request), .. }
1268			)
1269		});
1270		assert!(
1271			!any_check_collects,
1272			"non-body predicate must not set collect_body_before on any Check node"
1273		);
1274	}
1275
1276	#[test]
1277	fn malformed_base64_in_http_body_predicate_fails_compile() {
1278		// The base64 literal in a Bytes-typed field predicate is decoded at
1279		// lower time, not at predicate-test time. A syntactically invalid
1280		// base64 string must produce a compile-time error so the rule never
1281		// reaches the runtime graph. "not-valid-base64!!!" contains '!'
1282		// which is not in the standard base64 alphabet.
1283		let r = parse_rule(serde_json::json!({
1284			"name": "r",
1285			"listen": [":7912"],
1286			"match": { "http.body": { "contains": "not-valid-base64!!!" } },
1287			"terminate": { "type": "http_proxy" },
1288		}));
1289		let err =
1290			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect_err("must fail");
1291		let msg = err.to_string();
1292		assert!(
1293			msg.contains("base64") || msg.contains("base 64"),
1294			"error must mention base64 decoding failure, got: {msg}"
1295		);
1296	}
1297}