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