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