vane-core 0.10.8

Core types, FlowGraph IR, and compilation pipeline for the vane proxy engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
use crate::compile::expand::RawRuleSet;
use crate::error::{Diagnostics, Error};
use crate::fetch::FetchKind;
use crate::metadata::{FetchMetadataProvider, MiddlewareMetadataProvider};
use crate::predicate::{FieldPath, Predicate};
use crate::rule::RawRule;

#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
pub enum InspectionLevel {
	L4Only,
	L4Peek,
	L7Header,
	L7Body,
}

#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub enum Posture {
	L4,
	L7,
}

#[derive(Debug, Clone)]
pub struct AnalyzedRule {
	pub raw: RawRule,
	pub inspection_level: InspectionLevel,
	pub specificity: usize,
	pub posture: Posture,
	pub needs_request_body: bool,
	pub needs_response_body: bool,
}

#[derive(Debug, Clone)]
pub struct AnalyzedRuleSet {
	pub rules: Vec<AnalyzedRule>,
	pub source_files: Vec<std::path::PathBuf>,
}

/// Compute per-rule inspection level, specificity, posture (L4 vs L7), and
/// `LazyBuffer` per-side buffer triggers.
///
/// # Errors
/// Returns [`Error::compile`] when a referenced middleware name is missing
/// from the provider registry (so compile-time analysis cannot decide what
/// phase it sits in or whether it buffers the body).
pub fn analyze(
	set: RawRuleSet,
	mw_meta: &dyn MiddlewareMetadataProvider,
	fetch_meta: &dyn FetchMetadataProvider,
) -> Result<AnalyzedRuleSet, Error> {
	let (rules, d) = analyze_collecting(set, mw_meta, fetch_meta);
	d.into_result(rules).map_err(Error::from)
}

/// Push+continue form of [`analyze`]: every rule is analyzed
/// independently; per-rule errors are collected and the offending
/// rule is dropped from the returned [`AnalyzedRuleSet`]. The caller
/// uses [`Diagnostics::has_fatal`] at the stage boundary to decide
/// whether to bail or feed the (partial) set into the next stage —
/// today the compile pipeline always bails because every downstream
/// stage assumes a complete rule set, but the partial set is still
/// useful for the dry-run dump endpoint.
pub fn analyze_collecting(
	set: RawRuleSet,
	mw_meta: &dyn MiddlewareMetadataProvider,
	fetch_meta: &dyn FetchMetadataProvider,
) -> (AnalyzedRuleSet, Diagnostics) {
	let mut analyzed = Vec::with_capacity(set.rules.len());
	let mut d = Diagnostics::new();
	for raw in set.rules {
		match analyze_rule(raw, mw_meta, fetch_meta) {
			Ok(rule) => analyzed.push(rule),
			Err(e) => d.push(e),
		}
	}
	(AnalyzedRuleSet { rules: analyzed, source_files: set.source_files }, d)
}

