1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize, Serializer};
6
7use crate::PageId;
8
9pub const FORM_SNAPSHOT_SCHEMA_VERSION: u16 = 1;
11pub const MAX_FORM_SNAPSHOT_FORMS: usize = 64;
12pub const MAX_FORM_SNAPSHOT_CONTROLS: usize = 512;
13pub const MAX_FORM_GROUPS: usize = 128;
14pub const MAX_FORM_OPTIONS: usize = 512;
15pub const MAX_FORM_REFERENCES: usize = 512;
16pub const MAX_FORM_ACCEPT_TYPES: usize = 128;
17pub const MAX_FORM_TARGET_PATH: usize = 8;
18pub const MAX_FORM_TARGET_ORDINAL: usize = 2_047;
19pub const MAX_FORM_ID_BYTES: usize = 128;
20pub const MAX_FORM_TEXT_BYTES: usize = 2_048;
21pub const MAX_FORM_VALUE_BYTES: usize = 4_096;
22pub const MAX_FORM_VALIDATION_MESSAGE_BYTES: usize = 1_024;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct SemanticTargetSegment {
28 pub role: String,
29 pub accessible_name: String,
30 pub ordinal: Option<usize>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct FormControlTarget {
37 pub role: String,
38 pub accessible_name: String,
39 pub ordinal: Option<usize>,
40 pub frame_path: Vec<SemanticTargetSegment>,
41 pub shadow_path: Vec<SemanticTargetSegment>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[serde(rename_all = "camelCase")]
47pub enum FormControlKind {
48 Text,
49 Email,
50 Password,
51 Search,
52 Number,
53 Checkbox,
54 Radio,
55 Switch,
56 SelectOne,
57 SelectMultiple,
58 Date,
59 Time,
60 DateTimeLocal,
61 Range,
62 File,
63 ContentEditable,
64 Combobox,
65 Listbox,
66 Submit,
67 Reset,
68 Other,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
74pub enum FormControlState {
75 Empty,
76 Text { value: String },
77 Redacted { present: bool },
78 Checked { checked: bool },
79 Selection { values: Vec<String> },
80 Files { count: usize },
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85#[serde(rename_all = "camelCase")]
86pub enum FormControlOperation {
87 SetText,
88 SetChecked,
89 SelectOne,
90 SelectMany,
91 SetFiles,
92 Clear,
93 Activate,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98#[serde(rename_all = "camelCase", deny_unknown_fields)]
99pub struct FormControlConstraints {
100 pub required: bool,
101 pub read_only: bool,
102 pub disabled: bool,
103 pub pattern: Option<String>,
104 pub min_length: Option<u32>,
105 pub max_length: Option<u32>,
106 pub min: Option<String>,
107 pub max: Option<String>,
108 pub step: Option<String>,
109 pub multiple: bool,
110 pub accept: Vec<String>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115#[serde(rename_all = "camelCase")]
116pub enum FormValidityFlag {
117 ValueMissing,
118 TypeMismatch,
119 PatternMismatch,
120 TooLong,
121 TooShort,
122 RangeUnderflow,
123 RangeOverflow,
124 StepMismatch,
125 BadInput,
126 CustomError,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
131#[serde(rename_all = "camelCase", deny_unknown_fields)]
132pub struct FormControlValidity {
133 pub will_validate: bool,
134 pub valid: bool,
135 pub flags: Vec<FormValidityFlag>,
136 pub message: Option<String>,
137 pub described_by: Vec<String>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142#[serde(rename_all = "camelCase", deny_unknown_fields)]
143pub struct FormOption {
144 pub value: String,
145 pub label: String,
146 pub disabled: bool,
147 pub selected: bool,
148 pub group_label: Option<String>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153#[serde(rename_all = "camelCase", deny_unknown_fields)]
154pub struct FormControl {
155 pub id: String,
156 pub form_id: Option<String>,
157 pub group_id: Option<String>,
158 pub target: Option<FormControlTarget>,
159 pub control_kind: FormControlKind,
160 pub accessible_name: Option<String>,
161 pub label: Option<String>,
162 pub description: Option<String>,
163 pub placeholder: Option<String>,
164 pub autocomplete: Option<String>,
165 pub state: FormControlState,
166 pub constraints: FormControlConstraints,
167 pub validity: FormControlValidity,
168 pub options: Vec<FormOption>,
169 pub supported_operations: Vec<FormControlOperation>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174#[serde(rename_all = "camelCase", deny_unknown_fields)]
175pub struct FormGroup {
176 pub id: String,
177 pub label: Option<String>,
178 pub description: Option<String>,
179 pub control_ids: Vec<String>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184#[serde(rename_all = "camelCase", deny_unknown_fields)]
185pub struct FormValidity {
186 pub valid: bool,
187 pub invalid_control_ids: Vec<String>,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct FormDescriptor {
194 pub id: String,
195 pub target: Option<FormControlTarget>,
196 pub accessible_name: Option<String>,
197 pub description: Option<String>,
198 pub groups: Vec<FormGroup>,
199 pub controls: Vec<FormControl>,
200 pub submit_control_ids: Vec<String>,
201 pub reset_control_ids: Vec<String>,
202 pub validity: FormValidity,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
207#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208#[serde(
209 rename_all = "camelCase",
210 deny_unknown_fields,
211 try_from = "FormSnapshotWire"
212)]
213pub struct FormSnapshot {
214 pub schema_version: u16,
215 pub page_id: PageId,
216 pub forms: Vec<FormDescriptor>,
217 pub unowned_controls: Vec<FormControl>,
218 pub truncated: bool,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223#[serde(rename_all = "camelCase", deny_unknown_fields)]
224struct FormSnapshotWire {
225 schema_version: u16,
226 page_id: PageId,
227 #[cfg_attr(feature = "schema", schemars(length(max = 64)))]
228 forms: Vec<FormDescriptor>,
229 #[cfg_attr(feature = "schema", schemars(length(max = 512)))]
230 unowned_controls: Vec<FormControl>,
231 truncated: bool,
232}
233
234impl TryFrom<FormSnapshotWire> for FormSnapshot {
235 type Error = String;
236
237 fn try_from(wire: FormSnapshotWire) -> Result<Self, Self::Error> {
238 let snapshot = Self {
239 schema_version: wire.schema_version,
240 page_id: wire.page_id,
241 forms: wire.forms,
242 unowned_controls: wire.unowned_controls,
243 truncated: wire.truncated,
244 };
245 snapshot.validate()?;
246 Ok(snapshot)
247 }
248}
249
250impl Serialize for FormSnapshot {
251 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
252 where
253 S: Serializer,
254 {
255 self.validate().map_err(serde::ser::Error::custom)?;
256 FormSnapshotWire {
257 schema_version: self.schema_version,
258 page_id: self.page_id.clone(),
259 forms: self.forms.clone(),
260 unowned_controls: self.unowned_controls.clone(),
261 truncated: self.truncated,
262 }
263 .serialize(serializer)
264 }
265}
266
267impl FormSnapshot {
268 pub fn validate(&self) -> Result<(), String> {
269 if self.schema_version != FORM_SNAPSHOT_SCHEMA_VERSION {
270 return Err("unsupported form snapshot schema version".into());
271 }
272 if self.forms.len() > MAX_FORM_SNAPSHOT_FORMS {
273 return Err("form snapshot exceeds its form bound".into());
274 }
275 let control_count = self
276 .forms
277 .iter()
278 .try_fold(self.unowned_controls.len(), |count, form| {
279 count.checked_add(form.controls.len())
280 })
281 .ok_or_else(|| "form snapshot control count overflow".to_owned())?;
282 if control_count > MAX_FORM_SNAPSHOT_CONTROLS {
283 return Err("form snapshot exceeds its control bound".into());
284 }
285
286 let mut form_ids = BTreeSet::new();
287 let mut control_ids = BTreeSet::new();
288 for form in &self.forms {
289 validate_id(&form.id, "form")?;
290 if !form_ids.insert(form.id.as_str()) {
291 return Err("form snapshot contains duplicate form IDs".into());
292 }
293 validate_optional_text(&form.accessible_name, MAX_FORM_TEXT_BYTES, "form name")?;
294 validate_optional_text(&form.description, MAX_FORM_TEXT_BYTES, "form description")?;
295 validate_target(form.target.as_ref())?;
296 validate_form(form, &mut control_ids)?;
297 }
298 for control in &self.unowned_controls {
299 if control.form_id.is_some() || control.group_id.is_some() {
300 return Err("unowned controls cannot reference a form or group".into());
301 }
302 validate_control(control)?;
303 if !control_ids.insert(control.id.as_str()) {
304 return Err("form snapshot contains duplicate control IDs".into());
305 }
306 }
307 Ok(())
308 }
309}
310
311fn validate_form<'a>(
312 form: &'a FormDescriptor,
313 all_control_ids: &mut BTreeSet<&'a str>,
314) -> Result<(), String> {
315 if form.groups.len() > MAX_FORM_GROUPS || form.controls.len() > MAX_FORM_SNAPSHOT_CONTROLS {
316 return Err("form exceeds a collection bound".into());
317 }
318 let mut local_controls = BTreeSet::new();
319 for control in &form.controls {
320 if control.form_id.as_deref() != Some(form.id.as_str()) {
321 return Err("form control references the wrong form".into());
322 }
323 validate_control(control)?;
324 if !local_controls.insert(control.id.as_str())
325 || !all_control_ids.insert(control.id.as_str())
326 {
327 return Err("form snapshot contains duplicate control IDs".into());
328 }
329 }
330
331 let mut group_ids = BTreeSet::new();
332 for group in &form.groups {
333 validate_id(&group.id, "form group")?;
334 validate_optional_text(&group.label, MAX_FORM_TEXT_BYTES, "form group label")?;
335 validate_optional_text(
336 &group.description,
337 MAX_FORM_TEXT_BYTES,
338 "form group description",
339 )?;
340 if !group_ids.insert(group.id.as_str()) || group.control_ids.len() > MAX_FORM_REFERENCES {
341 return Err("form contains invalid group references".into());
342 }
343 for id in &group.control_ids {
344 if !local_controls.contains(id.as_str()) {
345 return Err("form group references an unknown control".into());
346 }
347 let control = form
348 .controls
349 .iter()
350 .find(|control| control.id == *id)
351 .unwrap();
352 if control.group_id.as_deref() != Some(group.id.as_str()) {
353 return Err("form group membership is inconsistent".into());
354 }
355 }
356 }
357 for control in &form.controls {
358 if let Some(group_id) = &control.group_id {
359 if !group_ids.contains(group_id.as_str()) {
360 return Err("form control references an unknown group".into());
361 }
362 let group = form
363 .groups
364 .iter()
365 .find(|group| group.id == *group_id)
366 .unwrap();
367 if !group.control_ids.iter().any(|id| id == &control.id) {
368 return Err("form group membership is inconsistent".into());
369 }
370 }
371 }
372 validate_control_references(form, &local_controls)?;
373 Ok(())
374}
375
376fn validate_control_references(
377 form: &FormDescriptor,
378 controls: &BTreeSet<&str>,
379) -> Result<(), String> {
380 if form.submit_control_ids.len() > MAX_FORM_REFERENCES
381 || form.reset_control_ids.len() > MAX_FORM_REFERENCES
382 || form.validity.invalid_control_ids.len() > MAX_FORM_REFERENCES
383 {
384 return Err("form exceeds its control reference bound".into());
385 }
386 if !references_are_unique(&form.submit_control_ids)
387 || !references_are_unique(&form.reset_control_ids)
388 || !references_are_unique(&form.validity.invalid_control_ids)
389 {
390 return Err("form contains duplicate control references".into());
391 }
392 for id in &form.submit_control_ids {
393 let Some(control) = form.controls.iter().find(|control| control.id == *id) else {
394 return Err("form submit list references an unknown control".into());
395 };
396 if control.control_kind != FormControlKind::Submit {
397 return Err("form submit list references a non-submit control".into());
398 }
399 }
400 for id in &form.reset_control_ids {
401 let Some(control) = form.controls.iter().find(|control| control.id == *id) else {
402 return Err("form reset list references an unknown control".into());
403 };
404 if control.control_kind != FormControlKind::Reset {
405 return Err("form reset list references a non-reset control".into());
406 }
407 }
408 for id in &form.validity.invalid_control_ids {
409 if !controls.contains(id.as_str()) {
410 return Err("form validity references an unknown control".into());
411 }
412 }
413 Ok(())
414}
415
416fn references_are_unique(values: &[String]) -> bool {
417 values.iter().collect::<BTreeSet<_>>().len() == values.len()
418}
419
420fn validate_control(control: &FormControl) -> Result<(), String> {
421 validate_id(&control.id, "form control")?;
422 if let Some(id) = &control.form_id {
423 validate_id(id, "form")?;
424 }
425 if let Some(id) = &control.group_id {
426 validate_id(id, "form group")?;
427 }
428 validate_target(control.target.as_ref())?;
429 validate_optional_text(
430 &control.accessible_name,
431 MAX_FORM_TEXT_BYTES,
432 "control name",
433 )?;
434 validate_optional_text(&control.label, MAX_FORM_TEXT_BYTES, "control label")?;
435 validate_optional_text(
436 &control.description,
437 MAX_FORM_TEXT_BYTES,
438 "control description",
439 )?;
440 validate_optional_text(
441 &control.placeholder,
442 MAX_FORM_TEXT_BYTES,
443 "control placeholder",
444 )?;
445 validate_optional_text(
446 &control.autocomplete,
447 MAX_FORM_TEXT_BYTES,
448 "control autocomplete",
449 )?;
450 validate_state(control.control_kind, &control.state)?;
451 validate_constraints(&control.constraints)?;
452 validate_validity(&control.validity)?;
453 if control.options.len() > MAX_FORM_OPTIONS {
454 return Err("form control exceeds its option bound".into());
455 }
456 for option in &control.options {
457 validate_text(&option.value, MAX_FORM_VALUE_BYTES, "option value", true)?;
458 validate_text(&option.label, MAX_FORM_TEXT_BYTES, "option label", true)?;
459 validate_optional_text(&option.group_label, MAX_FORM_TEXT_BYTES, "option group")?;
460 }
461 let operations = control
462 .supported_operations
463 .iter()
464 .copied()
465 .collect::<BTreeSet<_>>();
466 if operations.len() != control.supported_operations.len() {
467 return Err("form control contains duplicate supported operations".into());
468 }
469 Ok(())
470}
471
472fn validate_state(kind: FormControlKind, state: &FormControlState) -> Result<(), String> {
473 match state {
474 FormControlState::Text { value } => {
475 if kind == FormControlKind::Password {
476 return Err("password controls cannot expose text state".into());
477 }
478 validate_text(value, MAX_FORM_VALUE_BYTES, "control value", true)?;
479 }
480 FormControlState::Selection { values } => {
481 if values.len() > MAX_FORM_OPTIONS {
482 return Err("selection state exceeds its value bound".into());
483 }
484 for value in values {
485 validate_text(value, MAX_FORM_VALUE_BYTES, "selection value", true)?;
486 }
487 }
488 FormControlState::Files { count } if *count > MAX_FORM_OPTIONS => {
489 return Err("file state exceeds its count bound".into());
490 }
491 _ => {}
492 }
493 Ok(())
494}
495
496fn validate_constraints(constraints: &FormControlConstraints) -> Result<(), String> {
497 validate_optional_text(&constraints.pattern, MAX_FORM_TEXT_BYTES, "control pattern")?;
498 validate_optional_text(&constraints.min, MAX_FORM_VALUE_BYTES, "control minimum")?;
499 validate_optional_text(&constraints.max, MAX_FORM_VALUE_BYTES, "control maximum")?;
500 validate_optional_text(&constraints.step, MAX_FORM_VALUE_BYTES, "control step")?;
501 if constraints.accept.len() > MAX_FORM_ACCEPT_TYPES {
502 return Err("control accept list exceeds its bound".into());
503 }
504 for accept in &constraints.accept {
505 validate_text(accept, MAX_FORM_TEXT_BYTES, "accepted type", false)?;
506 }
507 if constraints
508 .min_length
509 .zip(constraints.max_length)
510 .is_some_and(|(min, max)| min > max)
511 {
512 return Err("control minimum length exceeds maximum length".into());
513 }
514 Ok(())
515}
516
517fn validate_validity(validity: &FormControlValidity) -> Result<(), String> {
518 if validity.flags.len() > 10 || validity.described_by.len() > MAX_FORM_REFERENCES {
519 return Err("control validity exceeds a collection bound".into());
520 }
521 if validity.valid && !validity.flags.is_empty() {
522 return Err("valid control cannot carry failing validity flags".into());
523 }
524 let flags = validity.flags.iter().copied().collect::<BTreeSet<_>>();
525 if flags.len() != validity.flags.len() {
526 return Err("control validity contains duplicate flags".into());
527 }
528 validate_optional_text(
529 &validity.message,
530 MAX_FORM_VALIDATION_MESSAGE_BYTES,
531 "validation message",
532 )?;
533 for text in &validity.described_by {
534 validate_text(text, MAX_FORM_TEXT_BYTES, "described-by text", false)?;
535 }
536 Ok(())
537}
538
539fn validate_target(target: Option<&FormControlTarget>) -> Result<(), String> {
540 let Some(target) = target else {
541 return Ok(());
542 };
543 validate_text(&target.role, MAX_FORM_ID_BYTES, "target role", false)?;
544 validate_text(
545 &target.accessible_name,
546 MAX_FORM_TEXT_BYTES,
547 "target accessible name",
548 false,
549 )?;
550 if target
551 .ordinal
552 .is_some_and(|ordinal| ordinal > MAX_FORM_TARGET_ORDINAL)
553 || target.frame_path.len() > MAX_FORM_TARGET_PATH
554 || target.shadow_path.len() > MAX_FORM_TARGET_PATH
555 {
556 return Err("form target exceeds its bound".into());
557 }
558 for segment in target.frame_path.iter().chain(&target.shadow_path) {
559 validate_text(&segment.role, MAX_FORM_ID_BYTES, "target path role", false)?;
560 validate_text(
561 &segment.accessible_name,
562 MAX_FORM_TEXT_BYTES,
563 "target path name",
564 false,
565 )?;
566 if segment
567 .ordinal
568 .is_some_and(|ordinal| ordinal > MAX_FORM_TARGET_ORDINAL)
569 {
570 return Err("form target path ordinal exceeds its bound".into());
571 }
572 }
573 Ok(())
574}
575
576fn validate_id(value: &str, field: &str) -> Result<(), String> {
577 validate_text(value, MAX_FORM_ID_BYTES, field, false)
578}
579
580fn validate_optional_text(value: &Option<String>, max: usize, field: &str) -> Result<(), String> {
581 if let Some(value) = value {
582 validate_text(value, max, field, false)?;
583 }
584 Ok(())
585}
586
587fn validate_text(value: &str, max: usize, field: &str, allow_empty: bool) -> Result<(), String> {
588 if (!allow_empty && value.is_empty())
589 || value.len() > max
590 || value.chars().any(char::is_control)
591 {
592 return Err(format!(
593 "{field} is empty, oversized, or contains control characters"
594 ));
595 }
596 Ok(())
597}