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// Each arm mirrors one row of the spec/flow-model.md § _Phase state machine_. Merging
44// arms with equal bodies would hide the table structure, which the spec
45// calls out as the whole point of the single-source design.
46#[must_use]
47#[allow(clippy::match_same_arms)]
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(clippy::match_same_arms)]
79pub fn transition(kind: PhaseNodeKind, cur: Phase) -> Result<Transition, PhaseError> {
80	let accepted = accepted_in_phases(kind);
81	if !accepted.contains(&cur) {
82		return Err(PhaseError { expected: accepted, got: cur });
83	}
84	Ok(match kind {
85		PhaseNodeKind::Check => Transition::PassThrough,
86		PhaseNodeKind::Middleware(MiddlewareKind::L4Peek) => Transition::Into(Phase::L4Peeked),
87		PhaseNodeKind::Middleware(MiddlewareKind::L4Bytes) => Transition::PassThrough,
88		PhaseNodeKind::Middleware(MiddlewareKind::L7Request) => Transition::Into(Phase::L7Request),
89		PhaseNodeKind::Middleware(MiddlewareKind::L7Response) => Transition::Into(Phase::L7Response),
90		PhaseNodeKind::Upgrade => Transition::Into(Phase::L7Request),
91		PhaseNodeKind::Fetch(FetchKind::L4Forward) => Transition::Into(Phase::Tunnel),
92		PhaseNodeKind::Fetch(FetchKind::HttpProxy) => Transition::Into(Phase::L7Response),
93		PhaseNodeKind::Fetch(FetchKind::HttpSynthesize) => Transition::Into(Phase::L7Response),
94		PhaseNodeKind::Fetch(FetchKind::AcmeChallenge) => Transition::Into(Phase::L7Response),
95		PhaseNodeKind::Fetch(FetchKind::WebSocketUpgrade) => {
96			Transition::BiOutcome { response: Phase::L7Response, tunnel: Phase::Tunnel }
97		}
98		PhaseNodeKind::Terminate(_) => Transition::Terminal,
99	})
100}
101
102#[cfg(test)]
103mod tests {
104	use super::*;
105
106	const ALL_PHASES: [Phase; 5] =
107		[Phase::L4Raw, Phase::L4Peeked, Phase::L7Request, Phase::L7Response, Phase::Tunnel];
108
109	#[test]
110	fn phase_serde_round_trip_per_variant() {
111		for p in ALL_PHASES {
112			let encoded = serde_json::to_string(&p).expect("serialize");
113			let decoded: Phase = serde_json::from_str(&encoded).expect("deserialize");
114			assert_eq!(decoded, p);
115		}
116	}
117
118	#[test]
119	fn check_accepts_any_phase() {
120		assert_eq!(accepted_in_phases(PhaseNodeKind::Check), ANY_PHASE);
121	}
122
123	#[test]
124	fn l4_peek_accepts_l4_phases_only() {
125		assert_eq!(
126			accepted_in_phases(PhaseNodeKind::Middleware(MiddlewareKind::L4Peek)),
127			&[Phase::L4Raw, Phase::L4Peeked] as &[Phase],
128		);
129	}
130
131	#[test]
132	fn l4_bytes_accepts_l4_phases_only() {
133		assert_eq!(
134			accepted_in_phases(PhaseNodeKind::Middleware(MiddlewareKind::L4Bytes)),
135			&[Phase::L4Raw, Phase::L4Peeked] as &[Phase],
136		);
137	}
138
139	#[test]
140	fn l7_request_middleware_accepts_only_l7_request() {
141		assert_eq!(
142			accepted_in_phases(PhaseNodeKind::Middleware(MiddlewareKind::L7Request)),
143			&[Phase::L7Request] as &[Phase],
144		);
145	}
146
147	#[test]
148	fn l7_response_middleware_accepts_only_l7_response() {
149		assert_eq!(
150			accepted_in_phases(PhaseNodeKind::Middleware(MiddlewareKind::L7Response)),
151			&[Phase::L7Response] as &[Phase],
152		);
153	}
154
155	#[test]
156	fn upgrade_accepts_both_l4_phases() {
157		// Pure-HTTP listeners take `L4Raw → Upgrade` directly; mixed-posture
158		// listeners advance via L4Peek into `L4Peeked` first.
159		assert_eq!(
160			accepted_in_phases(PhaseNodeKind::Upgrade),
161			&[Phase::L4Raw, Phase::L4Peeked] as &[Phase],
162		);
163	}
164
165	#[test]
166	fn l4_forward_fetch_accepts_l4_phases() {
167		assert_eq!(
168			accepted_in_phases(PhaseNodeKind::Fetch(FetchKind::L4Forward)),
169			&[Phase::L4Raw, Phase::L4Peeked] as &[Phase],
170		);
171	}
172
173	#[test]
174	fn http_fetches_accept_only_l7_request() {
175		for f in [
176			FetchKind::HttpProxy,
177			FetchKind::HttpSynthesize,
178			FetchKind::WebSocketUpgrade,
179			FetchKind::AcmeChallenge,
180		] {
181			assert_eq!(accepted_in_phases(PhaseNodeKind::Fetch(f)), &[Phase::L7Request] as &[Phase],);
182		}
183	}
184
185	#[test]
186	fn write_http_response_accepts_only_l7_response() {
187		assert_eq!(
188			accepted_in_phases(PhaseNodeKind::Terminate(Terminator::WriteHttpResponse)),
189			&[Phase::L7Response] as &[Phase],
190		);
191	}
192
193	#[test]
194	fn byte_tunnel_accepts_only_tunnel() {
195		assert_eq!(
196			accepted_in_phases(PhaseNodeKind::Terminate(Terminator::ByteTunnel)),
197			&[Phase::Tunnel] as &[Phase],
198		);
199	}
200
201	#[test]
202	fn check_is_pass_through_at_every_phase() {
203		for cur in ALL_PHASES {
204			assert_eq!(transition(PhaseNodeKind::Check, cur), Ok(Transition::PassThrough));
205		}
206	}
207
208	#[test]
209	fn l4_peek_forces_out_to_l4_peeked() {
210		for cur in [Phase::L4Raw, Phase::L4Peeked] {
211			assert_eq!(
212				transition(PhaseNodeKind::Middleware(MiddlewareKind::L4Peek), cur),
213				Ok(Transition::Into(Phase::L4Peeked)),
214			);
215		}
216	}
217
218	#[test]
219	fn l4_bytes_is_pass_through_on_l4_phases() {
220		for cur in [Phase::L4Raw, Phase::L4Peeked] {
221			assert_eq!(
222				transition(PhaseNodeKind::Middleware(MiddlewareKind::L4Bytes), cur),
223				Ok(Transition::PassThrough),
224			);
225		}
226	}
227
228	#[test]
229	fn upgrade_transitions_to_l7_request_from_any_l4_phase() {
230		for cur in [Phase::L4Raw, Phase::L4Peeked] {
231			assert_eq!(transition(PhaseNodeKind::Upgrade, cur), Ok(Transition::Into(Phase::L7Request)),);
232		}
233	}
234
235	#[test]
236	fn l7_request_middleware_stays_in_l7_request() {
237		assert_eq!(
238			transition(PhaseNodeKind::Middleware(MiddlewareKind::L7Request), Phase::L7Request),
239			Ok(Transition::Into(Phase::L7Request)),
240		);
241	}
242
243	#[test]
244	fn l7_response_middleware_stays_in_l7_response() {
245		assert_eq!(
246			transition(PhaseNodeKind::Middleware(MiddlewareKind::L7Response), Phase::L7Response),
247			Ok(Transition::Into(Phase::L7Response)),
248		);
249	}
250
251	#[test]
252	fn l4_forward_fetch_goes_to_tunnel_from_any_l4_phase() {
253		for cur in [Phase::L4Raw, Phase::L4Peeked] {
254			assert_eq!(
255				transition(PhaseNodeKind::Fetch(FetchKind::L4Forward), cur),
256				Ok(Transition::Into(Phase::Tunnel)),
257			);
258		}
259	}
260
261	#[test]
262	fn http_fetch_variants_go_to_l7_response() {
263		for f in [FetchKind::HttpProxy, FetchKind::HttpSynthesize, FetchKind::AcmeChallenge] {
264			assert_eq!(
265				transition(PhaseNodeKind::Fetch(f), Phase::L7Request),
266				Ok(Transition::Into(Phase::L7Response)),
267			);
268		}
269	}
270
271	#[test]
272	fn websocket_fetch_is_bi_outcome() {
273		assert_eq!(
274			transition(PhaseNodeKind::Fetch(FetchKind::WebSocketUpgrade), Phase::L7Request),
275			Ok(Transition::BiOutcome { response: Phase::L7Response, tunnel: Phase::Tunnel }),
276		);
277	}
278
279	#[test]
280	fn write_http_response_is_terminal() {
281		assert_eq!(
282			transition(PhaseNodeKind::Terminate(Terminator::WriteHttpResponse), Phase::L7Response),
283			Ok(Transition::Terminal),
284		);
285	}
286
287	#[test]
288	fn byte_tunnel_is_terminal() {
289		assert_eq!(
290			transition(PhaseNodeKind::Terminate(Terminator::ByteTunnel), Phase::Tunnel),
291			Ok(Transition::Terminal),
292		);
293	}
294
295	#[test]
296	fn close_is_terminal_at_every_phase() {
297		for p in ALL_PHASES {
298			assert_eq!(
299				transition(PhaseNodeKind::Terminate(Terminator::Close), p),
300				Ok(Transition::Terminal),
301			);
302		}
303	}
304
305	#[test]
306	fn close_accepts_any_phase() {
307		assert_eq!(accepted_in_phases(PhaseNodeKind::Terminate(Terminator::Close)), ANY_PHASE,);
308	}
309
310	#[test]
311	fn rejects_out_of_phase_attempts() {
312		let cases: &[(PhaseNodeKind, Phase)] = &[
313			(PhaseNodeKind::Upgrade, Phase::L7Request),
314			(PhaseNodeKind::Upgrade, Phase::L7Response),
315			(PhaseNodeKind::Middleware(MiddlewareKind::L7Request), Phase::L4Raw),
316			(PhaseNodeKind::Middleware(MiddlewareKind::L7Response), Phase::L7Request),
317			(PhaseNodeKind::Fetch(FetchKind::HttpProxy), Phase::L7Response),
318			(PhaseNodeKind::Fetch(FetchKind::L4Forward), Phase::L7Request),
319			(PhaseNodeKind::Terminate(Terminator::WriteHttpResponse), Phase::Tunnel),
320			(PhaseNodeKind::Terminate(Terminator::ByteTunnel), Phase::L7Response),
321		];
322		for (kind, cur) in cases.iter().copied() {
323			let err = transition(kind, cur).expect_err("out-of-phase must error");
324			assert_eq!(err.got, cur);
325			assert_eq!(err.expected, accepted_in_phases(kind));
326		}
327	}
328}