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)]
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/// Semantic form observation returned by form-snapshot endpoints and evidence.
209#[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}