Skip to main content

termwright_protocol/
validate.rs

1//! Snapshot validation.
2//!
3//! A structural port of the reference `validate.ts`: same invariants, same
4//! error codes, same order of checks, so a snapshot rejected here is rejected
5//! by the driver and vice versa. Never panics on hostile input.
6
7use std::collections::{HashMap, HashSet};
8
9use serde_json::{Map, Value};
10
11use crate::error::ValidationError;
12use crate::framing::project_dto;
13use crate::limits::Limits;
14use crate::marker::MAX_SAFE_INTEGER;
15use crate::roles::{valid_action, valid_role};
16
17/// A schema defect, carrying the path the reference implementation reports.
18/// The path is what decides the error code.
19struct Issue {
20    path: Vec<String>,
21    message: String,
22    too_big: bool,
23}
24
25impl Issue {
26    fn new(path: Vec<String>, message: impl Into<String>) -> Self {
27        Self {
28            path,
29            message: message.into(),
30            too_big: false,
31        }
32    }
33
34    fn too_big(path: Vec<String>, message: impl Into<String>) -> Self {
35        Self {
36            path,
37            message: message.into(),
38            too_big: true,
39        }
40    }
41
42    fn code(&self) -> &'static str {
43        let has = |key: &str| self.path.iter().any(|element| element == key);
44        if has("role") {
45            "unknown-role"
46        } else if has("revision") {
47            "revision"
48        } else if has("bounds")
49            || has("rect")
50            || has("regionBounds")
51            || has("paintedRegion")
52            || has("paintedRegions")
53        {
54            "bad-rect"
55        } else if self.too_big && (has("nodes") || has("rootIds")) {
56            "count"
57        } else if self.message.contains("UTF-8 bytes") {
58            "string-bytes"
59        } else {
60            "schema"
61        }
62    }
63
64    fn into_error(self) -> ValidationError {
65        let where_ = if self.path.is_empty() {
66            "<root>".to_owned()
67        } else {
68            self.path.join(".")
69        };
70        let code = self.code();
71        ValidationError::new(code, format!("{where_}: {}", self.message))
72    }
73}
74
75fn path(base: &[String], more: &[&str]) -> Vec<String> {
76    let mut next: Vec<String> = base.to_vec();
77    next.extend(more.iter().map(|element| (*element).to_owned()));
78    next
79}
80
81// -- scalar checks ---------------------------------------------------------
82
83fn as_object<'a>(value: &'a Value, at: &[String]) -> Result<&'a Map<String, Value>, Issue> {
84    value
85        .as_object()
86        .ok_or_else(|| Issue::new(at.to_vec(), "expected an object"))
87}
88
89fn strict(object: &Map<String, Value>, allowed: &[&str], at: &[String]) -> Result<(), Issue> {
90    let mut unknown: Vec<&str> = object
91        .keys()
92        .map(String::as_str)
93        .filter(|key| !allowed.contains(key))
94        .collect();
95    if unknown.is_empty() {
96        return Ok(());
97    }
98    unknown.sort_unstable();
99    Err(Issue::new(
100        at.to_vec(),
101        format!("Unrecognized key(s) in object: {}", unknown.join(", ")),
102    ))
103}
104
105fn whole(
106    value: Option<&Value>,
107    at: Vec<String>,
108    message: &str,
109    ok: impl Fn(i64) -> bool,
110) -> Result<i64, Issue> {
111    let number = value
112        .and_then(Value::as_i64)
113        .filter(|n| n.abs() <= MAX_SAFE_INTEGER);
114    match number {
115        Some(number) if ok(number) => Ok(number),
116        _ => Err(Issue::new(at, message)),
117    }
118}
119
120fn safe_int(value: Option<&Value>, at: Vec<String>) -> Result<i64, Issue> {
121    whole(value, at, "expected a safe integer", |_| true)
122}
123
124fn non_negative(value: Option<&Value>, at: Vec<String>) -> Result<i64, Issue> {
125    whole(value, at, "expected a non-negative safe integer", |n| {
126        n >= 0
127    })
128}
129
130fn positive(value: Option<&Value>, at: Vec<String>) -> Result<i64, Issue> {
131    whole(value, at, "expected a positive safe integer", |n| n > 0)
132}
133
134fn text<'a>(value: Option<&'a Value>, at: Vec<String>, limits: &Limits) -> Result<&'a str, Issue> {
135    let Some(text) = value.and_then(Value::as_str) else {
136        return Err(Issue::new(at, "expected a string"));
137    };
138    if text.len() > limits.max_string_bytes {
139        return Err(Issue::new(
140            at,
141            format!("expected at most {} UTF-8 bytes", limits.max_string_bytes),
142        ));
143    }
144    Ok(text)
145}
146
147fn boolean(value: Option<&Value>, at: Vec<String>) -> Result<bool, Issue> {
148    value
149        .and_then(Value::as_bool)
150        .ok_or_else(|| Issue::new(at, "expected a boolean"))
151}
152
153// -- schema layer ----------------------------------------------------------
154
155const RECT_KEYS: [&str; 4] = ["row", "column", "width", "height"];
156
157fn check_rect(value: &Value, at: &[String]) -> Result<Rect, Issue> {
158    let object = as_object(value, at)?;
159    strict(object, &RECT_KEYS, at)?;
160    Ok(Rect {
161        row: safe_int(object.get("row"), path(at, &["row"]))?,
162        column: safe_int(object.get("column"), path(at, &["column"]))?,
163        width: non_negative(object.get("width"), path(at, &["width"]))?,
164        height: non_negative(object.get("height"), path(at, &["height"]))?,
165    })
166}
167
168/// The four numbers of a rect, once they are known to be well-formed.
169struct Rect {
170    row: i64,
171    column: i64,
172    width: i64,
173    height: i64,
174}
175
176const STATE_BOOL_KEYS: [&str; 12] = [
177    "disabled",
178    "focused",
179    "selected",
180    "expanded",
181    "modal",
182    "busy",
183    "hidden",
184    "offscreen",
185    "readonly",
186    "multiline",
187    "required",
188    "multiselectable",
189];
190
191/// Every field a `state` object may carry, as this client knows them.
192pub const STATE_KEYS: [&str; 17] = [
193    "disabled",
194    "focused",
195    "selected",
196    "expanded",
197    "modal",
198    "busy",
199    "hidden",
200    "offscreen",
201    "readonly",
202    "multiline",
203    "required",
204    "multiselectable",
205    "checked",
206    "orientation",
207    "level",
208    "positionInSet",
209    "setSize",
210];
211
212fn check_state(value: &Value, at: &[String]) -> Result<(), Issue> {
213    let object = as_object(value, at)?;
214    strict(object, &STATE_KEYS, at)?;
215    for key in STATE_BOOL_KEYS {
216        if object.contains_key(key) {
217            boolean(object.get(key), path(at, &[key]))?;
218        }
219    }
220    if let Some(checked) = object.get("checked") {
221        if !checked.is_boolean() && checked.as_str() != Some("mixed") {
222            return Err(Issue::new(
223                path(at, &["checked"]),
224                "expected a boolean or 'mixed'",
225            ));
226        }
227    }
228    if let Some(orientation) = object.get("orientation") {
229        if !matches!(orientation.as_str(), Some("horizontal") | Some("vertical")) {
230            return Err(Issue::new(
231                path(at, &["orientation"]),
232                "expected 'horizontal' or 'vertical'",
233            ));
234        }
235    }
236    for key in ["level", "positionInSet"] {
237        if object.contains_key(key) {
238            positive(object.get(key), path(at, &[key]))?;
239        }
240    }
241    for key in ["setSize"] {
242        if object.contains_key(key) {
243            non_negative(object.get(key), path(at, &[key]))?;
244        }
245    }
246    Ok(())
247}
248
249/// Every field a node may carry, as this client knows them.
250pub const NODE_KEYS: [&str; 21] = [
251    "id",
252    "parentId",
253    "role",
254    "name",
255    "description",
256    "value",
257    "geometry",
258    "state",
259    "extended",
260    "actions",
261    "inputRecipes",
262    "labelledBy",
263    "describedBy",
264    "textRanges",
265    "testId",
266    "frameworkType",
267    "opaqueChildren",
268    "p",
269    "px",
270    "scroll",
271    "paintedRegion",
272];
273
274fn check_painted_region(value: &Value, at: &[String], limits: &Limits) -> Result<(), Issue> {
275    let region = as_object(value, at)?;
276    strict(region, &["regionBounds", "spans"], at)?;
277    let bounds = check_rect(
278        region.get("regionBounds").unwrap_or(&Value::Null),
279        &path(at, &["regionBounds"]),
280    )?;
281    let spans = region
282        .get("spans")
283        .and_then(Value::as_array)
284        .ok_or_else(|| Issue::new(path(at, &["spans"]), "expected an array"))?;
285    if spans.len() > limits.max_nodes {
286        return Err(Issue::too_big(
287            path(at, &["spans"]),
288            "too many region spans",
289        ));
290    }
291    let mut previous: Option<(i64, i64)> = None;
292    for (index, raw_span) in spans.iter().enumerate() {
293        let span_path = path(at, &["spans", &index.to_string()]);
294        let span = as_object(raw_span, &span_path)?;
295        strict(span, &["row", "from", "to"], &span_path)?;
296        let row = non_negative(span.get("row"), path(&span_path, &["row"]))?;
297        let from = non_negative(span.get("from"), path(&span_path, &["from"]))?;
298        let to = positive(span.get("to"), path(&span_path, &["to"]))?;
299        if to <= from {
300            return Err(Issue::new(span_path, "region span must be non-empty"));
301        }
302        if previous.is_some_and(|(previous_row, previous_to)| {
303            row < previous_row || (row == previous_row && from < previous_to)
304        }) {
305            return Err(Issue::new(
306                span_path,
307                "region spans must be non-overlapping row-major runs",
308            ));
309        }
310        if row < bounds.row
311            || row >= bounds.row + bounds.height
312            || from < bounds.column
313            || to > bounds.column + bounds.width
314        {
315            return Err(Issue::new(
316                span_path,
317                "region span lies outside regionBounds",
318            ));
319        }
320        previous = Some((row, to));
321    }
322    Ok(())
323}
324fn check_evidence(value: &Value, at: &[String], limits: &Limits) -> Result<(), Issue> {
325    let evidence = as_object(value, at)?;
326    strict(
327        evidence,
328        &["source", "method", "strength", "providerId"],
329        at,
330    )?;
331    let source = text(evidence.get("source"), path(at, &["source"]), limits)?;
332    if ![
333        "framework",
334        "application",
335        "terminal",
336        "recognizer",
337        "driver",
338    ]
339    .contains(&source)
340    {
341        return Err(Issue::new(path(at, &["source"]), "invalid evidence source"));
342    }
343    let method = text(evidence.get("method"), path(at, &["method"]), limits)?;
344    if ![
345        "native",
346        "instrumented",
347        "declared",
348        "correlated",
349        "measured",
350        "derived",
351        "heuristic",
352    ]
353    .contains(&method)
354    {
355        return Err(Issue::new(path(at, &["method"]), "invalid evidence method"));
356    }
357    let strength = text(evidence.get("strength"), path(at, &["strength"]), limits)?;
358    if !["authoritative", "diagnostic"].contains(&strength) {
359        return Err(Issue::new(
360            path(at, &["strength"]),
361            "invalid evidence strength",
362        ));
363    }
364    if text(
365        evidence.get("providerId"),
366        path(at, &["providerId"]),
367        limits,
368    )?
369    .is_empty()
370    {
371        return Err(Issue::new(
372            path(at, &["providerId"]),
373            "providerId must not be empty",
374        ));
375    }
376    Ok(())
377}
378
379fn check_observation<F>(
380    value: &Value,
381    at: &[String],
382    limits: &Limits,
383    known: F,
384) -> Result<(), Issue>
385where
386    F: Fn(&Value, &[String]) -> Result<(), Issue>,
387{
388    let object = as_object(value, at)?;
389    match object.get("status").and_then(Value::as_str) {
390        Some("known") => {
391            strict(object, &["status", "value", "evidence"], at)?;
392            let evidence_path = path(at, &["evidence"]);
393            check_evidence(
394                object.get("evidence").unwrap_or(&Value::Null),
395                &evidence_path,
396                limits,
397            )?;
398            known(
399                object.get("value").unwrap_or(&Value::Null),
400                &path(at, &["value"]),
401            )
402        }
403        Some("absent") => {
404            strict(object, &["status", "reason", "evidence"], at)?;
405            let reason = text(object.get("reason"), path(at, &["reason"]), limits)?;
406            if !["detached", "not-displayed", "not-laid-out"].contains(&reason) {
407                return Err(Issue::new(path(at, &["reason"]), "invalid absent reason"));
408            }
409            let evidence_path = path(at, &["evidence"]);
410            check_evidence(
411                object.get("evidence").unwrap_or(&Value::Null),
412                &evidence_path,
413                limits,
414            )?;
415            if object
416                .get("evidence")
417                .and_then(Value::as_object)
418                .and_then(|value| value.get("strength"))
419                .and_then(Value::as_str)
420                != Some("authoritative")
421            {
422                return Err(Issue::new(
423                    path(&evidence_path, &["strength"]),
424                    "absent observation requires authoritative evidence",
425                ));
426            }
427            Ok(())
428        }
429        Some("unknown") => {
430            strict(object, &["status", "reason"], at)?;
431            let reason = text(object.get("reason"), path(at, &["reason"]), limits)?;
432            if ![
433                "awaiting-revision-pair",
434                "provider-refresh",
435                "stale-revision",
436            ]
437            .contains(&reason)
438            {
439                return Err(Issue::new(path(at, &["reason"]), "invalid unknown reason"));
440            }
441            Ok(())
442        }
443        Some("unsupported") => {
444            strict(object, &["status", "capability", "reason"], at)?;
445            text(object.get("capability"), path(at, &["capability"]), limits)?;
446            text(object.get("reason"), path(at, &["reason"]), limits)?;
447            Ok(())
448        }
449        _ => Err(Issue::new(
450            path(at, &["status"]),
451            "invalid observation status",
452        )),
453    }
454}
455
456fn check_semantic_value(value: &Value, at: &[String], limits: &Limits) -> Result<(), Issue> {
457    let object = as_object(value, at)?;
458    let check_sensitivity = || -> Result<(), Issue> {
459        let sensitivity = text(
460            object.get("sensitivity"),
461            path(at, &["sensitivity"]),
462            limits,
463        )?;
464        if !["public", "sensitive"].contains(&sensitivity) {
465            return Err(Issue::new(
466                path(at, &["sensitivity"]),
467                "invalid semantic value sensitivity",
468            ));
469        }
470        Ok(())
471    };
472    match object.get("status").and_then(Value::as_str) {
473        Some("known") => {
474            strict(object, &["status", "value", "sensitivity", "evidence"], at)?;
475            text(object.get("value"), path(at, &["value"]), limits)?;
476            check_sensitivity()?;
477            check_evidence(
478                object.get("evidence").unwrap_or(&Value::Null),
479                &path(at, &["evidence"]),
480                limits,
481            )
482        }
483        Some("absent") => {
484            strict(object, &["status", "reason", "evidence"], at)?;
485            let reason = text(object.get("reason"), path(at, &["reason"]), limits)?;
486            if !["detached", "not-displayed", "not-laid-out", "no-value"].contains(&reason) {
487                return Err(Issue::new(
488                    path(at, &["reason"]),
489                    "invalid semantic value absent reason",
490                ));
491            }
492            let evidence_path = path(at, &["evidence"]);
493            check_evidence(
494                object.get("evidence").unwrap_or(&Value::Null),
495                &evidence_path,
496                limits,
497            )?;
498            if object
499                .get("evidence")
500                .and_then(Value::as_object)
501                .and_then(|v| v.get("strength"))
502                .and_then(Value::as_str)
503                != Some("authoritative")
504            {
505                return Err(Issue::new(
506                    path(&evidence_path, &["strength"]),
507                    "absent semantic value requires authoritative evidence",
508                ));
509            }
510            Ok(())
511        }
512        Some("unknown") => {
513            strict(object, &["status", "reason"], at)?;
514            let reason = text(object.get("reason"), path(at, &["reason"]), limits)?;
515            if ![
516                "awaiting-revision-pair",
517                "provider-refresh",
518                "stale-revision",
519            ]
520            .contains(&reason)
521            {
522                return Err(Issue::new(
523                    path(at, &["reason"]),
524                    "invalid semantic value unknown reason",
525                ));
526            }
527            Ok(())
528        }
529        Some("unsupported") => {
530            strict(object, &["status", "capability", "reason"], at)?;
531            if text(object.get("capability"), path(at, &["capability"]), limits)?
532                != "semantic-value"
533            {
534                return Err(Issue::new(
535                    path(at, &["capability"]),
536                    "expected semantic-value capability",
537                ));
538            }
539            let reason = text(object.get("reason"), path(at, &["reason"]), limits)?;
540            if !["capability", "framework-unobservable", "not-negotiated"].contains(&reason) {
541                return Err(Issue::new(
542                    path(at, &["reason"]),
543                    "invalid semantic value unsupported reason",
544                ));
545            }
546            Ok(())
547        }
548        Some("withheld") => {
549            strict(object, &["status", "reason", "sensitivity"], at)?;
550            let reason = text(object.get("reason"), path(at, &["reason"]), limits)?;
551            if !["sensitive", "artifact-policy", "provider-policy"].contains(&reason) {
552                return Err(Issue::new(
553                    path(at, &["reason"]),
554                    "invalid semantic value withheld reason",
555                ));
556            }
557            check_sensitivity()
558        }
559        _ => Err(Issue::new(
560            path(at, &["status"]),
561            "invalid semantic value status",
562        )),
563    }
564}
565
566fn check_extended(value: &Value, at: &[String], limits: &Limits) -> Result<(), Issue> {
567    match value {
568        Value::Null | Value::Bool(_) => Ok(()),
569        Value::String(_) => {
570            text(Some(value), at.to_vec(), limits)?;
571            Ok(())
572        }
573        Value::Number(number) => {
574            let valid = number
575                .as_f64()
576                .is_some_and(|value| value.is_finite() && value.abs() <= MAX_SAFE_INTEGER as f64);
577            if valid {
578                Ok(())
579            } else {
580                Err(Issue::new(
581                    at.to_vec(),
582                    "expected a finite JSON number in the safe range",
583                ))
584            }
585        }
586        Value::Array(items) => {
587            if items.len() > limits.max_relation_targets {
588                return Err(Issue::too_big(
589                    at.to_vec(),
590                    format!("expected at most {} items", limits.max_relation_targets),
591                ));
592            }
593            for (index, item) in items.iter().enumerate() {
594                check_extended(item, &path(at, &[&index.to_string()]), limits)?;
595            }
596            Ok(())
597        }
598        Value::Object(fields) => {
599            if fields.len() > limits.max_relation_targets {
600                return Err(Issue::too_big(
601                    at.to_vec(),
602                    format!(
603                        "expected at most {} properties",
604                        limits.max_relation_targets
605                    ),
606                ));
607            }
608            for (key, item) in fields {
609                text(Some(&Value::String(key.clone())), path(at, &[key]), limits)?;
610                check_extended(item, &path(at, &[key]), limits)?;
611            }
612            Ok(())
613        }
614    }
615}
616
617/// Where a semantic fact came from. Closed set, so an unknown source is a
618/// rejection rather than a silently ignored annotation.
619const PROVENANCE_SOURCES: [&str; 6] = [
620    "annotation",
621    "recognizer",
622    "framework",
623    "application",
624    "correlation",
625    "heuristic",
626];
627
628fn check_relations(value: &Value, at: &[String], limits: &Limits) -> Result<(), Issue> {
629    let Some(items) = value.as_array() else {
630        return Err(Issue::new(at.to_vec(), "expected an array"));
631    };
632    if items.len() > limits.max_relation_targets {
633        return Err(Issue::too_big(
634            at.to_vec(),
635            format!("expected at most {} items", limits.max_relation_targets),
636        ));
637    }
638    for (index, item) in items.iter().enumerate() {
639        text(Some(item), path(at, &[&index.to_string()]), limits)?;
640    }
641    Ok(())
642}
643
644const PHYSICAL_INPUT_RECIPE_ACTIONS: [&str; 4] = ["focus", "activate", "toggle", "setValue"];
645
646fn check_input_recipes(value: &Value, at: &[String], limits: &Limits) -> Result<(), Issue> {
647    let Some(items) = value.as_array() else {
648        return Err(Issue::new(at.to_vec(), "expected an array"));
649    };
650    if items.len() > PHYSICAL_INPUT_RECIPE_ACTIONS.len() {
651        return Err(Issue::too_big(at.to_vec(), "too many input recipes"));
652    }
653    let mut seen = HashSet::new();
654    for (index, item) in items.iter().enumerate() {
655        let item_path = path(at, &[&index.to_string()]);
656        let recipe = as_object(item, &item_path)?;
657        strict(recipe, &["action", "requiresFocus", "steps"], &item_path)?;
658        let action = text(recipe.get("action"), path(&item_path, &["action"]), limits)?;
659        if !PHYSICAL_INPUT_RECIPE_ACTIONS.contains(&action) {
660            return Err(Issue::new(
661                path(&item_path, &["action"]),
662                "expected a physical input recipe action",
663            ));
664        }
665        if !seen.insert(action) {
666            return Err(Issue::new(
667                path(&item_path, &["action"]),
668                "input recipe actions must be unique",
669            ));
670        }
671        let requires_focus = boolean(
672            recipe.get("requiresFocus"),
673            path(&item_path, &["requiresFocus"]),
674        )?;
675        if action == "focus" && requires_focus {
676            return Err(Issue::new(
677                path(&item_path, &["requiresFocus"]),
678                "focus recipe cannot require focus",
679            ));
680        }
681        let steps_path = path(&item_path, &["steps"]);
682        let Some(steps) = recipe.get("steps").and_then(Value::as_array) else {
683            return Err(Issue::new(steps_path, "expected an array"));
684        };
685        if steps.is_empty() {
686            return Err(Issue::new(steps_path, "expected at least one step"));
687        }
688        if steps.len() > limits.max_relation_targets {
689            return Err(Issue::too_big(steps_path, "too many recipe steps"));
690        }
691        let mut insert_count = 0;
692        for (step_index, raw_step) in steps.iter().enumerate() {
693            let step_path = path(&item_path, &["steps", &step_index.to_string()]);
694            let step = as_object(raw_step, &step_path)?;
695            let kind = text(step.get("kind"), path(&step_path, &["kind"]), limits)?;
696            match kind {
697                "press" => {
698                    strict(step, &["kind", "key"], &step_path)?;
699                    let key = text(step.get("key"), path(&step_path, &["key"]), limits)?;
700                    if key.is_empty() {
701                        return Err(Issue::new(
702                            path(&step_path, &["key"]),
703                            "key must not be empty",
704                        ));
705                    }
706                }
707                "insert-action-value" => {
708                    strict(step, &["kind"], &step_path)?;
709                    insert_count += 1;
710                }
711                _ => {
712                    return Err(Issue::new(
713                        path(&step_path, &["kind"]),
714                        "expected a physical input recipe step",
715                    ));
716                }
717            }
718        }
719        if (action == "setValue" && insert_count != 1)
720            || (action != "setValue" && insert_count != 0)
721        {
722            return Err(Issue::new(
723                path(&item_path, &["steps"]),
724                "setValue requires exactly one insert-action-value step",
725            ));
726        }
727    }
728    Ok(())
729}
730
731fn check_node_schema(value: &Value, at: &[String], limits: &Limits) -> Result<(), Issue> {
732    let object = as_object(value, at)?;
733    strict(object, &NODE_KEYS, at)?;
734
735    if text(object.get("id"), path(at, &["id"]), limits)?.is_empty() {
736        return Err(Issue::new(path(at, &["id"]), "node id must not be empty"));
737    }
738    if object.contains_key("parentId") {
739        text(object.get("parentId"), path(at, &["parentId"]), limits)?;
740    }
741    match object.get("role").and_then(Value::as_str) {
742        Some(role) if valid_role(role) => {}
743        _ => {
744            return Err(Issue::new(
745                path(at, &["role"]),
746                "expected one of the semantic roles",
747            ))
748        }
749    }
750    text(object.get("name"), path(at, &["name"]), limits)?;
751    for key in ["description", "testId", "frameworkType"] {
752        if object.contains_key(key) {
753            text(object.get(key), path(at, &[key]), limits)?;
754        }
755    }
756    if object
757        .get("opaqueChildren")
758        .is_some_and(|value| !value.is_boolean())
759    {
760        return Err(Issue::new(
761            path(at, &["opaqueChildren"]),
762            "expected boolean",
763        ));
764    }
765    if let Some(value) = object.get("value") {
766        check_semantic_value(value, &path(at, &["value"]), limits)?;
767    }
768    if let Some(source) = object.get("p") {
769        if !source
770            .as_str()
771            .is_some_and(|value| PROVENANCE_SOURCES.contains(&value))
772        {
773            return Err(Issue::new(
774                path(at, &["p"]),
775                "expected one of the provenance sources",
776            ));
777        }
778    }
779    if let Some(per_field) = object.get("px") {
780        let Some(fields) = per_field.as_object() else {
781            return Err(Issue::new(path(at, &["px"]), "expected an object"));
782        };
783        for (field, source) in fields {
784            text(
785                Some(&Value::String(field.clone())),
786                path(at, &["px", field]),
787                limits,
788            )?;
789            if !source
790                .as_str()
791                .is_some_and(|value| PROVENANCE_SOURCES.contains(&value))
792            {
793                return Err(Issue::new(
794                    path(at, &["px", field]),
795                    "expected one of the provenance sources",
796                ));
797            }
798        }
799    }
800    if object.get("role").and_then(Value::as_str) == Some("generic") {
801        // An unrecognised widget must at least name what the framework called
802        // it. An empty string carries no more than its absence, so both fail.
803        let named = object
804            .get("frameworkType")
805            .and_then(Value::as_str)
806            .is_some_and(|value| !value.is_empty());
807        if !named {
808            let id = object.get("id").and_then(Value::as_str).unwrap_or("");
809            return Err(Issue::new(
810                path(at, &["frameworkType"]),
811                format!(
812                    "node {id} has role 'generic' without a frameworkType; an unrecognised \
813                     widget must name what the framework called it"
814                ),
815            ));
816        }
817    }
818    let geometry_path = path(at, &["geometry"]);
819    let geometry = as_object(
820        object.get("geometry").unwrap_or(&Value::Null),
821        &geometry_path,
822    )?;
823    strict(
824        geometry,
825        &["displayed", "intendedRect", "visibleRect"],
826        &geometry_path,
827    )?;
828    check_observation(
829        geometry.get("displayed").unwrap_or(&Value::Null),
830        &path(&geometry_path, &["displayed"]),
831        limits,
832        |value, at| boolean(Some(value), at.to_vec()).map(|_| ()),
833    )?;
834    for field in ["intendedRect", "visibleRect"] {
835        check_observation(
836            geometry.get(field).unwrap_or(&Value::Null),
837            &path(&geometry_path, &[field]),
838            limits,
839            |value, at| check_rect(value, at).map(|_| ()),
840        )?;
841    }
842    if let Some(scroll) = object.get("scroll") {
843        check_observation(
844            scroll,
845            &path(at, &["scroll"]),
846            limits,
847            |value, scroll_path| {
848                let state = as_object(value, scroll_path)?;
849                strict(
850                    state,
851                    &["axis", "offset", "viewport", "extent"],
852                    scroll_path,
853                )?;
854                if !matches!(
855                    state.get("axis").and_then(Value::as_str),
856                    Some("vertical") | Some("horizontal")
857                ) {
858                    return Err(Issue::new(
859                        path(scroll_path, &["axis"]),
860                        "invalid scroll axis",
861                    ));
862                }
863                let offset = non_negative(state.get("offset"), path(scroll_path, &["offset"]))?;
864                let viewport =
865                    non_negative(state.get("viewport"), path(scroll_path, &["viewport"]))?;
866                let extent = non_negative(state.get("extent"), path(scroll_path, &["extent"]))?;
867                if offset + viewport > extent {
868                    return Err(Issue::new(
869                        scroll_path.to_vec(),
870                        "scroll state must fit inside its extent",
871                    ));
872                }
873                Ok(())
874            },
875        )?;
876    }
877    if let Some(painted_region) = object.get("paintedRegion") {
878        check_observation(
879            painted_region,
880            &path(at, &["paintedRegion"]),
881            limits,
882            |value, painted_path| check_painted_region(value, painted_path, limits),
883        )?;
884    }
885    if let Some(state) = object.get("state") {
886        check_state(state, &path(at, &["state"]))?;
887        // Every cell outside the visible area and the node still visible
888        // cannot both be true. Refusing the pair keeps `offscreen` a claim
889        // about scrolling rather than a second, weaker way of saying hidden.
890        let offscreen = state.get("offscreen").and_then(Value::as_bool) == Some(true);
891        let hidden = state.get("hidden").and_then(Value::as_bool) == Some(true);
892        if offscreen && !hidden {
893            let id = object.get("id").and_then(Value::as_str).unwrap_or("");
894            return Err(Issue::new(
895                path(at, &["state", "offscreen"]),
896                format!(
897                    "node {id}: state.offscreen implies state.hidden — every cell is outside \
898                     the visible area, so the node cannot also be visible"
899                ),
900            ));
901        }
902    }
903    if let Some(extended) = object.get("extended") {
904        if !extended.is_object() {
905            return Err(Issue::new(path(at, &["extended"]), "expected an object"));
906        }
907        check_extended(extended, &path(at, &["extended"]), limits)?;
908    }
909    let mut declared_actions = HashSet::new();
910    if let Some(actions) = object.get("actions") {
911        let Some(items) = actions.as_array() else {
912            return Err(Issue::new(path(at, &["actions"]), "expected an array"));
913        };
914        if items.len() > crate::roles::SEMANTIC_ACTIONS.len() {
915            return Err(Issue::too_big(path(at, &["actions"]), "too many actions"));
916        }
917        for (index, item) in items.iter().enumerate() {
918            match item.as_str() {
919                Some(action) if valid_action(action) => {
920                    declared_actions.insert(action);
921                }
922                _ => {
923                    return Err(Issue::new(
924                        path(at, &["actions", &index.to_string()]),
925                        "expected one of the semantic actions",
926                    ))
927                }
928            }
929        }
930    }
931    if let Some(recipes) = object.get("inputRecipes") {
932        check_input_recipes(recipes, &path(at, &["inputRecipes"]), limits)?;
933        for (index, recipe) in recipes
934            .as_array()
935            .expect("validated recipe array")
936            .iter()
937            .enumerate()
938        {
939            let action = recipe
940                .get("action")
941                .and_then(Value::as_str)
942                .expect("validated recipe action");
943            if !declared_actions.contains(action) {
944                return Err(Issue::new(
945                    path(at, &["inputRecipes", &index.to_string(), "action"]),
946                    format!("input recipe {action:?} requires the matching semantic action intent"),
947                ));
948            }
949        }
950    }
951    for key in ["labelledBy", "describedBy"] {
952        if let Some(relations) = object.get(key) {
953            check_relations(relations, &path(at, &[key]), limits)?;
954        }
955    }
956    if let Some(ranges) = object.get("textRanges") {
957        let Some(items) = ranges.as_array() else {
958            return Err(Issue::new(path(at, &["textRanges"]), "expected an array"));
959        };
960        if items.len() > limits.max_relation_targets {
961            return Err(Issue::too_big(
962                path(at, &["textRanges"]),
963                "too many text ranges",
964            ));
965        }
966        for (index, item) in items.iter().enumerate() {
967            let item_path = path(at, &["textRanges", &index.to_string()]);
968            let entry = as_object(item, &item_path)?;
969            strict(entry, &["startOffset", "endOffset", "rect"], &item_path)?;
970            non_negative(entry.get("startOffset"), path(&item_path, &["startOffset"]))?;
971            non_negative(entry.get("endOffset"), path(&item_path, &["endOffset"]))?;
972            let rect = entry
973                .get("rect")
974                .ok_or_else(|| Issue::new(path(&item_path, &["rect"]), "expected an object"))?;
975            check_rect(rect, &path(&item_path, &["rect"]))?;
976        }
977    }
978    Ok(())
979}
980
981fn check_cursor(value: &Value, at: &[String]) -> Result<(), Issue> {
982    let object = as_object(value, at)?;
983    strict(object, &["row", "column", "visible", "shape"], at)?;
984    non_negative(object.get("row"), path(at, &["row"]))?;
985    non_negative(object.get("column"), path(at, &["column"]))?;
986    boolean(object.get("visible"), path(at, &["visible"]))?;
987    if let Some(shape) = object.get("shape") {
988        if !matches!(
989            shape.as_str(),
990            Some("block") | Some("underline") | Some("bar")
991        ) {
992            return Err(Issue::new(
993                path(at, &["shape"]),
994                "expected 'block', 'underline' or 'bar'",
995            ));
996        }
997    }
998    Ok(())
999}
1000
1001const SNAPSHOT_KEYS: [&str; 11] = [
1002    "v",
1003    "sessionId",
1004    "revision",
1005    "columns",
1006    "rows",
1007    "cursor",
1008    "rootIds",
1009    "nodes",
1010    "coordinateSpace",
1011    "hitGrid",
1012    "providerEvidence",
1013];
1014
1015fn check_snapshot_schema(value: &Value, limits: &Limits) -> Result<(), Issue> {
1016    let root: Vec<String> = Vec::new();
1017    let object = as_object(value, &root)?;
1018    let version = object.get("v").and_then(Value::as_i64);
1019    strict(object, &SNAPSHOT_KEYS, &root)?;
1020
1021    if version != Some(2) {
1022        return Err(Issue::new(vec!["v".into()], "expected the literal 2"));
1023    }
1024    if text(object.get("sessionId"), vec!["sessionId".into()], limits)?.is_empty() {
1025        return Err(Issue::new(
1026            vec!["sessionId".into()],
1027            "sessionId must not be empty",
1028        ));
1029    }
1030    positive(object.get("revision"), vec!["revision".into()])?;
1031    positive(object.get("columns"), vec!["columns".into()])?;
1032    positive(object.get("rows"), vec!["rows".into()])?;
1033    if let Some(cursor) = object.get("cursor") {
1034        check_cursor(cursor, &["cursor".to_owned()])?;
1035    }
1036
1037    let Some(root_ids) = object.get("rootIds").and_then(Value::as_array) else {
1038        return Err(Issue::new(vec!["rootIds".into()], "expected an array"));
1039    };
1040    if root_ids.len() > limits.max_nodes {
1041        return Err(Issue::too_big(
1042            vec!["rootIds".into()],
1043            format!("expected at most {} items", limits.max_nodes),
1044        ));
1045    }
1046    for (index, item) in root_ids.iter().enumerate() {
1047        text(
1048            Some(item),
1049            vec!["rootIds".into(), index.to_string()],
1050            limits,
1051        )?;
1052    }
1053
1054    let Some(nodes) = object.get("nodes").and_then(Value::as_array) else {
1055        return Err(Issue::new(vec!["nodes".into()], "expected an array"));
1056    };
1057    if nodes.len() > limits.max_nodes {
1058        return Err(Issue::too_big(
1059            vec!["nodes".into()],
1060            format!("expected at most {} items", limits.max_nodes),
1061        ));
1062    }
1063    for (index, node) in nodes.iter().enumerate() {
1064        check_node_schema(node, &["nodes".to_owned(), index.to_string()], limits)?;
1065    }
1066    check_observation(
1067        object.get("coordinateSpace").unwrap_or(&Value::Null),
1068        &["coordinateSpace".into()],
1069        limits,
1070        |value, at| {
1071            if matches!(
1072                value.as_str(),
1073                Some("viewport-cells") | Some("framework-local-cells")
1074            ) {
1075                Ok(())
1076            } else {
1077                Err(Issue::new(at.to_vec(), "invalid coordinate space"))
1078            }
1079        },
1080    )?;
1081    if let Some(provider_evidence) = object.get("providerEvidence") {
1082        let entries = provider_evidence
1083            .as_array()
1084            .ok_or_else(|| Issue::new(vec!["providerEvidence".into()], "expected an array"))?;
1085        if entries.len() > 64 {
1086            return Err(Issue::too_big(
1087                vec!["providerEvidence".into()],
1088                "expected at most 64 items",
1089            ));
1090        }
1091        for (index, raw) in entries.iter().enumerate() {
1092            let entry_path = vec!["providerEvidence".into(), index.to_string()];
1093            let entry = as_object(raw, &entry_path)?;
1094            let status = entry.get("status").and_then(Value::as_str);
1095            match status {
1096                Some("available") => {
1097                    strict(
1098                        entry,
1099                        &[
1100                            "providerId",
1101                            "sessionId",
1102                            "revision",
1103                            "status",
1104                            "evidence",
1105                            "pointerRegions",
1106                            "paintedRegions",
1107                            "inputModes",
1108                            "focusState",
1109                            "actionRecipes",
1110                            "scrollStates",
1111                            "hitGrid",
1112                        ],
1113                        &entry_path,
1114                    )?;
1115                    check_evidence(
1116                        entry.get("evidence").unwrap_or(&Value::Null),
1117                        &path(&entry_path, &["evidence"]),
1118                        limits,
1119                    )?;
1120                    let regions = entry
1121                        .get("pointerRegions")
1122                        .and_then(Value::as_array)
1123                        .ok_or_else(|| {
1124                            Issue::new(path(&entry_path, &["pointerRegions"]), "expected an array")
1125                        })?;
1126                    if regions.len() > limits.max_nodes {
1127                        return Err(Issue::too_big(
1128                            path(&entry_path, &["pointerRegions"]),
1129                            "too many pointer regions",
1130                        ));
1131                    }
1132                    for (region_index, raw_region) in regions.iter().enumerate() {
1133                        let region_path =
1134                            path(&entry_path, &["pointerRegions", &region_index.to_string()]);
1135                        let region = as_object(raw_region, &region_path)?;
1136                        strict(
1137                            region,
1138                            &["recipientId", "regionBounds", "spans"],
1139                            &region_path,
1140                        )?;
1141                        if text(
1142                            region.get("recipientId"),
1143                            path(&region_path, &["recipientId"]),
1144                            limits,
1145                        )?
1146                        .is_empty()
1147                        {
1148                            return Err(Issue::new(
1149                                path(&region_path, &["recipientId"]),
1150                                "recipient id must not be empty",
1151                            ));
1152                        }
1153                        let projected = serde_json::json!({
1154                            "regionBounds": region.get("regionBounds"),
1155                            "spans": region.get("spans"),
1156                        });
1157                        check_painted_region(&projected, &region_path, limits)?;
1158                    }
1159                    if let Some(painted_regions) = entry.get("paintedRegions") {
1160                        let regions = painted_regions.as_array().ok_or_else(|| {
1161                            Issue::new(path(&entry_path, &["paintedRegions"]), "expected an array")
1162                        })?;
1163                        if regions.len() > limits.max_nodes {
1164                            return Err(Issue::too_big(
1165                                path(&entry_path, &["paintedRegions"]),
1166                                "too many painted regions",
1167                            ));
1168                        }
1169                        let mut recipients = HashSet::new();
1170                        for (region_index, raw_region) in regions.iter().enumerate() {
1171                            let region_path =
1172                                path(&entry_path, &["paintedRegions", &region_index.to_string()]);
1173                            let region = as_object(raw_region, &region_path)?;
1174                            strict(
1175                                region,
1176                                &["recipientId", "regionBounds", "spans"],
1177                                &region_path,
1178                            )?;
1179                            let recipient = text(
1180                                region.get("recipientId"),
1181                                path(&region_path, &["recipientId"]),
1182                                limits,
1183                            )?;
1184                            if recipient.is_empty() || !recipients.insert(recipient) {
1185                                return Err(Issue::new(
1186                                    path(&region_path, &["recipientId"]),
1187                                    "painted region recipients must be non-empty and unique",
1188                                ));
1189                            }
1190                            let projected = serde_json::json!({
1191                                "regionBounds": region.get("regionBounds"),
1192                                "spans": region.get("spans"),
1193                            });
1194                            check_painted_region(&projected, &region_path, limits)?;
1195                        }
1196                    }
1197                    if let Some(raw_modes) = entry.get("inputModes") {
1198                        let modes_path = path(&entry_path, &["inputModes"]);
1199                        let modes = as_object(raw_modes, &modes_path)?;
1200                        strict(
1201                            modes,
1202                            &["mouseTracking", "mouseEncoding", "focusReporting"],
1203                            &modes_path,
1204                        )?;
1205                        if !matches!(
1206                            modes.get("mouseTracking").and_then(Value::as_str),
1207                            Some("none" | "x10" | "vt200" | "drag" | "any")
1208                        ) {
1209                            return Err(Issue::new(
1210                                path(&modes_path, &["mouseTracking"]),
1211                                "invalid mouse tracking mode",
1212                            ));
1213                        }
1214                        if !matches!(
1215                            modes.get("mouseEncoding").and_then(Value::as_str),
1216                            Some("default" | "sgr" | "urxvt" | "utf8")
1217                        ) {
1218                            return Err(Issue::new(
1219                                path(&modes_path, &["mouseEncoding"]),
1220                                "invalid mouse encoding",
1221                            ));
1222                        }
1223                        if !matches!(
1224                            modes.get("focusReporting").and_then(Value::as_str),
1225                            Some("on" | "off")
1226                        ) {
1227                            return Err(Issue::new(
1228                                path(&modes_path, &["focusReporting"]),
1229                                "invalid focus reporting mode",
1230                            ));
1231                        }
1232                    }
1233                    if let Some(raw_focus) = entry.get("focusState") {
1234                        let focus_path = path(&entry_path, &["focusState"]);
1235                        let focus = as_object(raw_focus, &focus_path)?;
1236                        match focus.get("status").and_then(Value::as_str) {
1237                            Some("focused") => {
1238                                strict(focus, &["status", "recipientId"], &focus_path)?;
1239                                if text(
1240                                    focus.get("recipientId"),
1241                                    path(&focus_path, &["recipientId"]),
1242                                    limits,
1243                                )?
1244                                .is_empty()
1245                                {
1246                                    return Err(Issue::new(
1247                                        path(&focus_path, &["recipientId"]),
1248                                        "recipient id must not be empty",
1249                                    ));
1250                                }
1251                            }
1252                            Some("none") => strict(focus, &["status"], &focus_path)?,
1253                            _ => {
1254                                return Err(Issue::new(
1255                                    path(&focus_path, &["status"]),
1256                                    "expected focused or none",
1257                                ))
1258                            }
1259                        }
1260                    }
1261                    if let Some(action_recipes) = entry.get("actionRecipes") {
1262                        let targets = action_recipes.as_array().ok_or_else(|| {
1263                            Issue::new(path(&entry_path, &["actionRecipes"]), "expected an array")
1264                        })?;
1265                        if targets.len() > limits.max_nodes {
1266                            return Err(Issue::too_big(
1267                                path(&entry_path, &["actionRecipes"]),
1268                                "too many action recipe recipients",
1269                            ));
1270                        }
1271                        let mut recipients = HashSet::new();
1272                        for (target_index, raw_target) in targets.iter().enumerate() {
1273                            let target_path =
1274                                path(&entry_path, &["actionRecipes", &target_index.to_string()]);
1275                            let target = as_object(raw_target, &target_path)?;
1276                            strict(target, &["recipientId", "recipes"], &target_path)?;
1277                            let recipient = text(
1278                                target.get("recipientId"),
1279                                path(&target_path, &["recipientId"]),
1280                                limits,
1281                            )?;
1282                            if recipient.is_empty() {
1283                                return Err(Issue::new(
1284                                    path(&target_path, &["recipientId"]),
1285                                    "recipient id must not be empty",
1286                                ));
1287                            }
1288                            if !recipients.insert(recipient) {
1289                                return Err(Issue::new(
1290                                    path(&target_path, &["recipientId"]),
1291                                    "provider action recipe recipients must be unique",
1292                                ));
1293                            }
1294                            check_input_recipes(
1295                                target.get("recipes").unwrap_or(&Value::Null),
1296                                &path(&target_path, &["recipes"]),
1297                                limits,
1298                            )?;
1299                        }
1300                    }
1301                    if let Some(scroll_states) = entry.get("scrollStates") {
1302                        let states = scroll_states.as_array().ok_or_else(|| {
1303                            Issue::new(path(&entry_path, &["scrollStates"]), "expected an array")
1304                        })?;
1305                        if states.len() > limits.max_nodes {
1306                            return Err(Issue::too_big(
1307                                path(&entry_path, &["scrollStates"]),
1308                                "too many scroll recipients",
1309                            ));
1310                        }
1311                        let mut recipients = HashSet::new();
1312                        for (state_index, raw_state) in states.iter().enumerate() {
1313                            let state_path =
1314                                path(&entry_path, &["scrollStates", &state_index.to_string()]);
1315                            let state = as_object(raw_state, &state_path)?;
1316                            strict(
1317                                state,
1318                                &["recipientId", "axis", "offset", "viewport", "extent"],
1319                                &state_path,
1320                            )?;
1321                            let recipient = text(
1322                                state.get("recipientId"),
1323                                path(&state_path, &["recipientId"]),
1324                                limits,
1325                            )?;
1326                            if recipient.is_empty() || !recipients.insert(recipient) {
1327                                return Err(Issue::new(
1328                                    path(&state_path, &["recipientId"]),
1329                                    "scroll recipients must be non-empty and unique",
1330                                ));
1331                            }
1332                            if !matches!(
1333                                state.get("axis").and_then(Value::as_str),
1334                                Some("vertical") | Some("horizontal")
1335                            ) {
1336                                return Err(Issue::new(
1337                                    path(&state_path, &["axis"]),
1338                                    "invalid scroll axis",
1339                                ));
1340                            }
1341                            let offset =
1342                                non_negative(state.get("offset"), path(&state_path, &["offset"]))?;
1343                            let viewport = non_negative(
1344                                state.get("viewport"),
1345                                path(&state_path, &["viewport"]),
1346                            )?;
1347                            let extent =
1348                                non_negative(state.get("extent"), path(&state_path, &["extent"]))?;
1349                            if offset + viewport > extent {
1350                                return Err(Issue::new(
1351                                    state_path,
1352                                    "scroll state must fit inside its extent",
1353                                ));
1354                            }
1355                        }
1356                    }
1357                }
1358                Some("lost") | Some("violation") => {
1359                    strict(
1360                        entry,
1361                        &["providerId", "sessionId", "revision", "status", "reason"],
1362                        &entry_path,
1363                    )?;
1364                    let reason = text(entry.get("reason"), path(&entry_path, &["reason"]), limits)?;
1365                    if reason.is_empty() {
1366                        return Err(Issue::new(
1367                            path(&entry_path, &["reason"]),
1368                            "provider reason must not be empty",
1369                        ));
1370                    }
1371                }
1372                _ => {
1373                    return Err(Issue::new(
1374                        path(&entry_path, &["status"]),
1375                        "expected available, lost, or violation",
1376                    ));
1377                }
1378            }
1379            for key in ["providerId", "sessionId"] {
1380                let value = text(entry.get(key), path(&entry_path, &[key]), limits)?;
1381                if value.is_empty() {
1382                    return Err(Issue::new(
1383                        path(&entry_path, &[key]),
1384                        "provider identity must not be empty",
1385                    ));
1386                }
1387            }
1388            positive(entry.get("revision"), path(&entry_path, &["revision"]))?;
1389        }
1390    }
1391    check_observation(
1392        object.get("hitGrid").unwrap_or(&Value::Null),
1393        &["hitGrid".into()],
1394        limits,
1395        |value, at| {
1396            let grid = as_object(value, at)?;
1397            strict(grid, &["regions"], at)?;
1398            let regions = grid
1399                .get("regions")
1400                .and_then(Value::as_array)
1401                .ok_or_else(|| Issue::new(path(at, &["regions"]), "expected an array"))?;
1402            if regions.len() > limits.max_nodes {
1403                return Err(Issue::too_big(
1404                    path(at, &["regions"]),
1405                    "too many hit regions",
1406                ));
1407            }
1408            let mut previous: Option<Rect> = None;
1409            for (index, raw) in regions.iter().enumerate() {
1410                let rp = path(at, &["regions", &index.to_string()]);
1411                let region = as_object(raw, &rp)?;
1412                strict(region, &["rect", "recipientId"], &rp)?;
1413                let rect = check_rect(
1414                    region.get("rect").unwrap_or(&Value::Null),
1415                    &path(&rp, &["rect"]),
1416                )?;
1417                if rect.width <= 0 || rect.height != 1 {
1418                    return Err(Issue::new(
1419                        path(&rp, &["rect"]),
1420                        "hit regions must be non-empty row runs",
1421                    ));
1422                }
1423                if previous.as_ref().is_some_and(|last| {
1424                    rect.row < last.row
1425                        || (rect.row == last.row && rect.column < last.column + last.width)
1426                }) {
1427                    return Err(Issue::new(
1428                        path(&rp, &["rect"]),
1429                        "hit regions must be non-overlapping row-major runs",
1430                    ));
1431                }
1432                previous = Some(rect);
1433                text(
1434                    region.get("recipientId"),
1435                    path(&rp, &["recipientId"]),
1436                    limits,
1437                )?;
1438            }
1439            Ok(())
1440        },
1441    )?;
1442    Ok(())
1443}
1444
1445// -- structural layer ------------------------------------------------------
1446
1447fn intersects_viewport(rect: &Rect, columns: i64, rows: i64) -> bool {
1448    rect.width != 0
1449        && rect.height != 0
1450        && rect.column < columns
1451        && rect.row < rows
1452        && rect.column + rect.width > 0
1453        && rect.row + rect.height > 0
1454}
1455
1456/// Whether the sum still round-trips through a JavaScript number.
1457fn is_safe_sum(left: i64, right: i64) -> bool {
1458    matches!(left.checked_add(right), Some(sum) if sum.abs() <= MAX_SAFE_INTEGER)
1459}
1460
1461fn node_id(node: &Map<String, Value>) -> &str {
1462    node.get("id").and_then(Value::as_str).unwrap_or_default()
1463}
1464
1465fn check_node_shape(
1466    node: &Map<String, Value>,
1467    columns: i64,
1468    rows: i64,
1469    ids: &HashSet<&str>,
1470    limits: &Limits,
1471) -> Result<(), ValidationError> {
1472    let id = node_id(node);
1473
1474    if let Some(painted) = node.get("paintedRegion").and_then(Value::as_object) {
1475        if painted.get("status").and_then(Value::as_str) == Some("known") {
1476            let spans = painted["value"]["spans"]
1477                .as_array()
1478                .expect("painted region schema checked");
1479            for span in spans {
1480                let row = span["row"].as_i64().unwrap_or_default();
1481                let from = span["from"].as_i64().unwrap_or_default();
1482                let to = span["to"].as_i64().unwrap_or_default();
1483                if row >= rows || from >= columns || to > columns {
1484                    return Err(ValidationError::new(
1485                        "bad-rect",
1486                        format!("node {id} painted region span lies outside the viewport"),
1487                    ));
1488                }
1489            }
1490        }
1491    }
1492
1493    if let Some(ranges) = node.get("textRanges").and_then(Value::as_array) {
1494        for item in ranges {
1495            let entry = item.as_object().expect("schema layer checked the shape");
1496            let start = entry
1497                .get("startOffset")
1498                .and_then(Value::as_i64)
1499                .unwrap_or_default();
1500            let end = entry
1501                .get("endOffset")
1502                .and_then(Value::as_i64)
1503                .unwrap_or_default();
1504            if end < start {
1505                return Err(ValidationError::new(
1506                    "bad-rect",
1507                    format!("node {id}: text range ends before it starts"),
1508                ));
1509            }
1510            let rect = check_rect(&entry["rect"], &[]).map_err(Issue::into_error)?;
1511            if !is_safe_sum(rect.row, rect.height) {
1512                return Err(ValidationError::new(
1513                    "bad-rect",
1514                    format!("node {id}: text range rect overflows the safe-integer range"),
1515                ));
1516            }
1517        }
1518    }
1519
1520    for field in ["labelledBy", "describedBy"] {
1521        let Some(targets) = node.get(field).and_then(Value::as_array) else {
1522            continue;
1523        };
1524        if targets.len() > limits.max_relation_targets {
1525            return Err(ValidationError::new(
1526                "count",
1527                format!(
1528                    "node {id}: {field} exceeds {} targets",
1529                    limits.max_relation_targets
1530                ),
1531            ));
1532        }
1533        for target in targets {
1534            let target = target.as_str().unwrap_or_default();
1535            if !ids.contains(target) {
1536                return Err(ValidationError::new(
1537                    "missing-parent",
1538                    format!("node {id}: {field} references unknown node {target}"),
1539                ));
1540            }
1541        }
1542    }
1543    Ok(())
1544}
1545
1546/// Depth of every node (roots at 1), or the id where a parent chain closes.
1547fn compute_depths<'a>(
1548    nodes: &[&'a Map<String, Value>],
1549    by_id: &HashMap<&'a str, &'a Map<String, Value>>,
1550) -> Result<HashMap<&'a str, usize>, &'a str> {
1551    let mut depths: HashMap<&str, usize> = HashMap::new();
1552
1553    for start in nodes {
1554        if depths.contains_key(node_id(start)) {
1555            continue;
1556        }
1557        let mut chain: Vec<&str> = Vec::new();
1558        let mut on_chain: HashSet<&str> = HashSet::new();
1559        let mut current: Option<&&Map<String, Value>> = Some(start);
1560
1561        while let Some(node) = current {
1562            let id = node_id(node);
1563            if depths.contains_key(id) {
1564                break;
1565            }
1566            if !on_chain.insert(id) {
1567                return Err(id);
1568            }
1569            chain.push(id);
1570            current = match node.get("parentId").and_then(Value::as_str) {
1571                Some(parent_id) => by_id.get(parent_id),
1572                None => None,
1573            };
1574        }
1575
1576        let mut depth = current.map(|node| depths[node_id(node)]).unwrap_or(0);
1577        for id in chain.iter().rev() {
1578            depth += 1;
1579            depths.insert(id, depth);
1580        }
1581    }
1582    Ok(depths)
1583}
1584
1585///
1586/// Checks unique ids, existing and acyclic parents, the closed role, action
1587/// and state vocabularies, bounded strings and counts, and rects that
1588/// intersect the viewport unless the node is hidden.
1589///
1590/// # Errors
1591/// Returns a [`ValidationError`] whose `code` matches the reference
1592/// implementation's.
1593pub fn validate_snapshot(value: &Value, limits: &Limits) -> Result<(), ValidationError> {
1594    if let Err(violation) = project_dto(value, limits.max_depth) {
1595        let code = if violation.code == "dto-depth" {
1596            "depth"
1597        } else {
1598            "schema"
1599        };
1600        return Err(ValidationError::new(code, violation.to_string()));
1601    }
1602
1603    let serialised = serde_json::to_vec(value)
1604        .map_err(|_| ValidationError::new("schema", "snapshot is not JSON-serialisable"))?;
1605    if serialised.len() > limits.max_snapshot_bytes {
1606        return Err(ValidationError::new(
1607            "bytes",
1608            format!(
1609                "snapshot is {} bytes, ceiling is {}",
1610                serialised.len(),
1611                limits.max_snapshot_bytes
1612            ),
1613        ));
1614    }
1615
1616    check_snapshot_schema(value, limits).map_err(Issue::into_error)?;
1617
1618    let snapshot = value.as_object().expect("schema layer checked the shape");
1619    let columns = snapshot["columns"]
1620        .as_i64()
1621        .expect("checked by the schema layer");
1622    let rows = snapshot["rows"]
1623        .as_i64()
1624        .expect("checked by the schema layer");
1625
1626    let raw_nodes = snapshot["nodes"]
1627        .as_array()
1628        .expect("checked by the schema layer");
1629    if raw_nodes.len() > limits.max_nodes {
1630        return Err(ValidationError::new(
1631            "count",
1632            format!(
1633                "snapshot carries {} nodes, ceiling is {}",
1634                raw_nodes.len(),
1635                limits.max_nodes
1636            ),
1637        ));
1638    }
1639
1640    let mut nodes: Vec<&Map<String, Value>> = Vec::with_capacity(raw_nodes.len());
1641    let mut by_id: HashMap<&str, &Map<String, Value>> = HashMap::with_capacity(raw_nodes.len());
1642    for raw in raw_nodes {
1643        let node = raw.as_object().expect("checked by the schema layer");
1644        let id = node_id(node);
1645        if by_id.insert(id, node).is_some() {
1646            return Err(ValidationError::new(
1647                "duplicate-id",
1648                format!("node id {id} appears more than once"),
1649            ));
1650        }
1651        nodes.push(node);
1652    }
1653
1654    let mut root_ids: HashSet<&str> = HashSet::new();
1655    for raw in snapshot["rootIds"]
1656        .as_array()
1657        .expect("checked by the schema layer")
1658    {
1659        let id = raw.as_str().unwrap_or_default();
1660        if !root_ids.insert(id) {
1661            return Err(ValidationError::new(
1662                "duplicate-id",
1663                format!("root id {id} appears more than once"),
1664            ));
1665        }
1666        let Some(node) = by_id.get(id) else {
1667            return Err(ValidationError::new(
1668                "missing-parent",
1669                format!("rootIds references unknown node {id}"),
1670            ));
1671        };
1672        if node.contains_key("parentId") {
1673            return Err(ValidationError::new(
1674                "schema",
1675                format!("root node {id} declares a parent"),
1676            ));
1677        }
1678    }
1679
1680    let ids: HashSet<&str> = by_id.keys().copied().collect();
1681
1682    if snapshot["v"].as_i64() == Some(2) {
1683        let hit_grid = snapshot["hitGrid"]
1684            .as_object()
1685            .expect("checked by the schema layer");
1686        if hit_grid.get("status").and_then(Value::as_str) == Some("known") {
1687            for raw in hit_grid["value"]["regions"]
1688                .as_array()
1689                .expect("checked by the schema layer")
1690            {
1691                let region = raw.as_object().expect("checked by the schema layer");
1692                let recipient_id = region["recipientId"].as_str().unwrap_or_default();
1693                if !ids.contains(recipient_id) {
1694                    return Err(ValidationError::new(
1695                        "missing-parent",
1696                        format!("hitGrid references unknown recipient {recipient_id}"),
1697                    ));
1698                }
1699                let rect = check_rect(&region["rect"], &[]).map_err(Issue::into_error)?;
1700                if !intersects_viewport(&rect, columns, rows) {
1701                    return Err(ValidationError::new(
1702                        "bad-rect",
1703                        format!(
1704                            "hitGrid region for {recipient_id} does not intersect the viewport"
1705                        ),
1706                    ));
1707                }
1708            }
1709        }
1710    }
1711
1712    if let Some(provider_evidence) = snapshot.get("providerEvidence").and_then(Value::as_array) {
1713        let mut provider_ids = HashSet::new();
1714        for raw in provider_evidence {
1715            let entry = raw.as_object().expect("provider evidence schema checked");
1716            let provider_id = entry["providerId"].as_str().unwrap_or_default();
1717            if !provider_ids.insert(provider_id) {
1718                return Err(ValidationError::new(
1719                    "provider",
1720                    format!("provider evidence id {provider_id} appears more than once"),
1721                ));
1722            }
1723            if entry["sessionId"] != snapshot["sessionId"]
1724                || entry["revision"] != snapshot["revision"]
1725            {
1726                return Err(ValidationError::new(
1727                    "provider",
1728                    format!("provider {provider_id} evidence does not match snapshot revision"),
1729                ));
1730            }
1731            if entry.get("status").and_then(Value::as_str) != Some("available") {
1732                continue;
1733            }
1734            if let Some(focus) = entry.get("focusState").and_then(Value::as_object) {
1735                if focus.get("status").and_then(Value::as_str) == Some("focused") {
1736                    let recipient = focus
1737                        .get("recipientId")
1738                        .and_then(Value::as_str)
1739                        .unwrap_or_default();
1740                    if !by_id.contains_key(recipient) {
1741                        return Err(ValidationError::new(
1742                            "missing-parent",
1743                            format!("provider {provider_id} focus references unknown recipient {recipient}"),
1744                        ));
1745                    }
1746                }
1747            }
1748            for target in entry
1749                .get("actionRecipes")
1750                .and_then(Value::as_array)
1751                .into_iter()
1752                .flatten()
1753            {
1754                let target = target
1755                    .as_object()
1756                    .expect("action recipe target schema checked");
1757                let recipient = target["recipientId"].as_str().unwrap_or_default();
1758                let Some(node) = by_id.get(recipient) else {
1759                    return Err(ValidationError::new(
1760                        "missing-parent",
1761                        format!(
1762                            "provider {provider_id} action recipes reference unknown recipient {recipient}"
1763                        ),
1764                    ));
1765                };
1766                let intents: HashSet<&str> = node
1767                    .get("actions")
1768                    .and_then(Value::as_array)
1769                    .into_iter()
1770                    .flatten()
1771                    .filter_map(Value::as_str)
1772                    .collect();
1773                for recipe in target["recipes"].as_array().expect("recipe schema checked") {
1774                    let action = recipe["action"].as_str().unwrap_or_default();
1775                    if !intents.contains(action) {
1776                        return Err(ValidationError::new(
1777                            "provider",
1778                            format!(
1779                                "provider {provider_id} {action} recipe has no matching semantic action intent on {recipient}"
1780                            ),
1781                        ));
1782                    }
1783                }
1784            }
1785            for state in entry
1786                .get("scrollStates")
1787                .and_then(Value::as_array)
1788                .into_iter()
1789                .flatten()
1790            {
1791                let state = state.as_object().expect("scroll state schema checked");
1792                let recipient = state["recipientId"].as_str().unwrap_or_default();
1793                if !by_id.contains_key(recipient) {
1794                    return Err(ValidationError::new(
1795                        "missing-parent",
1796                        format!("provider {provider_id} scroll state references unknown recipient {recipient}"),
1797                    ));
1798                }
1799            }
1800            for region in entry
1801                .get("paintedRegions")
1802                .and_then(Value::as_array)
1803                .into_iter()
1804                .flatten()
1805            {
1806                let region = region.as_object().expect("painted region schema checked");
1807                let recipient = region["recipientId"].as_str().unwrap_or_default();
1808                if !by_id.contains_key(recipient) {
1809                    return Err(ValidationError::new(
1810                        "missing-parent",
1811                        format!("provider {provider_id} painted region references unknown recipient {recipient}"),
1812                    ));
1813                }
1814                for span in region["spans"]
1815                    .as_array()
1816                    .expect("painted region schema checked")
1817                {
1818                    let row = span["row"].as_i64().unwrap_or_default();
1819                    let from = span["from"].as_i64().unwrap_or_default();
1820                    let to = span["to"].as_i64().unwrap_or_default();
1821                    if row >= rows || from >= columns || to > columns {
1822                        return Err(ValidationError::new(
1823                            "bad-rect",
1824                            format!("provider {provider_id} painted region for {recipient} lies outside the viewport"),
1825                        ));
1826                    }
1827                }
1828            }
1829        }
1830    }
1831
1832    for node in &nodes {
1833        let id = node_id(node);
1834        match node.get("parentId").and_then(Value::as_str) {
1835            None => {
1836                if !root_ids.contains(id) {
1837                    return Err(ValidationError::new(
1838                        "schema",
1839                        format!("parentless node {id} is missing from rootIds"),
1840                    ));
1841                }
1842            }
1843            Some(parent_id) if !by_id.contains_key(parent_id) => {
1844                return Err(ValidationError::new(
1845                    "missing-parent",
1846                    format!("node {id} references unknown parent {parent_id}"),
1847                ));
1848            }
1849            Some(parent_id) if parent_id == id => {
1850                return Err(ValidationError::new(
1851                    "cycle",
1852                    format!("node {id} is its own parent"),
1853                ));
1854            }
1855            Some(_) => {}
1856        }
1857        check_node_shape(node, columns, rows, &ids, limits)?;
1858    }
1859
1860    match compute_depths(&nodes, &by_id) {
1861        Err(cycle_at) => {
1862            return Err(ValidationError::new(
1863                "cycle",
1864                format!("parent chain through node {cycle_at} is cyclic"),
1865            ))
1866        }
1867        Ok(depths) => {
1868            for (id, depth) in depths {
1869                if depth > limits.max_depth {
1870                    return Err(ValidationError::new(
1871                        "depth",
1872                        format!(
1873                            "node {id} sits at depth {depth}, ceiling is {}",
1874                            limits.max_depth
1875                        ),
1876                    ));
1877                }
1878            }
1879        }
1880    }
1881
1882    if let Some(cursor) = snapshot.get("cursor").and_then(Value::as_object) {
1883        let row = cursor["row"].as_i64().expect("checked by the schema layer");
1884        let column = cursor["column"]
1885            .as_i64()
1886            .expect("checked by the schema layer");
1887        if row >= rows || column >= columns {
1888            return Err(ValidationError::new(
1889                "bad-rect",
1890                format!("cursor ({row}, {column}) lies outside the viewport"),
1891            ));
1892        }
1893    }
1894
1895    Ok(())
1896}