fn analyze_rule(
	raw: RawRule,
	mw_meta: &dyn MiddlewareMetadataProvider,
	fetch_meta: &dyn FetchMetadataProvider,
) -> Result<AnalyzedRule, Error> {
	// Per-rule TLS validation runs at the analyze stage so the lower
	// pass — which aggregates resolved specs into per-listener pools —
	// can assume each `TlsConfig` is internally consistent. Surfacing
	// the violation through the rule name keeps multi-file configs
	// debuggable.
	if let Some(tls) = raw.tls.as_ref() {
		tls.validate().map_err(|e| Error::compile(format!("rule {:?}: {}", raw.name, e)))?;
	}

	let fetch_kind = Some(raw.terminate.kind);
	let fetch_phase = fetch_phase_of(fetch_kind);

	let mut max_level = InspectionLevel::L4Only;
	let mut specificity = 0usize;
	let mut reads_http_body = false;
	if let Some(pred) = &raw.match_predicate {
		// Bound predicate nesting depth before any recursive walker
		// (here, in lower, or in collect_levels) touches the tree — a
		// pathologically nested operator-authored rule should fail
		// loud at compile, not crash the recursive walks at runtime.
		crate::predicate::check_max_depth(pred)
			.map_err(|e| Error::compile(format!("rule {:?}: {}", raw.name, e)))?;
		walk_predicate(pred, &mut |p| match p {
			Predicate::Check(c) => {
				specificity += 1;
				let lvl = field_path_inspection_level(&c.path);
				if lvl > max_level {
					max_level = lvl;
				}
				if matches!(c.path, FieldPath::HttpBody) {
					reads_http_body = true;
				}
			}
			Predicate::AnyOf(_) | Predicate::AllOf(_) | Predicate::Not(_) => {}
		});
	}

	let mut needs_request_body = reads_http_body;
	let mut needs_response_body = false;
	for mw_ref in &raw.middleware_chain {
		let meta = mw_meta
			.get(&mw_ref.name)
			.ok_or_else(|| Error::compile(format!("unknown middleware: {:?}", mw_ref.name)))?;
		// Schema-check each middleware's args at the analyze stage so a
		// rule with a misspelled key fails compile loudly, instead of
		// surfacing at runtime when the middleware instantiates.
		(meta.validate_args)(&mw_ref.args).map_err(|e| {
			Error::compile(format!("rule {:?}: middleware {:?} args invalid: {e}", raw.name, mw_ref.name))
		})?;
		if meta.needs_body {
			match meta.kind {
				crate::middleware::MiddlewareKind::L7Request => needs_request_body = true,
				crate::middleware::MiddlewareKind::L7Response => needs_response_body = true,
				crate::middleware::MiddlewareKind::L4Peek | crate::middleware::MiddlewareKind::L4Bytes => {}
			}
		}
	}

	// Schema-check the terminator's args via `fetch_meta`. Unknown
	// fetch kinds are reported here too, matching the way the link
	// pass would surface them — but now with the rule name attached.
	if let Some(kind) = fetch_kind {
		let meta = fetch_meta.get(kind).ok_or_else(|| {
			Error::compile(format!("rule {:?}: unknown fetch kind {:?}", raw.name, kind))
		})?;
		(meta.validate_args)(&raw.terminate.args).map_err(|e| {
			Error::compile(format!("rule {:?}: terminate.args for {:?} invalid: {e}", raw.name, kind))
		})?;
	}

	let posture = match fetch_phase {
		FetchPhase::L4 if max_level <= InspectionLevel::L4Peek => Posture::L4,
		FetchPhase::L4 => {
			return Err(Error::compile(format!(
				"rule {:?}: L7-level predicate on an L4 fetch is invalid",
				raw.name
			)));
		}
		FetchPhase::L7 => Posture::L7,
	};

	Ok(AnalyzedRule {
		raw,
		inspection_level: max_level,
		specificity,
		posture,
		needs_request_body,
		needs_response_body,
	})
}

#[derive(Copy, Clone, Eq, PartialEq, Debug)]
enum FetchPhase {
	L4,
	L7,
}

const fn fetch_phase_of(kind: Option<FetchKind>) -> FetchPhase {
	match kind {
		Some(FetchKind::L4Forward) => FetchPhase::L4,
		_ => FetchPhase::L7,
	}
}

/// Pre-order walk over a predicate tree using an explicit stack.
///
/// Depth is bounded by [`crate::predicate::MAX_PREDICATE_DEPTH`]
/// thanks to the upstream `check_max_depth` guard in `analyze_rule`,
/// but the iterative form keeps the walker independent of the system
/// stack and matches the spec recommendation to mirror
/// `check_acyclic`'s explicit-stack shape.
fn walk_predicate(root: &Predicate, f: &mut impl FnMut(&Predicate)) {
	let mut stack: Vec<&Predicate> = vec![root];
	while let Some(p) = stack.pop() {
		f(p);
		match p {
			Predicate::AnyOf(a) => {
				for child in a.any_of.iter().rev() {
					stack.push(child);
				}
			}
			Predicate::AllOf(a) => {
				for child in a.all_of.iter().rev() {
					stack.push(child);
				}
			}
			Predicate::Not(n) => stack.push(n.not.as_ref()),
			Predicate::Check(_) => {}
		}
	}
}

