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