Skip to main content

vane_core/
rule.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use serde_json::Value;
6
7use crate::error::Error;
8use crate::fetch::FetchKind;
9use crate::predicate::Predicate;
10
11pub type ListenSpec = String;
12
13#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
14pub struct RawRule {
15	pub name: String,
16	pub listen: Vec<ListenSpec>,
17	#[serde(default, rename = "match")]
18	pub match_predicate: Option<Predicate>,
19	#[serde(default)]
20	pub middleware_chain: Vec<MiddlewareRef>,
21	pub terminate: TerminateSpec,
22	/// Optional TLS termination config. When set, the listener wraps
23	/// each accepted TCP stream in a `rustls` server-side handshake
24	/// before driving the L7 sub-graph; cleartext sockets get
25	/// `Box<dyn AsyncReadWrite>` instead of raw `TcpStream`.
26	///
27	/// `lower_port` enforces consistency: every rule on the same
28	/// listener must agree on `tls` (all `None` or all the same
29	/// `Some(_)`); L4-only listeners cannot carry TLS (terminate +
30	/// re-emit cleartext is not a useful proxy shape — it leaks the
31	/// upstream traffic).
32	#[serde(default)]
33	pub tls: Option<TlsConfig>,
34	/// Per-rule TLS 1.3 0-RTT (early data) acceptance. Required on
35	/// every rule whose listener is TLS-terminating L7; absent on
36	/// rules whose listener is plaintext or pure-L4 (a present value
37	/// in those positions is a compile error). See
38	/// `spec/crates/engine-tls.md` § _TLS 1.3 0-RTT (early data)_.
39	#[serde(default)]
40	pub allow_zero_rtt: Option<bool>,
41	/// Maximum bytes to buffer for request body `LazyBuffer` collection.
42	/// Default 8 MiB. Exceeding this produces 413 Payload Too Large.
43	#[serde(default = "default_max_body_bytes")]
44	pub max_body_bytes_request: usize,
45	/// Maximum bytes to buffer for response body `LazyBuffer` collection.
46	/// Default 8 MiB. Exceeding this produces 502 Bad Gateway.
47	#[serde(default = "default_max_body_bytes")]
48	pub max_body_bytes_response: usize,
49	#[serde(default)]
50	pub source: SourceInfo,
51}
52
53fn default_max_body_bytes() -> usize {
54	8 * 1024 * 1024
55}
56
57/// Listener-side TLS termination config — paths to the cert chain +
58/// private key in PEM, plus an optional SNI hostname this cert serves.
59///
60/// `sni: None` marks the cert as the listener's _default_ — used when
61/// the `ClientHello` has no SNI extension, or when the SNI doesn't
62/// match any of the listener's `Some(_)` entries. A listener has at
63/// most one default cert.
64///
65/// SNI hostnames are normalised to ASCII-lowercase at every ingest
66/// boundary per spec/crates/engine-tls.md § _SNI peek (L4, no decrypt)_; comparison against
67/// rustls's already-lowercased `ClientHello::server_name()` is then
68/// byte-for-byte.
69#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
70pub struct TlsConfig {
71	#[serde(default)]
72	pub sni: Option<String>,
73	/// Path to the leaf+chain PEM. Required when the cert is operator-
74	/// supplied (static); absent when the cert comes from `managed`.
75	/// Per-rule validation enforces "exactly one of static paths or
76	/// `managed`"; lower-pass branches on the result.
77	#[serde(default, skip_serializing_if = "Option::is_none")]
78	pub cert_file: Option<PathBuf>,
79	/// Path to the private key PEM. Same lifecycle as `cert_file`.
80	#[serde(default, skip_serializing_if = "Option::is_none")]
81	pub key_file: Option<PathBuf>,
82	/// ACME-managed cert source. When set, `cert_file` / `key_file`
83	/// must be absent. The compiler routes this rule into the
84	/// listener's `managed_snis` table; the engine's
85	/// `ManagedCertPopulator` supplies the actual cert.
86	#[serde(default, skip_serializing_if = "Option::is_none")]
87	pub managed: Option<ManagedSpec>,
88	/// Listener-side TLS 1.3 0-RTT opt-in. Required on every rule that
89	/// carries a `tls` block; rules sharing one listener must agree on
90	/// this value (lower aggregates them). See
91	/// `spec/crates/engine-tls.md` § _TLS 1.3 0-RTT (early data)_.
92	pub enable_zero_rtt: bool,
93	/// Listener-side mTLS — per `spec/crates/engine-tls.md` § _Client certificate verification (mTLS on listener)_. Per-rule input; the lower pass aggregates each
94	/// rule's `client_auth` into one `ClientAuthSpec` per listener
95	/// address (rules on the same listener must agree, else compile
96	/// error). `None` keeps the listener at `ClientAuth::None`.
97	#[serde(default)]
98	pub client_auth: Option<ClientAuthConfig>,
99	/// Path to a pre-fetched OCSP response (DER) on disk. The
100	/// populator reads this file at every refresh and stages the
101	/// bytes into the resolver. Useful for HTTPS-only OCSP
102	/// responders (which `vane` does not fetch from — see
103	/// `spec/crates/engine-tls.md` § _OCSP stapling_) and for
104	/// air-gapped deployments where the operator cron-runs
105	/// `openssl ocsp` themselves. Mutually exclusive with
106	/// [`Self::ocsp_fetch`].
107	#[serde(default, skip_serializing_if = "Option::is_none")]
108	pub ocsp_path: Option<PathBuf>,
109	/// When `true`, the populator extracts the OCSP responder URL
110	/// from the cert's AIA extension and fetches the response over
111	/// HTTP at refresh time. HTTP-only by policy (per
112	/// `spec/crates/engine-tls.md` § _OCSP stapling_).
113	/// Mutually exclusive with [`Self::ocsp_path`].
114	#[serde(default, skip_serializing_if = "is_default_false")]
115	pub ocsp_fetch: bool,
116}
117
118#[allow(
119	clippy::trivially_copy_pass_by_ref,
120	reason = "serde skip_serializing_if requires fn(&T) -> bool"
121)]
122fn is_default_false(b: &bool) -> bool {
123	!*b
124}
125
126impl TlsConfig {
127	/// `true` when this `tls` block routes through ACME, not static disk
128	/// paths. Inverse of [`Self::is_static`].
129	#[must_use]
130	pub const fn is_managed(&self) -> bool {
131		self.managed.is_some()
132	}
133
134	/// `true` when both `cert_file` and `key_file` are present and
135	/// `managed` is absent. The lower pass guarantees this for every
136	/// `TlsConfig` it stores in [`ListenerTlsSpec::default`] /
137	/// [`ListenerTlsSpec::sni_certs`], so static-cert consumers can
138	/// rely on the static-paths invariant downstream.
139	#[must_use]
140	pub const fn is_static(&self) -> bool {
141		self.managed.is_none() && self.cert_file.is_some() && self.key_file.is_some()
142	}
143
144	/// Static cert paths if this is a static config. The lower pass
145	/// guarantees `(cert_file, key_file)` are both `Some` whenever
146	/// `managed` is `None`, so this returns `Some` for every
147	/// post-lower static `TlsConfig`.
148	#[must_use]
149	pub fn static_paths(&self) -> Option<(&Path, &Path)> {
150		match (&self.cert_file, &self.key_file, &self.managed) {
151			(Some(c), Some(k), None) => Some((c.as_path(), k.as_path())),
152			_ => None,
153		}
154	}
155
156	/// Per-rule pre-lower validation per `spec/crates/engine-acme.md` § _Configuration schema_ and `spec/crates/engine-tls.md` § _Upstream-side TLS_:
157	///
158	/// 1. Exactly one of (`cert_file` ∧ `key_file`) or `managed` is
159	///    present.
160	/// 2. When `managed` is set, every required `ManagedSpec` invariant
161	///    holds: `agree_tos == true`, non-empty `contact`, non-empty
162	///    `san`, `tls.sni ∈ san`, no wildcard SAN unless `dns-01`,
163	///    `dns-01` ⇒ `dns_provider`, `renew_before` parses to a
164	///    positive `Duration`.
165	///
166	/// # Errors
167	/// Returns [`Error::compile`] with a single sentence pointing at
168	/// the offending field. The error string is operator-readable —
169	/// the `vane compile` UI surfaces it verbatim.
170	pub fn validate(&self) -> Result<(), Error> {
171		// OCSP source mutex per `spec/crates/engine-tls.md` § _OCSP stapling_:
172		// `ocsp_path` and `ocsp_fetch` are independent strategies
173		// for the same goal and must not both be set on one rule.
174		// We check this before the cert-source branching so the
175		// error message points operators at OCSP rather than the
176		// cert-mode confusion that would otherwise mask it.
177		if self.ocsp_path.is_some() && self.ocsp_fetch {
178			return Err(Error::compile(
179				"tls: `ocsp_path` and `ocsp_fetch` are mutually exclusive — pick one OCSP source",
180			));
181		}
182		let static_present = self.cert_file.is_some() || self.key_file.is_some();
183		match (static_present, &self.managed) {
184			(true, Some(_)) => Err(Error::compile(
185				"tls: `managed` must not coexist with `cert_file` / `key_file` — pick one source",
186			)),
187			(false, None) => Err(Error::compile(
188				"tls: missing cert source — set either `cert_file` + `key_file` or `managed`",
189			)),
190			(true, None) => match (&self.cert_file, &self.key_file) {
191				(Some(_), Some(_)) => Ok(()),
192				(Some(_), None) => {
193					Err(Error::compile("tls: `key_file` is required when `cert_file` is set"))
194				}
195				(None, Some(_)) => {
196					Err(Error::compile("tls: `cert_file` is required when `key_file` is set"))
197				}
198				(None, None) => unreachable!("static_present implies one path is Some"),
199			},
200			(false, Some(m)) => m.validate(self.sni.as_deref()),
201		}
202	}
203}
204
205/// ACME-managed cert spec — operator-supplied, parsed verbatim from
206/// `tls.managed` per `spec/crates/engine-acme.md` § _Configuration schema_.
207///
208/// Every required field is mandatory in JSON: there are no implicit
209/// defaults, since the JSON is generated by `vane`'s CLI / TUI rather
210/// than hand-written. Defaulting in the schema would let a regression
211/// silently swap directory URLs (LE prod vs staging) or key types.
212#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
213pub struct ManagedSpec {
214	pub directory_url: String,
215	pub contact: Vec<String>,
216	pub agree_tos: bool,
217	pub challenge: ChallengeKind,
218	pub key_type: ManagedKeyType,
219	/// Renewal anticipation: kick off renewal when
220	/// `now + renew_before >= not_after`. Duration grammar mirrors
221	/// `rate_limit.window` (extended with `h` and `d` units —
222	/// renewal windows are typically days, not minutes).
223	pub renew_before: String,
224	pub san: Vec<String>,
225	/// BYO account key (PEM PKCS#8). When absent, the registry
226	/// auto-creates and persists via `AcmeStore::save_account`.
227	#[serde(default, skip_serializing_if = "Option::is_none")]
228	pub account_key_path: Option<PathBuf>,
229	/// DNS provider config — required when `challenge == "dns-01"`,
230	/// must be absent for `http-01`. The schema is provider-specific
231	/// (Cargo-feature-gated parser); core stores the raw JSON.
232	#[serde(default, skip_serializing_if = "Option::is_none")]
233	pub dns_provider: Option<Value>,
234}
235
236impl ManagedSpec {
237	/// Parsed `renew_before`. Re-parses on every call; callers that
238	/// need the value hot-path-frequent should cache it.
239	///
240	/// # Errors
241	/// Returns [`Error::compile`] when the literal is malformed or
242	/// non-positive.
243	pub fn renew_before_duration(&self) -> Result<Duration, Error> {
244		parse_renewal_duration(&self.renew_before)
245	}
246
247	/// Per-rule invariants, called from [`TlsConfig::validate`].
248	///
249	/// `tls_sni` is the parent rule's `tls.sni`; `spec/crates/engine-acme.md` § _Configuration schema_ requires `san ⊇ {tls.sni}`.
250	///
251	/// # Errors
252	/// One [`Error::compile`] per violation, in declaration order.
253	fn validate(&self, tls_sni: Option<&str>) -> Result<(), Error> {
254		if !self.agree_tos {
255			return Err(Error::compile("tls.managed.agree_tos must be true"));
256		}
257		if self.contact.is_empty() {
258			return Err(Error::compile("tls.managed.contact must list at least one URI"));
259		}
260		if self.directory_url.trim().is_empty() {
261			return Err(Error::compile("tls.managed.directory_url must not be empty"));
262		}
263		if self.san.is_empty() {
264			return Err(Error::compile("tls.managed.san must list at least one name"));
265		}
266		match tls_sni {
267			Some(sni) if !self.san.iter().any(|s| s.eq_ignore_ascii_case(sni)) => {
268				return Err(Error::compile(format!("tls.managed.san must contain tls.sni ({sni:?})")));
269			}
270			None => {
271				return Err(Error::compile("tls.managed requires tls.sni — managed certs are SNI-keyed"));
272			}
273			Some(_) => {}
274		}
275		match (self.challenge, self.dns_provider.is_some()) {
276			(ChallengeKind::Dns01, false) => {
277				return Err(Error::compile("tls.managed: challenge \"dns-01\" requires `dns_provider`"));
278			}
279			(ChallengeKind::Http01, true) => {
280				return Err(Error::compile(
281					"tls.managed: `dns_provider` is only meaningful when challenge == \"dns-01\"",
282				));
283			}
284			_ => {}
285		}
286		if matches!(self.challenge, ChallengeKind::Http01) {
287			for san in &self.san {
288				if san.starts_with("*.") {
289					return Err(Error::compile(format!(
290						"tls.managed: wildcard SAN {san:?} requires challenge \"dns-01\""
291					)));
292				}
293			}
294		}
295		let renew = self.renew_before_duration()?;
296		if renew.is_zero() {
297			return Err(Error::compile("tls.managed.renew_before must be > 0"));
298		}
299		Ok(())
300	}
301}
302
303#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)]
304pub enum ChallengeKind {
305	#[serde(rename = "http-01")]
306	Http01,
307	#[serde(rename = "dns-01")]
308	Dns01,
309}
310
311#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)]
312pub enum ManagedKeyType {
313	#[serde(rename = "ecdsa-p256")]
314	EcdsaP256,
315	#[serde(rename = "rsa-2048")]
316	Rsa2048,
317}
318
319/// Parse a duration literal of the form `<integer><unit>` where
320/// `unit ∈ { "ms", "s", "m", "h", "d" }`. Mirrors the
321/// `rate_limit.window` grammar (`engine/src/fetch/retry.rs`),
322/// extended with `h` and `d` because renewal windows are typically
323/// expressed in days. Hand-rolled to avoid pulling `humantime` into
324/// `vane-core`.
325///
326/// # Errors
327/// Returns [`Error::compile`] when the literal is empty, missing a
328/// unit, or has a non-integer numeric portion.
329fn parse_renewal_duration(s: &str) -> Result<Duration, Error> {
330	let s = s.trim();
331	if s.is_empty() {
332		return Err(Error::compile("duration must be non-empty"));
333	}
334	let (num, unit_secs) = if let Some(rest) = s.strip_suffix("ms") {
335		(rest, None) // milliseconds — special-cased below
336	} else if let Some(rest) = s.strip_suffix('s') {
337		(rest, Some(1u64))
338	} else if let Some(rest) = s.strip_suffix('m') {
339		(rest, Some(60u64))
340	} else if let Some(rest) = s.strip_suffix('h') {
341		(rest, Some(60 * 60))
342	} else if let Some(rest) = s.strip_suffix('d') {
343		(rest, Some(60 * 60 * 24))
344	} else {
345		return Err(Error::compile(format!(
346			"duration {s:?}: missing unit (expected ms / s / m / h / d)"
347		)));
348	};
349	let n: u64 = num.trim().parse().map_err(|e| Error::compile(format!("duration {s:?}: {e}")))?;
350	Ok(match unit_secs {
351		None => Duration::from_millis(n),
352		Some(secs) => Duration::from_secs(n.saturating_mul(secs)),
353	})
354}
355
356/// Per-rule mTLS config block, parsed from the `tls.client_auth` JSON.
357/// `mode == None` is operator-explicit "don't request a cert"; the
358/// trust store must be absent there. `mode == Request | Require`
359/// requires a non-empty `trust_store`.
360#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
361pub struct ClientAuthConfig {
362	pub mode: ClientAuthMode,
363	#[serde(default)]
364	pub trust_store: Option<ClientTrustStoreConfig>,
365}
366
367/// Three-valued client-auth mode (no implicit default per spec).
368#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
369#[serde(rename_all = "lowercase")]
370pub enum ClientAuthMode {
371	None,
372	Request,
373	Require,
374}
375
376/// Per-rule trust store config for verifying client certs. At least
377/// one of `ca_paths` / `ca_dir` must be present (enforced at compile).
378#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
379pub struct ClientTrustStoreConfig {
380	#[serde(default)]
381	pub ca_paths: Vec<PathBuf>,
382	#[serde(default)]
383	pub ca_dir: Option<PathBuf>,
384	#[serde(default)]
385	pub crls: Vec<CrlSourceConfig>,
386}
387
388/// One CRL source entry — file or URL, with a per-source
389/// `fetch_failure` policy. Bytes are owned by the daemon-wide CRL
390/// cache (`vane_engine::tls::CrlCache`); this struct only carries
391/// the parsed schema.
392#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
393#[serde(tag = "kind", rename_all = "lowercase")]
394pub enum CrlSourceConfig {
395	File { path: PathBuf, fetch_failure: CrlFetchFailure },
396	Url { url: String, fetch_failure: CrlFetchFailure },
397}
398
399/// CRL availability policy (per `spec/crates/engine-tls.md` § _CRL_).
400#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
401#[serde(rename_all = "lowercase")]
402pub enum CrlFetchFailure {
403	Tolerate,
404	Reject,
405}
406
407/// Per-listener cert pool — produced by `compile/lower` from every
408/// rule on the bind address that carries a `tls` block, after
409/// hash-consing identical entries and rejecting conflicts.
410///
411/// At most one `default` cert (sni-less); any number of SNI-keyed
412/// certs. The engine's link stage compiles this into a single
413/// `rustls::ServerConfig` whose cert resolver picks by SNI with
414/// `default` as the fallback for unmatched / missing SNI.
415#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
416pub struct ListenerTlsSpec {
417	#[serde(default)]
418	pub default: Option<TlsConfig>,
419	#[serde(default)]
420	pub sni_certs: BTreeMap<String, TlsConfig>,
421	/// ACME-managed certs declared on this listener, keyed by SNI
422	/// (lowercased). The lower pass populates this from rules whose
423	/// `tls.managed` is set; the daemon's `ManagedCertRegistry`
424	/// picks them up and delivers actual certs through the listener's
425	/// `ManagedCertPopulator`.
426	///
427	/// This map is the source of truth for boot-time issuance — every
428	/// entry triggers a one-shot `issue_http01` attempt — and feeds
429	/// the renewal scheduler.
430	#[serde(default)]
431	pub managed_snis: BTreeMap<String, ManagedSpec>,
432	/// Resolved per-listener mTLS policy. Per `spec/crates/engine-tls.md` § _Client certificate verification (mTLS on listener)_ this is per-listener, derived from the
433	/// union of every rule's `tls.client_auth` on the same address;
434	/// rules that disagree on `mode` or `trust_store` produce a compile
435	/// error. Defaults to `None` for cleartext clients.
436	#[serde(default)]
437	pub client_auth: ClientAuthSpec,
438	/// Resolved per-listener TLS 1.3 0-RTT opt-in. Aggregated by the
439	/// lower pass from every rule's `tls.enable_zero_rtt` on the same
440	/// address — rules that disagree produce a compile error. The
441	/// engine's link wires this into `ServerConfig.max_early_data_size`
442	/// (16 KiB when `true`, default 0 when `false`). Defaults to
443	/// `false` for cleartext / non-TLS listeners. See
444	/// `spec/crates/engine-tls.md` § _TLS 1.3 0-RTT (early data)_.
445	#[serde(default)]
446	pub enable_zero_rtt: bool,
447}
448
449impl ListenerTlsSpec {
450	#[must_use]
451	pub fn is_empty(&self) -> bool {
452		self.default.is_none()
453			&& self.sni_certs.is_empty()
454			&& self.managed_snis.is_empty()
455			&& matches!(self.client_auth, ClientAuthSpec::None)
456			&& !self.enable_zero_rtt
457	}
458}
459
460/// Listener-level resolved mTLS policy. Built by the lower pass from
461/// the union of per-rule `ClientAuthConfig` blocks; rules on the same
462/// listener must all agree.
463#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
464#[serde(tag = "mode", rename_all = "lowercase")]
465pub enum ClientAuthSpec {
466	#[default]
467	None,
468	Request {
469		trust_store: ClientTrustStoreConfig,
470	},
471	Require {
472		trust_store: ClientTrustStoreConfig,
473	},
474}
475
476#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
477pub struct MiddlewareRef {
478	#[serde(rename = "use")]
479	pub name: String,
480	#[serde(default)]
481	pub args: Value,
482	#[serde(default)]
483	pub on_error: Option<OnErrorSpec>,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
487pub enum OnErrorSpec {
488	Close,
489	Response(SynthResponse),
490}
491
492#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
493pub struct SynthResponse {
494	pub status: u16,
495	#[serde(default)]
496	pub headers: Option<BTreeMap<String, String>>,
497	#[serde(default)]
498	pub body: Option<String>,
499}
500
501impl<'de> serde::Deserialize<'de> for OnErrorSpec {
502	fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
503		#[derive(serde::Deserialize)]
504		#[serde(untagged)]
505		enum Raw {
506			Literal(String),
507			Response { response: SynthResponse },
508		}
509		match Raw::deserialize(de)? {
510			Raw::Literal(s) if s == "close" => Ok(Self::Close),
511			Raw::Literal(other) => Err(serde::de::Error::unknown_variant(&other, &["close"])),
512			Raw::Response { response } => Ok(Self::Response(response)),
513		}
514	}
515}
516
517#[derive(Debug, Clone, serde::Serialize)]
518pub struct TerminateSpec {
519	pub kind: FetchKind,
520	pub args: Value,
521}
522
523impl<'de> serde::Deserialize<'de> for TerminateSpec {
524	fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
525		let mut v = Value::deserialize(de)?;
526		let obj = v
527			.as_object_mut()
528			.ok_or_else(|| serde::de::Error::custom("`terminate` must be a JSON object"))?;
529		let type_val = obj.remove("type").ok_or_else(|| serde::de::Error::missing_field("type"))?;
530		let Value::String(alias) = type_val else {
531			return Err(serde::de::Error::custom("`terminate.type` must be a string"));
532		};
533		let kind = fetch_kind_from_alias(&alias)
534			.ok_or_else(|| serde::de::Error::custom(format!("unknown terminate type: {alias:?}")))?;
535		// spec/crates/engine.md `spec/crates/engine.md` § _Concrete fetches_:
536		// `httpN_proxy` is sugar for `http_proxy` + `version: "hN"`.
537		// Inject the version when the alias names a specific HTTP
538		// version and the user has not already set one explicitly —
539		// an explicit `args.version` always wins.
540		if let Some(version) = http_version_from_alias(&alias)
541			&& !obj.contains_key("version")
542		{
543			obj.insert("version".to_owned(), Value::String(version.to_owned()));
544		}
545		// `tcp_forward` / `udp_forward` are sugar for `L4Forward` +
546		// `transport: "tcp" | "udp"`. Same precedence rule: an
547		// explicit `args.transport` overrides the alias-derived value
548		// (preserved as an escape hatch for hand-written rules).
549		if let Some(transport) = transport_from_alias(&alias)
550			&& !obj.contains_key("transport")
551		{
552			obj.insert("transport".to_owned(), Value::String(transport.to_owned()));
553		}
554		// Every `HttpProxy` alias resolves to one of the upstream kinds
555		// the engine factory dispatches on: socket-based proxies
556		// (`http_proxy` / `httpN_proxy` / `unix_proxy`) carry
557		// `upstream_kind: "tcp"`; the CGI alias carries
558		// `upstream_kind: "cgi"`. Injecting the marker explicitly
559		// (rather than letting the factory infer from which fields are
560		// present) gives the engine a clean, fail-loud branch — a
561		// missing `upstream` on a socket-based rule produces "missing
562		// args.upstream", not "unknown CGI shape". An explicit
563		// `args.upstream_kind` always wins, same precedence rule as
564		// `version` / `transport`.
565		if let Some(upstream_kind) = upstream_kind_from_alias(&alias)
566			&& !obj.contains_key("upstream_kind")
567		{
568			obj.insert("upstream_kind".to_owned(), Value::String(upstream_kind.to_owned()));
569		}
570		Ok(Self { kind, args: v })
571	}
572}
573
574fn fetch_kind_from_alias(alias: &str) -> Option<FetchKind> {
575	match alias {
576		"tcp_forward" | "udp_forward" => Some(FetchKind::L4Forward),
577		"http_proxy" | "http1_proxy" | "http2_proxy" | "http3_proxy" | "unix_proxy" | "cgi" => {
578			Some(FetchKind::HttpProxy)
579		}
580		"websocket" => Some(FetchKind::WebSocketUpgrade),
581		"static" | "redirect_https" => Some(FetchKind::HttpSynthesize),
582		_ => None,
583	}
584}
585
586fn http_version_from_alias(alias: &str) -> Option<&'static str> {
587	match alias {
588		"http1_proxy" => Some("h1"),
589		"http2_proxy" => Some("h2"),
590		"http3_proxy" => Some("h3"),
591		_ => None,
592	}
593}
594
595fn transport_from_alias(alias: &str) -> Option<&'static str> {
596	match alias {
597		"tcp_forward" => Some("tcp"),
598		"udp_forward" => Some("udp"),
599		_ => None,
600	}
601}
602
603fn upstream_kind_from_alias(alias: &str) -> Option<&'static str> {
604	match alias {
605		"http_proxy" | "http1_proxy" | "http2_proxy" | "http3_proxy" | "unix_proxy" => Some("tcp"),
606		"cgi" => Some("cgi"),
607		_ => None,
608	}
609}
610
611#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
612pub struct SourceInfo {
613	#[serde(default)]
614	pub file: PathBuf,
615	#[serde(default)]
616	pub line: u32,
617}
618
619#[cfg(test)]
620mod tests {
621	use super::*;
622	use crate::predicate::{CheckMap, FieldPath, Operator, Predicate, Value as PredValue};
623
624	#[test]
625	fn raw_rule_minimal_parses_with_defaults() {
626		let raw = serde_json::json!({
627			"name": "r",
628			"listen": [":443"],
629			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
630		});
631		let rule: RawRule = serde_json::from_value(raw).expect("parse minimal rule");
632		assert_eq!(rule.name, "r");
633		assert_eq!(rule.listen, vec![":443".to_string()]);
634		assert!(rule.match_predicate.is_none());
635		assert!(rule.middleware_chain.is_empty());
636		assert_eq!(rule.terminate.kind, FetchKind::HttpProxy);
637		assert_eq!(
638			rule.terminate.args,
639			serde_json::json!({ "upstream": "127.0.0.1:8080", "upstream_kind": "tcp" }),
640		);
641		assert_eq!(rule.source.file, PathBuf::new());
642		assert_eq!(rule.source.line, 0);
643		assert_eq!(rule.max_body_bytes_request, 8 * 1024 * 1024);
644		assert_eq!(rule.max_body_bytes_response, 8 * 1024 * 1024);
645	}
646
647	#[test]
648	fn raw_rule_full_populates_every_field() {
649		let raw = serde_json::json!({
650			"name": "api",
651			"listen": [":443", "0.0.0.0:80"],
652			"match": { "tls.sni": { "equals": "api.example.com" } },
653			"middleware_chain": [
654				{ "use": "rate_limit", "args": { "rate": 100 } },
655				{ "use": "jwt", "args": { "secret": "x" }, "on_error": "close" },
656			],
657			"terminate": {
658				"type": "http_proxy",
659				"upstream": "127.0.0.1:8080",
660				"timeouts": { "connect": "5s" }
661			},
662			"source": { "file": "rules/30-api.json", "line": 14 },
663		});
664		let rule: RawRule = serde_json::from_value(raw).expect("parse full rule");
665		assert_eq!(rule.name, "api");
666		assert_eq!(rule.listen.len(), 2);
667		let check = match rule.match_predicate.as_ref().expect("match present") {
668			Predicate::Check(c) => c,
669			other => panic!("expected Check, got {other:?}"),
670		};
671		assert_eq!(check.path, FieldPath::TlsSni);
672		match &check.op {
673			Operator::Equals(PredValue::Str(s)) => assert_eq!(s, "api.example.com"),
674			other => panic!("unexpected op: {other:?}"),
675		}
676		assert_eq!(rule.middleware_chain.len(), 2);
677		assert_eq!(rule.middleware_chain[1].on_error, Some(OnErrorSpec::Close));
678		assert_eq!(rule.terminate.kind, FetchKind::HttpProxy);
679		assert_eq!(
680			rule.terminate.args,
681			serde_json::json!({
682				"upstream": "127.0.0.1:8080",
683				"upstream_kind": "tcp",
684				"timeouts": { "connect": "5s" }
685			}),
686		);
687		assert_eq!(rule.source.file, PathBuf::from("rules/30-api.json"));
688		assert_eq!(rule.source.line, 14);
689	}
690
691	#[test]
692	fn middleware_ref_flat_form_parses_name_and_args() {
693		let raw = serde_json::json!({ "use": "rate_limit", "args": { "rate": 100 } });
694		let m: MiddlewareRef = serde_json::from_value(raw).expect("parse middleware ref");
695		assert_eq!(m.name, "rate_limit");
696		assert_eq!(m.args, serde_json::json!({ "rate": 100 }));
697		assert!(m.on_error.is_none());
698	}
699
700	#[test]
701	fn middleware_ref_on_error_close_form() {
702		let raw = serde_json::json!({ "use": "jwt", "args": { "secret": "x" }, "on_error": "close" });
703		let m: MiddlewareRef = serde_json::from_value(raw).expect("parse middleware ref");
704		assert_eq!(m.on_error, Some(OnErrorSpec::Close));
705	}
706
707	#[test]
708	fn middleware_ref_on_error_response_object_form() {
709		let raw = serde_json::json!({
710			"use": "jwt",
711			"on_error": { "response": { "status": 503, "body": "maintenance" } },
712		});
713		let m: MiddlewareRef = serde_json::from_value(raw).expect("parse middleware ref");
714		assert_eq!(m.name, "jwt");
715		assert_eq!(m.args, Value::Null);
716		let resp = match m.on_error.expect("on_error present") {
717			OnErrorSpec::Response(r) => r,
718			OnErrorSpec::Close => panic!("expected Response"),
719		};
720		assert_eq!(resp.status, 503);
721		assert_eq!(resp.body.as_deref(), Some("maintenance"));
722		assert!(resp.headers.is_none());
723	}
724
725	#[test]
726	fn middleware_ref_args_defaults_to_null_when_omitted() {
727		let raw = serde_json::json!({ "use": "tag" });
728		let m: MiddlewareRef = serde_json::from_value(raw).expect("parse middleware ref");
729		assert_eq!(m.args, Value::Null);
730	}
731
732	#[test]
733	fn middleware_ref_requires_use_key() {
734		let raw = serde_json::json!({});
735		let err = serde_json::from_value::<MiddlewareRef>(raw).expect_err("missing `use` must fail");
736		let _ = err.to_string();
737	}
738
739	#[test]
740	fn on_error_spec_string_invalid_variant_rejected() {
741		let raw = serde_json::json!("crash");
742		let err = serde_json::from_value::<OnErrorSpec>(raw).expect_err("non-`close` literal rejected");
743		let msg = err.to_string();
744		assert!(msg.contains("close"), "error names the only valid literal: {msg}");
745	}
746
747	#[test]
748	fn on_error_spec_malformed_response_object_rejected() {
749		let raw = serde_json::json!({ "response": null });
750		let err = serde_json::from_value::<OnErrorSpec>(raw).expect_err("null response rejected");
751		let _ = err.to_string();
752	}
753
754	#[test]
755	fn on_error_spec_close_literal_parses() {
756		let raw = serde_json::json!("close");
757		let s: OnErrorSpec = serde_json::from_value(raw).expect("close literal parses");
758		assert_eq!(s, OnErrorSpec::Close);
759	}
760
761	#[test]
762	fn on_error_spec_response_object_parses() {
763		let raw = serde_json::json!({
764			"response": { "status": 503, "body": "maintenance" },
765		});
766		let s: OnErrorSpec = serde_json::from_value(raw).expect("response object parses");
767		match s {
768			OnErrorSpec::Response(r) => {
769				assert_eq!(r.status, 503);
770				assert_eq!(r.body.as_deref(), Some("maintenance"));
771				assert!(r.headers.is_none());
772			}
773			OnErrorSpec::Close => panic!("expected Response"),
774		}
775	}
776
777	#[test]
778	fn synth_response_minimal_status_only() {
779		let raw = serde_json::json!({ "status": 200 });
780		let r: SynthResponse = serde_json::from_value(raw).expect("parse status-only synth");
781		assert_eq!(r.status, 200);
782		assert!(r.headers.is_none());
783		assert!(r.body.is_none());
784	}
785
786	#[test]
787	fn synth_response_full_status_headers_body() {
788		let raw = serde_json::json!({
789			"status": 404,
790			"headers": { "content-type": "text/plain" },
791			"body": "not found",
792		});
793		let r: SynthResponse = serde_json::from_value(raw).expect("parse full synth");
794		assert_eq!(r.status, 404);
795		let headers = r.headers.as_ref().expect("headers present");
796		assert_eq!(headers.get("content-type").map(String::as_str), Some("text/plain"));
797		assert_eq!(r.body.as_deref(), Some("not found"));
798	}
799
800	#[test]
801	fn terminate_spec_alias_table_maps_to_fetch_kind() {
802		// Every row of spec/crates/engine.md `spec/crates/engine.md` § _Concrete fetches_.
803		let cases: &[(&str, FetchKind)] = &[
804			("tcp_forward", FetchKind::L4Forward),
805			("udp_forward", FetchKind::L4Forward),
806			("http_proxy", FetchKind::HttpProxy),
807			("http1_proxy", FetchKind::HttpProxy),
808			("http2_proxy", FetchKind::HttpProxy),
809			("http3_proxy", FetchKind::HttpProxy),
810			("unix_proxy", FetchKind::HttpProxy),
811			("cgi", FetchKind::HttpProxy),
812			("websocket", FetchKind::WebSocketUpgrade),
813			("static", FetchKind::HttpSynthesize),
814			("redirect_https", FetchKind::HttpSynthesize),
815		];
816		for (alias, expected) in cases {
817			let raw = serde_json::json!({ "type": alias });
818			let t: TerminateSpec =
819				serde_json::from_value(raw).unwrap_or_else(|e| panic!("alias {alias} must parse: {e}"));
820			assert_eq!(t.kind, *expected, "alias {alias} must map to {expected:?}");
821		}
822	}
823
824	#[test]
825	fn terminate_spec_args_preserves_all_non_type_keys_verbatim() {
826		// spec/crates/core.md § _Compile pipeline_: "every other key goes into `args`
827		// verbatim". Covers top-level scalars AND nested objects.
828		let raw = serde_json::json!({
829			"type": "http_proxy",
830			"upstream": "127.0.0.1:8080",
831			"timeouts": { "connect": "5s", "total": "60s" },
832		});
833		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
834		assert_eq!(t.kind, FetchKind::HttpProxy);
835		assert_eq!(
836			t.args,
837			serde_json::json!({
838				"upstream": "127.0.0.1:8080",
839				"upstream_kind": "tcp",
840				"timeouts": { "connect": "5s", "total": "60s" },
841			}),
842		);
843	}
844
845	#[test]
846	fn terminate_spec_udp_forward_alias_injects_transport_udp() {
847		let raw = serde_json::json!({ "type": "udp_forward", "upstream": "1.2.3.4:53" });
848		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
849		assert_eq!(t.kind, FetchKind::L4Forward);
850		assert_eq!(t.args["transport"], "udp");
851		assert_eq!(t.args["upstream"], "1.2.3.4:53");
852	}
853
854	#[test]
855	fn terminate_spec_tcp_forward_alias_injects_transport_tcp() {
856		let raw = serde_json::json!({ "type": "tcp_forward", "upstream": "10.0.0.5:22" });
857		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
858		assert_eq!(t.kind, FetchKind::L4Forward);
859		assert_eq!(t.args["transport"], "tcp");
860	}
861
862	#[test]
863	fn terminate_spec_cgi_alias_injects_upstream_kind_cgi() {
864		// The factory branches on `args.upstream_kind`; the alias
865		// resolution layer is what injects it. A bare `cgi` alias must
866		// surface as `upstream_kind: "cgi"` so the engine factory can
867		// dispatch without re-checking the alias.
868		let raw = serde_json::json!({ "type": "cgi", "binary": "/usr/bin/true" });
869		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
870		assert_eq!(t.kind, FetchKind::HttpProxy);
871		assert_eq!(t.args["upstream_kind"], "cgi");
872	}
873
874	#[test]
875	fn terminate_spec_http_proxy_aliases_inject_upstream_kind_tcp() {
876		// Every socket-based HttpProxy alias carries
877		// `upstream_kind: "tcp"`. Explicit injection (rather than
878		// leaving the marker absent for socket variants) makes the
879		// factory's dispatch table closed — no implicit fallback.
880		for alias in ["http_proxy", "http1_proxy", "http2_proxy", "http3_proxy", "unix_proxy"] {
881			let raw = serde_json::json!({ "type": alias, "upstream": "127.0.0.1:8080" });
882			let t: TerminateSpec =
883				serde_json::from_value(raw).unwrap_or_else(|e| panic!("alias {alias} must parse: {e}"));
884			assert_eq!(t.args["upstream_kind"], "tcp", "alias {alias} must inject upstream_kind: tcp");
885		}
886	}
887
888	#[test]
889	fn terminate_spec_explicit_upstream_kind_wins_over_alias() {
890		// Same escape-hatch rule the version/transport injections
891		// follow: an operator-supplied `args.upstream_kind` is never
892		// overridden by the alias-derived value.
893		let raw = serde_json::json!({
894			"type": "http_proxy",
895			"upstream": "127.0.0.1:8080",
896			"upstream_kind": "tcp",
897		});
898		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
899		assert_eq!(t.args["upstream_kind"], "tcp");
900	}
901
902	#[test]
903	fn terminate_spec_explicit_transport_wins_over_alias() {
904		// Explicit `args.transport` always overrides the alias-derived
905		// value — escape hatch for hand-written configs that want to
906		// pin a transport regardless of which alias spelled the rule.
907		let raw = serde_json::json!({ "type": "udp_forward", "upstream": "x", "transport": "tcp" });
908		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
909		assert_eq!(t.args["transport"], "tcp");
910	}
911
912	#[test]
913	fn terminate_spec_alias_only_yields_object_with_injected_markers() {
914		// spec/crates/core.md § _Compile pipeline_: the custom Deserialize removes `type`
915		// from a JSON object and keeps the rest. An alias-only terminate keeps
916		// the object shape; it now also carries the alias-resolution markers
917		// (`upstream_kind` for `HttpProxy` aliases). The point of this test is
918		// to lock in "args is an object, not Value::Null" — which the marker
919		// injection only reinforces.
920		let raw = serde_json::json!({ "type": "http_proxy" });
921		let t: TerminateSpec = serde_json::from_value(raw).expect("parse");
922		assert_eq!(t.kind, FetchKind::HttpProxy);
923		assert!(t.args.is_object(), "args must be an object, got {:?}", t.args);
924		assert_eq!(t.args["upstream_kind"], "tcp");
925	}
926
927	#[test]
928	fn terminate_spec_unknown_type_rejected_and_names_alias() {
929		let raw = serde_json::json!({ "type": "bogus" });
930		let err = serde_json::from_value::<TerminateSpec>(raw).expect_err("unknown alias rejected");
931		assert!(err.to_string().contains("bogus"), "error must name the offending alias: {err}");
932	}
933
934	#[test]
935	fn terminate_spec_missing_type_rejected_and_names_field() {
936		let raw = serde_json::json!({ "upstream": "127.0.0.1:8080" });
937		let err = serde_json::from_value::<TerminateSpec>(raw).expect_err("missing type rejected");
938		assert!(err.to_string().contains("type"), "error must name the missing field: {err}");
939	}
940
941	#[test]
942	fn source_info_default_is_empty_path_and_zero_line() {
943		let s = SourceInfo::default();
944		assert_eq!(s.file, PathBuf::new());
945		assert_eq!(s.line, 0);
946	}
947
948	#[test]
949	fn source_info_round_trip_via_json() {
950		let raw = serde_json::json!({ "file": "rules/a.json", "line": 7 });
951		let s: SourceInfo = serde_json::from_value(raw).expect("parse source info");
952		assert_eq!(s.file, PathBuf::from("rules/a.json"));
953		assert_eq!(s.line, 7);
954	}
955
956	#[test]
957	fn middleware_chain_defaults_to_empty_when_omitted() {
958		let raw = serde_json::json!({
959			"name": "r",
960			"listen": [":443"],
961			"terminate": { "type": "http_proxy" },
962		});
963		let rule: RawRule = serde_json::from_value(raw).expect("parse");
964		assert!(rule.middleware_chain.is_empty());
965	}
966
967	#[test]
968	fn middleware_ref_chain_mixes_on_error_forms() {
969		let raw = serde_json::json!({
970			"name": "r",
971			"listen": [":443"],
972			"middleware_chain": [
973				{ "use": "a" },
974				{ "use": "b", "on_error": "close" },
975				{ "use": "c", "on_error": { "response": { "status": 500 } } },
976			],
977			"terminate": { "type": "http_proxy" },
978		});
979		let rule: RawRule = serde_json::from_value(raw).expect("parse");
980		assert_eq!(rule.middleware_chain.len(), 3);
981		assert!(rule.middleware_chain[0].on_error.is_none());
982		assert_eq!(rule.middleware_chain[1].on_error, Some(OnErrorSpec::Close));
983		match rule.middleware_chain[2].on_error.as_ref().expect("on_error[2]") {
984			OnErrorSpec::Response(r) => {
985				assert_eq!(r.status, 500);
986				assert!(r.body.is_none());
987				assert!(r.headers.is_none());
988			}
989			OnErrorSpec::Close => panic!("expected Response at index 2"),
990		}
991	}
992
993	#[test]
994	fn raw_rule_accepts_top_level_check_predicate() {
995		let raw = serde_json::json!({
996			"name": "r",
997			"listen": [":80"],
998			"match": { "http.uri.path": { "prefix": "/api" } },
999			"terminate": { "type": "http_proxy" },
1000		});
1001		let rule: RawRule = serde_json::from_value(raw).expect("parse");
1002		let Some(Predicate::Check(CheckMap { path, op })) = rule.match_predicate else {
1003			panic!("expected Check predicate");
1004		};
1005		assert_eq!(path, FieldPath::HttpUriPath);
1006		match op {
1007			Operator::Prefix(PredValue::Str(s)) => assert_eq!(s, "/api"),
1008			other => panic!("unexpected op: {other:?}"),
1009		}
1010	}
1011
1012	#[test]
1013	fn raw_rule_without_tls_field_defaults_to_none() {
1014		let raw = serde_json::json!({
1015			"name": "r",
1016			"listen": [":80"],
1017			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1018		});
1019		let rule: RawRule = serde_json::from_value(raw).expect("parse rule without tls");
1020		assert!(rule.tls.is_none());
1021	}
1022
1023	#[test]
1024	fn raw_rule_with_tls_field_parses_paths() {
1025		let raw = serde_json::json!({
1026			"name": "r",
1027			"listen": [":443"],
1028			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1029			"tls": {
1030				"cert_file": "/etc/vaned/certs/api.pem",
1031				"key_file": "/etc/vaned/certs/api.key",
1032				"enable_zero_rtt": false,
1033			},
1034		});
1035		let rule: RawRule = serde_json::from_value(raw).expect("parse rule with tls");
1036		let tls = rule.tls.expect("tls present");
1037		assert_eq!(tls.cert_file.as_deref(), Some(Path::new("/etc/vaned/certs/api.pem")));
1038		assert_eq!(tls.key_file.as_deref(), Some(Path::new("/etc/vaned/certs/api.key")));
1039		assert!(!tls.enable_zero_rtt);
1040	}
1041
1042	#[test]
1043	fn tls_config_round_trips_through_json() {
1044		let original = TlsConfig {
1045			sni: None,
1046			cert_file: Some(PathBuf::from("/srv/cert.pem")),
1047			key_file: Some(PathBuf::from("/srv/key.pem")),
1048			managed: None,
1049			enable_zero_rtt: false,
1050			client_auth: None,
1051			ocsp_path: None,
1052			ocsp_fetch: false,
1053		};
1054		let encoded = serde_json::to_string(&original).expect("serialize");
1055		let decoded: TlsConfig = serde_json::from_str(&encoded).expect("deserialize");
1056		assert_eq!(decoded, original);
1057	}
1058
1059	#[test]
1060	fn tls_config_with_sni_field_parses() {
1061		let raw = serde_json::json!({
1062			"sni": "api.example.com",
1063			"cert_file": "/etc/vaned/certs/api.pem",
1064			"key_file": "/etc/vaned/certs/api.key",
1065			"enable_zero_rtt": false,
1066		});
1067		let tls: TlsConfig = serde_json::from_value(raw).expect("parse tls with sni");
1068		assert_eq!(tls.sni.as_deref(), Some("api.example.com"));
1069	}
1070
1071	#[test]
1072	fn tls_config_without_sni_parses_with_none() {
1073		let raw = serde_json::json!({
1074			"cert_file": "/etc/vaned/certs/default.pem",
1075			"key_file": "/etc/vaned/certs/default.key",
1076			"enable_zero_rtt": false,
1077		});
1078		let tls: TlsConfig = serde_json::from_value(raw).expect("parse tls without sni");
1079		assert!(tls.sni.is_none());
1080	}
1081
1082	#[test]
1083	fn tls_config_missing_enable_zero_rtt_field_rejected() {
1084		// `enable_zero_rtt` is required (no implicit default) per
1085		// `spec/crates/engine-tls.md` § _TLS 1.3 0-RTT (early data)_; absence on a `tls` block is a
1086		// hard parse error before the lower pass even sees the rule.
1087		let raw = serde_json::json!({
1088			"cert_file": "/etc/vaned/certs/default.pem",
1089			"key_file": "/etc/vaned/certs/default.key",
1090		});
1091		let err =
1092			serde_json::from_value::<TlsConfig>(raw).expect_err("missing enable_zero_rtt must reject");
1093		assert!(
1094			err.to_string().contains("enable_zero_rtt"),
1095			"error must name the missing field: {err}",
1096		);
1097	}
1098
1099	#[test]
1100	fn raw_rule_allow_zero_rtt_field_parses_when_present() {
1101		let raw = serde_json::json!({
1102			"name": "r",
1103			"listen": [":443"],
1104			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1105			"allow_zero_rtt": true,
1106			"tls": {
1107				"cert_file": "/etc/vaned/certs/api.pem",
1108				"key_file": "/etc/vaned/certs/api.key",
1109				"enable_zero_rtt": true,
1110			},
1111		});
1112		let rule: RawRule = serde_json::from_value(raw).expect("parse rule with allow_zero_rtt");
1113		assert_eq!(rule.allow_zero_rtt, Some(true));
1114	}
1115
1116	#[test]
1117	fn raw_rule_allow_zero_rtt_defaults_to_none_when_omitted() {
1118		let raw = serde_json::json!({
1119			"name": "r",
1120			"listen": [":80"],
1121			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" },
1122		});
1123		let rule: RawRule = serde_json::from_value(raw).expect("parse rule without allow_zero_rtt");
1124		assert!(rule.allow_zero_rtt.is_none());
1125	}
1126
1127	// `tls.managed` schema + validation. Each test exercises one of the
1128	// compile-time invariants from `spec/crates/engine-acme.md`
1129	// § _Configuration schema_, plus parser round-trips.
1130	// `TlsConfig::validate` returns the first violation in declaration
1131	// order; tests assert on the substring rather than the full message
1132	// so wording can evolve without churning fixtures.
1133
1134	fn managed_tls(challenge: &str, with_dns_provider: bool) -> serde_json::Value {
1135		let mut managed = serde_json::json!({
1136			"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
1137			"contact": ["mailto:ops@example.com"],
1138			"agree_tos": true,
1139			"challenge": challenge,
1140			"key_type": "ecdsa-p256",
1141			"renew_before": "30d",
1142			"san": ["api.example.com"],
1143		});
1144		if with_dns_provider {
1145			managed["dns_provider"] = serde_json::json!({ "kind": "cloudflare" });
1146		}
1147		serde_json::json!({
1148			"sni": "api.example.com",
1149			"managed": managed,
1150			"enable_zero_rtt": false,
1151		})
1152	}
1153
1154	#[test]
1155	fn tls_managed_round_trips_through_json() {
1156		let raw = managed_tls("http-01", false);
1157		let tls: TlsConfig = serde_json::from_value(raw).expect("parse managed");
1158		let m = tls.managed.as_ref().expect("managed");
1159		assert!(m.agree_tos);
1160		assert_eq!(m.challenge, ChallengeKind::Http01);
1161		assert_eq!(m.key_type, ManagedKeyType::EcdsaP256);
1162		assert_eq!(m.san, vec!["api.example.com".to_owned()]);
1163		assert_eq!(m.contact, vec!["mailto:ops@example.com".to_owned()]);
1164		assert!(m.dns_provider.is_none());
1165		assert!(tls.is_managed());
1166		assert!(!tls.is_static());
1167	}
1168
1169	#[test]
1170	fn tls_managed_validates_happy_path() {
1171		let raw = managed_tls("http-01", false);
1172		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1173		tls.validate().expect("happy path validates");
1174	}
1175
1176	#[test]
1177	fn tls_validate_rejects_both_static_and_managed() {
1178		let raw = serde_json::json!({
1179			"sni": "api.example.com",
1180			"cert_file": "/tmp/cert.pem",
1181			"key_file": "/tmp/key.pem",
1182			"managed": {
1183				"directory_url": "https://example",
1184				"contact": ["mailto:ops@example.com"],
1185				"agree_tos": true,
1186				"challenge": "http-01",
1187				"key_type": "ecdsa-p256",
1188				"renew_before": "30d",
1189				"san": ["api.example.com"],
1190			},
1191			"enable_zero_rtt": false,
1192		});
1193		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1194		let err = tls.validate().expect_err("must reject");
1195		assert!(err.to_string().contains("must not coexist"), "{err}");
1196	}
1197
1198	#[test]
1199	fn tls_validate_rejects_neither_static_nor_managed() {
1200		let raw = serde_json::json!({ "enable_zero_rtt": false });
1201		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1202		let err = tls.validate().expect_err("must reject");
1203		assert!(err.to_string().contains("missing cert source"), "{err}");
1204	}
1205
1206	#[test]
1207	fn tls_validate_rejects_partial_static_paths() {
1208		let raw = serde_json::json!({
1209			"cert_file": "/tmp/cert.pem",
1210			"enable_zero_rtt": false,
1211		});
1212		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1213		let err = tls.validate().expect_err("must reject");
1214		assert!(err.to_string().contains("`key_file`"), "{err}");
1215	}
1216
1217	#[test]
1218	fn tls_managed_rejects_agree_tos_false() {
1219		let mut raw = managed_tls("http-01", false);
1220		raw["managed"]["agree_tos"] = serde_json::Value::Bool(false);
1221		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1222		let err = tls.validate().expect_err("must reject");
1223		assert!(err.to_string().contains("agree_tos must be true"), "{err}");
1224	}
1225
1226	#[test]
1227	fn tls_managed_rejects_dns01_without_dns_provider() {
1228		let raw = managed_tls("dns-01", false);
1229		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1230		let err = tls.validate().expect_err("must reject");
1231		assert!(err.to_string().contains("requires `dns_provider`"), "{err}");
1232	}
1233
1234	#[test]
1235	fn tls_managed_rejects_http01_with_dns_provider() {
1236		let raw = managed_tls("http-01", true);
1237		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1238		let err = tls.validate().expect_err("must reject");
1239		assert!(err.to_string().contains("dns_provider"), "{err}");
1240	}
1241
1242	#[test]
1243	fn tls_managed_rejects_wildcard_san_with_http01() {
1244		let mut raw = managed_tls("http-01", false);
1245		raw["managed"]["san"] = serde_json::json!(["*.example.com", "api.example.com"]);
1246		raw["sni"] = serde_json::Value::String("api.example.com".to_owned());
1247		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1248		let err = tls.validate().expect_err("must reject");
1249		assert!(err.to_string().contains("wildcard"), "{err}");
1250	}
1251
1252	#[test]
1253	fn tls_managed_accepts_wildcard_san_with_dns01() {
1254		let mut raw = managed_tls("dns-01", true);
1255		raw["managed"]["san"] = serde_json::json!(["*.example.com", "api.example.com"]);
1256		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1257		tls.validate().expect("dns-01 wildcard ok");
1258	}
1259
1260	#[test]
1261	fn tls_managed_rejects_san_missing_sni() {
1262		let mut raw = managed_tls("http-01", false);
1263		raw["sni"] = serde_json::Value::String("other.example.com".to_owned());
1264		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1265		let err = tls.validate().expect_err("must reject");
1266		assert!(err.to_string().contains("must contain tls.sni"), "{err}");
1267	}
1268
1269	#[test]
1270	fn tls_managed_rejects_missing_sni() {
1271		let mut raw = managed_tls("http-01", false);
1272		raw.as_object_mut().expect("obj").remove("sni");
1273		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1274		let err = tls.validate().expect_err("must reject");
1275		assert!(err.to_string().contains("requires tls.sni"), "{err}");
1276	}
1277
1278	#[test]
1279	fn tls_managed_rejects_empty_contact() {
1280		let mut raw = managed_tls("http-01", false);
1281		raw["managed"]["contact"] = serde_json::json!([]);
1282		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1283		let err = tls.validate().expect_err("must reject");
1284		assert!(err.to_string().contains("contact must list"), "{err}");
1285	}
1286
1287	#[test]
1288	fn tls_managed_rejects_empty_san() {
1289		let mut raw = managed_tls("http-01", false);
1290		raw["managed"]["san"] = serde_json::json!([]);
1291		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1292		let err = tls.validate().expect_err("must reject");
1293		assert!(err.to_string().contains("san must list"), "{err}");
1294	}
1295
1296	#[test]
1297	fn tls_managed_rejects_empty_directory_url() {
1298		let mut raw = managed_tls("http-01", false);
1299		raw["managed"]["directory_url"] = serde_json::Value::String(String::new());
1300		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1301		let err = tls.validate().expect_err("must reject");
1302		assert!(err.to_string().contains("directory_url"), "{err}");
1303	}
1304
1305	#[test]
1306	fn tls_managed_rejects_zero_renew_before() {
1307		let mut raw = managed_tls("http-01", false);
1308		raw["managed"]["renew_before"] = serde_json::Value::String("0d".to_owned());
1309		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1310		let err = tls.validate().expect_err("must reject");
1311		assert!(err.to_string().contains("must be > 0"), "{err}");
1312	}
1313
1314	#[test]
1315	fn tls_managed_rejects_unparseable_renew_before() {
1316		let mut raw = managed_tls("http-01", false);
1317		raw["managed"]["renew_before"] = serde_json::Value::String("garbage".to_owned());
1318		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1319		let err = tls.validate().expect_err("must reject");
1320		assert!(err.to_string().contains("missing unit"), "{err}");
1321	}
1322
1323	#[test]
1324	fn renewal_duration_handles_h_d_units() {
1325		assert_eq!(parse_renewal_duration("30d").unwrap(), Duration::from_hours(720));
1326		assert_eq!(parse_renewal_duration("12h").unwrap(), Duration::from_hours(12));
1327		assert_eq!(parse_renewal_duration("90s").unwrap(), Duration::from_secs(90));
1328		assert_eq!(parse_renewal_duration("500ms").unwrap(), Duration::from_millis(500));
1329	}
1330
1331	#[test]
1332	fn tls_managed_serializes_omitting_optional_fields() {
1333		let raw = managed_tls("http-01", false);
1334		let tls: TlsConfig = serde_json::from_value(raw).expect("parse");
1335		let json = serde_json::to_value(&tls).expect("serialize");
1336		// `cert_file` / `key_file` are skip_serializing_if=Option::is_none.
1337		assert!(json.as_object().expect("obj").get("cert_file").is_none());
1338		assert!(json.as_object().expect("obj").get("key_file").is_none());
1339		// `managed.dns_provider` likewise omitted when absent.
1340		assert!(json["managed"].as_object().expect("managed obj").get("dns_provider").is_none());
1341	}
1342
1343	#[test]
1344	fn challenge_kind_round_trips_kebab_case() {
1345		assert_eq!(serde_json::to_string(&ChallengeKind::Http01).expect("ser"), "\"http-01\"");
1346		assert_eq!(serde_json::to_string(&ChallengeKind::Dns01).expect("ser"), "\"dns-01\"");
1347		let parsed: ChallengeKind = serde_json::from_str("\"http-01\"").expect("de");
1348		assert_eq!(parsed, ChallengeKind::Http01);
1349	}
1350
1351	#[test]
1352	fn key_type_round_trips_kebab_case() {
1353		assert_eq!(serde_json::to_string(&ManagedKeyType::EcdsaP256).expect("ser"), "\"ecdsa-p256\"");
1354		assert_eq!(serde_json::to_string(&ManagedKeyType::Rsa2048).expect("ser"), "\"rsa-2048\"");
1355	}
1356}