const fn field_path_inspection_level(path: &FieldPath) -> InspectionLevel {
	match path {
		FieldPath::Transport
		| FieldPath::RemoteIp
		| FieldPath::RemotePort
		| FieldPath::LocalIp
		| FieldPath::LocalPort => InspectionLevel::L4Only,
		FieldPath::Peek
		| FieldPath::TlsSni
		| FieldPath::TlsAlpn
		| FieldPath::TlsVersion
		| FieldPath::TlsPeerCertPresent
		| FieldPath::TlsPeerCertSubjectCn
		| FieldPath::TlsPeerCertSanDns
		| FieldPath::TlsPeerCertFingerprintSha256
		| FieldPath::TlsPeerCertSpkiSha256
		| FieldPath::TlsPeerCertIssuerCn
		| FieldPath::TlsPeerCertSerial => InspectionLevel::L4Peek,
		FieldPath::HttpMethod
		| FieldPath::HttpUriPath
		| FieldPath::HttpUriQuery
		| FieldPath::HttpHeader(_) => InspectionLevel::L7Header,
		FieldPath::HttpBody => InspectionLevel::L7Body,
	}
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::compile::expand::RawRuleSet;
	use crate::fetch::{FetchOutputModes, FetchPhase as FetchMetaPhase};
	use crate::metadata::{FetchMetadata, MiddlewareMetadata};
	use crate::middleware::MiddlewareKind;
	use serde_json::Value;

	struct Providers;

	fn validate_ok(_: &Value) -> Result<(), Error> {
		Ok(())
	}

	impl MiddlewareMetadataProvider for Providers {
		fn get(&self, name: &str) -> Option<MiddlewareMetadata> {
			match name {
				"req_plain" => Some(MiddlewareMetadata {
					kind: MiddlewareKind::L7Request,
					stateless: true,
					needs_body: false,
					validate_args: validate_ok,
				}),
				"req_body" => Some(MiddlewareMetadata {
					kind: MiddlewareKind::L7Request,
					stateless: true,
					needs_body: true,
					validate_args: validate_ok,
				}),
				"resp_body" => Some(MiddlewareMetadata {
					kind: MiddlewareKind::L7Response,
					stateless: true,
					needs_body: true,
					validate_args: validate_ok,
				}),
				_ => None,
			}
		}
	}

	impl FetchMetadataProvider for Providers {
		fn get(&self, kind: FetchKind) -> Option<FetchMetadata> {
			Some(FetchMetadata {
				kind,
				phase: match kind {
					FetchKind::L4Forward => FetchMetaPhase::L4,
					_ => FetchMetaPhase::L7,
				},
				output_modes: match kind {
					FetchKind::L4Forward => FetchOutputModes { response: false, tunnel: true },
					FetchKind::WebSocketUpgrade => FetchOutputModes { response: true, tunnel: true },
					_ => FetchOutputModes { response: true, tunnel: false },
				},
				validate_args: validate_ok,
			})
		}
	}

	fn set(rules: Vec<RawRule>) -> RawRuleSet {
		RawRuleSet { rules, source_files: vec![] }
	}

	fn parse_rule(j: serde_json::Value) -> RawRule {
		serde_json::from_value(j).expect("parse rule")
	}

	#[test]
	fn http_body_predicate_sets_request_body_flag_and_l7body_level() {
		let rule = parse_rule(serde_json::json!({
			"name": "r",
			"listen": [":443"],
			"match": { "http.body": { "contains": "admin" } },
			"terminate": { "type": "http_proxy" },
		}));
		let out = analyze(set(vec![rule]), &Providers, &Providers).expect("analyze");
		let a = &out.rules[0];
		assert!(a.needs_request_body);
		assert!(!a.needs_response_body);
		assert_eq!(a.inspection_level, InspectionLevel::L7Body);
		assert_eq!(a.posture, Posture::L7);
	}

	#[test]
	fn l7_request_needs_body_middleware_flags_request_side() {
		let rule = parse_rule(serde_json::json!({
			"name": "r",
			"listen": [":443"],
			"middleware_chain": [{ "use": "req_body" }],
			"terminate": { "type": "http_proxy" },
		}));
		let out = analyze(set(vec![rule]), &Providers, &Providers).expect("analyze");
		assert!(out.rules[0].needs_request_body);
		assert!(!out.rules[0].needs_response_body);
	}

	#[test]
	fn l7_response_needs_body_middleware_flags_response_side() {
		let rule = parse_rule(serde_json::json!({
			"name": "r",
			"listen": [":443"],
			"middleware_chain": [{ "use": "resp_body" }],
			"terminate": { "type": "http_proxy" },
		}));
		let out = analyze(set(vec![rule]), &Providers, &Providers).expect("analyze");
		assert!(!out.rules[0].needs_request_body);
		assert!(out.rules[0].needs_response_body);
	}

	#[test]
	fn l4_fetch_with_l7_predicate_errors() {
		let rule = parse_rule(serde_json::json!({
			"name": "r",
			"listen": [":22"],
			"match": { "http.method": { "equals": "GET" } },
			"terminate": { "type": "tcp_forward", "upstream": "10.0.0.1:22" },
		}));
		let err = analyze(set(vec![rule]), &Providers, &Providers).expect_err("must error");
		assert!(err.to_string().contains("L7-level predicate"));
	}

	#[test]
	fn unknown_middleware_name_errors() {
		let rule = parse_rule(serde_json::json!({
			"name": "r",
			"listen": [":443"],
			"middleware_chain": [{ "use": "does_not_exist" }],
			"terminate": { "type": "http_proxy" },
		}));
		let err = analyze(set(vec![rule]), &Providers, &Providers).expect_err("must error");
		assert!(err.to_string().contains("does_not_exist"));
	}

	#[test]
	fn rejects_middleware_args_failing_validate() {
		// Providers' `req_plain` accepts any args, but `validate_ok`
		// override below specifically rejects null args for the dummy
		// middleware named "strict_args".
		struct StrictProviders;
		fn reject_null(v: &Value) -> Result<(), Error> {
			if matches!(v, Value::Null) { Err(Error::compile("args must not be null")) } else { Ok(()) }
		}
		impl MiddlewareMetadataProvider for StrictProviders {
			fn get(&self, name: &str) -> Option<MiddlewareMetadata> {
				if name == "strict_args" {
					Some(MiddlewareMetadata {
						kind: MiddlewareKind::L7Request,
						stateless: true,
						needs_body: false,
						validate_args: reject_null,
					})
				} else {
					None
				}
			}
		}
		impl FetchMetadataProvider for StrictProviders {
			fn get(&self, kind: FetchKind) -> Option<FetchMetadata> {
				Some(FetchMetadata {
					kind,
					phase: FetchMetaPhase::L7,
					output_modes: FetchOutputModes { response: true, tunnel: false },
					validate_args: |_| Ok(()),
				})
			}
		}
		let rule = parse_rule(serde_json::json!({
			"name": "r",
			"listen": [":443"],
			"middleware_chain": [{ "use": "strict_args" }],
			"terminate": { "type": "http_proxy" },
		}));
		let err = analyze(set(vec![rule]), &StrictProviders, &StrictProviders)
			.expect_err("must reject bad middleware args");
		let msg = err.to_string();
		assert!(msg.contains("strict_args"), "{msg}");
		assert!(msg.contains("args invalid") || msg.contains("must not be null"), "{msg}");
	}

	#[test]
	fn rejects_terminate_args_failing_validate() {
		struct StrictProviders;
		fn require_port(v: &Value) -> Result<(), Error> {
			let ok = matches!(v, Value::Object(m) if m.get("port").is_some());
			if ok { Ok(()) } else { Err(Error::compile("missing required `port` arg")) }
		}
		impl MiddlewareMetadataProvider for StrictProviders {
			fn get(&self, _: &str) -> Option<MiddlewareMetadata> {
				None
			}
		}
		impl FetchMetadataProvider for StrictProviders {
			fn get(&self, kind: FetchKind) -> Option<FetchMetadata> {
				Some(FetchMetadata {
					kind,
					phase: FetchMetaPhase::L7,
					output_modes: FetchOutputModes { response: true, tunnel: false },
					validate_args: require_port,
				})
			}
		}
		let rule = parse_rule(serde_json::json!({
			"name": "r",
			"listen": [":443"],
			"terminate": { "type": "http_proxy" },
		}));
		let err = analyze(set(vec![rule]), &StrictProviders, &StrictProviders)
			.expect_err("must reject missing terminate args");
		let msg = err.to_string();
		assert!(msg.contains("terminate.args"), "{msg}");
		assert!(msg.contains("missing required `port` arg"), "{msg}");
	}

	#[test]
	fn rejects_predicate_nested_deeper_than_max_predicate_depth() {
		// Build `not(not(not(... check ...)))` over `MAX_PREDICATE_DEPTH+1`
		// levels — straight chains are the easiest pathological shape.
		let depth = crate::predicate::MAX_PREDICATE_DEPTH + 1;
		let mut inner = serde_json::json!({ "tls.sni": { "equals": "a" } });
		for _ in 0..depth {
			inner = serde_json::json!({ "not": inner });
		}
		let raw = serde_json::json!({
			"name": "r",
			"listen": [":443"],
			"match": inner,
			"terminate": { "type": "http_proxy" },
		});
		let rule: crate::rule::RawRule = serde_json::from_value(raw).expect("parse");
		let err =
			analyze(set(vec![rule]), &Providers, &Providers).expect_err("deep predicate must reject");
		assert!(err.to_string().contains("MAX_PREDICATE_DEPTH"), "{err}");
	}

	#[test]
	fn accepts_predicate_at_max_predicate_depth() {
		// Exactly MAX_PREDICATE_DEPTH levels of `not` wrapping a leaf
		// Check must still compile.
		let depth = crate::predicate::MAX_PREDICATE_DEPTH - 1;
		let mut inner = serde_json::json!({ "tls.sni": { "equals": "a" } });
		for _ in 0..depth {
			inner = serde_json::json!({ "not": inner });
		}
		let raw = serde_json::json!({
			"name": "r",
			"listen": [":443"],
			"match": inner,
			"terminate": { "type": "http_proxy" },
		});
		let rule: crate::rule::RawRule = serde_json::from_value(raw).expect("parse");
		analyze(set(vec![rule]), &Providers, &Providers).expect("at-limit predicate compiles");
	}

	#[test]
	fn specificity_counts_check_predicates() {
		let rule = parse_rule(serde_json::json!({
			"name": "r",
			"listen": [":443"],
			"match": {
				"any_of": [
					{ "tls.sni": { "equals": "a" } },
					{ "tls.sni": { "equals": "b" } },
				],
			},
			"terminate": { "type": "http_proxy" },
		}));
		let out = analyze(set(vec![rule]), &Providers, &Providers).expect("analyze");
		assert_eq!(out.rules[0].specificity, 2);
	}
}