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}
315
316impl TryFrom<FormSnapshotWire> for FormSnapshot {
317    type Error = String;
318
319    fn try_from(wire: FormSnapshotWire) -> Result<Self, Self::Error> {
320        let snapshot = Self {
321            schema_version: wire.schema_version,
322            page_id: wire.page_id,
323            forms: wire.forms,
324            unowned_controls: wire.unowned_controls,
325            truncated: wire.truncated,
326        };
327        snapshot.validate()?;
328        Ok(snapshot)
329    }
330}
331
332impl Serialize for FormSnapshot {
333    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
334    where
335        S: Serializer,
336    {
337        self.validate().map_err(serde::ser::Error::custom)?;
338        FormSnapshotWire {
339            schema_version: self.schema_version,
340            page_id: self.page_id.clone(),
341            forms: self.forms.clone(),
342            unowned_controls: self.unowned_controls.clone(),
343            truncated: self.truncated,
344        }
345        .serialize(serializer)
346    }
347}
348
349impl FormSnapshot {
350    pub fn validate(&self) -> Result<(), String> {
351        if self.schema_version != FORM_SNAPSHOT_SCHEMA_VERSION {
352            return Err("unsupported form snapshot schema version".into());
353        }
354        if self.forms.len() > MAX_FORM_SNAPSHOT_FORMS {
355            return Err("form snapshot exceeds its form bound".into());
356        }
357        let control_count = self
358            .forms
359            .iter()
360            .try_fold(self.unowned_controls.len(), |count, form| {
361                count.checked_add(form.controls.len())
362            })
363            .ok_or_else(|| "form snapshot control count overflow".to_owned())?;
364        if control_count > MAX_FORM_SNAPSHOT_CONTROLS {
365            return Err("form snapshot exceeds its control bound".into());
366        }
367
368        let mut form_ids = BTreeSet::new();
369        let mut control_ids = BTreeSet::new();
370        for form in &self.forms {
371            validate_id(&form.id, "form")?;
372            if !form_ids.insert(form.id.as_str()) {
373                return Err("form snapshot contains duplicate form IDs".into());
374            }
375            validate_optional_text(&form.accessible_name, MAX_FORM_TEXT_BYTES, "form name")?;
376            validate_optional_text(&form.description, MAX_FORM_TEXT_BYTES, "form description")?;
377            validate_target(form.target.as_ref())?;
378            validate_form(form, &mut control_ids)?;
379        }
380        for control in &self.unowned_controls {
381            if control.form_id.is_some() || control.group_id.is_some() {
382                return Err("unowned controls cannot reference a form or group".into());
383            }
384            validate_control(control)?;
385            if !control_ids.insert(control.id.as_str()) {
386                return Err("form snapshot contains duplicate control IDs".into());
387            }
388        }
389        Ok(())
390    }
391}
392
393fn validate_form<'a>(
394    form: &'a FormDescriptor,
395    all_control_ids: &mut BTreeSet<&'a str>,
396) -> Result<(), String> {
397    if form.groups.len() > MAX_FORM_GROUPS || form.controls.len() > MAX_FORM_SNAPSHOT_CONTROLS {
398        return Err("form exceeds a collection bound".into());
399    }
400    let mut local_controls = BTreeSet::new();
401    for control in &form.controls {
402        if control.form_id.as_deref() != Some(form.id.as_str()) {
403            return Err("form control references the wrong form".into());
404        }
405        validate_control(control)?;
406        if !local_controls.insert(control.id.as_str())
407            || !all_control_ids.insert(control.id.as_str())
408        {
409            return Err("form snapshot contains duplicate control IDs".into());
410        }
411    }
412
413    let mut group_ids = BTreeSet::new();
414    for group in &form.groups {
415        validate_id(&group.id, "form group")?;
416        validate_optional_text(&group.label, MAX_FORM_TEXT_BYTES, "form group label")?;
417        validate_optional_text(
418            &group.description,
419            MAX_FORM_TEXT_BYTES,
420            "form group description",
421        )?;
422        if !group_ids.insert(group.id.as_str()) || group.control_ids.len() > MAX_FORM_REFERENCES {
423            return Err("form contains invalid group references".into());
424        }
425        for id in &group.control_ids {
426            if !local_controls.contains(id.as_str()) {
427                return Err("form group references an unknown control".into());
428            }
429            let control = form
430                .controls
431                .iter()
432                .find(|control| control.id == *id)
433                .unwrap();
434            if control.group_id.as_deref() != Some(group.id.as_str()) {
435                return Err("form group membership is inconsistent".into());
436            }
437        }
438    }
439    for control in &form.controls {
440        if let Some(group_id) = &control.group_id {
441            if !group_ids.contains(group_id.as_str()) {
442                return Err("form control references an unknown group".into());
443            }
444            let group = form
445                .groups
446                .iter()
447                .find(|group| group.id == *group_id)
448                .unwrap();
449            if !group.control_ids.iter().any(|id| id == &control.id) {
450                return Err("form group membership is inconsistent".into());
451            }
452        }
453    }
454    validate_control_references(form, &local_controls)?;
455    Ok(())
456}
457
458fn validate_control_references(
459    form: &FormDescriptor,
460    controls: &BTreeSet<&str>,
461) -> Result<(), String> {
462    if form.submit_control_ids.len() > MAX_FORM_REFERENCES
463        || form.reset_control_ids.len() > MAX_FORM_REFERENCES
464        || form.validity.invalid_control_ids.len() > MAX_FORM_REFERENCES
465    {
466        return Err("form exceeds its control reference bound".into());
467    }
468    if !references_are_unique(&form.submit_control_ids)
469        || !references_are_unique(&form.reset_control_ids)
470        || !references_are_unique(&form.validity.invalid_control_ids)
471    {
472        return Err("form contains duplicate control references".into());
473    }
474    for id in &form.submit_control_ids {
475        let Some(control) = form.controls.iter().find(|control| control.id == *id) else {
476            return Err("form submit list references an unknown control".into());
477        };
478        if control.control_kind != FormControlKind::Submit {
479            return Err("form submit list references a non-submit control".into());
480        }
481    }
482    for id in &form.reset_control_ids {
483        let Some(control) = form.controls.iter().find(|control| control.id == *id) else {
484            return Err("form reset list references an unknown control".into());
485        };
486        if control.control_kind != FormControlKind::Reset {
487            return Err("form reset list references a non-reset control".into());
488        }
489    }
490    for id in &form.validity.invalid_control_ids {
491        if !controls.contains(id.as_str()) {
492            return Err("form validity references an unknown control".into());
493        }
494    }
495    Ok(())
496}
497
498fn references_are_unique(values: &[String]) -> bool {
499    values.iter().collect::<BTreeSet<_>>().len() == values.len()
500}
501
502fn validate_control(control: &FormControl) -> Result<(), String> {
503    validate_id(&control.id, "form control")?;
504    if let Some(id) = &control.form_id {
505        validate_id(id, "form")?;
506    }
507    if let Some(id) = &control.group_id {
508        validate_id(id, "form group")?;
509    }
510    validate_target(control.target.as_ref())?;
511    validate_optional_text(
512        &control.accessible_name,
513        MAX_FORM_TEXT_BYTES,
514        "control name",
515    )?;
516    validate_optional_text(&control.label, MAX_FORM_TEXT_BYTES, "control label")?;
517    validate_optional_text(
518        &control.description,
519        MAX_FORM_TEXT_BYTES,
520        "control description",
521    )?;
522    validate_optional_text(
523        &control.placeholder,
524        MAX_FORM_TEXT_BYTES,
525        "control placeholder",
526    )?;
527    validate_optional_text(
528        &control.autocomplete,
529        MAX_FORM_TEXT_BYTES,
530        "control autocomplete",
531    )?;
532    validate_state(control.control_kind, &control.state)?;
533    validate_constraints(&control.constraints)?;
534    validate_validity(&control.validity)?;
535    if control.options.len() > MAX_FORM_OPTIONS {
536        return Err("form control exceeds its option bound".into());
537    }
538    for option in &control.options {
539        validate_text(&option.value, MAX_FORM_VALUE_BYTES, "option value", true)?;
540        validate_text(&option.label, MAX_FORM_TEXT_BYTES, "option label", true)?;
541        validate_optional_text(&option.group_label, MAX_FORM_TEXT_BYTES, "option group")?;
542    }
543    let operations = control
544        .supported_operations
545        .iter()
546        .copied()
547        .collect::<BTreeSet<_>>();
548    if operations.len() != control.supported_operations.len() {
549        return Err("form control contains duplicate supported operations".into());
550    }
551    Ok(())
552}
553
554fn validate_state(kind: FormControlKind, state: &FormControlState) -> Result<(), String> {
555    match state {
556        FormControlState::Text { value } => {
557            if kind == FormControlKind::Password {
558                return Err("password controls cannot expose text state".into());
559            }
560            validate_text(value, MAX_FORM_VALUE_BYTES, "control value", true)?;
561        }
562        FormControlState::Selection { values } => {
563            if values.len() > MAX_FORM_OPTIONS {
564                return Err("selection state exceeds its value bound".into());
565            }
566            for value in values {
567                validate_text(value, MAX_FORM_VALUE_BYTES, "selection value", true)?;
568            }
569        }
570        FormControlState::Files { count } if *count > MAX_FORM_OPTIONS => {
571            return Err("file state exceeds its count bound".into());
572        }
573        _ => {}
574    }
575    Ok(())
576}
577
578fn validate_constraints(constraints: &FormControlConstraints) -> Result<(), String> {
579    validate_optional_text(&constraints.pattern, MAX_FORM_TEXT_BYTES, "control pattern")?;
580    validate_optional_text(&constraints.min, MAX_FORM_VALUE_BYTES, "control minimum")?;
581    validate_optional_text(&constraints.max, MAX_FORM_VALUE_BYTES, "control maximum")?;
582    validate_optional_text(&constraints.step, MAX_FORM_VALUE_BYTES, "control step")?;
583    if constraints.accept.len() > MAX_FORM_ACCEPT_TYPES {
584        return Err("control accept list exceeds its bound".into());
585    }
586    for accept in &constraints.accept {
587        validate_text(accept, MAX_FORM_TEXT_BYTES, "accepted type", false)?;
588    }
589    if constraints
590        .min_length
591        .zip(constraints.max_length)
592        .is_some_and(|(min, max)| min > max)
593    {
594        return Err("control minimum length exceeds maximum length".into());
595    }
596    Ok(())
597}
598
599fn validate_validity(validity: &FormControlValidity) -> Result<(), String> {
600    if validity.flags.len() > 10 || validity.described_by.len() > MAX_FORM_REFERENCES {
601        return Err("control validity exceeds a collection bound".into());
602    }
603    if validity.valid && !validity.flags.is_empty() {
604        return Err("valid control cannot carry failing validity flags".into());
605    }
606    let flags = validity.flags.iter().copied().collect::<BTreeSet<_>>();
607    if flags.len() != validity.flags.len() {
608        return Err("control validity contains duplicate flags".into());
609    }
610    validate_optional_text(
611        &validity.message,
612        MAX_FORM_VALIDATION_MESSAGE_BYTES,
613        "validation message",
614    )?;
615    for text in &validity.described_by {
616        validate_text(text, MAX_FORM_TEXT_BYTES, "described-by text", false)?;
617    }
618    Ok(())
619}
620
621fn validate_target(target: Option<&FormControlTarget>) -> Result<(), String> {
622    let Some(target) = target else {
623        return Ok(());
624    };
625    validate_text(&target.role, MAX_FORM_ID_BYTES, "target role", false)?;
626    validate_text(
627        &target.accessible_name,
628        MAX_FORM_TEXT_BYTES,
629        "target accessible name",
630        false,
631    )?;
632    if target
633        .ordinal
634        .is_some_and(|ordinal| ordinal > MAX_FORM_TARGET_ORDINAL)
635        || target.frame_path.len() > MAX_FORM_TARGET_PATH
636        || target.shadow_path.len() > MAX_FORM_TARGET_PATH
637    {
638        return Err("form target exceeds its bound".into());
639    }
640    for segment in target.frame_path.iter().chain(&target.shadow_path) {
641        validate_text(&segment.role, MAX_FORM_ID_BYTES, "target path role", false)?;
642        validate_text(
643            &segment.accessible_name,
644            MAX_FORM_TEXT_BYTES,
645            "target path name",
646            false,
647        )?;
648        if segment
649            .ordinal
650            .is_some_and(|ordinal| ordinal > MAX_FORM_TARGET_ORDINAL)
651        {
652            return Err("form target path ordinal exceeds its bound".into());
653        }
654    }
655    Ok(())
656}
657
658fn validate_id(value: &str, field: &str) -> Result<(), String> {
659    validate_text(value, MAX_FORM_ID_BYTES, field, false)
660}
661
662fn validate_optional_text(value: &Option<String>, max: usize, field: &str) -> Result<(), String> {
663    if let Some(value) = value {
664        validate_text(value, max, field, false)?;
665    }
666    Ok(())
667}
668
669fn validate_text(value: &str, max: usize, field: &str, allow_empty: bool) -> Result<(), String> {
670    if (!allow_empty && value.is_empty())
671        || value.len() > max
672        || value.chars().any(char::is_control)
673    {
674        return Err(format!(
675            "{field} is empty, oversized, or contains control characters"
676        ));
677    }
678    Ok(())
679}