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