Skip to main content

vane_core/
phase.rs

1use crate::fetch::{FetchKind, Terminator};
2use crate::middleware::MiddlewareKind;
3
4#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, serde::Serialize, serde::Deserialize)]
5pub enum Phase {
6	L4Raw,
7	L4Peeked,
8	L7Request,
9	L7Response,
10	Tunnel,
11}
12
13#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
14pub enum PhaseNodeKind {
15	Check,
16	Middleware(MiddlewareKind),
17	Upgrade,
18	Fetch(FetchKind),
19	Terminate(Terminator),
20}
21
22#[derive(Copy, Clone, Eq, PartialEq, Debug)]
23pub enum Transition {
24	PassThrough,
25	Into(Phase),
26	BiOutcome { response: Phase, tunnel: Phase },
27	Terminal,
28}
29
30#[derive(Copy, Clone, Eq, PartialEq, Debug)]
31pub struct PhaseError {
32	pub expected: &'static [Phase],
33	pub got: Phase,
34}
35
36const L4_ANY: &[Phase] = &[Phase::L4Raw, Phase::L4Peeked];
37const L7_REQ: &[Phase] = &[Phase::L7Request];
38const L7_RESP: &[Phase] = &[Phase::L7Response];
39const TUNNEL: &[Phase] = &[Phase::Tunnel];
40const ANY_PHASE: &[Phase] =
41	&[Phase::L4Raw, Phase::L4Peeked, Phase::L7Request, Phase::L7Response, Phase::Tunnel];
42
43#[must_use]
44#[allow(
45	clippy::match_same_arms,
46	reason = "truth table per spec/flow-model.md § Phase state machine: each arm = one PhaseNodeKind row; merging by RHS hides the table structure"
47)]
48pub const fn accepted_in_phases(kind: PhaseNodeKind) -> &'static [Phase] {
49	match kind {
50		PhaseNodeKind::Check => ANY_PHASE,
51		PhaseNodeKind::Middleware(MiddlewareKind::L4Peek) => L4_ANY,
52		PhaseNodeKind::Middleware(MiddlewareKind::L4Bytes) => L4_ANY,
53		PhaseNodeKind::Middleware(MiddlewareKind::L7Request) => L7_REQ,
54		PhaseNodeKind::Middleware(MiddlewareKind::L7Response) => L7_RESP,
55		// Upgrade accepts both `L4Raw` and `L4Peeked`. Pure-HTTP listeners
56		// enter via `L4Raw → Upgrade → L7Request` without an intermediate
57		// peek; mixed-posture listeners run an `L4Peek` middleware first
58		// to advance into `L4Peeked` before Upgrade.
59		PhaseNodeKind::Upgrade => L4_ANY,
60		PhaseNodeKind::Fetch(FetchKind::L4Forward) => L4_ANY,
61		PhaseNodeKind::Fetch(FetchKind::HttpProxy) => L7_REQ,
62		PhaseNodeKind::Fetch(FetchKind::HttpSynthesize) => L7_REQ,
63		PhaseNodeKind::Fetch(FetchKind::WebSocketUpgrade) => L7_REQ,
64		PhaseNodeKind::Fetch(FetchKind::AcmeChallenge) => L7_REQ,
65		PhaseNodeKind::Terminate(Terminator::WriteHttpResponse) => L7_RESP,
66		PhaseNodeKind::Terminate(Terminator::ByteTunnel) => TUNNEL,
67		// `Close` is phase-agnostic per spec/crates/engine.md — lower emits it on
68		// L4 and L7 paths alike as the default-miss fallback.
69		PhaseNodeKind::Terminate(Terminator::Close) => ANY_PHASE,
70	}
71}
72
73/// Look up the out-phase for a node at its current in-phase.
74///
75/// # Errors
76/// Returns [`PhaseError`] when `cur` is not in the node's accepted in-phase
77/// set. Validator consumers translate this into the spec/flow-model.md error format.
78#[allow(
79	clippy::match_same_arms,
80	reason = "truth table per spec/flow-model.md § Phase state machine: each arm = one PhaseNodeKind row; merging by RHS hides the table structure"
81)]
82pub fn transition(kind: PhaseNodeKind, cur: Phase) -> Result<Transition, PhaseError> {
83	let accepted = accepted_in_phases(kind);
84	if !accepted.contains(&cur) {
85		return Err(PhaseError { expected: accepted, got: cur });
86	}
87	Ok(match kind {
88		PhaseNodeKind::Check => Transition::PassThrough,
89		PhaseNodeKind::Middleware(MiddlewareKind::L4Peek) => Transition::Into(Phase::L4Peeked),
90		PhaseNodeKind::Middleware(MiddlewareKind::L4Bytes) => Transition::PassThrough,
91		PhaseNodeKind::Middleware(MiddlewareKind::L7Request) => Transition::Into(Phase::L7Request),
92		PhaseNodeKind::Middleware(MiddlewareKind::L7Response) => Transition::Into(Phase::L7Response),
93		PhaseNodeKind::Upgrade => Transition::Into(Phase::L7Request),
94		PhaseNodeKind::Fetch(FetchKind::L4Forward) => Transition::Into(Phase::Tunnel),
95		PhaseNodeKind::Fetch(FetchKind::HttpProxy) => Transition::Into(Phase::L7Response),
96		PhaseNodeKind::Fetch(FetchKind::HttpSynthesize) => Transition::Into(Phase::L7Response),
97		PhaseNodeKind::Fetch(FetchKind::AcmeChallenge) => Transition::Into(Phase::L7Response),
98		PhaseNodeKind::Fetch(FetchKind::WebSocketUpgrade) => {
99			Transition::BiOutcome { response: Phase::L7Response, tunnel: Phase::Tunnel }
100		}
101		PhaseNodeKind::Terminate(_) => Transition::Terminal,
102	})
103}
104
105#[cfg(test)]
106mod tests {
107	use super::*;
108
109	const ALL_PHASES: [Phase; 5] =
110		[Phase::L4Raw, Phase::L4Peeked, Phase::L7Request, Phase::L7Response, Phase::Tunnel];
111
112	#[test]
113	fn phase_serde_round_trip_per_variant() {
114		for p in ALL_PHASES {
115			let encoded = serde_json::to_string(&p).expect("serialize");
116			let decoded: Phase = serde_json::from_str(&encoded).expect("deserialize");
117			assert_eq!(decoded, p);
118		}
119	}
120
121	#[test]
122	fn check_accepts_any_phase() {
123		assert_eq!(accepted_in_phases(PhaseNodeKind::Check), ANY_PHASE);
124	}
125
126	#[test]
127	fn l4_peek_accepts_l4_phases_only() {
128		assert_eq!(
129			accepted_in_phases(PhaseNodeKind::Middleware(MiddlewareKind::L4Peek)),
130			&[Phase::L4Raw, Phase::L4Peeked] as &[Phase],
131		);
132	}
133
134	#[test]
135	fn l4_bytes_accepts_l4_phases_only() {
136		assert_eq!(
137			accepted_in_phases(PhaseNodeKind::Middleware(MiddlewareKind::L4Bytes)),
138			&[Phase::L4Raw, Phase::L4Peeked] as &[Phase],
139		);
140	}
141
142	#[test]
143	fn l7_request_middleware_accepts_only_l7_request() {
144		assert_eq!(
145			accepted_in_phases(PhaseNodeKind::Middleware(MiddlewareKind::L7Request)),
146			&[Phase::L7Request] as &[Phase],
147		);
148	}
149
150	#[test]
151	fn l7_response_middleware_accepts_only_l7_response() {
152		assert_eq!(
153			accepted_in_phases(PhaseNodeKind::Middleware(MiddlewareKind::L7Response)),
154			&[Phase::L7Response] as &[Phase],
155		);
156	}
157
158	#[test]
159	fn upgrade_accepts_both_l4_phases() {
160		// Pure-HTTP listeners take `L4Raw → Upgrade` directly; mixed-posture
161		// listeners advance via L4Peek into `L4Peeked` first.
162		assert_eq!(
163			accepted_in_phases(PhaseNodeKind::Upgrade),
164			&[Phase::L4Raw, Phase::L4Peeked] as &[Phase],
165		);
166	}
167
168	#[test]
169	fn l4_forward_fetch_accepts_l4_phases() {
170		assert_eq!(
171			accepted_in_phases(PhaseNodeKind::Fetch(FetchKind::L4Forward)),
172			&[Phase::L4Raw, Phase::L4Peeked] as &[Phase],
173		);
174	}
175
176	#[test]
177	fn http_fetches_accept_only_l7_request() {
178		for f in [
179			FetchKind::HttpProxy,
180			FetchKind::HttpSynthesize,
181			FetchKind::WebSocketUpgrade,
182			FetchKind::AcmeChallenge,
183		] {
184			assert_eq!(accepted_in_phases(PhaseNodeKind::Fetch(f)), &[Phase::L7Request] as &[Phase],);
185		}
186	}
187
188	#[test]
189	fn write_http_response_accepts_only_l7_response() {
190		assert_eq!(
191			accepted_in_phases(PhaseNodeKind::Terminate(Terminator::WriteHttpResponse)),
192			&[Phase::L7Response] as &[Phase],
193		);
194	}
195
196	#[test]
197	fn byte_tunnel_accepts_only_tunnel() {
198		assert_eq!(
199			accepted_in_phases(PhaseNodeKind::Terminate(Terminator::ByteTunnel)),
200			&[Phase::Tunnel] as &[Phase],
201		);
202	}
203
204	#[test]
205	fn check_is_pass_through_at_every_phase() {
206		for cur in ALL_PHASES {
207			assert_eq!(transition(PhaseNodeKind::Check, cur), Ok(Transition::PassThrough));
208		}
209	}
210
211	#[test]
212	fn l4_peek_forces_out_to_l4_peeked() {
213		for cur in [Phase::L4Raw, Phase::L4Peeked] {
214			assert_eq!(
215				transition(PhaseNodeKind::Middleware(MiddlewareKind::L4Peek), cur),
216				Ok(Transition::Into(Phase::L4Peeked)),
217			);
218		}
219	}
220
221	#[test]
222	fn l4_bytes_is_pass_through_on_l4_phases() {
223		for cur in [Phase::L4Raw, Phase::L4Peeked] {
224			assert_eq!(
225				transition(PhaseNodeKind::Middleware(MiddlewareKind::L4Bytes), cur),
226				Ok(Transition::PassThrough),
227			);
228		}
229	}
230
231	#[test]
232	fn upgrade_transitions_to_l7_request_from_any_l4_phase() {
233		for cur in [Phase::L4Raw, Phase::L4Peeked] {
234			assert_eq!(transition(PhaseNodeKind::Upgrade, cur), Ok(Transition::Into(Phase::L7Request)),);
235		}
236	}
237
238	#[test]
239	fn l7_request_middleware_stays_in_l7_request() {
240		assert_eq!(
241			transition(PhaseNodeKind::Middleware(MiddlewareKind::L7Request), Phase::L7Request),
242			Ok(Transition::Into(Phase::L7Request)),
243		);
244	}
245
246	#[test]
247	fn l7_response_middleware_stays_in_l7_response() {
248		assert_eq!(
249			transition(PhaseNodeKind::Middleware(MiddlewareKind::L7Response), Phase::L7Response),
250			Ok(Transition::Into(Phase::L7Response)),
251		);
252	}
253
254	#[test]
255	fn l4_forward_fetch_goes_to_tunnel_from_any_l4_phase() {
256		for cur in [Phase::L4Raw, Phase::L4Peeked] {
257			assert_eq!(
258				transition(PhaseNodeKind::Fetch(FetchKind::L4Forward), cur),
259				Ok(Transition::Into(Phase::Tunnel)),
260			);
261		}
262	}
263
264	#[test]
265	fn http_fetch_variants_go_to_l7_response() {
266		for f in [FetchKind::HttpProxy, FetchKind::HttpSynthesize, FetchKind::AcmeChallenge] {
267			assert_eq!(
268				transition(PhaseNodeKind::Fetch(f), Phase::L7Request),
269				Ok(Transition::Into(Phase::L7Response)),
270			);
271		}
272	}
273
274	#[test]
275	fn websocket_fetch_is_bi_outcome() {
276		assert_eq!(
277			transition(PhaseNodeKind::Fetch(FetchKind::WebSocketUpgrade), Phase::L7Request),
278			Ok(Transition::BiOutcome { response: Phase::L7Response, tunnel: Phase::Tunnel }),
279		);
280	}
281
282	#[test]
283	fn write_http_response_is_terminal() {
284		assert_eq!(
285			transition(PhaseNodeKind::Terminate(Terminator::WriteHttpResponse), Phase::L7Response),
286			Ok(Transition::Terminal),
287		);
288	}
289
290	#[test]
291	fn byte_tunnel_is_terminal() {
292		assert_eq!(
293			transition(PhaseNodeKind::Terminate(Terminator::ByteTunnel), Phase::Tunnel),
294			Ok(Transition::Terminal),
295		);
296	}
297
298	#[test]
299	fn close_is_terminal_at_every_phase() {
300		for p in ALL_PHASES {
301			assert_eq!(
302				transition(PhaseNodeKind::Terminate(Terminator::Close), p),
303				Ok(Transition::Terminal),
304			);
305		}
306	}
307
308	#[test]
309	fn close_accepts_any_phase() {
310		assert_eq!(accepted_in_phases(PhaseNodeKind::Terminate(Terminator::Close)), ANY_PHASE,);
311	}
312
313	#[test]
314	fn rejects_out_of_phase_attempts() {
315		let cases: &[(PhaseNodeKind, Phase)] = &[
316			(PhaseNodeKind::Upgrade, Phase::L7Request),
317			(PhaseNodeKind::Upgrade, Phase::L7Response),
318			(PhaseNodeKind::Middleware(MiddlewareKind::L7Request), Phase::L4Raw),
319			(PhaseNodeKind::Middleware(MiddlewareKind::L7Response), Phase::L7Request),
320			(PhaseNodeKind::Fetch(FetchKind::HttpProxy), Phase::L7Response),
321			(PhaseNodeKind::Fetch(FetchKind::L4Forward), Phase::L7Request),
322			(PhaseNodeKind::Terminate(Terminator::WriteHttpResponse), Phase::Tunnel),
323			(PhaseNodeKind::Terminate(Terminator::ByteTunnel), Phase::L7Response),
324		];
325		for (kind, cur) in cases.iter().copied() {
326			let err = transition(kind, cur).expect_err("out-of-phase must error");
327			assert_eq!(err.got, cur);
328			assert_eq!(err.expected, accepted_in_phases(kind));
329		}
330	}
331}