Skip to main content

bobby_browser_client/
forms.rs

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