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") || has("rect") {
49            "bad-rect"
50        } else if self.too_big && (has("nodes") || has("rootIds")) {
51            "count"
52        } else if self.message.contains("UTF-8 bytes") {
53            "string-bytes"
54        } else {
55            "schema"
56        }
57    }
58
59    fn into_error(self) -> ValidationError {
60        let where_ = if self.path.is_empty() {
61            "<root>".to_owned()
62        } else {
63            self.path.join(".")
64        };
65        let code = self.code();
66        ValidationError::new(code, format!("{where_}: {}", self.message))
67    }
68}
69
70fn path(base: &[String], more: &[&str]) -> Vec<String> {
71    let mut next: Vec<String> = base.to_vec();
72    next.extend(more.iter().map(|element| (*element).to_owned()));
73    next
74}
75
76// -- scalar checks ---------------------------------------------------------
77
78fn as_object<'a>(value: &'a Value, at: &[String]) -> Result<&'a Map<String, Value>, Issue> {
79    value
80        .as_object()
81        .ok_or_else(|| Issue::new(at.to_vec(), "expected an object"))
82}
83
84fn strict(object: &Map<String, Value>, allowed: &[&str], at: &[String]) -> Result<(), Issue> {
85    let mut unknown: Vec<&str> = object
86        .keys()
87        .map(String::as_str)
88        .filter(|key| !allowed.contains(key))
89        .collect();
90    if unknown.is_empty() {
91        return Ok(());
92    }
93    unknown.sort_unstable();
94    Err(Issue::new(
95        at.to_vec(),
96        format!("Unrecognized key(s) in object: {}", unknown.join(", ")),
97    ))
98}
99
100fn whole(
101    value: Option<&Value>,
102    at: Vec<String>,
103    message: &str,
104    ok: impl Fn(i64) -> bool,
105) -> Result<i64, Issue> {
106    let number = value
107        .and_then(Value::as_i64)
108        .filter(|n| n.abs() <= MAX_SAFE_INTEGER);
109    match number {
110        Some(number) if ok(number) => Ok(number),
111        _ => Err(Issue::new(at, message)),
112    }
113}
114
115fn safe_int(value: Option<&Value>, at: Vec<String>) -> Result<i64, Issue> {
116    whole(value, at, "expected a safe integer", |_| true)
117}
118
119fn non_negative(value: Option<&Value>, at: Vec<String>) -> Result<i64, Issue> {
120    whole(value, at, "expected a non-negative safe integer", |n| {
121        n >= 0
122    })
123}
124
125fn positive(value: Option<&Value>, at: Vec<String>) -> Result<i64, Issue> {
126    whole(value, at, "expected a positive safe integer", |n| n > 0)
127}
128
129fn text<'a>(value: Option<&'a Value>, at: Vec<String>, limits: &Limits) -> Result<&'a str, Issue> {
130    let Some(text) = value.and_then(Value::as_str) else {
131        return Err(Issue::new(at, "expected a string"));
132    };
133    if text.len() > limits.max_string_bytes {
134        return Err(Issue::new(
135            at,
136            format!("expected at most {} UTF-8 bytes", limits.max_string_bytes),
137        ));
138    }
139    Ok(text)
140}
141
142fn boolean(value: Option<&Value>, at: Vec<String>) -> Result<bool, Issue> {
143    value
144        .and_then(Value::as_bool)
145        .ok_or_else(|| Issue::new(at, "expected a boolean"))
146}
147
148// -- schema layer ----------------------------------------------------------
149
150const RECT_KEYS: [&str; 4] = ["row", "column", "width", "height"];
151
152fn check_rect(value: &Value, at: &[String]) -> Result<Rect, Issue> {
153    let object = as_object(value, at)?;
154    strict(object, &RECT_KEYS, at)?;
155    Ok(Rect {
156        row: safe_int(object.get("row"), path(at, &["row"]))?,
157        column: safe_int(object.get("column"), path(at, &["column"]))?,
158        width: non_negative(object.get("width"), path(at, &["width"]))?,
159        height: non_negative(object.get("height"), path(at, &["height"]))?,
160    })
161}
162
163/// The four numbers of a rect, once they are known to be well-formed.
164struct Rect {
165    row: i64,
166    column: i64,
167    width: i64,
168    height: i64,
169}
170
171const STATE_BOOL_KEYS: [&str; 10] = [
172    "disabled",
173    "focused",
174    "selected",
175    "expanded",
176    "modal",
177    "busy",
178    "hidden",
179    "offscreen",
180    "readonly",
181    "multiline",
182];
183
184/// Every field a `state` object may carry, as this client knows them.
185pub const STATE_KEYS: [&str; 17] = [
186    "disabled",
187    "focused",
188    "selected",
189    "expanded",
190    "modal",
191    "busy",
192    "hidden",
193    "offscreen",
194    "readonly",
195    "multiline",
196    "checked",
197    "orientation",
198    "level",
199    "positionInSet",
200    "setSize",
201    "scrollOffset",
202    "scrollExtent",
203];
204
205fn check_state(value: &Value, at: &[String]) -> Result<(), Issue> {
206    let object = as_object(value, at)?;
207    strict(object, &STATE_KEYS, at)?;
208    for key in STATE_BOOL_KEYS {
209        if object.contains_key(key) {
210            boolean(object.get(key), path(at, &[key]))?;
211        }
212    }
213    if let Some(checked) = object.get("checked") {
214        if !checked.is_boolean() && checked.as_str() != Some("mixed") {
215            return Err(Issue::new(
216                path(at, &["checked"]),
217                "expected a boolean or 'mixed'",
218            ));
219        }
220    }
221    if let Some(orientation) = object.get("orientation") {
222        if !matches!(orientation.as_str(), Some("horizontal") | Some("vertical")) {
223            return Err(Issue::new(
224                path(at, &["orientation"]),
225                "expected 'horizontal' or 'vertical'",
226            ));
227        }
228    }
229    for key in ["level", "positionInSet"] {
230        if object.contains_key(key) {
231            positive(object.get(key), path(at, &[key]))?;
232        }
233    }
234    for key in ["setSize", "scrollOffset", "scrollExtent"] {
235        if object.contains_key(key) {
236            non_negative(object.get(key), path(at, &[key]))?;
237        }
238    }
239    Ok(())
240}
241
242/// Every field a node may carry, as this client knows them.
243pub const NODE_KEYS: [&str; 18] = [
244    "id",
245    "parentId",
246    "role",
247    "name",
248    "description",
249    "value",
250    "bounds",
251    "state",
252    "extended",
253    "actions",
254    "labelledBy",
255    "describedBy",
256    "textRanges",
257    "testId",
258    "frameworkType",
259    "occlusion",
260    "p",
261    "px",
262];
263const NODE_V2_KEYS: [&str; 17] = [
264    "id",
265    "parentId",
266    "role",
267    "name",
268    "description",
269    "value",
270    "geometry",
271    "state",
272    "extended",
273    "actions",
274    "labelledBy",
275    "describedBy",
276    "textRanges",
277    "testId",
278    "frameworkType",
279    "p",
280    "px",
281];
282
283fn check_observation<F>(
284    value: &Value,
285    at: &[String],
286    limits: &Limits,
287    known: F,
288) -> Result<(), Issue>
289where
290    F: Fn(&Value, &[String]) -> Result<(), Issue>,
291{
292    let object = as_object(value, at)?;
293    match object.get("status").and_then(Value::as_str) {
294        Some("known") => {
295            strict(object, &["status", "value", "evidence"], at)?;
296            text(object.get("evidence"), path(at, &["evidence"]), limits)?;
297            known(
298                object.get("value").unwrap_or(&Value::Null),
299                &path(at, &["value"]),
300            )
301        }
302        Some("absent") | Some("unknown") => {
303            strict(object, &["status", "reason"], at)?;
304            text(object.get("reason"), path(at, &["reason"]), limits)?;
305            Ok(())
306        }
307        Some("unsupported") => {
308            strict(object, &["status", "capability", "reason"], at)?;
309            text(object.get("capability"), path(at, &["capability"]), limits)?;
310            text(object.get("reason"), path(at, &["reason"]), limits)?;
311            Ok(())
312        }
313        _ => Err(Issue::new(
314            path(at, &["status"]),
315            "invalid observation status",
316        )),
317    }
318}
319
320fn check_extended(value: &Value, at: &[String], limits: &Limits) -> Result<(), Issue> {
321    match value {
322        Value::Null | Value::Bool(_) => Ok(()),
323        Value::String(_) => {
324            text(Some(value), at.to_vec(), limits)?;
325            Ok(())
326        }
327        Value::Number(number) => {
328            let valid = number
329                .as_f64()
330                .is_some_and(|value| value.is_finite() && value.abs() <= MAX_SAFE_INTEGER as f64);
331            if valid {
332                Ok(())
333            } else {
334                Err(Issue::new(
335                    at.to_vec(),
336                    "expected a finite JSON number in the safe range",
337                ))
338            }
339        }
340        Value::Array(items) => {
341            if items.len() > limits.max_relation_targets {
342                return Err(Issue::too_big(
343                    at.to_vec(),
344                    format!("expected at most {} items", limits.max_relation_targets),
345                ));
346            }
347            for (index, item) in items.iter().enumerate() {
348                check_extended(item, &path(at, &[&index.to_string()]), limits)?;
349            }
350            Ok(())
351        }
352        Value::Object(fields) => {
353            if fields.len() > limits.max_relation_targets {
354                return Err(Issue::too_big(
355                    at.to_vec(),
356                    format!(
357                        "expected at most {} properties",
358                        limits.max_relation_targets
359                    ),
360                ));
361            }
362            for (key, item) in fields {
363                text(Some(&Value::String(key.clone())), path(at, &[key]), limits)?;
364                check_extended(item, &path(at, &[key]), limits)?;
365            }
366            Ok(())
367        }
368    }
369}
370
371/// Where a semantic fact came from. Closed set, so an unknown source is a
372/// rejection rather than a silently ignored annotation.
373const PROVENANCE_SOURCES: [&str; 5] = [
374    "annotation",
375    "recognizer",
376    "framework",
377    "correlation",
378    "heuristic",
379];
380
381fn check_relations(value: &Value, at: &[String], limits: &Limits) -> Result<(), Issue> {
382    let Some(items) = value.as_array() else {
383        return Err(Issue::new(at.to_vec(), "expected an array"));
384    };
385    if items.len() > limits.max_relation_targets {
386        return Err(Issue::too_big(
387            at.to_vec(),
388            format!("expected at most {} items", limits.max_relation_targets),
389        ));
390    }
391    for (index, item) in items.iter().enumerate() {
392        text(Some(item), path(at, &[&index.to_string()]), limits)?;
393    }
394    Ok(())
395}
396
397fn check_node_schema(value: &Value, at: &[String], limits: &Limits, v2: bool) -> Result<(), Issue> {
398    let object = as_object(value, at)?;
399    if v2 && object.contains_key("bounds") {
400        return Err(Issue::new(
401            path(at, &["bounds"]),
402            "legacy bounds are forbidden in v2",
403        ));
404    }
405    let node_keys: &[&str] = if v2 { &NODE_V2_KEYS } else { &NODE_KEYS };
406    strict(object, node_keys, at)?;
407
408    if text(object.get("id"), path(at, &["id"]), limits)?.is_empty() {
409        return Err(Issue::new(path(at, &["id"]), "node id must not be empty"));
410    }
411    if object.contains_key("parentId") {
412        text(object.get("parentId"), path(at, &["parentId"]), limits)?;
413    }
414    match object.get("role").and_then(Value::as_str) {
415        Some(role) if valid_role(role) => {}
416        _ => {
417            return Err(Issue::new(
418                path(at, &["role"]),
419                "expected one of the v1 semantic roles",
420            ))
421        }
422    }
423    text(object.get("name"), path(at, &["name"]), limits)?;
424    for key in ["description", "value", "testId", "frameworkType"] {
425        if object.contains_key(key) {
426            text(object.get(key), path(at, &[key]), limits)?;
427        }
428    }
429    if let Some(occlusion) = object.get("occlusion") {
430        let known = matches!(occlusion.as_str(), Some("known") | Some("unknown"));
431        if !known {
432            return Err(Issue::new(
433                path(at, &["occlusion"]),
434                "expected 'known' or 'unknown'",
435            ));
436        }
437    }
438    if let Some(source) = object.get("p") {
439        if !source
440            .as_str()
441            .is_some_and(|value| PROVENANCE_SOURCES.contains(&value))
442        {
443            return Err(Issue::new(
444                path(at, &["p"]),
445                "expected one of the provenance sources",
446            ));
447        }
448    }
449    if let Some(per_field) = object.get("px") {
450        let Some(fields) = per_field.as_object() else {
451            return Err(Issue::new(path(at, &["px"]), "expected an object"));
452        };
453        for (field, source) in fields {
454            text(
455                Some(&Value::String(field.clone())),
456                path(at, &["px", field]),
457                limits,
458            )?;
459            if !source
460                .as_str()
461                .is_some_and(|value| PROVENANCE_SOURCES.contains(&value))
462            {
463                return Err(Issue::new(
464                    path(at, &["px", field]),
465                    "expected one of the provenance sources",
466                ));
467            }
468        }
469    }
470    if object.get("role").and_then(Value::as_str) == Some("generic") {
471        // An unrecognised widget must at least name what the framework called
472        // it. An empty string carries no more than its absence, so both fail.
473        let named = object
474            .get("frameworkType")
475            .and_then(Value::as_str)
476            .is_some_and(|value| !value.is_empty());
477        if !named {
478            let id = object.get("id").and_then(Value::as_str).unwrap_or("");
479            return Err(Issue::new(
480                path(at, &["frameworkType"]),
481                format!(
482                    "node {id} has role 'generic' without a frameworkType; an unrecognised \
483                     widget must name what the framework called it"
484                ),
485            ));
486        }
487    }
488    if let Some(bounds) = object.get("bounds") {
489        check_rect(bounds, &path(at, &["bounds"]))?;
490    }
491    if v2 {
492        let geometry_path = path(at, &["geometry"]);
493        let geometry = as_object(
494            object.get("geometry").unwrap_or(&Value::Null),
495            &geometry_path,
496        )?;
497        strict(
498            geometry,
499            &["displayed", "intendedRect", "visibleRect"],
500            &geometry_path,
501        )?;
502        check_observation(
503            geometry.get("displayed").unwrap_or(&Value::Null),
504            &path(&geometry_path, &["displayed"]),
505            limits,
506            |value, at| boolean(Some(value), at.to_vec()).map(|_| ()),
507        )?;
508        for field in ["intendedRect", "visibleRect"] {
509            check_observation(
510                geometry.get(field).unwrap_or(&Value::Null),
511                &path(&geometry_path, &[field]),
512                limits,
513                |value, at| check_rect(value, at).map(|_| ()),
514            )?;
515        }
516    }
517    if let Some(state) = object.get("state") {
518        check_state(state, &path(at, &["state"]))?;
519        // Every cell outside the visible area and the node still visible
520        // cannot both be true. Refusing the pair keeps `offscreen` a claim
521        // about scrolling rather than a second, weaker way of saying hidden.
522        let offscreen = state.get("offscreen").and_then(Value::as_bool) == Some(true);
523        let hidden = state.get("hidden").and_then(Value::as_bool) == Some(true);
524        if offscreen && !hidden {
525            let id = object.get("id").and_then(Value::as_str).unwrap_or("");
526            return Err(Issue::new(
527                path(at, &["state", "offscreen"]),
528                format!(
529                    "node {id}: state.offscreen implies state.hidden — every cell is outside \
530                     the visible area, so the node cannot also be visible"
531                ),
532            ));
533        }
534    }
535    if let Some(extended) = object.get("extended") {
536        if !extended.is_object() {
537            return Err(Issue::new(path(at, &["extended"]), "expected an object"));
538        }
539        check_extended(extended, &path(at, &["extended"]), limits)?;
540    }
541    if let Some(actions) = object.get("actions") {
542        let Some(items) = actions.as_array() else {
543            return Err(Issue::new(path(at, &["actions"]), "expected an array"));
544        };
545        if items.len() > crate::roles::SEMANTIC_ACTIONS.len() {
546            return Err(Issue::too_big(path(at, &["actions"]), "too many actions"));
547        }
548        for (index, item) in items.iter().enumerate() {
549            match item.as_str() {
550                Some(action) if valid_action(action) => {}
551                _ => {
552                    return Err(Issue::new(
553                        path(at, &["actions", &index.to_string()]),
554                        "expected one of the v1 semantic actions",
555                    ))
556                }
557            }
558        }
559    }
560    for key in ["labelledBy", "describedBy"] {
561        if let Some(relations) = object.get(key) {
562            check_relations(relations, &path(at, &[key]), limits)?;
563        }
564    }
565    if let Some(ranges) = object.get("textRanges") {
566        let Some(items) = ranges.as_array() else {
567            return Err(Issue::new(path(at, &["textRanges"]), "expected an array"));
568        };
569        if items.len() > limits.max_relation_targets {
570            return Err(Issue::too_big(
571                path(at, &["textRanges"]),
572                "too many text ranges",
573            ));
574        }
575        for (index, item) in items.iter().enumerate() {
576            let item_path = path(at, &["textRanges", &index.to_string()]);
577            let entry = as_object(item, &item_path)?;
578            strict(entry, &["startOffset", "endOffset", "rect"], &item_path)?;
579            non_negative(entry.get("startOffset"), path(&item_path, &["startOffset"]))?;
580            non_negative(entry.get("endOffset"), path(&item_path, &["endOffset"]))?;
581            let rect = entry
582                .get("rect")
583                .ok_or_else(|| Issue::new(path(&item_path, &["rect"]), "expected an object"))?;
584            check_rect(rect, &path(&item_path, &["rect"]))?;
585        }
586    }
587    Ok(())
588}
589
590fn check_cursor(value: &Value, at: &[String]) -> Result<(), Issue> {
591    let object = as_object(value, at)?;
592    strict(object, &["row", "column", "visible", "shape"], at)?;
593    non_negative(object.get("row"), path(at, &["row"]))?;
594    non_negative(object.get("column"), path(at, &["column"]))?;
595    boolean(object.get("visible"), path(at, &["visible"]))?;
596    if let Some(shape) = object.get("shape") {
597        if !matches!(
598            shape.as_str(),
599            Some("block") | Some("underline") | Some("bar")
600        ) {
601            return Err(Issue::new(
602                path(at, &["shape"]),
603                "expected 'block', 'underline' or 'bar'",
604            ));
605        }
606    }
607    Ok(())
608}
609
610const SNAPSHOT_KEYS: [&str; 8] = [
611    "v",
612    "sessionId",
613    "revision",
614    "columns",
615    "rows",
616    "cursor",
617    "rootIds",
618    "nodes",
619];
620const SNAPSHOT_V2_KEYS: [&str; 10] = [
621    "v",
622    "sessionId",
623    "revision",
624    "columns",
625    "rows",
626    "cursor",
627    "rootIds",
628    "nodes",
629    "coordinateSpace",
630    "hitGrid",
631];
632
633fn check_snapshot_schema(value: &Value, limits: &Limits) -> Result<(), Issue> {
634    let root: Vec<String> = Vec::new();
635    let object = as_object(value, &root)?;
636    let version = object.get("v").and_then(Value::as_i64);
637    let v2 = version == Some(2);
638    let snapshot_keys: &[&str] = if v2 {
639        &SNAPSHOT_V2_KEYS
640    } else {
641        &SNAPSHOT_KEYS
642    };
643    strict(object, snapshot_keys, &root)?;
644
645    if !matches!(version, Some(1) | Some(2)) {
646        return Err(Issue::new(vec!["v".into()], "expected the literal 1 or 2"));
647    }
648    if text(object.get("sessionId"), vec!["sessionId".into()], limits)?.is_empty() {
649        return Err(Issue::new(
650            vec!["sessionId".into()],
651            "sessionId must not be empty",
652        ));
653    }
654    positive(object.get("revision"), vec!["revision".into()])?;
655    positive(object.get("columns"), vec!["columns".into()])?;
656    positive(object.get("rows"), vec!["rows".into()])?;
657    if let Some(cursor) = object.get("cursor") {
658        check_cursor(cursor, &["cursor".to_owned()])?;
659    }
660
661    let Some(root_ids) = object.get("rootIds").and_then(Value::as_array) else {
662        return Err(Issue::new(vec!["rootIds".into()], "expected an array"));
663    };
664    if root_ids.len() > limits.max_nodes {
665        return Err(Issue::too_big(
666            vec!["rootIds".into()],
667            format!("expected at most {} items", limits.max_nodes),
668        ));
669    }
670    for (index, item) in root_ids.iter().enumerate() {
671        text(
672            Some(item),
673            vec!["rootIds".into(), index.to_string()],
674            limits,
675        )?;
676    }
677
678    let Some(nodes) = object.get("nodes").and_then(Value::as_array) else {
679        return Err(Issue::new(vec!["nodes".into()], "expected an array"));
680    };
681    if nodes.len() > limits.max_nodes {
682        return Err(Issue::too_big(
683            vec!["nodes".into()],
684            format!("expected at most {} items", limits.max_nodes),
685        ));
686    }
687    for (index, node) in nodes.iter().enumerate() {
688        check_node_schema(node, &["nodes".to_owned(), index.to_string()], limits, v2)?;
689    }
690    if v2 {
691        check_observation(
692            object.get("coordinateSpace").unwrap_or(&Value::Null),
693            &["coordinateSpace".into()],
694            limits,
695            |value, at| {
696                if matches!(
697                    value.as_str(),
698                    Some("viewport-cells") | Some("framework-local-cells")
699                ) {
700                    Ok(())
701                } else {
702                    Err(Issue::new(at.to_vec(), "invalid coordinate space"))
703                }
704            },
705        )?;
706        check_observation(
707            object.get("hitGrid").unwrap_or(&Value::Null),
708            &["hitGrid".into()],
709            limits,
710            |value, at| {
711                let grid = as_object(value, at)?;
712                strict(grid, &["regions"], at)?;
713                let regions = grid
714                    .get("regions")
715                    .and_then(Value::as_array)
716                    .ok_or_else(|| Issue::new(path(at, &["regions"]), "expected an array"))?;
717                if regions.len() > limits.max_nodes {
718                    return Err(Issue::too_big(
719                        path(at, &["regions"]),
720                        "too many hit regions",
721                    ));
722                }
723                let mut previous: Option<Rect> = None;
724                for (index, raw) in regions.iter().enumerate() {
725                    let rp = path(at, &["regions", &index.to_string()]);
726                    let region = as_object(raw, &rp)?;
727                    strict(region, &["rect", "recipientId"], &rp)?;
728                    let rect = check_rect(
729                        region.get("rect").unwrap_or(&Value::Null),
730                        &path(&rp, &["rect"]),
731                    )?;
732                    if rect.width <= 0 || rect.height != 1 {
733                        return Err(Issue::new(
734                            path(&rp, &["rect"]),
735                            "hit regions must be non-empty row runs",
736                        ));
737                    }
738                    if previous.as_ref().is_some_and(|last| {
739                        rect.row < last.row
740                            || (rect.row == last.row && rect.column < last.column + last.width)
741                    }) {
742                        return Err(Issue::new(
743                            path(&rp, &["rect"]),
744                            "hit regions must be non-overlapping row-major runs",
745                        ));
746                    }
747                    previous = Some(rect);
748                    text(
749                        region.get("recipientId"),
750                        path(&rp, &["recipientId"]),
751                        limits,
752                    )?;
753                }
754                Ok(())
755            },
756        )?;
757    }
758    Ok(())
759}
760
761// -- structural layer ------------------------------------------------------
762
763fn intersects_viewport(rect: &Rect, columns: i64, rows: i64) -> bool {
764    rect.width != 0
765        && rect.height != 0
766        && rect.column < columns
767        && rect.row < rows
768        && rect.column + rect.width > 0
769        && rect.row + rect.height > 0
770}
771
772/// Whether the sum still round-trips through a JavaScript number.
773fn is_safe_sum(left: i64, right: i64) -> bool {
774    matches!(left.checked_add(right), Some(sum) if sum.abs() <= MAX_SAFE_INTEGER)
775}
776
777fn node_id(node: &Map<String, Value>) -> &str {
778    node.get("id").and_then(Value::as_str).unwrap_or_default()
779}
780
781fn check_node_shape(
782    node: &Map<String, Value>,
783    columns: i64,
784    rows: i64,
785    ids: &HashSet<&str>,
786    limits: &Limits,
787) -> Result<(), ValidationError> {
788    let id = node_id(node);
789
790    if let Some(bounds) = node.get("bounds") {
791        let rect = check_rect(bounds, &[]).map_err(Issue::into_error)?;
792        if !is_safe_sum(rect.row, rect.height) || !is_safe_sum(rect.column, rect.width) {
793            return Err(ValidationError::new(
794                "bad-rect",
795                format!("node {id}: bounds overflow the safe-integer range"),
796            ));
797        }
798        let hidden = node
799            .get("state")
800            .and_then(Value::as_object)
801            .and_then(|state| state.get("hidden"))
802            .and_then(Value::as_bool)
803            .unwrap_or(false);
804        if !hidden && !intersects_viewport(&rect, columns, rows) {
805            return Err(ValidationError::new(
806                "bad-rect",
807                format!(
808                    "node {id}: bounds do not intersect the {columns}x{rows} viewport and the node is not hidden"
809                ),
810            ));
811        }
812    }
813
814    if let Some(ranges) = node.get("textRanges").and_then(Value::as_array) {
815        for item in ranges {
816            let entry = item.as_object().expect("schema layer checked the shape");
817            let start = entry
818                .get("startOffset")
819                .and_then(Value::as_i64)
820                .unwrap_or_default();
821            let end = entry
822                .get("endOffset")
823                .and_then(Value::as_i64)
824                .unwrap_or_default();
825            if end < start {
826                return Err(ValidationError::new(
827                    "bad-rect",
828                    format!("node {id}: text range ends before it starts"),
829                ));
830            }
831            let rect = check_rect(&entry["rect"], &[]).map_err(Issue::into_error)?;
832            if !is_safe_sum(rect.row, rect.height) {
833                return Err(ValidationError::new(
834                    "bad-rect",
835                    format!("node {id}: text range rect overflows the safe-integer range"),
836                ));
837            }
838        }
839    }
840
841    for field in ["labelledBy", "describedBy"] {
842        let Some(targets) = node.get(field).and_then(Value::as_array) else {
843            continue;
844        };
845        if targets.len() > limits.max_relation_targets {
846            return Err(ValidationError::new(
847                "count",
848                format!(
849                    "node {id}: {field} exceeds {} targets",
850                    limits.max_relation_targets
851                ),
852            ));
853        }
854        for target in targets {
855            let target = target.as_str().unwrap_or_default();
856            if !ids.contains(target) {
857                return Err(ValidationError::new(
858                    "missing-parent",
859                    format!("node {id}: {field} references unknown node {target}"),
860                ));
861            }
862        }
863    }
864    Ok(())
865}
866
867/// Depth of every node (roots at 1), or the id where a parent chain closes.
868fn compute_depths<'a>(
869    nodes: &[&'a Map<String, Value>],
870    by_id: &HashMap<&'a str, &'a Map<String, Value>>,
871) -> Result<HashMap<&'a str, usize>, &'a str> {
872    let mut depths: HashMap<&str, usize> = HashMap::new();
873
874    for start in nodes {
875        if depths.contains_key(node_id(start)) {
876            continue;
877        }
878        let mut chain: Vec<&str> = Vec::new();
879        let mut on_chain: HashSet<&str> = HashSet::new();
880        let mut current: Option<&&Map<String, Value>> = Some(start);
881
882        while let Some(node) = current {
883            let id = node_id(node);
884            if depths.contains_key(id) {
885                break;
886            }
887            if !on_chain.insert(id) {
888                return Err(id);
889            }
890            chain.push(id);
891            current = match node.get("parentId").and_then(Value::as_str) {
892                Some(parent_id) => by_id.get(parent_id),
893                None => None,
894            };
895        }
896
897        let mut depth = current.map(|node| depths[node_id(node)]).unwrap_or(0);
898        for id in chain.iter().rev() {
899            depth += 1;
900            depths.insert(id, depth);
901        }
902    }
903    Ok(depths)
904}
905
906const DELTA_KEYS: [&str; 7] = [
907    "type",
908    "baseRevision",
909    "revision",
910    "changed",
911    "removed",
912    "rootIds",
913    "cursor",
914];
915
916/// Validate the SHAPE of a `tree-delta` message.
917///
918/// Only the shape is checkable here. A delta carries no `columns`/`rows`, so
919/// whether a parent exists, whether the tree stays acyclic and inside the
920/// depth ceiling, and whether bounds or the cursor fall within the viewport
921/// can only be judged once the delta is applied to its base — put the
922/// assembled tree through [`validate_snapshot`] for that.
923///
924/// What is checkable without the base: sizes, node shape, unique ids, a
925/// revision that moves forward, and the same id never both upserted and
926/// removed by one delta.
927///
928/// # Errors
929/// Returns a [`ValidationError`] whose `code` matches the reference.
930pub fn validate_tree_delta(value: &Value, limits: &Limits) -> Result<(), ValidationError> {
931    if let Err(violation) = project_dto(value, limits.max_depth) {
932        let code = if violation.code == "dto-depth" {
933            "depth"
934        } else {
935            "schema"
936        };
937        return Err(ValidationError::new(code, violation.to_string()));
938    }
939
940    let serialised = serde_json::to_vec(value)
941        .map_err(|_| ValidationError::new("schema", "delta is not JSON-serialisable"))?;
942    if serialised.len() > limits.max_snapshot_bytes {
943        return Err(ValidationError::new(
944            "bytes",
945            format!(
946                "delta is {} bytes, ceiling is {}",
947                serialised.len(),
948                limits.max_snapshot_bytes
949            ),
950        ));
951    }
952
953    check_tree_delta_schema(value, limits).map_err(Issue::into_error)?;
954    let delta = value.as_object().expect("schema layer checked the shape");
955
956    let mut changed_ids: HashSet<&str> = HashSet::new();
957    for raw in delta["changed"]
958        .as_array()
959        .expect("checked by the schema layer")
960    {
961        let node = raw.as_object().expect("checked by the schema layer");
962        let id = node_id(node);
963        if !changed_ids.insert(id) {
964            return Err(ValidationError::new(
965                "duplicate-id",
966                format!("node id {id} appears twice in changed"),
967            ));
968        }
969        if node.get("parentId").and_then(Value::as_str) == Some(id) {
970            return Err(ValidationError::new(
971                "cycle",
972                format!("node {id} is its own parent"),
973            ));
974        }
975    }
976
977    let mut removed_ids: HashSet<&str> = HashSet::new();
978    for raw in delta["removed"]
979        .as_array()
980        .expect("checked by the schema layer")
981    {
982        let id = raw.as_str().unwrap_or_default();
983        if !removed_ids.insert(id) {
984            return Err(ValidationError::new(
985                "duplicate-id",
986                format!("node id {id} appears twice in removed"),
987            ));
988        }
989    }
990
991    if let Some(id) = changed_ids.intersection(&removed_ids).next() {
992        // Removals apply before upserts, so this would be a delta arguing with
993        // itself about one id rather than moving a node elsewhere.
994        return Err(ValidationError::new(
995            "schema",
996            format!("node id {id} is both changed and removed by one delta"),
997        ));
998    }
999
1000    if let Some(root_ids) = delta.get("rootIds").and_then(Value::as_array) {
1001        let mut seen: HashSet<&str> = HashSet::new();
1002        for raw in root_ids {
1003            let id = raw.as_str().unwrap_or_default();
1004            if !seen.insert(id) {
1005                return Err(ValidationError::new(
1006                    "duplicate-id",
1007                    format!("root id {id} appears more than once"),
1008                ));
1009            }
1010        }
1011    }
1012
1013    Ok(())
1014}
1015
1016fn check_tree_delta_schema(value: &Value, limits: &Limits) -> Result<(), Issue> {
1017    let root: Vec<String> = Vec::new();
1018    let delta = as_object(value, &root)?;
1019    strict(delta, &DELTA_KEYS, &root)?;
1020
1021    let base = positive(delta.get("baseRevision"), vec!["baseRevision".into()])?;
1022    let revision = positive(delta.get("revision"), vec!["revision".into()])?;
1023    if revision <= base {
1024        return Err(Issue::new(
1025            vec!["revision".into()],
1026            format!("revision {revision} must move forward from base {base}"),
1027        ));
1028    }
1029
1030    let Some(changed) = delta.get("changed").and_then(Value::as_array) else {
1031        return Err(Issue::new(vec!["changed".into()], "expected an array"));
1032    };
1033    if changed.len() > limits.max_nodes {
1034        return Err(Issue::too_big(
1035            vec!["changed".into()],
1036            format!("expected at most {} items", limits.max_nodes),
1037        ));
1038    }
1039    for (index, node) in changed.iter().enumerate() {
1040        check_node_schema(
1041            node,
1042            &["changed".to_owned(), index.to_string()],
1043            limits,
1044            false,
1045        )?;
1046    }
1047
1048    let Some(removed) = delta.get("removed").and_then(Value::as_array) else {
1049        return Err(Issue::new(vec!["removed".into()], "expected an array"));
1050    };
1051    if removed.len() > limits.max_nodes {
1052        return Err(Issue::too_big(
1053            vec!["removed".into()],
1054            format!("expected at most {} items", limits.max_nodes),
1055        ));
1056    }
1057    for (index, id) in removed.iter().enumerate() {
1058        let at = vec!["removed".to_owned(), index.to_string()];
1059        if text(Some(id), at.clone(), limits)?.is_empty() {
1060            return Err(Issue::new(at, "node id must not be empty"));
1061        }
1062    }
1063
1064    if let Some(root_ids) = delta.get("rootIds") {
1065        let Some(items) = root_ids.as_array() else {
1066            return Err(Issue::new(vec!["rootIds".into()], "expected an array"));
1067        };
1068        if items.len() > limits.max_nodes {
1069            return Err(Issue::too_big(
1070                vec!["rootIds".into()],
1071                format!("expected at most {} items", limits.max_nodes),
1072            ));
1073        }
1074        for (index, id) in items.iter().enumerate() {
1075            text(
1076                Some(id),
1077                vec!["rootIds".to_owned(), index.to_string()],
1078                limits,
1079            )?;
1080        }
1081    }
1082
1083    if let Some(cursor) = delta.get("cursor") {
1084        check_cursor(cursor, &["cursor".to_owned()])?;
1085    }
1086    Ok(())
1087}
1088
1089/// Validate an untrusted snapshot against `limits`.
1090///
1091/// Checks unique ids, existing and acyclic parents, the closed role, action
1092/// and state vocabularies, bounded strings and counts, and rects that
1093/// intersect the viewport unless the node is hidden.
1094///
1095/// # Errors
1096/// Returns a [`ValidationError`] whose `code` matches the reference
1097/// implementation's.
1098pub fn validate_snapshot(value: &Value, limits: &Limits) -> Result<(), ValidationError> {
1099    if let Err(violation) = project_dto(value, limits.max_depth) {
1100        let code = if violation.code == "dto-depth" {
1101            "depth"
1102        } else {
1103            "schema"
1104        };
1105        return Err(ValidationError::new(code, violation.to_string()));
1106    }
1107
1108    let serialised = serde_json::to_vec(value)
1109        .map_err(|_| ValidationError::new("schema", "snapshot is not JSON-serialisable"))?;
1110    if serialised.len() > limits.max_snapshot_bytes {
1111        return Err(ValidationError::new(
1112            "bytes",
1113            format!(
1114                "snapshot is {} bytes, ceiling is {}",
1115                serialised.len(),
1116                limits.max_snapshot_bytes
1117            ),
1118        ));
1119    }
1120
1121    check_snapshot_schema(value, limits).map_err(Issue::into_error)?;
1122
1123    let snapshot = value.as_object().expect("schema layer checked the shape");
1124    let columns = snapshot["columns"]
1125        .as_i64()
1126        .expect("checked by the schema layer");
1127    let rows = snapshot["rows"]
1128        .as_i64()
1129        .expect("checked by the schema layer");
1130
1131    let raw_nodes = snapshot["nodes"]
1132        .as_array()
1133        .expect("checked by the schema layer");
1134    if raw_nodes.len() > limits.max_nodes {
1135        return Err(ValidationError::new(
1136            "count",
1137            format!(
1138                "snapshot carries {} nodes, ceiling is {}",
1139                raw_nodes.len(),
1140                limits.max_nodes
1141            ),
1142        ));
1143    }
1144
1145    let mut nodes: Vec<&Map<String, Value>> = Vec::with_capacity(raw_nodes.len());
1146    let mut by_id: HashMap<&str, &Map<String, Value>> = HashMap::with_capacity(raw_nodes.len());
1147    for raw in raw_nodes {
1148        let node = raw.as_object().expect("checked by the schema layer");
1149        let id = node_id(node);
1150        if by_id.insert(id, node).is_some() {
1151            return Err(ValidationError::new(
1152                "duplicate-id",
1153                format!("node id {id} appears more than once"),
1154            ));
1155        }
1156        nodes.push(node);
1157    }
1158
1159    let mut root_ids: HashSet<&str> = HashSet::new();
1160    for raw in snapshot["rootIds"]
1161        .as_array()
1162        .expect("checked by the schema layer")
1163    {
1164        let id = raw.as_str().unwrap_or_default();
1165        if !root_ids.insert(id) {
1166            return Err(ValidationError::new(
1167                "duplicate-id",
1168                format!("root id {id} appears more than once"),
1169            ));
1170        }
1171        let Some(node) = by_id.get(id) else {
1172            return Err(ValidationError::new(
1173                "missing-parent",
1174                format!("rootIds references unknown node {id}"),
1175            ));
1176        };
1177        if node.contains_key("parentId") {
1178            return Err(ValidationError::new(
1179                "schema",
1180                format!("root node {id} declares a parent"),
1181            ));
1182        }
1183    }
1184
1185    let ids: HashSet<&str> = by_id.keys().copied().collect();
1186
1187    if snapshot["v"].as_i64() == Some(2) {
1188        let hit_grid = snapshot["hitGrid"]
1189            .as_object()
1190            .expect("checked by the schema layer");
1191        if hit_grid.get("status").and_then(Value::as_str) == Some("known") {
1192            for raw in hit_grid["value"]["regions"]
1193                .as_array()
1194                .expect("checked by the schema layer")
1195            {
1196                let region = raw.as_object().expect("checked by the schema layer");
1197                let recipient_id = region["recipientId"].as_str().unwrap_or_default();
1198                if !ids.contains(recipient_id) {
1199                    return Err(ValidationError::new(
1200                        "missing-parent",
1201                        format!("hitGrid references unknown recipient {recipient_id}"),
1202                    ));
1203                }
1204                let rect = check_rect(&region["rect"], &[]).map_err(Issue::into_error)?;
1205                if !intersects_viewport(&rect, columns, rows) {
1206                    return Err(ValidationError::new(
1207                        "bad-rect",
1208                        format!(
1209                            "hitGrid region for {recipient_id} does not intersect the viewport"
1210                        ),
1211                    ));
1212                }
1213            }
1214        }
1215    }
1216
1217    for node in &nodes {
1218        let id = node_id(node);
1219        match node.get("parentId").and_then(Value::as_str) {
1220            None => {
1221                if !root_ids.contains(id) {
1222                    return Err(ValidationError::new(
1223                        "schema",
1224                        format!("parentless node {id} is missing from rootIds"),
1225                    ));
1226                }
1227            }
1228            Some(parent_id) if !by_id.contains_key(parent_id) => {
1229                return Err(ValidationError::new(
1230                    "missing-parent",
1231                    format!("node {id} references unknown parent {parent_id}"),
1232                ));
1233            }
1234            Some(parent_id) if parent_id == id => {
1235                return Err(ValidationError::new(
1236                    "cycle",
1237                    format!("node {id} is its own parent"),
1238                ));
1239            }
1240            Some(_) => {}
1241        }
1242        check_node_shape(node, columns, rows, &ids, limits)?;
1243    }
1244
1245    match compute_depths(&nodes, &by_id) {
1246        Err(cycle_at) => {
1247            return Err(ValidationError::new(
1248                "cycle",
1249                format!("parent chain through node {cycle_at} is cyclic"),
1250            ))
1251        }
1252        Ok(depths) => {
1253            for (id, depth) in depths {
1254                if depth > limits.max_depth {
1255                    return Err(ValidationError::new(
1256                        "depth",
1257                        format!(
1258                            "node {id} sits at depth {depth}, ceiling is {}",
1259                            limits.max_depth
1260                        ),
1261                    ));
1262                }
1263            }
1264        }
1265    }
1266
1267    if let Some(cursor) = snapshot.get("cursor").and_then(Value::as_object) {
1268        let row = cursor["row"].as_i64().expect("checked by the schema layer");
1269        let column = cursor["column"]
1270            .as_i64()
1271            .expect("checked by the schema layer");
1272        if row >= rows || column >= columns {
1273            return Err(ValidationError::new(
1274                "bad-rect",
1275                format!("cursor ({row}, {column}) lies outside the viewport"),
1276            ));
1277        }
1278    }
1279
1280    Ok(())
1281}
1282
1283/// Compose a delta onto the snapshot it names, then validate the result.
1284///
1285/// The four composition rules, in the order they are applied:
1286///
1287/// 1. `removed` takes each id **with its whole subtree**. The cascade is what
1288///    keeps a delta small — dropping a dialog is one id, not one per
1289///    descendant — and it is the only rule that leaves no orphans behind.
1290/// 2. Removals happen **before** upserts, so one delta can move a node out of
1291///    a subtree it is deleting.
1292/// 3. `changed` upserts by id, **replacing a node wholesale**. Merging would
1293///    need a third state meaning "clear this optional field", which the wire
1294///    cannot express.
1295/// 4. `rootIds` present replaces the list; absent inherits the base's minus
1296///    whatever the removals took. Adding a new root therefore *requires*
1297///    sending `rootIds` — otherwise the parentless node is missing from the
1298///    root list and validation says so, loudly.
1299///
1300/// An absent `cursor` is inherited; there is no way to remove one, and none is
1301/// needed, because hiding it is `visible: false`.
1302///
1303/// A base that disagrees is reported rather than patched around: the caller
1304/// asks for a full snapshot instead of guessing (§8.3). The composed tree then
1305/// goes through [`validate_snapshot`], because a delta is trusted to
1306/// *describe* a valid tree, never to produce one.
1307///
1308/// The order of the composed `nodes` is not normative; this implementation
1309/// keeps base order with new nodes appended, which makes output deterministic.
1310///
1311/// # Errors
1312/// Returns a [`ValidationError`] whose `code` matches the reference.
1313pub fn apply_tree_delta(
1314    base: &Value,
1315    delta: &Value,
1316    limits: &Limits,
1317) -> Result<Value, ValidationError> {
1318    let base_revision = delta
1319        .get("baseRevision")
1320        .and_then(Value::as_i64)
1321        .unwrap_or(-1);
1322    let held = base.get("revision").and_then(Value::as_i64).unwrap_or(-2);
1323    if base_revision != held {
1324        return Err(ValidationError::new(
1325            "revision",
1326            format!(
1327                "delta is based on revision {base_revision} but the held snapshot is revision \
1328                 {held}; request a full snapshot instead of patching"
1329            ),
1330        ));
1331    }
1332
1333    let empty = Vec::new();
1334    let base_nodes = base
1335        .get("nodes")
1336        .and_then(Value::as_array)
1337        .unwrap_or(&empty);
1338
1339    let mut order: Vec<String> = Vec::with_capacity(base_nodes.len());
1340    let mut by_id: HashMap<String, Value> = HashMap::with_capacity(base_nodes.len());
1341    let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
1342    for node in base_nodes {
1343        let id = node
1344            .get("id")
1345            .and_then(Value::as_str)
1346            .unwrap_or_default()
1347            .to_owned();
1348        if let Some(parent) = node.get("parentId").and_then(Value::as_str) {
1349            children_of
1350                .entry(parent.to_owned())
1351                .or_default()
1352                .push(id.clone());
1353        }
1354        order.push(id.clone());
1355        by_id.insert(id, node.clone());
1356    }
1357
1358    for raw in delta
1359        .get("removed")
1360        .and_then(Value::as_array)
1361        .unwrap_or(&empty)
1362    {
1363        let id = raw.as_str().unwrap_or_default();
1364        if !by_id.contains_key(id) {
1365            return Err(ValidationError::new(
1366                "missing-parent",
1367                format!(
1368                    "delta removes unknown node {id}; the producer's base disagrees with ours, \
1369                     so the tree must be resynchronised rather than patched"
1370                ),
1371            ));
1372        }
1373        // Iterative descent: a hostile delta must not be able to blow the stack.
1374        let mut pending = vec![id.to_owned()];
1375        while let Some(current) = pending.pop() {
1376            if by_id.remove(&current).is_none() {
1377                continue;
1378            }
1379            if let Some(children) = children_of.get(&current) {
1380                pending.extend(children.iter().cloned());
1381            }
1382        }
1383    }
1384
1385    for node in delta
1386        .get("changed")
1387        .and_then(Value::as_array)
1388        .unwrap_or(&empty)
1389    {
1390        let id = node
1391            .get("id")
1392            .and_then(Value::as_str)
1393            .unwrap_or_default()
1394            .to_owned();
1395        if !by_id.contains_key(&id) {
1396            order.push(id.clone());
1397        }
1398        by_id.insert(id, node.clone());
1399    }
1400
1401    let root_ids: Vec<Value> = match delta.get("rootIds").and_then(Value::as_array) {
1402        Some(explicit) => explicit.clone(),
1403        None => base
1404            .get("rootIds")
1405            .and_then(Value::as_array)
1406            .unwrap_or(&empty)
1407            .iter()
1408            .filter(|raw| by_id.contains_key(raw.as_str().unwrap_or_default()))
1409            .cloned()
1410            .collect(),
1411    };
1412
1413    let mut nodes: Vec<Value> = Vec::with_capacity(by_id.len());
1414    let mut seen: HashSet<&str> = HashSet::new();
1415    for id in &order {
1416        if !seen.insert(id.as_str()) {
1417            continue;
1418        }
1419        if let Some(node) = by_id.get(id) {
1420            nodes.push(node.clone());
1421        }
1422    }
1423
1424    let mut composed = serde_json::Map::new();
1425    composed.insert("v".into(), Value::from(1));
1426    composed.insert(
1427        "sessionId".into(),
1428        base.get("sessionId").cloned().unwrap_or(Value::Null),
1429    );
1430    composed.insert(
1431        "revision".into(),
1432        delta.get("revision").cloned().unwrap_or(Value::Null),
1433    );
1434    composed.insert(
1435        "columns".into(),
1436        base.get("columns").cloned().unwrap_or(Value::Null),
1437    );
1438    composed.insert(
1439        "rows".into(),
1440        base.get("rows").cloned().unwrap_or(Value::Null),
1441    );
1442    // Absent cursor means unchanged, so the base's carries over.
1443    if let Some(cursor) = delta.get("cursor").or_else(|| base.get("cursor")) {
1444        composed.insert("cursor".into(), cursor.clone());
1445    }
1446    composed.insert("rootIds".into(), Value::Array(root_ids));
1447    composed.insert("nodes".into(), Value::Array(nodes));
1448
1449    let composed = Value::Object(composed);
1450    validate_snapshot(&composed, limits)?;
1451    Ok(composed)
1452}