1use 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
17struct 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
76fn 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
148const 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
163struct 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
184pub 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
242pub 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
371const 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 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 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
761fn 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
772fn 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
867fn 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
916pub 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 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
1089pub 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(®ion["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
1283pub 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 let mut pending = vec![id.to_owned()];
1375 while let Some(current) = pending.pop() {
1376 if by_id.remove(¤t).is_none() {
1377 continue;
1378 }
1379 if let Some(children) = children_of.get(¤t) {
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 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}