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::{Diagnostics, 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/// On error the message carries every diagnostic the pipeline
23/// accumulated — the underlying [`compile_collecting`] runs each
24/// stage's "leaf checks" with push+continue, and only the stage
25/// boundary decides whether to bail. Operators running
26/// `vane compile <dir>` see all problems at once instead of fixing
27/// them one-at-a-time.
28///
29/// # Errors
30/// Returns [`Error::compile`] on duplicate rule names, unknown middleware
31/// or fetch names referenced by rules, bad `ListenSpec` strings, predicate
32/// type mismatches, or graph-level validation failures (dangling IDs,
33/// cycles, phase mismatches).
34pub fn compile(
35	files: Vec<RawRuleFile>,
36	mw_meta: &dyn MiddlewareMetadataProvider,
37	fetch_meta: &dyn FetchMetadataProvider,
38) -> Result<Arc<SymbolicFlowGraph>, Error> {
39	compile_collecting(files, mw_meta, fetch_meta).map_err(Error::from)
40}
41
42/// Collecting form of [`compile`]: returns the accumulated
43/// [`Diagnostics`] when one or more stages report errors, instead of
44/// collapsing them into a single `Error`. Used by callers (CLI,
45/// management RPC dry-run) that want to surface every problem in one
46/// turn.
47///
48/// # Errors
49/// Returns [`Diagnostics`] when any stage pushes a fatal entry. The
50/// accumulator's `Display` impl formats a numbered list suitable for
51/// direct printing.
52pub fn compile_collecting(
53	files: Vec<RawRuleFile>,
54	mw_meta: &dyn MiddlewareMetadataProvider,
55	fetch_meta: &dyn FetchMetadataProvider,
56) -> Result<Arc<SymbolicFlowGraph>, Diagnostics> {
57	// merge + expand are early-bail today: each carries at most one
58	// authoritative error (duplicate rule names, unknown preset,
59	// etc.) so collecting buys nothing. Lift them through the
60	// Diagnostics channel so the caller's match is uniform.
61	let merged = merge::merge(files).map_err(Diagnostics::from)?;
62	let expanded = expand::expand(merged).map_err(Diagnostics::from)?;
63
64	// analyze collects per-rule errors and returns the partial set
65	// alongside diagnostics. If anything was pushed, bail before
66	// lower (every downstream stage assumes a complete rule set).
67	let (analyzed, analyze_d) = analyze::analyze_collecting(expanded, mw_meta, fetch_meta);
68	analyze_d.into_result(())?;
69
70	// lower still uses ?-return internally; collect at the stage
71	// boundary into the same accumulator.
72	let graph = lower::lower(analyzed, mw_meta, fetch_meta).map_err(Diagnostics::from)?;
73
74	// validate runs every IR-level check to completion, accumulates
75	// all findings, and the stage boundary turns them into a single
76	// Err if any.
77	let validate_d = validate::validate_collecting(&graph);
78	validate_d.into_result(())?;
79
80	Ok(Arc::new(graph))
81}
82
83#[cfg(test)]
84mod tests {
85	use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
86	use std::path::PathBuf;
87
88	use super::*;
89	use crate::fetch::{FetchKind, FetchOutputModes, FetchPhase, Terminator};
90	use crate::ir::{Node, NodeId, PredicateId};
91	use crate::metadata::{FetchMetadata, MiddlewareMetadata};
92	use crate::middleware::MiddlewareKind;
93	use crate::rule::{RawRule, TerminateSpec};
94
95	struct Providers;
96
97	fn validate_ok(_: &serde_json::Value) -> Result<(), Error> {
98		Ok(())
99	}
100
101	impl MiddlewareMetadataProvider for Providers {
102		fn get(&self, name: &str) -> Option<MiddlewareMetadata> {
103			match name {
104				"forward_client_ip" => Some(MiddlewareMetadata {
105					kind: MiddlewareKind::L7Request,
106					stateless: true,
107					needs_body: false,
108					validate_args: validate_ok,
109				}),
110				"rate_limit" => Some(MiddlewareMetadata {
111					kind: MiddlewareKind::L7Request,
112					stateless: false,
113					needs_body: false,
114					validate_args: validate_ok,
115				}),
116				_ => None,
117			}
118		}
119	}
120
121	impl FetchMetadataProvider for Providers {
122		fn get(&self, kind: FetchKind) -> Option<FetchMetadata> {
123			Some(FetchMetadata {
124				kind,
125				phase: match kind {
126					FetchKind::L4Forward => FetchPhase::L4,
127					_ => FetchPhase::L7,
128				},
129				output_modes: match kind {
130					FetchKind::L4Forward => FetchOutputModes { response: false, tunnel: true },
131					FetchKind::WebSocketUpgrade => FetchOutputModes { response: true, tunnel: true },
132					_ => FetchOutputModes { response: true, tunnel: false },
133				},
134				validate_args: validate_ok,
135			})
136		}
137	}
138
139	fn parse_rule(j: serde_json::Value) -> RawRule {
140		serde_json::from_value(j).expect("parse rule")
141	}
142
143	fn rule_file(path: &str, rules: Vec<RawRule>) -> RawRuleFile {
144		let entries = rules.into_iter().map(crate::preset::RuleEntry::Raw).collect();
145		RawRuleFile { path: PathBuf::from(path), order: 0, rules: entries }
146	}
147
148	fn _unused_mentions() {
149		let _ = TerminateSpec { kind: FetchKind::HttpProxy, args: serde_json::Value::Null };
150	}
151
152	#[test]
153	fn reverse_proxy_end_to_end_compiles_with_dual_stack_entries() {
154		let r = parse_rule(serde_json::json!({
155			"name": "proxy",
156			"listen": [":443"],
157			"middleware_chain": [{ "use": "forward_client_ip" }, { "use": "rate_limit", "args": { "rate": 100 } }],
158			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
159		}));
160		let graph =
161			compile(vec![rule_file("30-proxy.json", vec![r])], &Providers, &Providers).expect("compile");
162		assert!(!graph.nodes.is_empty());
163		// Dual-stack `:443` expands to both v4 and v6 SocketAddrs sharing one entry NodeId.
164		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 443);
165		let v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 443);
166		let e_v4 = graph.entries.get(&v4).expect("v4 entry present");
167		let e_v6 = graph.entries.get(&v6).expect("v6 entry present");
168		assert_eq!(e_v4, e_v6);
169		// The terminator set contains WriteHttpResponse (both the rule terminator
170		// and the synthesised default-miss write it).
171		assert!(
172			graph.terminators.iter().any(|t| matches!(t, Terminator::WriteHttpResponse)),
173			"expected WriteHttpResponse terminator",
174		);
175	}
176
177	#[test]
178	fn predicate_hash_cons_shares_id_across_rules() {
179		// Two rules on different listeners both match `tls.sni == "api"`.
180		// Spec spec/flow-model.md § _Hash-consing_: predicates always dedup.
181		let a = parse_rule(serde_json::json!({
182			"name": "a",
183			"listen": [":8443"],
184			"match": { "tls.sni": { "equals": "api" } },
185			"terminate": { "type": "http_proxy" },
186		}));
187		let b = parse_rule(serde_json::json!({
188			"name": "b",
189			"listen": [":9443"],
190			"match": { "tls.sni": { "equals": "api" } },
191			"terminate": { "type": "http_proxy" },
192		}));
193		let graph =
194			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
195		assert_eq!(graph.predicates.len(), 1, "identical predicates must hash-cons to one slot");
196	}
197
198	#[test]
199	fn stateless_middleware_hash_cons_across_rules() {
200		// Two rules sharing an identical `forward_client_ip` (stateless, no args)
201		// must share one MiddlewareId.
202		let a = parse_rule(serde_json::json!({
203			"name": "a",
204			"listen": [":7001"],
205			"middleware_chain": [{ "use": "forward_client_ip" }],
206			"terminate": { "type": "http_proxy" },
207		}));
208		let b = parse_rule(serde_json::json!({
209			"name": "b",
210			"listen": [":7002"],
211			"middleware_chain": [{ "use": "forward_client_ip" }],
212			"terminate": { "type": "http_proxy" },
213		}));
214		let graph =
215			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
216		let shared = graph
217			.middlewares
218			.iter()
219			.filter(|m| m.name.as_ref() == "forward_client_ip" && m.stateless)
220			.count();
221		assert_eq!(shared, 1, "stateless middleware dedups across rules");
222	}
223
224	#[test]
225	fn stateful_middleware_per_site_not_shared() {
226		// Two rules both use `rate_limit` (stateful). Each call site must get
227		// its own MiddlewareId per `spec/flow-model.md` § _Hash-consing_ — sharing buckets
228		// would silently halve the effective rate.
229		let a = parse_rule(serde_json::json!({
230			"name": "a",
231			"listen": [":7003"],
232			"middleware_chain": [{ "use": "rate_limit", "args": { "rate": 100 } }],
233			"terminate": { "type": "http_proxy" },
234		}));
235		let b = parse_rule(serde_json::json!({
236			"name": "b",
237			"listen": [":7004"],
238			"middleware_chain": [{ "use": "rate_limit", "args": { "rate": 100 } }],
239			"terminate": { "type": "http_proxy" },
240		}));
241		let graph =
242			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
243		let rate_limit_count =
244			graph.middlewares.iter().filter(|m| m.name.as_ref() == "rate_limit").count();
245		assert_eq!(rate_limit_count, 2, "stateful middleware must not share ids across call sites");
246	}
247
248	#[test]
249	fn terminator_variant_derives_from_fetch_kind() {
250		// HttpProxy / HttpSynthesize → WriteHttpResponse; L4Forward → ByteTunnel.
251		let http = parse_rule(serde_json::json!({
252			"name": "http",
253			"listen": [":8080"],
254			"terminate": { "type": "http_proxy" },
255		}));
256		let tcp = parse_rule(serde_json::json!({
257			"name": "tcp",
258			"listen": [":2222"],
259			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
260		}));
261		let graph =
262			compile(vec![rule_file("a.json", vec![http, tcp])], &Providers, &Providers).expect("compile");
263		let terms: std::collections::HashSet<_> = graph.terminators.iter().copied().collect();
264		assert!(terms.contains(&Terminator::WriteHttpResponse));
265		assert!(terms.contains(&Terminator::ByteTunnel));
266	}
267
268	#[test]
269	fn l7_rule_inserts_upgrade_node() {
270		let r = parse_rule(serde_json::json!({
271			"name": "r",
272			"listen": [":443"],
273			"terminate": { "type": "http_proxy" },
274		}));
275		let graph =
276			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
277		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
278		assert!(upgrades >= 1, "L7 listener must have at least one Upgrade node");
279	}
280
281	#[test]
282	fn l4_only_rule_has_no_upgrade() {
283		let r = parse_rule(serde_json::json!({
284			"name": "r",
285			"listen": [":2222"],
286			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
287		}));
288		let graph =
289			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
290		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
291		assert_eq!(upgrades, 0);
292	}
293
294	#[test]
295	fn duplicate_rule_names_fail_at_merge_stage() {
296		let a = parse_rule(serde_json::json!({
297			"name": "same",
298			"listen": [":1000"],
299			"terminate": { "type": "http_proxy" },
300		}));
301		let b = parse_rule(serde_json::json!({
302			"name": "same",
303			"listen": [":1001"],
304			"terminate": { "type": "http_proxy" },
305		}));
306		let err = compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers)
307			.expect_err("duplicate must fail");
308		assert!(err.to_string().contains("duplicate"));
309	}
310
311	#[test]
312	fn wildcard_port_listen_spec_is_rejected() {
313		let r = parse_rule(serde_json::json!({
314			"name": "r",
315			"listen": [":0"],
316			"terminate": { "type": "http_proxy" },
317		}));
318		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
319			.expect_err("wildcard port must fail");
320		assert!(err.to_string().contains("wildcard port"));
321	}
322
323	#[test]
324	fn validate_runs_and_catches_basic_graph_integrity() {
325		// End-to-end: `compile` runs `validate` inside. A clean reverse_proxy
326		// graph must pass — this is an end-to-end sanity check that validate
327		// is wired into the pipeline and doesn't falsely reject good graphs.
328		let r = parse_rule(serde_json::json!({
329			"name": "r",
330			"listen": [":443"],
331			"terminate": { "type": "http_proxy" },
332		}));
333		let graph =
334			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
335		// Running validate again on the returned graph must still succeed.
336		validate::validate(&graph).expect("re-validate");
337	}
338
339	#[test]
340	#[allow(
341		clippy::cognitive_complexity,
342		reason = "exhaustive Node-variant round-trip assertion: complexity grows with the number of variants, not with logic. Splitting to a per-variant helper just renames the variant-tag dispatch"
343	)]
344	fn symbolic_flow_graph_round_trip_preserves_structure_and_revalidates() {
345		// Dry-run JSON contract (spec/flow-model.md § _The compiled form_): a compiled
346		// SymbolicFlowGraph serializes to JSON and the result deserializes
347		// back to an equivalent graph that re-`validate()`s green. Slab
348		// contents and `entries` map key set must survive the round-trip.
349		use crate::ir::SymbolicFlowGraph;
350		let r = parse_rule(serde_json::json!({
351			"name": "proxy",
352			"listen": [":443"],
353			"middleware_chain": [{ "use": "forward_client_ip" }, { "use": "rate_limit", "args": { "rate": 100 } }],
354			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
355		}));
356		let graph =
357			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
358
359		let encoded = serde_json::to_string(&*graph).expect("serialize graph");
360		let decoded: SymbolicFlowGraph = serde_json::from_str(&encoded).expect("deserialize graph");
361
362		// Re-validate the decoded graph: the contract is that dry-run JSON
363		// is a ground-truth snapshot that the engine could rehydrate.
364		validate::validate(&decoded).expect("decoded graph revalidates");
365
366		// Slab lengths survive.
367		assert_eq!(decoded.nodes.len(), graph.nodes.len(), "nodes slab length");
368		assert_eq!(decoded.predicates.len(), graph.predicates.len(), "predicates slab length");
369		assert_eq!(decoded.middlewares.len(), graph.middlewares.len(), "middlewares slab length");
370		assert_eq!(decoded.fetches.len(), graph.fetches.len(), "fetches slab length");
371		assert_eq!(decoded.terminators.len(), graph.terminators.len(), "terminators slab length");
372
373		// `entries` key set (SocketAddr → NodeId) survives.
374		let orig_keys: std::collections::BTreeSet<_> = graph.entries.keys().copied().collect();
375		let dec_keys: std::collections::BTreeSet<_> = decoded.entries.keys().copied().collect();
376		assert_eq!(orig_keys, dec_keys, "entries key set must round-trip");
377
378		// PredicateInst / SymbolicMiddlewareRef / Terminator implement
379		// PartialEq; compare their slabs directly.
380		assert_eq!(decoded.predicates, graph.predicates, "predicates slab content");
381		assert_eq!(decoded.middlewares, graph.middlewares, "middlewares slab content");
382		assert_eq!(decoded.terminators, graph.terminators, "terminators slab content");
383
384		// `Node` does not implement PartialEq (by design — the enum holds
385		// id newtypes and Option<NodeId>s only). Compare node-by-node via
386		// variant destructuring to pin that the control-flow structure
387		// survived the round-trip.
388		for (i, (a, b)) in graph.nodes.iter().zip(decoded.nodes.iter()).enumerate() {
389			match (a, b) {
390				(
391					Node::Check {
392						predicate: pa,
393						on_match: ma,
394						on_miss: sa,
395						collect_body_before: ca,
396						body_limit: la,
397					},
398					Node::Check {
399						predicate: pb,
400						on_match: mb,
401						on_miss: sb,
402						collect_body_before: cb,
403						body_limit: lb,
404					},
405				) => {
406					assert_eq!(pa, pb, "node[{i}] Check predicate");
407					assert_eq!(ma, mb, "node[{i}] Check on_match");
408					assert_eq!(sa, sb, "node[{i}] Check on_miss");
409					assert_eq!(ca, cb, "node[{i}] Check collect_body_before");
410					assert_eq!(la, lb, "node[{i}] Check body_limit");
411				}
412				(
413					Node::Middleware {
414						id: ia,
415						next: na,
416						on_error: ea,
417						collect_body_before: ca,
418						body_limit: la,
419					},
420					Node::Middleware {
421						id: ib,
422						next: nb,
423						on_error: eb,
424						collect_body_before: cb,
425						body_limit: lb,
426					},
427				) => {
428					assert_eq!(ia, ib, "node[{i}] Middleware id");
429					assert_eq!(na, nb, "node[{i}] Middleware next");
430					assert_eq!(ea, eb, "node[{i}] Middleware on_error");
431					assert_eq!(ca, cb, "node[{i}] Middleware collect_body_before");
432					assert_eq!(la, lb, "node[{i}] Middleware body_limit");
433				}
434				(
435					Node::Fetch {
436						id: ia,
437						next_response: ra,
438						next_tunnel: ta,
439						collect_body_before: ca,
440						body_limit: la,
441					},
442					Node::Fetch {
443						id: ib,
444						next_response: rb,
445						next_tunnel: tb,
446						collect_body_before: cb,
447						body_limit: lb,
448					},
449				) => {
450					assert_eq!(ia, ib, "node[{i}] Fetch id");
451					assert_eq!(ra, rb, "node[{i}] Fetch next_response");
452					assert_eq!(ta, tb, "node[{i}] Fetch next_tunnel");
453					assert_eq!(ca, cb, "node[{i}] Fetch collect_body_before");
454					assert_eq!(la, lb, "node[{i}] Fetch body_limit");
455				}
456				(Node::Upgrade { next: a }, Node::Upgrade { next: b }) => {
457					assert_eq!(a, b, "node[{i}] Upgrade next");
458				}
459				(Node::Terminate(a), Node::Terminate(b)) => {
460					assert_eq!(a, b, "node[{i}] Terminate");
461				}
462				(a, b) => panic!("node[{i}] variant changed across round-trip: {a:?} -> {b:?}"),
463			}
464		}
465	}
466
467	// AnyOf / Not lowering tests
468	fn check_rule(name: &str, port: u16, match_predicate: &serde_json::Value) -> RawRule {
469		parse_rule(serde_json::json!({
470			"name": name,
471			"listen": [format!(":{port}")],
472			"match": match_predicate,
473			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
474		}))
475	}
476
477	fn find_entry_check(graph: &SymbolicFlowGraph, port: u16) -> NodeId {
478		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
479		let entry = *graph.entries.get(&v4).expect("entry present");
480		// Per spec/flow-model.md § _The compiled form_, every L7
481		// listener carries one shared Upgrade above the rule chains. Tests
482		// that probe Check structure below the Upgrade skip past it here.
483		match &graph[entry] {
484			Node::Upgrade { next } => *next,
485			_ => entry,
486		}
487	}
488
489	fn unwrap_check(node: &Node) -> (PredicateId, NodeId, NodeId) {
490		match node {
491			Node::Check { predicate, on_match, on_miss, .. } => (*predicate, *on_match, *on_miss),
492			other => panic!("expected Check, got {other:?}"),
493		}
494	}
495
496	#[test]
497	fn any_of_two_checks_chains_via_on_miss_sharing_on_match() {
498		let r = check_rule(
499			"r",
500			7100,
501			&serde_json::json!({
502				"any_of": [
503					{ "tls.sni": { "equals": "a" } },
504					{ "tls.sni": { "equals": "b" } },
505				],
506			}),
507		);
508		let graph =
509			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
510
511		let entry = find_entry_check(&graph, 7100);
512		let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
513		let (_, match_b, _miss_b) = unwrap_check(&graph[miss_a]);
514		assert_eq!(match_a, match_b, "both any_of branches share on_match");
515		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
516		assert_eq!(check_count, 2);
517		assert_eq!(graph.predicates.len(), 2, "tls.sni=\"a\" and tls.sni=\"b\" are distinct");
518	}
519
520	#[test]
521	fn any_of_three_checks_chains_right_to_left() {
522		let r = check_rule(
523			"r",
524			7101,
525			&serde_json::json!({
526				"any_of": [
527					{ "tls.sni": { "equals": "a" } },
528					{ "tls.sni": { "equals": "b" } },
529					{ "tls.sni": { "equals": "c" } },
530				],
531			}),
532		);
533		let graph =
534			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
535
536		let c0 = find_entry_check(&graph, 7101);
537		let (_, m0, miss0) = unwrap_check(&graph[c0]);
538		let (_, m1, miss1) = unwrap_check(&graph[miss0]);
539		let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
540		assert_eq!(m0, m1);
541		assert_eq!(m1, m2, "all three any_of branches share on_match");
542		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
543	}
544
545	#[test]
546	fn not_wrapping_a_check_swaps_on_match_and_on_miss() {
547		let r =
548			check_rule("r", 7102, &serde_json::json!({ "not": { "tls.sni": { "equals": "internal" } } }));
549		let graph =
550			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
551
552		// Not adds no node — exactly one Check.
553		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
554		assert_eq!(check_count, 1);
555		let entry = find_entry_check(&graph, 7102);
556		let (_, on_match, on_miss) = unwrap_check(&graph[entry]);
557		// Per the equivalence `not P match=>X miss=>Y` ≡ lower(P, match=>Y, miss=>X),
558		// the emitted Check has swapped edges: its on_match is the outer on_miss
559		// (the default-miss fallback) and its on_miss is the rule body entry.
560		// Assert they're distinct — before-task-2 code had them both pointing
561		// at the body entry.
562		assert_ne!(on_match, on_miss);
563		// Walking `on_miss` should land at something reachable; walking
564		// `on_match` should land at a node that cannot reach the rule's Fetch.
565		// Minimal structural check: the two targets differ.
566	}
567
568	#[test]
569	fn not_wrapping_any_of_swaps_edges_and_produces_two_checks() {
570		let r = check_rule(
571			"r",
572			7103,
573			&serde_json::json!({
574				"not": {
575					"any_of": [
576						{ "tls.sni": { "equals": "a" } },
577						{ "tls.sni": { "equals": "b" } },
578					],
579				},
580			}),
581		);
582		let graph =
583			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
584
585		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
586		let c0 = find_entry_check(&graph, 7103);
587		let (_, m0, miss0) = unwrap_check(&graph[c0]);
588		let (_, m1, _miss1) = unwrap_check(&graph[miss0]);
589		// `not (any_of [A, B])` = lower(any_of, match=>Y, miss=>X) =
590		//   Check(A) match=>Y miss=>Check(B) match=>Y miss=>X.
591		// Both Checks share on_match (== outer on_miss, i.e. the default-miss).
592		assert_eq!(m0, m1);
593	}
594
595	#[test]
596	fn any_of_nested_inside_any_of_produces_three_checks_with_shared_on_match() {
597		let r = check_rule(
598			"r",
599			7104,
600			&serde_json::json!({
601				"any_of": [
602					{ "tls.sni": { "equals": "a" } },
603					{
604						"any_of": [
605							{ "tls.sni": { "equals": "b" } },
606							{ "tls.sni": { "equals": "c" } },
607						],
608					},
609				],
610			}),
611		);
612		let graph =
613			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
614
615		let c0 = find_entry_check(&graph, 7104);
616		let (_, m0, miss0) = unwrap_check(&graph[c0]);
617		let (_, m1, miss1) = unwrap_check(&graph[miss0]);
618		let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
619		assert_eq!(m0, m1);
620		assert_eq!(m1, m2);
621		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
622	}
623
624	#[test]
625	fn empty_any_of_short_circuits_to_on_miss() {
626		let r = check_rule("r", 7105, &serde_json::json!({ "any_of": [] }));
627		// Empty any_of ≡ never matches. The rule's chain entry equals the
628		// on_miss target (default-miss); no Check node is emitted for the
629		// empty any_of itself.
630		let graph =
631			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
632		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
633		assert_eq!(check_count, 0, "empty any_of must not emit a Check node");
634	}
635
636	#[test]
637	fn any_of_hash_cons_shares_predicate_slot_across_rules() {
638		// Two rules on different listeners both use the same `tls.sni ==
639		// "shared"` predicate inside any_of. Per spec/flow-model.md § _Hash-consing_,
640		// predicates dedup transparently regardless of the combinator tree
641		// they're nested inside.
642		let a = check_rule(
643			"a",
644			7106,
645			&serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
646		);
647		let b = check_rule(
648			"b",
649			7107,
650			&serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
651		);
652		let graph =
653			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
654		assert_eq!(graph.predicates.len(), 1);
655	}
656
657	#[test]
658	fn l4_predicate_on_l7_rule_sits_post_upgrade() {
659		// Every L7 listener carries one shared Upgrade above the rule
660		// chains, so L4-level Check leaves on L7 rules sit AFTER the
661		// Upgrade. `PredicateView::L7Req` carries `conn`, so reads of
662		// `tls.sni` / `remote.ip` remain functionally correct.
663		let r = check_rule("r", 7300, &serde_json::json!({ "tls.sni": { "equals": "a" } }));
664		let graph =
665			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
666		// Listener entry is the shared Upgrade.
667		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7300);
668		let listener_entry = *graph.entries.get(&v4).expect("entry present");
669		assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
670		// One step below the Upgrade is the Check.
671		let check_below = find_entry_check(&graph, 7300);
672		assert!(matches!(&graph[check_below], Node::Check { .. }));
673	}
674
675	#[test]
676	fn l7_predicate_on_l7_rule_sits_after_upgrade() {
677		// Upgrade is listener-level rather than per-rule; L7-level Checks
678		// still descend from it.
679		let r = check_rule(
680			"r",
681			7301,
682			&serde_json::json!({ "http.header.host": { "equals": "api.example.com" } }),
683		);
684		let graph =
685			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
686		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7301);
687		let listener_entry = *graph.entries.get(&v4).expect("entry present");
688		assert!(
689			matches!(&graph[listener_entry], Node::Upgrade { .. }),
690			"L7 listener entry is the shared Upgrade",
691		);
692		let Node::Upgrade { next } = &graph[listener_entry] else {
693			panic!("expected Upgrade");
694		};
695		assert!(matches!(&graph[*next], Node::Check { .. }));
696	}
697
698	#[test]
699	fn pure_l4_rule_with_predicate_synthesises_close_miss() {
700		let r = parse_rule(serde_json::json!({
701			"name": "r",
702			"listen": [":7302"],
703			"match": { "remote.ip": { "cidr": "10.0.0.0/8" } },
704			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
705		}));
706		let graph =
707			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
708		let entry = find_entry_check(&graph, 7302);
709		assert!(matches!(&graph[entry], Node::Check { .. }));
710		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
711		assert_eq!(upgrades, 0, "L4 posture never Upgrades");
712		assert!(
713			graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
714			"default-miss must synthesise a Close terminator",
715		);
716	}
717
718	#[test]
719	fn l7_rule_with_predicate_uses_close_not_500_for_default_miss() {
720		// Pre-task-4 the L7 default-miss was a synthesised 500. Task 4 unified
721		// both postures on `Terminator::Close`, so no HttpSynthesize fetch
722		// should appear in the graph for a rule whose terminator is http_proxy.
723		let r = check_rule("r", 7400, &serde_json::json!({ "tls.sni": { "equals": "api" } }));
724		let graph =
725			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
726		assert!(
727			graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
728			"default-miss must be Close",
729		);
730		let synth_fetches =
731			graph.fetches.iter().filter(|f| f.kind == FetchKind::HttpSynthesize).count();
732		assert_eq!(synth_fetches, 0, "no 500 synth for unmatched L7 traffic — just Close");
733	}
734
735	#[test]
736	fn catch_all_rule_set_omits_close_fallback() {
737		// A predicate-less rule is always matched; the default-miss is dead
738		// code and must not appear in the graph.
739		let r = parse_rule(serde_json::json!({
740			"name": "r",
741			"listen": [":7401"],
742			"terminate": { "type": "http_proxy" },
743		}));
744		let graph =
745			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
746		let close_count = graph.terminators.iter().filter(|t| matches!(t, Terminator::Close)).count();
747		assert_eq!(close_count, 0, "no predicate means no miss path means no Close");
748	}
749
750	#[test]
751	fn close_terminator_serde_round_trip() {
752		// Pin the wire form of the new variant for dry-run JSON.
753		let t = Terminator::Close;
754		let encoded = serde_json::to_string(&t).expect("serialize");
755		let decoded: Terminator = serde_json::from_str(&encoded).expect("deserialize");
756		assert_eq!(decoded, t);
757	}
758
759	#[test]
760	fn l7_rule_without_predicate_has_upgrade_as_entry() {
761		let r = parse_rule(serde_json::json!({
762			"name": "r",
763			"listen": [":7303"],
764			"terminate": { "type": "http_proxy" },
765		}));
766		let graph =
767			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
768		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7303);
769		let listener_entry = *graph.entries.get(&v4).expect("entry present");
770		assert!(matches!(&graph[listener_entry], Node::Upgrade { .. }));
771	}
772
773	#[test]
774	fn cross_level_any_of_is_rejected() {
775		let r = check_rule(
776			"r",
777			7304,
778			&serde_json::json!({
779				"any_of": [
780					{ "tls.sni": { "equals": "a" } },
781					{ "http.method": { "equals": "GET" } },
782				],
783			}),
784		);
785		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
786			.expect_err("cross-level any_of must fail");
787		assert!(err.to_string().contains("cross-level"), "error message names the constraint: {err}");
788	}
789
790	#[test]
791	fn cross_level_not_is_rejected() {
792		let r = check_rule(
793			"r",
794			7305,
795			&serde_json::json!({
796				"not": {
797					"any_of": [
798						{ "tls.sni": { "equals": "a" } },
799						{ "http.method": { "equals": "GET" } },
800					],
801				},
802			}),
803		);
804		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
805			.expect_err("cross-level not(any_of) must fail");
806		assert!(err.to_string().contains("cross-level"));
807	}
808
809	#[test]
810	fn same_level_any_of_compiles_at_one_side_of_upgrade() {
811		// Two L4Peek checks: Upgrade sits BELOW both Checks.
812		let r = check_rule(
813			"r",
814			7306,
815			&serde_json::json!({
816				"any_of": [
817					{ "tls.sni": { "equals": "a" } },
818					{ "tls.sni": { "equals": "b" } },
819				],
820			}),
821		);
822		let graph =
823			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
824		let entry = find_entry_check(&graph, 7306);
825		// Entry is a Check; walking any_of's shared on_match must reach an Upgrade.
826		assert!(matches!(&graph[entry], Node::Check { .. }));
827	}
828
829	#[test]
830	fn validate_stays_green_for_all_combinator_shapes() {
831		let shapes = [
832			serde_json::json!({ "tls.sni": { "equals": "x" } }),
833			serde_json::json!({
834				"any_of": [
835					{ "tls.sni": { "equals": "a" } },
836					{ "tls.sni": { "equals": "b" } },
837				],
838			}),
839			serde_json::json!({ "not": { "tls.sni": { "equals": "y" } } }),
840			serde_json::json!({
841				"not": {
842					"any_of": [
843						{ "tls.sni": { "equals": "a" } },
844						{ "tls.sni": { "equals": "b" } },
845					],
846				},
847			}),
848			serde_json::json!({
849				"all_of": [
850					{ "tls.sni": { "equals": "a" } },
851					{ "tls.sni": { "equals": "b" } },
852				],
853			}),
854			serde_json::json!({
855				"not": {
856					"all_of": [
857						{ "tls.sni": { "equals": "a" } },
858						{ "tls.sni": { "equals": "b" } },
859					],
860				},
861			}),
862		];
863		for (i, m) in shapes.iter().enumerate() {
864			let port = 7200 + u16::try_from(i).expect("fits u16");
865			let r = check_rule(&format!("r{i}"), port, m);
866			let graph =
867				compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
868			validate::validate(&graph).expect("validate");
869		}
870	}
871
872	#[test]
873	fn all_of_two_checks_chains_left_match_to_right_entry() {
874		// all_of [A, B] match=>X miss=>Y  ≡
875		//   Check(A) match=>Check(B) match=>X miss=>Y, miss=>Y
876		// Both Checks share on_miss; the first Check's on_match points at
877		// the second Check's entry, not at X directly.
878		let r = check_rule(
879			"r",
880			7500,
881			&serde_json::json!({
882				"all_of": [
883					{ "tls.sni": { "equals": "a" } },
884					{ "tls.sni": { "equals": "b" } },
885				],
886			}),
887		);
888		let graph =
889			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
890		let entry = find_entry_check(&graph, 7500);
891		let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
892		let (_, _match_b, miss_b) = unwrap_check(&graph[match_a]);
893		assert_eq!(miss_a, miss_b, "both all_of branches share on_miss");
894		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
895	}
896
897	#[test]
898	fn all_of_empty_array_short_circuits_to_on_match() {
899		// `all_of: []` is vacuously true → no Check emitted, the rule's
900		// chain entry equals on_match (the rule body).
901		let r = check_rule("r", 7501, &serde_json::json!({ "all_of": [] }));
902		let graph =
903			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
904		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
905		assert_eq!(check_count, 0, "empty all_of must not emit a Check node");
906	}
907
908	#[test]
909	fn all_of_cross_level_combinator_is_rejected() {
910		// Same uniform-level rule as any_of: AllOf can't mix L4 and L7 leaves.
911		let r = check_rule(
912			"r",
913			7502,
914			&serde_json::json!({
915				"all_of": [
916					{ "tls.sni": { "equals": "a" } },
917					{ "http.method": { "equals": "GET" } },
918				],
919			}),
920		);
921		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
922			.expect_err("cross-level all_of must fail");
923		let msg = err.to_string();
924		assert!(msg.contains("cross-level"), "error names the constraint: {msg}");
925		assert!(msg.contains("all_of"), "error mentions all_of: {msg}");
926	}
927
928	#[test]
929	fn all_of_nested_inside_any_of_works() {
930		let r = check_rule(
931			"r",
932			7503,
933			&serde_json::json!({
934				"any_of": [
935					{ "all_of": [
936						{ "http.header.upgrade": { "equals": "websocket" } },
937						{ "http.uri.path": { "prefix": "/ws" } },
938					]},
939					{ "all_of": [
940						{ "http.header.upgrade": { "equals": "websocket" } },
941						{ "http.uri.path": { "prefix": "/api/stream" } },
942					]},
943				],
944			}),
945		);
946		let graph =
947			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
948		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 4);
949	}
950
951	#[test]
952	fn l7_listener_emits_single_upgrade_at_top_for_two_rules() {
953		// spec/flow-model.md § _The compiled form_: two L7 rules on
954		// the same listener share one Upgrade — the entire graph must
955		// contain exactly one Upgrade node, not two.
956		let a = parse_rule(serde_json::json!({
957			"name": "a",
958			"listen": [":7600"],
959			"match": { "http.header.host": { "equals": "a.example.com" } },
960			"terminate": { "type": "http_proxy" },
961		}));
962		let b = parse_rule(serde_json::json!({
963			"name": "b",
964			"listen": [":7600"],
965			"match": { "http.header.host": { "equals": "b.example.com" } },
966			"terminate": { "type": "http_proxy" },
967		}));
968		let graph =
969			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
970		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
971		assert_eq!(upgrades, 1, "exactly one Upgrade per L7 listener regardless of rule count");
972	}
973
974	#[test]
975	fn l4_listener_has_no_upgrade() {
976		// L4 posture stays as-is — no Upgrade emitted.
977		let r = parse_rule(serde_json::json!({
978			"name": "fwd",
979			"listen": [":7601"],
980			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
981		}));
982		let graph =
983			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
984		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
985		assert_eq!(upgrades, 0);
986	}
987
988	#[test]
989	fn websocket_upgrade_emits_two_distinct_terminators() {
990		// `WebSocketUpgrade` is dual-output — the response branch lands at
991		// `WriteHttpResponse` (rejection); the tunnel branch lands at
992		// `ByteTunnel` (101 Switching handoff).
993		let r = parse_rule(serde_json::json!({
994			"name": "ws",
995			"listen": [":7602"],
996			"match": { "http.header.upgrade": { "equals": "websocket" } },
997			"terminate": { "type": "websocket", "upstream": "127.0.0.1:8080" },
998		}));
999		let graph =
1000			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1001		let terms: std::collections::HashSet<_> = graph.terminators.iter().copied().collect();
1002		assert!(terms.contains(&Terminator::WriteHttpResponse), "response branch terminator");
1003		assert!(terms.contains(&Terminator::ByteTunnel), "tunnel branch terminator");
1004	}
1005
1006	// Short(Response) synth target
1007	#[test]
1008	fn lower_l7_listener_synthesizes_short_circuit_response_target() {
1009		// Every L7 listener gets a synth `Terminate(WriteHttpResponse)` that
1010		// `meta.short_circuit_response_entry` maps the listener entry to.
1011		// Without this synth, an L7 request middleware returning
1012		// `Decision::Short(ShortCircuit::Response(_))` has nowhere to land.
1013		let r = parse_rule(serde_json::json!({
1014			"name": "r",
1015			"listen": [":7700"],
1016			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1017		}));
1018		let graph =
1019			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1020		// L7 listener → exactly one synth target per *unique* entry NodeId.
1021		// Dual-stack `:N` shorthand maps both v4 and v6 SocketAddrs to the
1022		// same listener NodeId, so the synth map keys by NodeId and is
1023		// smaller than `entries.len()` for the dual-stack case.
1024		let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
1025		assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
1026		// Every value points at a `Terminate(WriteHttpResponse)`.
1027		for synth in graph.meta.short_circuit_response_entry.values() {
1028			let Node::Terminate(tid) = &graph[*synth] else {
1029				panic!("synth node is not a Terminate: {:?}", &graph[*synth]);
1030			};
1031			assert_eq!(graph.terminators[tid.get() as usize], Terminator::WriteHttpResponse);
1032		}
1033	}
1034
1035	#[test]
1036	fn lower_l4_listener_has_no_short_circuit_response_target() {
1037		// Pure L4 (port_forward) listeners never go through Upgrade — no
1038		// L7Request middleware can run, so no synth target is needed.
1039		let r = parse_rule(serde_json::json!({
1040			"name": "r",
1041			"listen": [":7701"],
1042			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1043		}));
1044		let graph =
1045			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1046		assert!(graph.meta.short_circuit_response_entry.is_empty());
1047	}
1048
1049	#[test]
1050	fn lower_derives_raw_when_only_l4_forward_terminator() {
1051		// `port_forward`-shaped rule: single `tcp_forward` upstream on a
1052		// listener with no L7 path. Per `spec/crates/core.md` § _Listener kind
1053		// derivation_, every reachable terminator is L4 → `Raw`.
1054		let r = parse_rule(serde_json::json!({
1055			"name": "r",
1056			"listen": [":7800"],
1057			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1058		}));
1059		let graph =
1060			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1061		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7800);
1062		assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Raw));
1063	}
1064
1065	#[test]
1066	fn lower_derives_http_when_only_l7_terminators() {
1067		let r = parse_rule(serde_json::json!({
1068			"name": "r",
1069			"listen": [":7801"],
1070			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1071		}));
1072		let graph =
1073			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1074		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7801);
1075		assert_eq!(graph.meta.listener_kinds.get(&v4), Some(&crate::ir::ListenerKind::Http));
1076	}
1077
1078	#[test]
1079	fn lower_derives_http_for_udp_prefix_listener() {
1080		// `spec/crates/core.md` § _Listener kind derivation_ is graph-shape-only —
1081		// transport doesn't enter the rule. The `udp:` prefix flows
1082		// through `parse_listen` to `listener_transports`, while the
1083		// L7 terminator independently picks `Http`. The combination is
1084		// what the engine needs to spawn the per-listener H3 stack.
1085		let r = parse_rule(serde_json::json!({
1086			"name": "h3",
1087			"listen": ["udp:7802"],
1088			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1089		}));
1090		let graph =
1091			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1092		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7802);
1093		assert_eq!(
1094			graph.meta.listener_kinds.get(&v4),
1095			Some(&crate::ir::ListenerKind::Http),
1096			"udp listener with http_proxy terminator must derive ListenerKind::Http",
1097		);
1098		assert_eq!(
1099			graph.meta.listener_transports.get(&v4),
1100			Some(&crate::conn_context::Transport::Udp),
1101			"udp: prefix must populate listener_transports as Udp",
1102		);
1103	}
1104
1105	#[test]
1106	fn lower_derives_auto_when_l4_and_l7_share_listener() {
1107		// `analyze` currently rejects a single rule set with mixed L4 + L7
1108		// postures on the same listener (the protocol-detect frontend
1109		// that legitimises that combination is a separate feature). We
1110		// exercise the kind-derivation rule directly by hand-building a
1111		// graph whose entry can reach both an `L4Forward` fetch and an
1112		// `HttpProxy` fetch — the union of the two paths is what the
1113		// spec says yields `Auto`.
1114		use crate::compile::lower::test_only::derive_listener_kind_for_test;
1115		use crate::fetch::{FetchKind, SymbolicFetchRef};
1116		use crate::ir::{FetchId, ListenerKind, Node, NodeId, TerminatorId};
1117
1118		let nodes = vec![
1119			Node::Check {
1120				predicate: PredicateId::new(0),
1121				on_match: NodeId::new(1),
1122				on_miss: NodeId::new(2),
1123				collect_body_before: None,
1124				body_limit: 0,
1125			},
1126			Node::Fetch {
1127				id: FetchId::new(0),
1128				next_response: None,
1129				next_tunnel: Some(NodeId::new(3)),
1130				collect_body_before: None,
1131				body_limit: 0,
1132			},
1133			Node::Fetch {
1134				id: FetchId::new(1),
1135				next_response: Some(NodeId::new(3)),
1136				next_tunnel: None,
1137				collect_body_before: None,
1138				body_limit: 0,
1139			},
1140			Node::Terminate(TerminatorId::new(0)),
1141		];
1142		let fetches = vec![
1143			SymbolicFetchRef {
1144				kind: FetchKind::L4Forward,
1145				args: serde_json::Value::Null,
1146				retry_buffer_required: false,
1147				allow_zero_rtt: None,
1148			},
1149			SymbolicFetchRef {
1150				kind: FetchKind::HttpProxy,
1151				args: serde_json::Value::Null,
1152				retry_buffer_required: false,
1153				allow_zero_rtt: None,
1154			},
1155		];
1156		assert_eq!(derive_listener_kind_for_test(&nodes, &fetches, NodeId::new(0)), ListenerKind::Auto);
1157	}
1158
1159	#[test]
1160	fn listener_kinds_round_trip_through_dry_run_json() {
1161		let l4 = parse_rule(serde_json::json!({
1162			"name": "tcp",
1163			"listen": [":7803"],
1164			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
1165		}));
1166		let l7 = parse_rule(serde_json::json!({
1167			"name": "http",
1168			"listen": [":7804"],
1169			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1170		}));
1171		let graph =
1172			compile(vec![rule_file("a.json", vec![l4, l7])], &Providers, &Providers).expect("compile");
1173		let encoded = serde_json::to_string(&*graph).expect("serialize graph");
1174		let decoded: crate::ir::SymbolicFlowGraph =
1175			serde_json::from_str(&encoded).expect("deserialize graph");
1176		let v4_raw = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7803);
1177		let v4_http = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7804);
1178		assert_eq!(decoded.meta.listener_kinds.get(&v4_raw), Some(&crate::ir::ListenerKind::Raw));
1179		assert_eq!(decoded.meta.listener_kinds.get(&v4_http), Some(&crate::ir::ListenerKind::Http));
1180	}
1181
1182	#[test]
1183	fn lower_force_buffering_triggers_collect_body_before_request() {
1184		// `retry: { max_attempts: 3, buffering: "force" }` flags the
1185		// fetch node itself with `collect_body_before:
1186		// Some(BodySide::Request)` so the executor buffers the body
1187		// before the fetch runs. See `spec/crates/engine.md`
1188		// § _Retry_.
1189		let r = parse_rule(serde_json::json!({
1190			"name": "r",
1191			"listen": [":7900"],
1192			"terminate": {
1193				"type": "http_proxy",
1194				"upstream": "127.0.0.1:8080",
1195				"retry": { "max_attempts": 3, "buffering": "force" },
1196			},
1197		}));
1198		let graph =
1199			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1200		let fetch_with_collect = graph.nodes.iter().find_map(|n| match n {
1201			crate::ir::Node::Fetch {
1202				collect_body_before: Some(crate::ir::BodySide::Request), ..
1203			} => Some(()),
1204			_ => None,
1205		});
1206		assert!(
1207			fetch_with_collect.is_some(),
1208			"force buffering must flag fetch with collect_body_before"
1209		);
1210	}
1211
1212	#[test]
1213	fn lower_opportunistic_buffering_does_not_trigger_collect() {
1214		let r = parse_rule(serde_json::json!({
1215			"name": "r",
1216			"listen": [":7901"],
1217			"terminate": {
1218				"type": "http_proxy",
1219				"upstream": "127.0.0.1:8080",
1220				"retry": { "max_attempts": 3, "buffering": "opportunistic" },
1221			},
1222		}));
1223		let graph =
1224			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1225		let any_fetch_collects = graph.nodes.iter().any(|n| {
1226			matches!(
1227				n,
1228				crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1229			)
1230		});
1231		assert!(
1232			!any_fetch_collects,
1233			"opportunistic buffering must NOT flag fetch with collect_body_before",
1234		);
1235	}
1236
1237	#[test]
1238	fn lower_max_attempts_one_with_force_does_not_trigger_collect() {
1239		// `force` only means anything together with `max_attempts > 1`.
1240		// Pinning `max_attempts: 1` keeps retry off and the buffering
1241		// trigger inactive even when the JSON spells `force`.
1242		let r = parse_rule(serde_json::json!({
1243			"name": "r",
1244			"listen": [":7902"],
1245			"terminate": {
1246				"type": "http_proxy",
1247				"upstream": "127.0.0.1:8080",
1248				"retry": { "max_attempts": 1, "buffering": "force" },
1249			},
1250		}));
1251		let graph =
1252			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1253		let any_fetch_collects = graph.nodes.iter().any(|n| {
1254			matches!(
1255				n,
1256				crate::ir::Node::Fetch { collect_body_before: Some(crate::ir::BodySide::Request), .. },
1257			)
1258		});
1259		assert!(!any_fetch_collects, "max_attempts=1 disables the force-buffering trigger");
1260	}
1261
1262	#[test]
1263	fn lower_two_l7_listeners_have_independent_synth_entries() {
1264		// Two L7 listeners on distinct ports each get their own synth
1265		// target keyed by their listener entry. (Whether the synth nodes
1266		// collapse via terminator-id hash-cons is an internal detail; the
1267		// public contract is one map entry per listener entry.)
1268		let a = parse_rule(serde_json::json!({
1269			"name": "a",
1270			"listen": [":7702"],
1271			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1272		}));
1273		let b = parse_rule(serde_json::json!({
1274			"name": "b",
1275			"listen": [":7703"],
1276			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8081" },
1277		}));
1278		let graph =
1279			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
1280		// One synth entry per listener entry — the dual-stack `:7702`
1281		// expands to v4+v6 sharing one entry NodeId, so map size matches
1282		// the number of *unique* entry NodeIds in the graph.
1283		let unique_entries: std::collections::HashSet<_> = graph.entries.values().copied().collect();
1284		assert_eq!(graph.meta.short_circuit_response_entry.len(), unique_entries.len());
1285		assert!(
1286			graph.meta.short_circuit_response_entry.len() >= 2,
1287			"two listeners → at least two synth entries"
1288		);
1289	}
1290
1291	#[test]
1292	fn http_body_check_node_sets_collect_body_before_request() {
1293		// A rule whose predicate reads http.body must produce a Check node
1294		// with collect_body_before = Some(BodySide::Request). The executor
1295		// uses this flag to materialise the request body before running the
1296		// predicate test — without it, as_static() would panic.
1297		//
1298		// "aGVsbG8=" is standard base64 for the ASCII bytes "hello".
1299		let r = parse_rule(serde_json::json!({
1300			"name": "r",
1301			"listen": [":7910"],
1302			"match": { "http.body": { "contains": "aGVsbG8=" } },
1303			"terminate": { "type": "http_proxy" },
1304		}));
1305		let graph =
1306			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1307		let has_collecting_check = graph.nodes.iter().any(|n| {
1308			matches!(
1309				n,
1310				crate::ir::Node::Check { collect_body_before: Some(crate::ir::BodySide::Request), .. }
1311			)
1312		});
1313		assert!(has_collecting_check, "http.body Check must carry collect_body_before = Some(Request)");
1314	}
1315
1316	#[test]
1317	fn rule_without_http_body_predicate_has_no_request_collect_on_check() {
1318		// A rule whose predicate does not read http.body must not trigger
1319		// request-side buffering. The Check node produced for an
1320		// http.method predicate must have collect_body_before = None so the
1321		// pay-as-you-go invariant holds.
1322		let r = parse_rule(serde_json::json!({
1323			"name": "r",
1324			"listen": [":7911"],
1325			"match": { "http.method": { "equals": "GET" } },
1326			"terminate": { "type": "http_proxy" },
1327		}));
1328		let graph =
1329			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
1330		let any_check_collects = graph.nodes.iter().any(|n| {
1331			matches!(
1332				n,
1333				crate::ir::Node::Check { collect_body_before: Some(crate::ir::BodySide::Request), .. }
1334			)
1335		});
1336		assert!(
1337			!any_check_collects,
1338			"non-body predicate must not set collect_body_before on any Check node"
1339		);
1340	}
1341
1342	#[test]
1343	fn compile_collecting_surfaces_every_analyze_failure_in_one_pass() {
1344		// Two rules each reference an unknown middleware — the legacy
1345		// `compile()` short-circuits on the first; the collecting form
1346		// must accumulate both.
1347		let a = parse_rule(serde_json::json!({
1348			"name": "a",
1349			"listen": [":9201"],
1350			"middleware_chain": [{ "use": "does_not_exist_a" }],
1351			"terminate": { "type": "http_proxy" },
1352		}));
1353		let b = parse_rule(serde_json::json!({
1354			"name": "b",
1355			"listen": [":9202"],
1356			"middleware_chain": [{ "use": "does_not_exist_b" }],
1357			"terminate": { "type": "http_proxy" },
1358		}));
1359		let d =
1360			super::compile_collecting(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers)
1361				.expect_err("must accumulate errors");
1362		assert_eq!(d.len(), 2, "expected one error per bad rule, got {d}");
1363		let s = d.to_string();
1364		assert!(s.contains("does_not_exist_a"), "{s}");
1365		assert!(s.contains("does_not_exist_b"), "{s}");
1366	}
1367
1368	#[test]
1369	fn compile_facade_collapses_diagnostics_into_single_error_message() {
1370		// The back-compat `compile()` API must keep emitting one
1371		// Error whose message names every accumulated failure, so
1372		// existing callers (daemon RPC, etc.) still surface every
1373		// problem in one turn.
1374		let a = parse_rule(serde_json::json!({
1375			"name": "a",
1376			"listen": [":9301"],
1377			"middleware_chain": [{ "use": "does_not_exist_x" }],
1378			"terminate": { "type": "http_proxy" },
1379		}));
1380		let b = parse_rule(serde_json::json!({
1381			"name": "b",
1382			"listen": [":9302"],
1383			"middleware_chain": [{ "use": "does_not_exist_y" }],
1384			"terminate": { "type": "http_proxy" },
1385		}));
1386		let err = super::compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers)
1387			.expect_err("must error");
1388		let msg = err.to_string();
1389		assert!(msg.contains("does_not_exist_x"), "{msg}");
1390		assert!(msg.contains("does_not_exist_y"), "{msg}");
1391		assert!(msg.contains("2 compile errors"), "{msg}");
1392	}
1393
1394	#[test]
1395	fn version_hash_changes_with_match_predicate() {
1396		let with_match = parse_rule(serde_json::json!({
1397			"name": "r",
1398			"listen": [":9001"],
1399			"match": { "tls.sni": { "equals": "a.example.com" } },
1400			"terminate": { "type": "http_proxy" },
1401		}));
1402		let without_match = parse_rule(serde_json::json!({
1403			"name": "r",
1404			"listen": [":9001"],
1405			"terminate": { "type": "http_proxy" },
1406		}));
1407		let g1 = compile(vec![rule_file("a.json", vec![with_match])], &Providers, &Providers)
1408			.expect("compile with match");
1409		let g2 = compile(vec![rule_file("a.json", vec![without_match])], &Providers, &Providers)
1410			.expect("compile without match");
1411		assert_ne!(
1412			g1.meta.version_hash, g2.meta.version_hash,
1413			"version_hash must distinguish rules whose match predicate differs",
1414		);
1415	}
1416
1417	#[test]
1418	fn version_hash_changes_with_middleware_chain() {
1419		let with_mw = parse_rule(serde_json::json!({
1420			"name": "r",
1421			"listen": [":9002"],
1422			"middleware_chain": [{ "use": "forward_client_ip" }],
1423			"terminate": { "type": "http_proxy" },
1424		}));
1425		let without_mw = parse_rule(serde_json::json!({
1426			"name": "r",
1427			"listen": [":9002"],
1428			"terminate": { "type": "http_proxy" },
1429		}));
1430		let g1 = compile(vec![rule_file("a.json", vec![with_mw])], &Providers, &Providers)
1431			.expect("compile with chain");
1432		let g2 = compile(vec![rule_file("a.json", vec![without_mw])], &Providers, &Providers)
1433			.expect("compile without chain");
1434		assert_ne!(
1435			g1.meta.version_hash, g2.meta.version_hash,
1436			"version_hash must distinguish rules whose middleware_chain differs",
1437		);
1438	}
1439
1440	#[test]
1441	fn version_hash_stable_under_rule_input_order_and_source_path() {
1442		// Two equivalent configs differing only in rule input order and
1443		// the file path they come from must produce the same hash —
1444		// `hash_rules` sorts by rule name and strips source metadata.
1445		let a = parse_rule(serde_json::json!({
1446			"name": "a",
1447			"listen": [":9101"],
1448			"terminate": { "type": "http_proxy" },
1449		}));
1450		let b = parse_rule(serde_json::json!({
1451			"name": "b",
1452			"listen": [":9102"],
1453			"terminate": { "type": "http_proxy" },
1454		}));
1455		let g1 =
1456			compile(vec![rule_file("first.json", vec![a.clone(), b.clone()])], &Providers, &Providers)
1457				.expect("compile first order");
1458		let g2 = compile(vec![rule_file("second.json", vec![b, a])], &Providers, &Providers)
1459			.expect("compile reversed order");
1460		assert_eq!(
1461			g1.meta.version_hash, g2.meta.version_hash,
1462			"version_hash must be stable across rule order and file path",
1463		);
1464	}
1465
1466	#[test]
1467	fn malformed_base64_in_http_body_predicate_fails_compile() {
1468		// The base64 literal in a Bytes-typed field predicate is decoded at
1469		// lower time, not at predicate-test time. A syntactically invalid
1470		// base64 string must produce a compile-time error so the rule never
1471		// reaches the runtime graph. "not-valid-base64!!!" contains '!'
1472		// which is not in the standard base64 alphabet.
1473		let r = parse_rule(serde_json::json!({
1474			"name": "r",
1475			"listen": [":7912"],
1476			"match": { "http.body": { "contains": "not-valid-base64!!!" } },
1477			"terminate": { "type": "http_proxy" },
1478		}));
1479		let err =
1480			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect_err("must fail");
1481		let msg = err.to_string();
1482		assert!(
1483			msg.contains("base64") || msg.contains("base 64"),
1484			"error must mention base64 decoding failure, got: {msg}"
1485		);
1486	}
1487}