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