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		RawRuleFile { path: PathBuf::from(path), order: 0, rules }
103	}
104
105	fn _unused_mentions() {
106		let _ = TerminateSpec { kind: FetchKind::HttpProxy, args: serde_json::Value::Null };
107	}
108
109	#[test]
110	fn reverse_proxy_end_to_end_compiles_with_dual_stack_entries() {
111		let r = parse_rule(serde_json::json!({
112			"name": "proxy",
113			"listen": [":443"],
114			"middleware_chain": [{ "use": "forward_client_ip" }, { "use": "rate_limit", "args": { "rate": 100 } }],
115			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
116		}));
117		let graph =
118			compile(vec![rule_file("30-proxy.json", vec![r])], &Providers, &Providers).expect("compile");
119		assert!(!graph.nodes.is_empty());
120		// Dual-stack `:443` expands to both v4 and v6 SocketAddrs sharing one entry NodeId.
121		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 443);
122		let v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 443);
123		let e_v4 = graph.entries.get(&v4).expect("v4 entry present");
124		let e_v6 = graph.entries.get(&v6).expect("v6 entry present");
125		assert_eq!(e_v4, e_v6);
126		// The terminator set contains WriteHttpResponse (both the rule terminator
127		// and the synthesised default-miss write it).
128		assert!(
129			graph.terminators.iter().any(|t| matches!(t, Terminator::WriteHttpResponse)),
130			"expected WriteHttpResponse terminator",
131		);
132	}
133
134	#[test]
135	fn predicate_hash_cons_shares_id_across_rules() {
136		// Two rules on different listeners both match `tls.sni == "api"`.
137		// Spec 02-flow.md § _Hash-consing_: predicates always dedup.
138		let a = parse_rule(serde_json::json!({
139			"name": "a",
140			"listen": [":8443"],
141			"match": { "tls.sni": { "equals": "api" } },
142			"terminate": { "type": "http_proxy" },
143		}));
144		let b = parse_rule(serde_json::json!({
145			"name": "b",
146			"listen": [":9443"],
147			"match": { "tls.sni": { "equals": "api" } },
148			"terminate": { "type": "http_proxy" },
149		}));
150		let graph =
151			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
152		assert_eq!(graph.predicates.len(), 1, "identical predicates must hash-cons to one slot");
153	}
154
155	#[test]
156	fn stateless_middleware_hash_cons_across_rules() {
157		// Two rules sharing an identical `forward_client_ip` (stateless, no args)
158		// must share one MiddlewareId.
159		let a = parse_rule(serde_json::json!({
160			"name": "a",
161			"listen": [":7001"],
162			"middleware_chain": [{ "use": "forward_client_ip" }],
163			"terminate": { "type": "http_proxy" },
164		}));
165		let b = parse_rule(serde_json::json!({
166			"name": "b",
167			"listen": [":7002"],
168			"middleware_chain": [{ "use": "forward_client_ip" }],
169			"terminate": { "type": "http_proxy" },
170		}));
171		let graph =
172			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
173		let shared = graph
174			.middlewares
175			.iter()
176			.filter(|m| m.name.as_ref() == "forward_client_ip" && m.stateless)
177			.count();
178		assert_eq!(shared, 1, "stateless middleware dedups across rules");
179	}
180
181	#[test]
182	fn stateful_middleware_per_site_not_shared() {
183		// Two rules both use `rate_limit` (stateful). Each call site must get
184		// its own MiddlewareId per spec § _Hash-consing_ — sharing buckets
185		// would silently halve the effective rate.
186		let a = parse_rule(serde_json::json!({
187			"name": "a",
188			"listen": [":7003"],
189			"middleware_chain": [{ "use": "rate_limit", "args": { "rate": 100 } }],
190			"terminate": { "type": "http_proxy" },
191		}));
192		let b = parse_rule(serde_json::json!({
193			"name": "b",
194			"listen": [":7004"],
195			"middleware_chain": [{ "use": "rate_limit", "args": { "rate": 100 } }],
196			"terminate": { "type": "http_proxy" },
197		}));
198		let graph =
199			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
200		let rate_limit_count =
201			graph.middlewares.iter().filter(|m| m.name.as_ref() == "rate_limit").count();
202		assert_eq!(rate_limit_count, 2, "stateful middleware must not share ids across call sites");
203	}
204
205	#[test]
206	fn terminator_variant_derives_from_fetch_kind() {
207		// HttpProxy / HttpSynthesize → WriteHttpResponse; L4Forward → ByteTunnel.
208		let http = parse_rule(serde_json::json!({
209			"name": "http",
210			"listen": [":8080"],
211			"terminate": { "type": "http_proxy" },
212		}));
213		let tcp = parse_rule(serde_json::json!({
214			"name": "tcp",
215			"listen": [":2222"],
216			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
217		}));
218		let graph =
219			compile(vec![rule_file("a.json", vec![http, tcp])], &Providers, &Providers).expect("compile");
220		let terms: std::collections::HashSet<_> = graph.terminators.iter().copied().collect();
221		assert!(terms.contains(&Terminator::WriteHttpResponse));
222		assert!(terms.contains(&Terminator::ByteTunnel));
223	}
224
225	#[test]
226	fn l7_rule_inserts_upgrade_node() {
227		let r = parse_rule(serde_json::json!({
228			"name": "r",
229			"listen": [":443"],
230			"terminate": { "type": "http_proxy" },
231		}));
232		let graph =
233			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
234		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
235		assert!(upgrades >= 1, "L7 listener must have at least one Upgrade node");
236	}
237
238	#[test]
239	fn l4_only_rule_has_no_upgrade() {
240		let r = parse_rule(serde_json::json!({
241			"name": "r",
242			"listen": [":2222"],
243			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
244		}));
245		let graph =
246			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
247		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
248		assert_eq!(upgrades, 0);
249	}
250
251	#[test]
252	fn duplicate_rule_names_fail_at_merge_stage() {
253		let a = parse_rule(serde_json::json!({
254			"name": "same",
255			"listen": [":1000"],
256			"terminate": { "type": "http_proxy" },
257		}));
258		let b = parse_rule(serde_json::json!({
259			"name": "same",
260			"listen": [":1001"],
261			"terminate": { "type": "http_proxy" },
262		}));
263		let err = compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers)
264			.expect_err("duplicate must fail");
265		assert!(err.to_string().contains("duplicate"));
266	}
267
268	#[test]
269	fn wildcard_port_listen_spec_is_rejected() {
270		let r = parse_rule(serde_json::json!({
271			"name": "r",
272			"listen": [":0"],
273			"terminate": { "type": "http_proxy" },
274		}));
275		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
276			.expect_err("wildcard port must fail");
277		assert!(err.to_string().contains("wildcard port"));
278	}
279
280	#[test]
281	fn validate_runs_and_catches_basic_graph_integrity() {
282		// End-to-end: `compile` runs `validate` inside. A clean reverse_proxy
283		// graph must pass — this is an end-to-end sanity check that validate
284		// is wired into the pipeline and doesn't falsely reject good graphs.
285		let r = parse_rule(serde_json::json!({
286			"name": "r",
287			"listen": [":443"],
288			"terminate": { "type": "http_proxy" },
289		}));
290		let graph =
291			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
292		// Running validate again on the returned graph must still succeed.
293		validate::validate(&graph).expect("re-validate");
294	}
295
296	#[test]
297	fn symbolic_flow_graph_round_trip_preserves_structure_and_revalidates() {
298		// Dry-run JSON contract (02-flow.md § _The compiled form_): a compiled
299		// SymbolicFlowGraph serializes to JSON and the result deserializes
300		// back to an equivalent graph that re-`validate()`s green. Slab
301		// contents and `entries` map key set must survive the round-trip.
302		use crate::ir::SymbolicFlowGraph;
303		let r = parse_rule(serde_json::json!({
304			"name": "proxy",
305			"listen": [":443"],
306			"middleware_chain": [{ "use": "forward_client_ip" }, { "use": "rate_limit", "args": { "rate": 100 } }],
307			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
308		}));
309		let graph =
310			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
311
312		let encoded = serde_json::to_string(&*graph).expect("serialize graph");
313		let decoded: SymbolicFlowGraph = serde_json::from_str(&encoded).expect("deserialize graph");
314
315		// Re-validate the decoded graph: the contract is that dry-run JSON
316		// is a ground-truth snapshot that the engine could rehydrate.
317		validate::validate(&decoded).expect("decoded graph revalidates");
318
319		// Slab lengths survive.
320		assert_eq!(decoded.nodes.len(), graph.nodes.len(), "nodes slab length");
321		assert_eq!(decoded.predicates.len(), graph.predicates.len(), "predicates slab length");
322		assert_eq!(decoded.middlewares.len(), graph.middlewares.len(), "middlewares slab length");
323		assert_eq!(decoded.fetches.len(), graph.fetches.len(), "fetches slab length");
324		assert_eq!(decoded.terminators.len(), graph.terminators.len(), "terminators slab length");
325
326		// `entries` key set (SocketAddr → NodeId) survives.
327		let orig_keys: std::collections::BTreeSet<_> = graph.entries.keys().copied().collect();
328		let dec_keys: std::collections::BTreeSet<_> = decoded.entries.keys().copied().collect();
329		assert_eq!(orig_keys, dec_keys, "entries key set must round-trip");
330
331		// PredicateInst / SymbolicMiddlewareRef / Terminator implement
332		// PartialEq; compare their slabs directly.
333		assert_eq!(decoded.predicates, graph.predicates, "predicates slab content");
334		assert_eq!(decoded.middlewares, graph.middlewares, "middlewares slab content");
335		assert_eq!(decoded.terminators, graph.terminators, "terminators slab content");
336
337		// `Node` does not implement PartialEq (by design — the enum holds
338		// id newtypes and Option<NodeId>s only). Compare node-by-node via
339		// variant destructuring to pin that the control-flow structure
340		// survived the round-trip.
341		for (i, (a, b)) in graph.nodes.iter().zip(decoded.nodes.iter()).enumerate() {
342			match (a, b) {
343				(
344					Node::Check { predicate: pa, on_match: ma, on_miss: sa, collect_body_before: ca },
345					Node::Check { predicate: pb, on_match: mb, on_miss: sb, collect_body_before: cb },
346				) => {
347					assert_eq!(pa, pb, "node[{i}] Check predicate");
348					assert_eq!(ma, mb, "node[{i}] Check on_match");
349					assert_eq!(sa, sb, "node[{i}] Check on_miss");
350					assert_eq!(ca, cb, "node[{i}] Check collect_body_before");
351				}
352				(
353					Node::Middleware { id: ia, next: na, on_error: ea, collect_body_before: ca },
354					Node::Middleware { id: ib, next: nb, on_error: eb, collect_body_before: cb },
355				) => {
356					assert_eq!(ia, ib, "node[{i}] Middleware id");
357					assert_eq!(na, nb, "node[{i}] Middleware next");
358					assert_eq!(ea, eb, "node[{i}] Middleware on_error");
359					assert_eq!(ca, cb, "node[{i}] Middleware collect_body_before");
360				}
361				(
362					Node::Fetch { id: ia, next_response: ra, next_tunnel: ta, collect_body_before: ca },
363					Node::Fetch { id: ib, next_response: rb, next_tunnel: tb, collect_body_before: cb },
364				) => {
365					assert_eq!(ia, ib, "node[{i}] Fetch id");
366					assert_eq!(ra, rb, "node[{i}] Fetch next_response");
367					assert_eq!(ta, tb, "node[{i}] Fetch next_tunnel");
368					assert_eq!(ca, cb, "node[{i}] Fetch collect_body_before");
369				}
370				(Node::Upgrade { next: a }, Node::Upgrade { next: b }) => {
371					assert_eq!(a, b, "node[{i}] Upgrade next");
372				}
373				(Node::Terminate(a), Node::Terminate(b)) => {
374					assert_eq!(a, b, "node[{i}] Terminate");
375				}
376				(a, b) => panic!("node[{i}] variant changed across round-trip: {a:?} -> {b:?}"),
377			}
378		}
379	}
380
381	// --- AnyOf / Not lowering tests -----------------------------------------
382
383	fn check_rule(name: &str, port: u16, match_predicate: &serde_json::Value) -> RawRule {
384		parse_rule(serde_json::json!({
385			"name": name,
386			"listen": [format!(":{port}")],
387			"match": match_predicate,
388			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
389		}))
390	}
391
392	fn find_entry_check(graph: &SymbolicFlowGraph, port: u16) -> NodeId {
393		let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
394		*graph.entries.get(&v4).expect("entry present")
395	}
396
397	fn unwrap_check(node: &Node) -> (PredicateId, NodeId, NodeId) {
398		match node {
399			Node::Check { predicate, on_match, on_miss, .. } => (*predicate, *on_match, *on_miss),
400			other => panic!("expected Check, got {other:?}"),
401		}
402	}
403
404	#[test]
405	fn any_of_two_checks_chains_via_on_miss_sharing_on_match() {
406		let r = check_rule(
407			"r",
408			7100,
409			&serde_json::json!({
410				"any_of": [
411					{ "tls.sni": { "equals": "a" } },
412					{ "tls.sni": { "equals": "b" } },
413				],
414			}),
415		);
416		let graph =
417			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
418
419		let entry = find_entry_check(&graph, 7100);
420		let (_, match_a, miss_a) = unwrap_check(&graph[entry]);
421		let (_, match_b, _miss_b) = unwrap_check(&graph[miss_a]);
422		assert_eq!(match_a, match_b, "both any_of branches share on_match");
423		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
424		assert_eq!(check_count, 2);
425		assert_eq!(graph.predicates.len(), 2, "tls.sni=\"a\" and tls.sni=\"b\" are distinct");
426	}
427
428	#[test]
429	fn any_of_three_checks_chains_right_to_left() {
430		let r = check_rule(
431			"r",
432			7101,
433			&serde_json::json!({
434				"any_of": [
435					{ "tls.sni": { "equals": "a" } },
436					{ "tls.sni": { "equals": "b" } },
437					{ "tls.sni": { "equals": "c" } },
438				],
439			}),
440		);
441		let graph =
442			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
443
444		let c0 = find_entry_check(&graph, 7101);
445		let (_, m0, miss0) = unwrap_check(&graph[c0]);
446		let (_, m1, miss1) = unwrap_check(&graph[miss0]);
447		let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
448		assert_eq!(m0, m1);
449		assert_eq!(m1, m2, "all three any_of branches share on_match");
450		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
451	}
452
453	#[test]
454	fn not_wrapping_a_check_swaps_on_match_and_on_miss() {
455		let r =
456			check_rule("r", 7102, &serde_json::json!({ "not": { "tls.sni": { "equals": "internal" } } }));
457		let graph =
458			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
459
460		// Not adds no node — exactly one Check.
461		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
462		assert_eq!(check_count, 1);
463		let entry = find_entry_check(&graph, 7102);
464		let (_, on_match, on_miss) = unwrap_check(&graph[entry]);
465		// Per the equivalence `not P match=>X miss=>Y` ≡ lower(P, match=>Y, miss=>X),
466		// the emitted Check has swapped edges: its on_match is the outer on_miss
467		// (the default-miss fallback) and its on_miss is the rule body entry.
468		// Assert they're distinct — before-task-2 code had them both pointing
469		// at the body entry.
470		assert_ne!(on_match, on_miss);
471		// Walking `on_miss` should land at something reachable; walking
472		// `on_match` should land at a node that cannot reach the rule's Fetch.
473		// Minimal structural check: the two targets differ.
474	}
475
476	#[test]
477	fn not_wrapping_any_of_swaps_edges_and_produces_two_checks() {
478		let r = check_rule(
479			"r",
480			7103,
481			&serde_json::json!({
482				"not": {
483					"any_of": [
484						{ "tls.sni": { "equals": "a" } },
485						{ "tls.sni": { "equals": "b" } },
486					],
487				},
488			}),
489		);
490		let graph =
491			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
492
493		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 2);
494		let c0 = find_entry_check(&graph, 7103);
495		let (_, m0, miss0) = unwrap_check(&graph[c0]);
496		let (_, m1, _miss1) = unwrap_check(&graph[miss0]);
497		// `not (any_of [A, B])` = lower(any_of, match=>Y, miss=>X) =
498		//   Check(A) match=>Y miss=>Check(B) match=>Y miss=>X.
499		// Both Checks share on_match (== outer on_miss, i.e. the default-miss).
500		assert_eq!(m0, m1);
501	}
502
503	#[test]
504	fn any_of_nested_inside_any_of_produces_three_checks_with_shared_on_match() {
505		let r = check_rule(
506			"r",
507			7104,
508			&serde_json::json!({
509				"any_of": [
510					{ "tls.sni": { "equals": "a" } },
511					{
512						"any_of": [
513							{ "tls.sni": { "equals": "b" } },
514							{ "tls.sni": { "equals": "c" } },
515						],
516					},
517				],
518			}),
519		);
520		let graph =
521			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
522
523		let c0 = find_entry_check(&graph, 7104);
524		let (_, m0, miss0) = unwrap_check(&graph[c0]);
525		let (_, m1, miss1) = unwrap_check(&graph[miss0]);
526		let (_, m2, _miss2) = unwrap_check(&graph[miss1]);
527		assert_eq!(m0, m1);
528		assert_eq!(m1, m2);
529		assert_eq!(graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count(), 3);
530	}
531
532	#[test]
533	fn empty_any_of_short_circuits_to_on_miss() {
534		let r = check_rule("r", 7105, &serde_json::json!({ "any_of": [] }));
535		// Empty any_of ≡ never matches. The rule's chain entry equals the
536		// on_miss target (default-miss); no Check node is emitted for the
537		// empty any_of itself.
538		let graph =
539			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
540		let check_count = graph.nodes.iter().filter(|n| matches!(n, Node::Check { .. })).count();
541		assert_eq!(check_count, 0, "empty any_of must not emit a Check node");
542	}
543
544	#[test]
545	fn any_of_hash_cons_shares_predicate_slot_across_rules() {
546		// Two rules on different listeners both use the same `tls.sni ==
547		// "shared"` predicate inside any_of. Per 02-flow.md § _Hash-consing_,
548		// predicates dedup transparently regardless of the combinator tree
549		// they're nested inside.
550		let a = check_rule(
551			"a",
552			7106,
553			&serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
554		);
555		let b = check_rule(
556			"b",
557			7107,
558			&serde_json::json!({ "any_of": [{ "tls.sni": { "equals": "shared" } }] }),
559		);
560		let graph =
561			compile(vec![rule_file("a.json", vec![a, b])], &Providers, &Providers).expect("compile");
562		assert_eq!(graph.predicates.len(), 1);
563	}
564
565	// --- Task 3: phase-split Check placement --------------------------------
566
567	fn node_successors(n: &Node) -> Vec<NodeId> {
568		match n {
569			Node::Check { on_match, on_miss, .. } => vec![*on_match, *on_miss],
570			Node::Middleware { next, on_error, .. } => {
571				let mut v = vec![*next];
572				if let Some(e) = on_error {
573					v.push(*e);
574				}
575				v
576			}
577			Node::Fetch { next_response, next_tunnel, .. } => {
578				let mut v = Vec::new();
579				if let Some(r) = next_response {
580					v.push(*r);
581				}
582				if let Some(t) = next_tunnel {
583					v.push(*t);
584				}
585				v
586			}
587			Node::Upgrade { next } => vec![*next],
588			Node::Terminate(_) => Vec::new(),
589		}
590	}
591
592	fn walk_reachable(graph: &SymbolicFlowGraph, from: NodeId) -> std::collections::HashSet<NodeId> {
593		let mut seen = std::collections::HashSet::new();
594		let mut stack = vec![from];
595		while let Some(id) = stack.pop() {
596			if !seen.insert(id) {
597				continue;
598			}
599			for s in node_successors(&graph[id]) {
600				stack.push(s);
601			}
602		}
603		seen
604	}
605
606	#[test]
607	fn l4_predicate_on_l7_rule_sits_before_upgrade() {
608		// `tls.sni == "a"` is L4Peek level. On an L7 posture rule it must be
609		// reachable BEFORE the Upgrade node so the predicate evaluates while
610		// no Request has been decoded yet.
611		let r = check_rule("r", 7300, &serde_json::json!({ "tls.sni": { "equals": "a" } }));
612		let graph =
613			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
614		let entry = find_entry_check(&graph, 7300);
615		// Entry must be a Check node.
616		assert!(matches!(&graph[entry], Node::Check { .. }));
617		// Walk from the Check's on_match — it should eventually pass through an Upgrade.
618		let (_, on_match, _) = unwrap_check(&graph[entry]);
619		let reached = walk_reachable(&graph, on_match);
620		let upgrade_reached = reached.iter().any(|id| matches!(&graph[*id], Node::Upgrade { .. }));
621		assert!(upgrade_reached, "Upgrade must sit below the L4-level Check");
622	}
623
624	#[test]
625	fn l7_predicate_on_l7_rule_sits_after_upgrade() {
626		// `http.header.host == "x"` is L7Header level. Placement requires
627		// Upgrade above the Check.
628		let r = check_rule(
629			"r",
630			7301,
631			&serde_json::json!({ "http.header.host": { "equals": "api.example.com" } }),
632		);
633		let graph =
634			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
635		let entry = find_entry_check(&graph, 7301);
636		// Entry must be an Upgrade node for an L7-level check on an L7 rule.
637		assert!(
638			matches!(&graph[entry], Node::Upgrade { .. }),
639			"L7-level check must sit below Upgrade, so listener entry is the Upgrade itself",
640		);
641		// The Upgrade's `next` is a Check node reading http.header.host.
642		let Node::Upgrade { next } = &graph[entry] else {
643			panic!("expected Upgrade");
644		};
645		assert!(matches!(&graph[*next], Node::Check { .. }));
646	}
647
648	#[test]
649	fn pure_l4_rule_with_predicate_synthesises_close_miss() {
650		let r = parse_rule(serde_json::json!({
651			"name": "r",
652			"listen": [":7302"],
653			"match": { "remote.ip": { "cidr": "10.0.0.0/8" } },
654			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.5:22" },
655		}));
656		let graph =
657			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
658		let entry = find_entry_check(&graph, 7302);
659		assert!(matches!(&graph[entry], Node::Check { .. }));
660		let upgrades = graph.nodes.iter().filter(|n| matches!(n, Node::Upgrade { .. })).count();
661		assert_eq!(upgrades, 0, "L4 posture never Upgrades");
662		assert!(
663			graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
664			"default-miss must synthesise a Close terminator",
665		);
666	}
667
668	#[test]
669	fn l7_rule_with_predicate_uses_close_not_500_for_default_miss() {
670		// Pre-task-4 the L7 default-miss was a synthesised 500. Task 4 unified
671		// both postures on `Terminator::Close`, so no HttpSynthesize fetch
672		// should appear in the graph for a rule whose terminator is http_proxy.
673		let r = check_rule("r", 7400, &serde_json::json!({ "tls.sni": { "equals": "api" } }));
674		let graph =
675			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
676		assert!(
677			graph.terminators.iter().any(|t| matches!(t, Terminator::Close)),
678			"default-miss must be Close",
679		);
680		let synth_fetches =
681			graph.fetches.iter().filter(|f| f.kind == FetchKind::HttpSynthesize).count();
682		assert_eq!(synth_fetches, 0, "no 500 synth for unmatched L7 traffic — just Close");
683	}
684
685	#[test]
686	fn catch_all_rule_set_omits_close_fallback() {
687		// A predicate-less rule is always matched; the default-miss is dead
688		// code and must not appear in the graph.
689		let r = parse_rule(serde_json::json!({
690			"name": "r",
691			"listen": [":7401"],
692			"terminate": { "type": "http_proxy" },
693		}));
694		let graph =
695			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
696		let close_count = graph.terminators.iter().filter(|t| matches!(t, Terminator::Close)).count();
697		assert_eq!(close_count, 0, "no predicate means no miss path means no Close");
698	}
699
700	#[test]
701	fn close_terminator_serde_round_trip() {
702		// Pin the wire form of the new variant for dry-run JSON.
703		let t = Terminator::Close;
704		let encoded = serde_json::to_string(&t).expect("serialize");
705		let decoded: Terminator = serde_json::from_str(&encoded).expect("deserialize");
706		assert_eq!(decoded, t);
707	}
708
709	#[test]
710	fn l7_rule_without_predicate_has_upgrade_as_entry() {
711		let r = parse_rule(serde_json::json!({
712			"name": "r",
713			"listen": [":7303"],
714			"terminate": { "type": "http_proxy" },
715		}));
716		let graph =
717			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
718		let entry = find_entry_check(&graph, 7303);
719		assert!(matches!(&graph[entry], Node::Upgrade { .. }));
720	}
721
722	#[test]
723	fn cross_level_any_of_is_rejected() {
724		let r = check_rule(
725			"r",
726			7304,
727			&serde_json::json!({
728				"any_of": [
729					{ "tls.sni": { "equals": "a" } },
730					{ "http.method": { "equals": "GET" } },
731				],
732			}),
733		);
734		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
735			.expect_err("cross-level any_of must fail");
736		assert!(err.to_string().contains("cross-level"), "error message names the constraint: {err}");
737	}
738
739	#[test]
740	fn cross_level_not_is_rejected() {
741		let r = check_rule(
742			"r",
743			7305,
744			&serde_json::json!({
745				"not": {
746					"any_of": [
747						{ "tls.sni": { "equals": "a" } },
748						{ "http.method": { "equals": "GET" } },
749					],
750				},
751			}),
752		);
753		let err = compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers)
754			.expect_err("cross-level not(any_of) must fail");
755		assert!(err.to_string().contains("cross-level"));
756	}
757
758	#[test]
759	fn same_level_any_of_compiles_at_one_side_of_upgrade() {
760		// Two L4Peek checks: Upgrade sits BELOW both Checks.
761		let r = check_rule(
762			"r",
763			7306,
764			&serde_json::json!({
765				"any_of": [
766					{ "tls.sni": { "equals": "a" } },
767					{ "tls.sni": { "equals": "b" } },
768				],
769			}),
770		);
771		let graph =
772			compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
773		let entry = find_entry_check(&graph, 7306);
774		// Entry is a Check; walking any_of's shared on_match must reach an Upgrade.
775		assert!(matches!(&graph[entry], Node::Check { .. }));
776	}
777
778	#[test]
779	fn validate_stays_green_for_all_combinator_shapes() {
780		let shapes = [
781			serde_json::json!({ "tls.sni": { "equals": "x" } }),
782			serde_json::json!({
783				"any_of": [
784					{ "tls.sni": { "equals": "a" } },
785					{ "tls.sni": { "equals": "b" } },
786				],
787			}),
788			serde_json::json!({ "not": { "tls.sni": { "equals": "y" } } }),
789			serde_json::json!({
790				"not": {
791					"any_of": [
792						{ "tls.sni": { "equals": "a" } },
793						{ "tls.sni": { "equals": "b" } },
794					],
795				},
796			}),
797		];
798		for (i, m) in shapes.iter().enumerate() {
799			let port = 7200 + u16::try_from(i).expect("fits u16");
800			let r = check_rule(&format!("r{i}"), port, m);
801			let graph =
802				compile(vec![rule_file("a.json", vec![r])], &Providers, &Providers).expect("compile");
803			validate::validate(&graph).expect("validate");
804		}
805	}
806}