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
17pub 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
42pub fn compile_collecting(
53 files: Vec<RawRuleFile>,
54 mw_meta: &dyn MiddlewareMetadataProvider,
55 fetch_meta: &dyn FetchMetadataProvider,
56) -> Result<Arc<SymbolicFlowGraph>, Diagnostics> {
57 let merged = merge::merge(files).map_err(Diagnostics::from)?;
62 let expanded = expand::expand(merged).map_err(Diagnostics::from)?;
63
64 let (analyzed, analyze_d) = analyze::analyze_collecting(expanded, mw_meta, fetch_meta);
68 analyze_d.into_result(())?;
69
70 let graph = lower::lower(analyzed, mw_meta, fetch_meta).map_err(Diagnostics::from)?;
73
74 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 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 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 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 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 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 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 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 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 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 validate::validate(&decoded).expect("decoded graph revalidates");
365
366 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 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 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 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 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 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 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 assert_ne!(on_match, on_miss);
563 }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
1008 fn lower_l7_listener_synthesizes_short_circuit_response_target() {
1009 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}