1use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet};
9use thiserror::Error;
10
11pub const UI_EVIDENCE_SCHEMA: &str = "falsegreen.ui-evidence/v1";
12pub const UI_PROTOCOL_VERSION: &str = "falsegreen.ui-interaction/v1";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub struct Viewport {
16 pub width: u32,
17 pub height: u32,
18 pub dpr_milli: u32,
19}
20
21impl Viewport {
22 pub const fn new(width: u32, height: u32) -> Self {
23 Self {
24 width,
25 height,
26 dpr_milli: 1000,
27 }
28 }
29
30 pub fn dpr(self) -> f32 {
31 self.dpr_milli as f32 / 1000.0
32 }
33
34 pub fn bounds(self) -> Rect {
35 Rect::new(0.0, 0.0, self.width as f32, self.height as f32)
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40pub struct Rect {
41 pub x: f32,
42 pub y: f32,
43 pub width: f32,
44 pub height: f32,
45}
46
47impl Rect {
48 pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
49 Self {
50 x,
51 y,
52 width,
53 height,
54 }
55 }
56
57 pub fn right(self) -> f32 {
58 self.x + self.width
59 }
60 pub fn bottom(self) -> f32 {
61 self.y + self.height
62 }
63 pub fn area(self) -> f32 {
64 self.width.max(0.0) * self.height.max(0.0)
65 }
66
67 pub fn contains(self, x: f32, y: f32) -> bool {
68 x >= self.x && x <= self.right() && y >= self.y && y <= self.bottom()
69 }
70
71 pub fn intersection(self, other: Self) -> Option<Self> {
72 let x = self.x.max(other.x);
73 let y = self.y.max(other.y);
74 let right = self.right().min(other.right());
75 let bottom = self.bottom().min(other.bottom());
76 if right <= x || bottom <= y {
77 None
78 } else {
79 Some(Self::new(x, y, right - x, bottom - y))
80 }
81 }
82
83 pub fn intersects(self, other: Self) -> bool {
84 self.intersection(other).is_some()
85 }
86
87 pub fn visible_fraction(self, clip: Option<Self>) -> f32 {
88 let Some(clip) = clip else { return 1.0 };
89 if self.area() <= 0.0 {
90 return 0.0;
91 }
92 self.intersection(clip)
93 .map(|r| r.area() / self.area())
94 .unwrap_or(0.0)
95 .clamp(0.0, 1.0)
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "kebab-case")]
101pub enum Role {
102 Application,
103 Main,
104 Navigation,
105 Article,
106 Group,
107 Heading,
108 StaticText,
109 Alert,
110 Status,
111 Button,
112 TextField,
113 Image,
114 Dialog,
115 Overlay,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
119#[serde(rename_all = "kebab-case")]
120pub enum Overflow {
121 #[default]
122 Visible,
123 Hidden,
124 Scroll,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct NodeState {
129 pub visible: bool,
130 pub enabled: bool,
131 pub focusable: bool,
132 pub focused: bool,
133 pub selected: bool,
134 pub expanded: Option<bool>,
135 pub checked: Option<bool>,
136 pub pressed: Option<bool>,
137}
138
139impl Default for NodeState {
140 fn default() -> Self {
141 Self {
142 visible: true,
143 enabled: true,
144 focusable: false,
145 focused: false,
146 selected: false,
147 expanded: None,
148 checked: None,
149 pressed: None,
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct AssetBinding {
156 pub logical_name: String,
157 pub kind: String,
158 pub sha256: String,
159}
160
161impl AssetBinding {
162 pub fn new(logical_name: impl Into<String>, kind: impl Into<String>, bytes: &[u8]) -> Self {
163 Self {
164 logical_name: logical_name.into(),
165 kind: kind.into(),
166 sha256: sha256_hex(bytes),
167 }
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "kebab-case")]
173pub enum PaintPrimitive {
174 Fill {
175 rect: Rect,
176 color: [u8; 4],
177 radius: f32,
178 },
179 Stroke {
180 rect: Rect,
181 color: [u8; 4],
182 width: f32,
183 radius: f32,
184 },
185 Text {
186 rect: Rect,
187 color: [u8; 4],
188 text: String,
189 font_family: String,
190 font_size: f32,
191 },
192 Asset {
193 rect: Rect,
194 asset: AssetBinding,
195 },
196}
197
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct UiNode {
200 pub id: String,
201 pub role: Role,
202 pub semantic_label: Option<String>,
203 pub text: Option<String>,
204 pub value: Option<String>,
205 pub state: NodeState,
206 pub bounds: Rect,
207 pub clip: Option<Rect>,
208 pub overflow: Overflow,
209 pub z_index: i32,
210 pub parent_id: Option<String>,
211 pub children: Vec<String>,
212 pub data_bindings: BTreeMap<String, String>,
213 #[serde(default)]
215 pub event_listeners: Vec<String>,
216 pub transition_id: Option<String>,
217 pub paint: Vec<PaintPrimitive>,
218 pub asset: Option<AssetBinding>,
219}
220
221impl UiNode {
222 pub fn new(id: impl Into<String>, role: Role, bounds: Rect) -> Self {
223 Self {
224 id: id.into(),
225 role,
226 semantic_label: None,
227 text: None,
228 value: None,
229 state: NodeState::default(),
230 bounds,
231 clip: None,
232 overflow: Overflow::Visible,
233 z_index: 0,
234 parent_id: None,
235 children: Vec::new(),
236 data_bindings: BTreeMap::new(),
237 event_listeners: Vec::new(),
238 transition_id: None,
239 paint: Vec::new(),
240 asset: None,
241 }
242 }
243
244 pub fn label(mut self, label: impl Into<String>) -> Self {
245 self.semantic_label = Some(label.into());
246 self
247 }
248 pub fn text(mut self, text: impl Into<String>) -> Self {
249 self.text = Some(text.into());
250 self
251 }
252 pub fn value(mut self, value: impl Into<String>) -> Self {
253 self.value = Some(value.into());
254 self
255 }
256 pub fn parent(mut self, parent: impl Into<String>) -> Self {
257 self.parent_id = Some(parent.into());
258 self
259 }
260 pub fn z(mut self, z_index: i32) -> Self {
261 self.z_index = z_index;
262 self
263 }
264 pub fn clip(mut self, clip: Rect) -> Self {
265 self.clip = Some(clip);
266 self
267 }
268 pub fn paint(mut self, primitive: PaintPrimitive) -> Self {
269 self.paint.push(primitive);
270 self
271 }
272 pub fn bind(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
273 self.data_bindings.insert(key.into(), value.into());
274 self
275 }
276 pub fn transition(mut self, id: impl Into<String>) -> Self {
277 self.transition_id = Some(id.into());
278 self
279 }
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct UiTree {
284 pub schema: String,
285 pub viewport: Viewport,
286 pub root_id: String,
287 pub nodes: Vec<UiNode>,
288 pub tree_digest: String,
289}
290
291impl UiTree {
292 pub fn new(viewport: Viewport, root_id: impl Into<String>, nodes: Vec<UiNode>) -> Self {
293 let mut tree = Self {
294 schema: UI_EVIDENCE_SCHEMA.into(),
295 viewport,
296 root_id: root_id.into(),
297 nodes,
298 tree_digest: String::new(),
299 };
300 tree.tree_digest = tree.compute_digest();
301 tree
302 }
303
304 pub fn node(&self, id: &str) -> Option<&UiNode> {
305 self.nodes.iter().find(|node| node.id == id)
306 }
307
308 pub fn visible_node(&self, id: &str) -> Option<&UiNode> {
309 self.node(id)
310 .filter(|node| self.is_effectively_visible(&node.id))
311 }
312
313 pub fn is_effectively_visible(&self, id: &str) -> bool {
314 let mut current = self.node(id);
315 let mut seen = BTreeSet::new();
316 while let Some(node) = current {
317 if !seen.insert(node.id.as_str()) {
318 return false;
319 }
320 if !node.state.visible || node.bounds.visible_fraction(node.clip) <= 0.0 {
321 return false;
322 }
323 current = node
324 .parent_id
325 .as_deref()
326 .and_then(|parent| self.node(parent));
327 }
328 true
329 }
330
331 pub fn compute_digest(&self) -> String {
332 let mut copy = self.clone();
333 copy.tree_digest.clear();
334 let bytes = serde_json::to_vec(©).expect("normalized UI tree is serializable");
335 sha256_hex(&bytes)
336 }
337
338 pub fn refresh_digest(&mut self) {
339 self.tree_digest = self.compute_digest();
340 }
341
342 pub fn validate(&self) -> Result<ValidationReport, ValidationError> {
343 if self.schema != UI_EVIDENCE_SCHEMA {
344 return Err(ValidationError::Schema(self.schema.clone()));
345 }
346 let mut seen = BTreeSet::new();
347 for node in &self.nodes {
348 validate_id(&node.id)?;
349 if !seen.insert(node.id.clone()) {
350 return Err(ValidationError::DuplicateId(node.id.clone()));
351 }
352 }
353 if self.node(&self.root_id).is_none() {
354 return Err(ValidationError::MissingRoot(self.root_id.clone()));
355 }
356 for node in &self.nodes {
357 if let Some(parent) = &node.parent_id {
358 let Some(parent_node) = self.node(parent) else {
359 return Err(ValidationError::MissingParent {
360 node: node.id.clone(),
361 parent: parent.clone(),
362 });
363 };
364 if !parent_node.children.iter().any(|child| child == &node.id) {
365 return Err(ValidationError::HierarchyDrift(node.id.clone()));
366 }
367 }
368 for child in &node.children {
369 let Some(child_node) = self.node(child) else {
370 return Err(ValidationError::MissingChild {
371 node: node.id.clone(),
372 child: child.clone(),
373 });
374 };
375 if child_node.parent_id.as_deref() != Some(node.id.as_str()) {
376 return Err(ValidationError::HierarchyDrift(child.clone()));
377 }
378 }
379 }
380 let root_count = self
381 .nodes
382 .iter()
383 .filter(|node| node.parent_id.is_none())
384 .count();
385 if root_count != 1 {
386 return Err(ValidationError::RootCount(root_count));
387 }
388 for node in &self.nodes {
389 let mut current = node.id.as_str();
390 let mut path = BTreeSet::new();
391 while let Some(parent) = self
392 .node(current)
393 .and_then(|candidate| candidate.parent_id.as_deref())
394 {
395 if !path.insert(parent) {
396 return Err(ValidationError::HierarchyDrift(node.id.clone()));
397 }
398 current = parent;
399 }
400 }
401 Ok(ValidationReport {
402 node_count: self.nodes.len(),
403 ids: seen.into_iter().collect(),
404 })
405 }
406
407 pub fn topmost_at(&self, x: f32, y: f32) -> Option<&UiNode> {
408 self.nodes
409 .iter()
410 .filter(|node| {
411 self.is_effectively_visible(&node.id)
412 && node.state.enabled
413 && node.bounds.contains(x, y)
414 })
415 .max_by_key(|node| node.z_index)
416 }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420pub struct ValidationReport {
421 pub node_count: usize,
422 pub ids: Vec<String>,
423}
424
425#[derive(Debug, Error, Clone, PartialEq, Eq)]
426pub enum ValidationError {
427 #[error("unsupported normalized tree schema: {0}")]
428 Schema(String),
429 #[error("stable verification ID is missing or malformed: {0}")]
430 MissingId(String),
431 #[error("duplicate stable verification ID: {0}")]
432 DuplicateId(String),
433 #[error("root node is missing: {0}")]
434 MissingRoot(String),
435 #[error("node {node} names missing parent {parent}")]
436 MissingParent { node: String, parent: String },
437 #[error("node {node} names missing child {child}")]
438 MissingChild { node: String, child: String },
439 #[error("parent/child hierarchy drift at {0}")]
440 HierarchyDrift(String),
441 #[error("normalized tree must have one root, found {0}")]
442 RootCount(usize),
443}
444
445pub fn validate_id(id: &str) -> Result<(), ValidationError> {
446 if id.is_empty()
447 || !id
448 .bytes()
449 .all(|byte| byte.is_ascii_alphanumeric() || b"_-.:".contains(&byte))
450 {
451 return Err(ValidationError::MissingId(id.to_string()));
452 }
453 Ok(())
454}
455
456pub fn sha256_hex(bytes: &[u8]) -> String {
457 let mut hasher = Sha256::new();
458 hasher.update(bytes);
459 hex::encode(hasher.finalize())
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 fn sample_tree() -> UiTree {
467 let root = UiNode::new("root", Role::Application, Rect::new(0.0, 0.0, 100.0, 100.0)).paint(
468 PaintPrimitive::Fill {
469 rect: Rect::new(0.0, 0.0, 100.0, 100.0),
470 color: [0, 0, 0, 255],
471 radius: 0.0,
472 },
473 );
474 let child =
475 UiNode::new("save", Role::Button, Rect::new(10.0, 10.0, 40.0, 20.0)).parent("root");
476 let mut tree = UiTree::new(Viewport::new(100, 100), "root", vec![root, child]);
477 tree.nodes[0].children.push("save".into());
478 tree.refresh_digest();
479 tree
480 }
481
482 #[test]
483 fn stable_tree_digest_ignores_stored_digest() {
484 let tree = sample_tree();
485 let mut altered = tree.clone();
486 altered.tree_digest = "attacker-edit".into();
487 assert_eq!(tree.compute_digest(), altered.compute_digest());
488 }
489
490 #[test]
491 fn rejects_duplicate_and_retargeted_identity() {
492 let mut tree = sample_tree();
493 tree.nodes.push(UiNode::new(
494 "save",
495 Role::StaticText,
496 Rect::new(0.0, 0.0, 1.0, 1.0),
497 ));
498 assert!(matches!(tree.validate(), Err(ValidationError::DuplicateId(id)) if id == "save"));
499 }
500
501 #[test]
502 fn visible_fraction_and_overlap_are_objective() {
503 let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
504 assert_eq!(
505 rect.visible_fraction(Some(Rect::new(0.0, 0.0, 50.0, 100.0))),
506 0.5
507 );
508 assert!(rect.intersects(Rect::new(99.0, 99.0, 5.0, 5.0)));
509 }
510
511 #[test]
512 fn rejects_hierarchy_cycles() {
513 let a = UiNode::new("a", Role::Application, Rect::new(0.0, 0.0, 10.0, 10.0)).parent("b");
514 let b = UiNode::new("b", Role::Group, Rect::new(0.0, 0.0, 10.0, 10.0)).parent("a");
515 let tree = UiTree::new(Viewport::new(10, 10), "a", vec![a, b]);
516 assert!(tree.validate().is_err());
517 }
518}