Skip to main content

vane_core/
rule.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde_json::Value;
5
6use crate::fetch::FetchKind;
7use crate::predicate::Predicate;
8
9pub type ListenSpec = String;
10
11#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
12pub struct RawRule {
13	pub name: String,
14	pub listen: Vec<ListenSpec>,
15	#[serde(default, rename = "match")]
16	pub match_predicate: Option<Predicate>,
17	#[serde(default)]
18	pub middleware_chain: Vec<MiddlewareRef>,
19	pub terminate: TerminateSpec,
20	/// Optional TLS termination config. When set, the listener wraps
21	/// each accepted TCP stream in a `rustls` server-side handshake
22	/// before driving the L7 sub-graph; cleartext sockets get
23	/// `Box<dyn AsyncReadWrite>` instead of raw `TcpStream`.
24	///
25	/// `lower_port` enforces consistency: every rule on the same
26	/// listener must agree on `tls` (all `None` or all the same
27	/// `Some(_)`); L4-only listeners cannot carry TLS (terminate +
28	/// re-emit cleartext is not a useful proxy shape — it leaks the
29	/// upstream traffic).
30	#[serde(default)]
31	pub tls: Option<TlsConfig>,
32	/// Maximum bytes to buffer for request body `LazyBuffer` collection.
33	/// Default 8 MiB. Exceeding this produces 413 Payload Too Large.
34	#[serde(default = "default_max_body_bytes")]
35	pub max_body_bytes_request: usize,
36	/// Maximum bytes to buffer for response body `LazyBuffer` collection.
37	/// Default 8 MiB. Exceeding this produces 502 Bad Gateway.
38	#[serde(default = "default_max_body_bytes")]
39	pub max_body_bytes_response: usize,
40	#[serde(default)]
41	pub source: SourceInfo,
42}
43
44fn default_max_body_bytes() -> usize {
45	8 * 1024 * 1024
46}
47
48/// Listener-side TLS termination config — paths to the cert chain +
49/// private key in PEM, plus an optional SNI hostname this cert serves.
50///
51/// `sni: None` marks the cert as the listener's _default_ — used when
52/// the `ClientHello` has no SNI extension, or when the SNI doesn't
53/// match any of the listener's `Some(_)` entries. A listener has at
54/// most one default cert.
55///
56/// SNI hostnames are normalised to ASCII-lowercase at every ingest
57/// boundary per 08-tls.md § _SNI normalization_; comparison against
58/// rustls's already-lowercased `ClientHello::server_name()` is then
59/// byte-for-byte.
60#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
61pub struct TlsConfig {
62	#[serde(default)]
63	pub sni: Option<String>,
64	pub cert_file: PathBuf,
65	pub key_file: PathBuf,
66	/// Listener-side mTLS — per `08-tls.md` § _Client certificate
67	/// verification_. Per-rule input; the lower pass aggregates each
68	/// rule's `client_auth` into one `ClientAuthSpec` per listener
69	/// address (rules on the same listener must agree, else compile
70	/// error). `None` keeps the listener at `ClientAuth::None`.
71	#[serde(default)]
72	pub client_auth: Option<ClientAuthConfig>,
73}
74
75/// Per-rule mTLS config block, parsed from the `tls.client_auth` JSON.
76/// `mode == None` is operator-explicit "don't request a cert"; the
77/// trust store must be absent there. `mode == Request | Require`
78/// requires a non-empty `trust_store`.
79#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
80pub struct ClientAuthConfig {
81	pub mode: ClientAuthMode,
82	#[serde(default)]
83	pub trust_store: Option<ClientTrustStoreConfig>,
84}
85
86/// Three-valued client-auth mode (no implicit default per spec).
87#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
88#[serde(rename_all = "lowercase")]
89pub enum ClientAuthMode {
90	None,
91	Request,
92	Require,
93}
94
95/// Per-rule trust store config for verifying client certs. At least
96/// one of `ca_paths` / `ca_dir` must be present (enforced at compile).
97#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
98pub struct ClientTrustStoreConfig {
99	#[serde(default)]
100	pub ca_paths: Vec<PathBuf>,
101	#[serde(default)]
102	pub ca_dir: Option<PathBuf>,
103	#[serde(default)]
104	pub crls: Vec<CrlSourceConfig>,
105}
106
107/// One CRL source entry — file or URL, with a per-source
108/// `fetch_failure` policy. URL sources are deferred (S3-11) and
109/// rejected at compile time in this PR.
110// TODO(s3-11): wire `Url` source kind, daemon-wide CRL cache,
111// adaptive fetch cadence per `08-tls.md` § _CRL checking_.
112#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
113#[serde(tag = "kind", rename_all = "lowercase")]
114pub enum CrlSourceConfig {
115	File { path: PathBuf, fetch_failure: CrlFetchFailure },
116	Url { url: String, fetch_failure: CrlFetchFailure },
117}
118
119/// CRL availability policy (per `08-tls.md` § _CRL checking_ § _Failure
120/// handling_). Parsed eagerly though only the structural error path is
121/// wired this PR — actual fetch / failure semantics land with S3-11.
122#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
123#[serde(rename_all = "lowercase")]
124pub enum CrlFetchFailure {
125	Tolerate,
126	Reject,
127}
128
129/// Per-listener cert pool — produced by `compile/lower` from every
130/// rule on the bind address that carries a `tls` block, after
131/// hash-consing identical entries and rejecting conflicts.
132///
133/// At most one `default` cert (sni-less); any number of SNI-keyed
134/// certs. The engine's link stage compiles this into a single
135/// `rustls::ServerConfig` whose cert resolver picks by SNI with
136/// `default` as the fallback for unmatched / missing SNI.
137#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
138pub struct ListenerTlsSpec {
139	#[serde(default)]
140	pub default: Option<TlsConfig>,
141	#[serde(default)]
142	pub sni_certs: BTreeMap<String, TlsConfig>,
143	/// Resolved per-listener mTLS policy. Per `08-tls.md` § _Client
144	/// certificate verification_ this is per-listener, derived from the
145	/// union of every rule's `tls.client_auth` on the same address;
146	/// rules that disagree on `mode` or `trust_store` produce a compile
147	/// error. Defaults to `None` for cleartext clients.
148	#[serde(default)]
149	pub client_auth: ClientAuthSpec,
150}
151
152impl ListenerTlsSpec {
153	#[must_use]
154	pub fn is_empty(&self) -> bool {
155		self.default.is_none()
156			&& self.sni_certs.is_empty()
157			&& matches!(self.client_auth, ClientAuthSpec::None)
158	}
159}
160
161/// Listener-level resolved mTLS policy. Built by the lower pass from
162/// the union of per-rule `ClientAuthConfig` blocks; rules on the same
163/// listener must all agree.
164#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
165#[serde(tag = "mode", rename_all = "lowercase")]
166pub enum ClientAuthSpec {
167	#[default]
168	None,
169	Request {
170		trust_store: ClientTrustStoreConfig,
171	},
172	Require {
173		trust_store: ClientTrustStoreConfig,
174	},
175}
176
177#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
178pub struct MiddlewareRef {
179	#[serde(rename = "use")]
180	pub name: String,
181	#[serde(default)]
182	pub args: Value,
183	#[serde(default)]
184	pub on_error: Option<OnErrorSpec>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
188pub enum OnErrorSpec {
189	Close,
190	Response(SynthResponse),
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
194pub struct SynthResponse {
195	pub status: u16,
196	#[serde(default)]
197	pub headers: Option<BTreeMap<String, String>>,
198	#[serde(default)]
199	pub body: Option<String>,
200}
201
202impl<'de> serde::Deserialize<'de> for OnErrorSpec {
203	fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
204		#[derive(serde::Deserialize)]
205		#[serde(untagged)]
206		enum Raw {
207			Literal(String),
208			Response { response: SynthResponse },
209		}
210		match Raw::deserialize(de)? {
211			Raw::Literal(s) if s == "close" => Ok(Self::Close),
212			Raw::Literal(other) => Err(serde::de::Error::unknown_variant(&other, &["close"])),
213			Raw::Response { response } => Ok(Self::Response(response)),
214		}
215	}
216}
217
218#[derive(Debug, Clone, serde::Serialize)]
219pub struct TerminateSpec {
220	pub kind: FetchKind,
221	pub args: Value,
222}
223
224impl<'de> serde::Deserialize<'de> for TerminateSpec {
225	fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
226		let mut v = Value::deserialize(de)?;
227		let obj = v
228			.as_object_mut()
229			.ok_or_else(|| serde::de::Error::custom("`terminate` must be a JSON object"))?;
230		let type_val = obj.remove("type").ok_or_else(|| serde::de::Error::missing_field("type"))?;
231		let Value::String(alias) = type_val else {
232			return Err(serde::de::Error::custom("`terminate.type` must be a string"));
233		};
234		let kind = fetch_kind_from_alias(&alias)
235			.ok_or_else(|| serde::de::Error::custom(format!("unknown terminate type: {alias:?}")))?;
236		// 05-terminator.md § _Variant ergonomics in config_:
237		// `httpN_proxy` is sugar for `http_proxy` + `version: "hN"`.
238		// Inject the version when the alias names a specific HTTP
239		// version and the user has not already set one explicitly —
240		// an explicit `args.version` always wins.
241		if let Some(version) = http_version_from_alias(&alias)
242			&& !obj.contains_key("version")
243		{
244			obj.insert("version".to_owned(), Value::String(version.to_owned()));
245		}
246		// `tcp_forward` / `udp_forward` are sugar for `L4Forward` +
247		// `transport: "tcp" | "udp"`. Same precedence rule: an
248		// explicit `args.transport` overrides the alias-derived value
249		// (preserved as an escape hatch for hand-written rules).
250		if let Some(transport) = transport_from_alias(&alias)
251			&& !obj.contains_key("transport")
252		{
253			obj.insert("transport".to_owned(), Value::String(transport.to_owned()));
254		}
255		Ok(Self { kind, args: v })
256	}
257}
258
259fn fetch_kind_from_alias(alias: &str) -> Option<FetchKind> {
260	match alias {
261		"tcp_forward" | "udp_forward" => Some(FetchKind::L4Forward),
262		"http_proxy" | "http1_proxy" | "http2_proxy" | "http3_proxy" | "unix_proxy" | "cgi" => {
263			Some(FetchKind::HttpProxy)
264		}
265		"websocket" => Some(FetchKind::WebSocketUpgrade),
266		"static" | "redirect_https" => Some(FetchKind::HttpSynthesize),
267		_ => None,
268	}
269}
270
271fn http_version_from_alias(alias: &str) -> Option<&'static str> {
272	match alias {
273		"http1_proxy" => Some("h1"),
274		"http2_proxy" => Some("h2"),
275		"http3_proxy" => Some("h3"),
276		_ => None,
277	}
278}
279
280fn transport_from_alias(alias: &str) -> Option<&'static str> {
281	match alias {
282		"tcp_forward" => Some("tcp"),
283		"udp_forward" => Some("udp"),
284		_ => None,
285	}
286}
287
288#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
289pub struct SourceInfo {
290	#[serde(default)]
291	pub file: PathBuf,
292	#[serde(default)]
293	pub line: u32,
294}
295
296#[cfg(test)]
297mod tests {
298	use super::*;
299	use crate::predicate::{CheckMap, FieldPath, Operator, Predicate, Value as PredValue};
300
301	#[test]
302	fn raw_rule_minimal_parses_with_defaults() {
303		let raw = serde_json::json!({
304			"name": "r",
305			"listen": [":443"],
306			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
307		});
308		let rule: RawRule = serde_json::from_value(raw).expect("parse minimal rule");
309		assert_eq!(rule.name, "r");
310		assert_eq!(rule.listen, vec![":443".to_string()]);
311		assert!(rule.match_predicate.is_none());
312		assert!(rule.middleware_chain.is_empty());
313		assert_eq!(rule.terminate.kind, FetchKind::HttpProxy);
314		assert_eq!(rule.terminate.args, serde_json::json!({ "upstream": "127.0.0.1:8080" }));
315		assert_eq!(rule.source.file, PathBuf::new());
316		assert_eq!(rule.source.line, 0);
317		assert_eq!(rule.max_body_bytes_request, 8 * 1024 * 1024);
318		assert_eq!(rule.max_body_bytes_response, 8 * 1024 * 1024);
319	}
320
321	#[test]
322	fn raw_rule_full_populates_every_field() {
323		let raw = serde_json::json!({
324			"name": "api",
325			"listen": [":443", "0.0.0.0:80"],
326			"match": { "tls.sni": { "equals": "api.example.com" } },
327			"middleware_chain": [
328				{ "use": "rate_limit", "args": { "rate": 100 } },
329				{ "use": "jwt", "args": { "secret": "x" }, "on_error": "close" },
330			],
331			"terminate": {
332				"type": "http_proxy",
333				"upstream": "127.0.0.1:8080",
334				"timeouts": { "connect": "5s" }
335			},
336			"source": { "file": "rules/30-api.json", "line": 14 },
337		});
338		let rule: RawRule = serde_json::from_value(raw).expect("parse full rule");
339		assert_eq!(rule.name, "api");
340		assert_eq!(rule.listen.len(), 2);
341		let check = match rule.match_predicate.as_ref().expect("match present") {
342			Predicate::Check(c) => c,
343			other => panic!("expected Check, got {other:?}"),
344		};
345		assert_eq!(check.path, FieldPath::TlsSni);
346		match &check.op {
347			Operator::Equals(PredValue::Str(s)) => assert_eq!(s, "api.example.com"),
348			other => panic!("unexpected op: {other:?}"),
349		}
350		assert_eq!(rule.middleware_chain.len(), 2);
351		assert_eq!(rule.middleware_chain[1].on_error, Some(OnErrorSpec::Close));
352		assert_eq!(rule.terminate.kind, FetchKind::HttpProxy);
353		assert_eq!(
354			rule.terminate.args,
355			serde_json::json!({
356				"upstream": "127.0.0.1:8080",
357				"timeouts": { "connect": "5s" }
358			}),
359		);
360		assert_eq!(rule.source.file, PathBuf::from("rules/30-api.json"));
361		assert_eq!(rule.source.line, 14);
362	}
363
364	#[test]
365	fn middleware_ref_flat_form_parses_name_and_args() {
366		let raw = serde_json::json!({ "use": "rate_limit", "args": { "rate": 100 } });
367		let m: MiddlewareRef = serde_json::from_value(raw).expect("parse middleware ref");
368		assert_eq!(m.name, "rate_limit");
369		assert_eq!(m.args, serde_json::json!({ "rate": 100 }));
370		assert!(m.on_error.is_none());
371	}
372
373	#[test]
374	fn middleware_ref_on_error_close_form() {
375		let raw = serde_json::json!({ "use": "jwt", "args": { "secret": "x" }, "on_error": "close" });
376		let m: MiddlewareRef = serde_json::from_value(raw).expect("parse middleware ref");
377		assert_eq!(m.on_error, Some(OnErrorSpec::Close));
378	}
379
380	#[test]
381	fn middleware_ref_on_error_response_object_form() {
382		let raw = serde_json::json!({
383			"use": "jwt",
384			"on_error": { "response": { "status": 503, "body": "maintenance" } },
385		});
386		let m: MiddlewareRef = serde_json::from_value(raw).expect("parse middleware ref");
387		assert_eq!(m.name, "jwt");
388		assert_eq!(m.args, Value::Null);
389		let resp = match m.on_error.expect("on_error present") {
390			OnErrorSpec::Response(r) => r,
391			OnErrorSpec::Close => panic!("expected Response"),
392		};
393		assert_eq!(resp.status, 503);
394		assert_eq!(resp.body.as_deref(), Some("maintenance"));
395		assert!(resp.headers.is_none());
396	}
397
398	#[test]
399	fn middleware_ref_args_defaults_to_null_when_omitted() {
400		let raw = serde_json::json!({ "use": "tag" });
401		let m: MiddlewareRef = serde_json::from_value(raw).expect("parse middleware ref");
402		assert_eq!(m.args, Value::Null);
403	}
404
405	#[test]
406	fn middleware_ref_requires_use_key() {
407		let raw = serde_json::json!({});
408		let err = serde_json::from_value::<MiddlewareRef>(raw).expect_err("missing `use` must fail");
409		let _ = err.to_string();
410	}
411
412	#[test]
413	fn on_error_spec_string_invalid_variant_rejected() {
414		let raw = serde_json::json!("crash");
415		let err = serde_json::from_value::<OnErrorSpec>(raw).expect_err("non-`close` literal rejected");
416		let msg = err.to_string();
417		assert!(msg.contains("close"), "error names the only valid literal: {msg}");
418	}
419
420	#[test]
421	fn on_error_spec_malformed_response_object_rejected() {
422		let raw = serde_json::json!({ "response": null });
423		let err = serde_json::from_value::<OnErrorSpec>(raw).expect_err("null response rejected");
424		let _ = err.to_string();
425	}
426
427	#[test]
428	fn on_error_spec_close_literal_parses() {
429		let raw = serde_json::json!("close");
430		let s: OnErrorSpec = serde_json::from_value(raw).expect("close literal parses");
431		assert_eq!(s, OnErrorSpec::Close);
432	}
433
434	#[test]
435	fn on_error_spec_response_object_parses() {
436		let raw = serde_json::json!({
437			"response": { "status": 503, "body": "maintenance" },
438		});
439		let s: OnErrorSpec = serde_json::from_value(raw).expect("response object parses");
440		match s {
441			OnErrorSpec::Response(r) => {
442				assert_eq!(r.status, 503);
443				assert_eq!(r.body.as_deref(), Some("maintenance"));
444				assert!(r.headers.is_none());
445			}
446			OnErrorSpec::Close => panic!("expected Response"),
447		}
448	}
449
450	#[test]
451	fn synth_response_minimal_status_only() {
452		let raw = serde_json::json!({ "status": 200 });
453		let r: SynthResponse = serde_json::from_value(raw).expect("parse status-only synth");
454		assert_eq!(r.status, 200);
455		assert!(r.headers.is_none());
456		assert!(r.body.is_none());
457	}
458
459	#[test]
460	fn synth_response_full_status_headers_body() {
461		let raw = serde_json::json!({
462			"status": 404,
463			"headers": { "content-type": "text/plain" },
464			"body": "not found",
465		});
466		let r: SynthResponse = serde_json::from_value(raw).expect("parse full synth");
467		assert_eq!(r.status, 404);
468		let headers = r.headers.as_ref().expect("headers present");
469		assert_eq!(headers.get("content-type").map(String::as_str), Some("text/plain"));
470		assert_eq!(r.body.as_deref(), Some("not found"));
471	}
472
473	#[test]
474	fn terminate_spec_alias_table_maps_to_fetch_kind() {
475		// Every row of 05-terminator.md § _Variant ergonomics in config_.
476		let cases: &[(&str, FetchKind)] = &[
477			("tcp_forward", FetchKind::L4Forward),
478			("udp_forward", FetchKind::L4Forward),
479			("http_proxy", FetchKind::HttpProxy),
480			("http1_proxy", FetchKind::HttpProxy),
481			("http2_proxy", FetchKind::HttpProxy),
482			("http3_proxy", FetchKind::HttpProxy),
483			("unix_proxy", FetchKind::HttpProxy),
484			("cgi", FetchKind::HttpProxy),
485			("websocket", FetchKind::WebSocketUpgrade),
486			("static", FetchKind::HttpSynthesize),
487			("redirect_https", FetchKind::HttpSynthesize),
488		];
489		for (alias, expected) in cases {
490			let raw = serde_json::json!({ "type": alias });
491			let t: TerminateSpec =
492				serde_json::from_value(raw).unwrap_or_else(|e| panic!("alias {alias} must parse: {e}"));
493			assert_eq!(t.kind, *expected, "alias {alias} must map to {expected:?}");
494		}
495	}
496
497	#[test]
498	fn terminate_spec_args_preserves_all_non_type_keys_verbatim() {
499		// 14-presets.md § _RawRule shape_: "every other key goes into `args`
500		// verbatim". Covers top-level scalars AND nested objects.
501		let raw = serde_json::json!({
502			"type": "http_proxy",
503			"upstream": "127.0.0.1:8080",
504			"timeouts": { "connect": "5s", "total": "60s" },
505		});
506		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
507		assert_eq!(t.kind, FetchKind::HttpProxy);
508		assert_eq!(
509			t.args,
510			serde_json::json!({
511				"upstream": "127.0.0.1:8080",
512				"timeouts": { "connect": "5s", "total": "60s" },
513			}),
514		);
515	}
516
517	#[test]
518	fn terminate_spec_udp_forward_alias_injects_transport_udp() {
519		let raw = serde_json::json!({ "type": "udp_forward", "upstream": "1.2.3.4:53" });
520		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
521		assert_eq!(t.kind, FetchKind::L4Forward);
522		assert_eq!(t.args["transport"], "udp");
523		assert_eq!(t.args["upstream"], "1.2.3.4:53");
524	}
525
526	#[test]
527	fn terminate_spec_tcp_forward_alias_injects_transport_tcp() {
528		let raw = serde_json::json!({ "type": "tcp_forward", "upstream": "10.0.0.5:22" });
529		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
530		assert_eq!(t.kind, FetchKind::L4Forward);
531		assert_eq!(t.args["transport"], "tcp");
532	}
533
534	#[test]
535	fn terminate_spec_explicit_transport_wins_over_alias() {
536		// Explicit `args.transport` always overrides the alias-derived
537		// value — escape hatch for hand-written configs that want to
538		// pin a transport regardless of which alias spelled the rule.
539		let raw = serde_json::json!({ "type": "udp_forward", "upstream": "x", "transport": "tcp" });
540		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
541		assert_eq!(t.args["transport"], "tcp");
542	}
543
544	#[test]
545	fn terminate_spec_alias_only_yields_empty_object_not_null() {
546		// 14-presets.md § _RawRule shape_: the custom Deserialize removes `type`
547		// from a JSON object and keeps the rest. An alias-only terminate leaves
548		// an empty object behind — NOT Value::Null.
549		let raw = serde_json::json!({ "type": "http_proxy" });
550		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
551		assert_eq!(t.kind, FetchKind::HttpProxy);
552		assert_eq!(t.args, serde_json::Value::Object(serde_json::Map::new()));
553		assert!(t.args.is_object(), "args must be an object, got {:?}", t.args);
554	}
555
556	#[test]
557	fn terminate_spec_unknown_type_rejected_and_names_alias() {
558		let raw = serde_json::json!({ "type": "bogus" });
559		let err = serde_json::from_value::<TerminateSpec>(raw).expect_err("unknown alias rejected");
560		assert!(err.to_string().contains("bogus"), "error must name the offending alias: {err}");
561	}
562
563	#[test]
564	fn terminate_spec_missing_type_rejected_and_names_field() {
565		let raw = serde_json::json!({ "upstream": "127.0.0.1:8080" });
566		let err = serde_json::from_value::<TerminateSpec>(raw).expect_err("missing type rejected");
567		assert!(err.to_string().contains("type"), "error must name the missing field: {err}");
568	}
569
570	#[test]
571	fn source_info_default_is_empty_path_and_zero_line() {
572		let s = SourceInfo::default();
573		assert_eq!(s.file, PathBuf::new());
574		assert_eq!(s.line, 0);
575	}
576
577	#[test]
578	fn source_info_round_trip_via_json() {
579		let raw = serde_json::json!({ "file": "rules/a.json", "line": 7 });
580		let s: SourceInfo = serde_json::from_value(raw).expect("parse source info");
581		assert_eq!(s.file, PathBuf::from("rules/a.json"));
582		assert_eq!(s.line, 7);
583	}
584
585	#[test]
586	fn middleware_chain_defaults_to_empty_when_omitted() {
587		let raw = serde_json::json!({
588			"name": "r",
589			"listen": [":443"],
590			"terminate": { "type": "http_proxy" },
591		});
592		let rule: RawRule = serde_json::from_value(raw).expect("parse");
593		assert!(rule.middleware_chain.is_empty());
594	}
595
596	#[test]
597	fn middleware_ref_chain_mixes_on_error_forms() {
598		let raw = serde_json::json!({
599			"name": "r",
600			"listen": [":443"],
601			"middleware_chain": [
602				{ "use": "a" },
603				{ "use": "b", "on_error": "close" },
604				{ "use": "c", "on_error": { "response": { "status": 500 } } },
605			],
606			"terminate": { "type": "http_proxy" },
607		});
608		let rule: RawRule = serde_json::from_value(raw).expect("parse");
609		assert_eq!(rule.middleware_chain.len(), 3);
610		assert!(rule.middleware_chain[0].on_error.is_none());
611		assert_eq!(rule.middleware_chain[1].on_error, Some(OnErrorSpec::Close));
612		match rule.middleware_chain[2].on_error.as_ref().expect("on_error[2]") {
613			OnErrorSpec::Response(r) => {
614				assert_eq!(r.status, 500);
615				assert!(r.body.is_none());
616				assert!(r.headers.is_none());
617			}
618			OnErrorSpec::Close => panic!("expected Response at index 2"),
619		}
620	}
621
622	#[test]
623	fn raw_rule_accepts_top_level_check_predicate() {
624		let raw = serde_json::json!({
625			"name": "r",
626			"listen": [":80"],
627			"match": { "http.uri.path": { "prefix": "/api" } },
628			"terminate": { "type": "http_proxy" },
629		});
630		let rule: RawRule = serde_json::from_value(raw).expect("parse");
631		let Some(Predicate::Check(CheckMap { path, op })) = rule.match_predicate else {
632			panic!("expected Check predicate");
633		};
634		assert_eq!(path, FieldPath::HttpUriPath);
635		match op {
636			Operator::Prefix(PredValue::Str(s)) => assert_eq!(s, "/api"),
637			other => panic!("unexpected op: {other:?}"),
638		}
639	}
640
641	#[test]
642	fn raw_rule_without_tls_field_defaults_to_none() {
643		let raw = serde_json::json!({
644			"name": "r",
645			"listen": [":80"],
646			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
647		});
648		let rule: RawRule = serde_json::from_value(raw).expect("parse rule without tls");
649		assert!(rule.tls.is_none());
650	}
651
652	#[test]
653	fn raw_rule_with_tls_field_parses_paths() {
654		let raw = serde_json::json!({
655			"name": "r",
656			"listen": [":443"],
657			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
658			"tls": { "cert_file": "/etc/vaned/certs/api.pem", "key_file": "/etc/vaned/certs/api.key" },
659		});
660		let rule: RawRule = serde_json::from_value(raw).expect("parse rule with tls");
661		let tls = rule.tls.expect("tls present");
662		assert_eq!(tls.cert_file, PathBuf::from("/etc/vaned/certs/api.pem"));
663		assert_eq!(tls.key_file, PathBuf::from("/etc/vaned/certs/api.key"));
664	}
665
666	#[test]
667	fn tls_config_round_trips_through_json() {
668		let original = TlsConfig {
669			sni: None,
670			cert_file: PathBuf::from("/srv/cert.pem"),
671			key_file: PathBuf::from("/srv/key.pem"),
672			client_auth: None,
673		};
674		let encoded = serde_json::to_string(&original).expect("serialize");
675		let decoded: TlsConfig = serde_json::from_str(&encoded).expect("deserialize");
676		assert_eq!(decoded, original);
677	}
678
679	#[test]
680	fn tls_config_with_sni_field_parses() {
681		let raw = serde_json::json!({
682			"sni": "api.example.com",
683			"cert_file": "/etc/vaned/certs/api.pem",
684			"key_file": "/etc/vaned/certs/api.key",
685		});
686		let tls: TlsConfig = serde_json::from_value(raw).expect("parse tls with sni");
687		assert_eq!(tls.sni.as_deref(), Some("api.example.com"));
688	}
689
690	#[test]
691	fn tls_config_without_sni_parses_with_none() {
692		// Wire-compat with TLS part 1 — files written before the `sni`
693		// field existed must still deserialise.
694		let raw = serde_json::json!({
695			"cert_file": "/etc/vaned/certs/default.pem",
696			"key_file": "/etc/vaned/certs/default.key",
697		});
698		let tls: TlsConfig = serde_json::from_value(raw).expect("parse tls without sni");
699		assert!(tls.sni.is_none());
700	}
701}