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