1use std::collections::HashSet;
2
3use serde::Deserialize;
4
5use crate::profile::TlsProfile;
6
7const LOCAL_TRANSPORT_PARAMS: [u64; 14] = [
8 0x01, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0e, 0x0f, 0x20,
9];
10const ENFORCED_TRANSPORT_PARAMS: [u64; 8] = [0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0e, 0x0f];
11const SERVER_TRANSPORT_PARAMS: [u64; 4] = [0x00, 0x02, 0x0d, 0x10];
12const INITIAL_SOURCE_CONNECTION_ID: u64 = 0x0f;
13const HTTP2_RESERVED_SETTINGS: [u64; 4] = [0x02, 0x03, 0x04, 0x05];
14const MAX_VARINT: u64 = (1 << 62) - 1;
15const MIN_INITIAL_DATAGRAM_SIZE: u16 = 1200;
16
17#[expect(
18 missing_docs,
19 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
20)]
21#[derive(Debug, Clone, Deserialize)]
22#[serde(deny_unknown_fields)]
23#[non_exhaustive]
24pub struct H3Profile {
25 pub initial_max_data: u64,
26 pub initial_max_stream_data_bidi_local: u64,
27 pub initial_max_stream_data_bidi_remote: u64,
28 pub initial_max_stream_data_uni: u64,
29 pub initial_max_streams_bidi: u64,
30 pub initial_max_streams_uni: u64,
31 pub max_idle_timeout_secs: u64,
32 pub max_udp_payload_size: u16,
33 #[serde(default)]
34 pub initial_datagram_size: Option<u16>,
35 #[serde(default)]
36 pub initial_crypto_split: H3CryptoSplit,
37 #[serde(default)]
38 pub initial_crypto_reorder: H3CryptoReorder,
39 pub active_connection_id_limit: u64,
40 pub dcid_length: H3ConnectionIdLength,
41 #[serde(default)]
42 pub scid_length: Option<usize>,
43 #[serde(default)]
44 pub qpack_max_table_capacity: Option<u64>,
45 #[serde(default)]
46 pub qpack_blocked_streams: Option<u64>,
47 #[serde(default)]
48 pub max_field_section_size: Option<u64>,
49 #[serde(default)]
50 pub race: bool,
51 #[serde(default)]
52 pub max_ack_delay_ms: Option<u64>,
53 #[serde(default)]
54 pub transport_parameters: Option<Vec<H3TransportParam>>,
55 #[serde(default)]
56 pub transport_order: H3Order,
57 #[serde(default)]
58 pub settings: Option<Vec<H3Setting>>,
59 #[serde(default)]
60 pub control_grease_frame: Option<H3Grease>,
61 #[serde(default)]
62 pub pseudo_order: Option<Vec<String>>,
63 #[serde(default)]
64 pub priority_update: bool,
65 #[serde(default)]
66 pub tls: Option<TlsProfile>,
67}
68
69#[expect(
70 missing_docs,
71 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
72)]
73#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
74#[serde(untagged)]
75#[non_exhaustive]
76pub enum H3ConnectionIdLength {
77 Fixed(usize),
78 Weighted { weights: Vec<(usize, u32)> },
79}
80
81#[expect(
82 missing_docs,
83 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
84)]
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
86#[serde(rename_all = "snake_case")]
87#[non_exhaustive]
88pub enum H3Order {
89 #[default]
90 Fixed,
91 Shuffle,
92 Rotate,
93}
94
95#[expect(
96 missing_docs,
97 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
98)]
99#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
100#[serde(rename_all = "snake_case")]
101#[non_exhaustive]
102pub enum H3CryptoSplit {
103 #[default]
104 Fill,
105 Even,
106}
107
108#[expect(
109 missing_docs,
110 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
111)]
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
113#[serde(rename_all = "snake_case")]
114#[non_exhaustive]
115pub enum H3CryptoReorder {
116 #[default]
117 None,
118 SniMidpoint,
119}
120
121#[expect(
122 missing_docs,
123 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
124)]
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
126#[serde(deny_unknown_fields)]
127#[non_exhaustive]
128pub struct H3Grease {
129 pub id_bits: u32,
130 #[serde(default)]
131 pub max_len: usize,
132 #[serde(default)]
133 pub value_bits: u32,
134}
135
136#[expect(
137 missing_docs,
138 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
139)]
140#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
141#[serde(deny_unknown_fields)]
142#[non_exhaustive]
143pub struct H3TransportParam {
144 #[serde(default)]
145 pub id: Option<u64>,
146 #[serde(default)]
147 pub varint: Option<u64>,
148 #[serde(default)]
149 pub hex: Option<String>,
150 #[serde(default)]
151 pub grease: Option<H3Grease>,
152 #[serde(default)]
153 pub versions: Option<H3VersionInformation>,
154 #[serde(default)]
155 pub pinned: bool,
156}
157
158#[expect(
159 missing_docs,
160 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
161)]
162#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
163#[serde(deny_unknown_fields)]
164#[non_exhaustive]
165pub struct H3VersionInformation {
166 pub chosen: u32,
167 pub available: Vec<u32>,
168 #[serde(default)]
169 pub grease: Option<H3VersionGrease>,
170}
171
172#[expect(
173 missing_docs,
174 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
175)]
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
177#[serde(rename_all = "snake_case")]
178#[non_exhaustive]
179pub enum H3VersionGrease {
180 First,
181 Random,
182}
183
184#[expect(
185 missing_docs,
186 reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
187)]
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
189#[serde(deny_unknown_fields)]
190#[non_exhaustive]
191pub struct H3Setting {
192 #[serde(default)]
193 pub id: Option<u64>,
194 #[serde(default)]
195 pub value: Option<u64>,
196 #[serde(default)]
197 pub grease: Option<H3Grease>,
198}
199
200impl H3Profile {
201 pub(crate) fn validate(&self) -> Result<(), String> {
202 let legacy_settings = self.qpack_max_table_capacity.is_some()
203 || self.qpack_blocked_streams.is_some()
204 || self.max_field_section_size.is_some();
205 if self.settings.is_some() == legacy_settings {
206 return Err(
207 "[h3] needs either a settings list or the qpack_max_table_capacity, \
208 qpack_blocked_streams and max_field_section_size fields, not both"
209 .into(),
210 );
211 }
212 if let Some(settings) = &self.settings {
213 validate_settings(settings)?;
214 }
215 if let Some(tls) = &self.tls {
216 tls.validate(true)?;
217 }
218 if let Some(params) = &self.transport_parameters {
219 validate_transport_parameters(params)?;
220 }
221 if let Some(size) = self.initial_datagram_size
222 && !(MIN_INITIAL_DATAGRAM_SIZE..=self.max_udp_payload_size).contains(&size)
223 {
224 return Err(format!(
225 "[h3] initial_datagram_size {size} must be {MIN_INITIAL_DATAGRAM_SIZE}..={}",
226 self.max_udp_payload_size
227 ));
228 }
229 if self.initial_crypto_reorder != H3CryptoReorder::None
230 && self.initial_crypto_split != H3CryptoSplit::Even
231 {
232 return Err("[h3] initial_crypto_reorder needs initial_crypto_split = \"even\"".into());
233 }
234 if let H3ConnectionIdLength::Weighted { weights } = &self.dcid_length
235 && weights.iter().all(|(_, weight)| *weight == 0)
236 {
237 return Err("[h3] dcid_length weights must not all be zero".into());
238 }
239 Ok(())
240 }
241}
242
243fn validate_settings(settings: &[H3Setting]) -> Result<(), String> {
244 let mut seen = HashSet::new();
245 for setting in settings {
246 let (id, value) = match (setting.id, setting.value, &setting.grease) {
247 (None, None, Some(_)) => continue,
248 (Some(id), Some(value), None) => (id, value),
249 _ => return Err("[h3] settings entry needs either id and value, or grease".into()),
250 };
251 if id > MAX_VARINT || value > MAX_VARINT {
252 return Err(format!("[h3] setting {id} does not fit in a QUIC varint"));
253 }
254 if HTTP2_RESERVED_SETTINGS.contains(&id) {
255 return Err(format!("[h3] setting {id} is reserved for HTTP/2"));
256 }
257 if !seen.insert(id) {
258 return Err(format!("[h3] setting {id} appears twice"));
259 }
260 }
261 Ok(())
262}
263
264fn validate_transport_parameters(params: &[H3TransportParam]) -> Result<(), String> {
265 let mut seen = HashSet::new();
266 for param in params {
267 param.validate()?;
268 if let Some(id) = param.id
269 && !seen.insert(id)
270 {
271 return Err(format!("[h3] transport parameter {id} appears twice"));
272 }
273 }
274 if !seen.contains(&INITIAL_SOURCE_CONNECTION_ID) {
275 return Err(format!(
276 "[h3] transport_parameters must list initial_source_connection_id \
277 ({INITIAL_SOURCE_CONNECTION_ID})"
278 ));
279 }
280 Ok(())
281}
282
283impl H3TransportParam {
284 fn validate(&self) -> Result<(), String> {
285 let kinds = [
286 self.varint.is_some(),
287 self.hex.is_some(),
288 self.grease.is_some(),
289 self.versions.is_some(),
290 ]
291 .iter()
292 .filter(|set| **set)
293 .count();
294 match (self.id, kinds) {
295 (Some(id), _) if id > MAX_VARINT => Err(format!(
296 "[h3] transport parameter {id} does not fit in a QUIC varint"
297 )),
298 (Some(id), _) if SERVER_TRANSPORT_PARAMS.contains(&id) => Err(format!(
299 "[h3] transport parameter {id} is sent only by servers"
300 )),
301 (Some(id), 0) if LOCAL_TRANSPORT_PARAMS.contains(&id) => Ok(()),
302 (Some(id), 0) => Err(format!(
303 "[h3] transport parameter {id} has no value and is not one Leyline fills in"
304 )),
305 (Some(id), 1) if ENFORCED_TRANSPORT_PARAMS.contains(&id) => Err(format!(
306 "[h3] transport parameter {id} is enforced locally, so it takes no raw value"
307 )),
308 (Some(id), 1) if self.varint.is_some_and(|value| value > MAX_VARINT) => Err(format!(
309 "[h3] transport parameter {id} value does not fit in a QUIC varint"
310 )),
311 (Some(_), 1) if self.versions.is_some() => self
312 .versions
313 .as_ref()
314 .map_or(Ok(()), H3VersionInformation::validate),
315 (Some(_), 1) if self.grease.is_none() => Ok(()),
316 (None, 1) if self.grease.is_some() => Ok(()),
317 _ => Err(format!(
318 "[h3] transport parameter entry {self:?} needs an id with at most one of varint, \
319 hex or versions, or a grease table alone"
320 )),
321 }
322 }
323}
324
325impl H3VersionInformation {
326 fn validate(&self) -> Result<(), String> {
327 if self.chosen != QUIC_START_VERSION || !self.available.contains(&self.chosen) {
328 return Err(format!(
329 "[h3] version_information chosen must be {QUIC_START_VERSION:#010x}, the version \
330 the connection starts in, and must appear in available"
331 ));
332 }
333 match std::iter::once(&self.chosen)
334 .chain(&self.available)
335 .find(|version| !quic_version_supported(**version))
336 {
337 Some(version) => Err(format!(
338 "[h3] QUIC version {version:#010x} is not supported by the QUIC stack"
339 )),
340 None => Ok(()),
341 }
342 }
343}
344
345#[cfg(feature = "http3")]
346fn quic_version_supported(version: u32) -> bool {
347 leyline_quiche::version_is_supported(version)
348}
349
350#[cfg(feature = "http3")]
351const QUIC_START_VERSION: u32 = leyline_quiche::PROTOCOL_VERSION;
352
353#[cfg(not(feature = "http3"))]
354const QUIC_START_VERSION: u32 = 1;
355
356#[cfg(not(feature = "http3"))]
357fn quic_version_supported(_version: u32) -> bool {
358 true
359}