use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use thiserror::Error;
pub const UI_EVIDENCE_SCHEMA: &str = "falsegreen.ui-evidence/v1";
pub const UI_PROTOCOL_VERSION: &str = "falsegreen.ui-interaction/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Viewport {
pub width: u32,
pub height: u32,
pub dpr_milli: u32,
}
impl Viewport {
pub const fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
dpr_milli: 1000,
}
}
pub fn dpr(self) -> f32 {
self.dpr_milli as f32 / 1000.0
}
pub fn bounds(self) -> Rect {
Rect::new(0.0, 0.0, self.width as f32, self.height as f32)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl Rect {
pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
Self {
x,
y,
width,
height,
}
}
pub fn right(self) -> f32 {
self.x + self.width
}
pub fn bottom(self) -> f32 {
self.y + self.height
}
pub fn area(self) -> f32 {
self.width.max(0.0) * self.height.max(0.0)
}
pub fn contains(self, x: f32, y: f32) -> bool {
x >= self.x && x <= self.right() && y >= self.y && y <= self.bottom()
}
pub fn intersection(self, other: Self) -> Option<Self> {
let x = self.x.max(other.x);
let y = self.y.max(other.y);
let right = self.right().min(other.right());
let bottom = self.bottom().min(other.bottom());
if right <= x || bottom <= y {
None
} else {
Some(Self::new(x, y, right - x, bottom - y))
}
}
pub fn intersects(self, other: Self) -> bool {
self.intersection(other).is_some()
}
pub fn visible_fraction(self, clip: Option<Self>) -> f32 {
let Some(clip) = clip else { return 1.0 };
if self.area() <= 0.0 {
return 0.0;
}
self.intersection(clip)
.map(|r| r.area() / self.area())
.unwrap_or(0.0)
.clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Role {
Application,
Main,
Navigation,
Article,
Group,
Heading,
StaticText,
Alert,
Status,
Button,
TextField,
Image,
Dialog,
Overlay,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Overflow {
#[default]
Visible,
Hidden,
Scroll,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeState {
pub visible: bool,
pub enabled: bool,
pub focusable: bool,
pub focused: bool,
pub selected: bool,
pub expanded: Option<bool>,
pub checked: Option<bool>,
pub pressed: Option<bool>,
}
impl Default for NodeState {
fn default() -> Self {
Self {
visible: true,
enabled: true,
focusable: false,
focused: false,
selected: false,
expanded: None,
checked: None,
pressed: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetBinding {
pub logical_name: String,
pub kind: String,
pub sha256: String,
}
impl AssetBinding {
pub fn new(logical_name: impl Into<String>, kind: impl Into<String>, bytes: &[u8]) -> Self {
Self {
logical_name: logical_name.into(),
kind: kind.into(),
sha256: sha256_hex(bytes),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum PaintPrimitive {
Fill {
rect: Rect,
color: [u8; 4],
radius: f32,
},
Stroke {
rect: Rect,
color: [u8; 4],
width: f32,
radius: f32,
},
Text {
rect: Rect,
color: [u8; 4],
text: String,
font_family: String,
font_size: f32,
},
Asset {
rect: Rect,
asset: AssetBinding,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UiNode {
pub id: String,
pub role: Role,
pub semantic_label: Option<String>,
pub text: Option<String>,
pub value: Option<String>,
pub state: NodeState,
pub bounds: Rect,
pub clip: Option<Rect>,
pub overflow: Overflow,
pub z_index: i32,
pub parent_id: Option<String>,
pub children: Vec<String>,
pub data_bindings: BTreeMap<String, String>,
#[serde(default)]
pub event_listeners: Vec<String>,
pub transition_id: Option<String>,
pub paint: Vec<PaintPrimitive>,
pub asset: Option<AssetBinding>,
}
impl UiNode {
pub fn new(id: impl Into<String>, role: Role, bounds: Rect) -> Self {
Self {
id: id.into(),
role,
semantic_label: None,
text: None,
value: None,
state: NodeState::default(),
bounds,
clip: None,
overflow: Overflow::Visible,
z_index: 0,
parent_id: None,
children: Vec::new(),
data_bindings: BTreeMap::new(),
event_listeners: Vec::new(),
transition_id: None,
paint: Vec::new(),
asset: None,
}
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
pub fn text(mut self, text: impl Into<String>) -> Self {
self.text = Some(text.into());
self
}
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
pub fn parent(mut self, parent: impl Into<String>) -> Self {
self.parent_id = Some(parent.into());
self
}
pub fn z(mut self, z_index: i32) -> Self {
self.z_index = z_index;
self
}
pub fn clip(mut self, clip: Rect) -> Self {
self.clip = Some(clip);
self
}
pub fn paint(mut self, primitive: PaintPrimitive) -> Self {
self.paint.push(primitive);
self
}
pub fn bind(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.data_bindings.insert(key.into(), value.into());
self
}
pub fn transition(mut self, id: impl Into<String>) -> Self {
self.transition_id = Some(id.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UiTree {
pub schema: String,
pub viewport: Viewport,
pub root_id: String,
pub nodes: Vec<UiNode>,
pub tree_digest: String,
}
impl UiTree {
pub fn new(viewport: Viewport, root_id: impl Into<String>, nodes: Vec<UiNode>) -> Self {
let mut tree = Self {
schema: UI_EVIDENCE_SCHEMA.into(),
viewport,
root_id: root_id.into(),
nodes,
tree_digest: String::new(),
};
tree.tree_digest = tree.compute_digest();
tree
}
pub fn node(&self, id: &str) -> Option<&UiNode> {
self.nodes.iter().find(|node| node.id == id)
}
pub fn visible_node(&self, id: &str) -> Option<&UiNode> {
self.node(id)
.filter(|node| self.is_effectively_visible(&node.id))
}
pub fn is_effectively_visible(&self, id: &str) -> bool {
let mut current = self.node(id);
let mut seen = BTreeSet::new();
while let Some(node) = current {
if !seen.insert(node.id.as_str()) {
return false;
}
if !node.state.visible || node.bounds.visible_fraction(node.clip) <= 0.0 {
return false;
}
current = node
.parent_id
.as_deref()
.and_then(|parent| self.node(parent));
}
true
}
pub fn compute_digest(&self) -> String {
let mut copy = self.clone();
copy.tree_digest.clear();
let bytes = serde_json::to_vec(©).expect("normalized UI tree is serializable");
sha256_hex(&bytes)
}
pub fn refresh_digest(&mut self) {
self.tree_digest = self.compute_digest();
}
pub fn validate(&self) -> Result<ValidationReport, ValidationError> {
if self.schema != UI_EVIDENCE_SCHEMA {
return Err(ValidationError::Schema(self.schema.clone()));
}
let mut seen = BTreeSet::new();
for node in &self.nodes {
validate_id(&node.id)?;
if !seen.insert(node.id.clone()) {
return Err(ValidationError::DuplicateId(node.id.clone()));
}
}
if self.node(&self.root_id).is_none() {
return Err(ValidationError::MissingRoot(self.root_id.clone()));
}
for node in &self.nodes {
if let Some(parent) = &node.parent_id {
let Some(parent_node) = self.node(parent) else {
return Err(ValidationError::MissingParent {
node: node.id.clone(),
parent: parent.clone(),
});
};
if !parent_node.children.iter().any(|child| child == &node.id) {
return Err(ValidationError::HierarchyDrift(node.id.clone()));
}
}
for child in &node.children {
let Some(child_node) = self.node(child) else {
return Err(ValidationError::MissingChild {
node: node.id.clone(),
child: child.clone(),
});
};
if child_node.parent_id.as_deref() != Some(node.id.as_str()) {
return Err(ValidationError::HierarchyDrift(child.clone()));
}
}
}
let root_count = self
.nodes
.iter()
.filter(|node| node.parent_id.is_none())
.count();
if root_count != 1 {
return Err(ValidationError::RootCount(root_count));
}
for node in &self.nodes {
let mut current = node.id.as_str();
let mut path = BTreeSet::new();
while let Some(parent) = self
.node(current)
.and_then(|candidate| candidate.parent_id.as_deref())
{
if !path.insert(parent) {
return Err(ValidationError::HierarchyDrift(node.id.clone()));
}
current = parent;
}
}
Ok(ValidationReport {
node_count: self.nodes.len(),
ids: seen.into_iter().collect(),
})
}
pub fn topmost_at(&self, x: f32, y: f32) -> Option<&UiNode> {
self.nodes
.iter()
.filter(|node| {
self.is_effectively_visible(&node.id)
&& node.state.enabled
&& node.bounds.contains(x, y)
})
.max_by_key(|node| node.z_index)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationReport {
pub node_count: usize,
pub ids: Vec<String>,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ValidationError {
#[error("unsupported normalized tree schema: {0}")]
Schema(String),
#[error("stable verification ID is missing or malformed: {0}")]
MissingId(String),
#[error("duplicate stable verification ID: {0}")]
DuplicateId(String),
#[error("root node is missing: {0}")]
MissingRoot(String),
#[error("node {node} names missing parent {parent}")]
MissingParent { node: String, parent: String },
#[error("node {node} names missing child {child}")]
MissingChild { node: String, child: String },
#[error("parent/child hierarchy drift at {0}")]
HierarchyDrift(String),
#[error("normalized tree must have one root, found {0}")]
RootCount(usize),
}
pub fn validate_id(id: &str) -> Result<(), ValidationError> {
if id.is_empty()
|| !id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"_-.:".contains(&byte))
{
return Err(ValidationError::MissingId(id.to_string()));
}
Ok(())
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_tree() -> UiTree {
let root = UiNode::new("root", Role::Application, Rect::new(0.0, 0.0, 100.0, 100.0)).paint(
PaintPrimitive::Fill {
rect: Rect::new(0.0, 0.0, 100.0, 100.0),
color: [0, 0, 0, 255],
radius: 0.0,
},
);
let child =
UiNode::new("save", Role::Button, Rect::new(10.0, 10.0, 40.0, 20.0)).parent("root");
let mut tree = UiTree::new(Viewport::new(100, 100), "root", vec![root, child]);
tree.nodes[0].children.push("save".into());
tree.refresh_digest();
tree
}
#[test]
fn stable_tree_digest_ignores_stored_digest() {
let tree = sample_tree();
let mut altered = tree.clone();
altered.tree_digest = "attacker-edit".into();
assert_eq!(tree.compute_digest(), altered.compute_digest());
}
#[test]
fn rejects_duplicate_and_retargeted_identity() {
let mut tree = sample_tree();
tree.nodes.push(UiNode::new(
"save",
Role::StaticText,
Rect::new(0.0, 0.0, 1.0, 1.0),
));
assert!(matches!(tree.validate(), Err(ValidationError::DuplicateId(id)) if id == "save"));
}
#[test]
fn visible_fraction_and_overlap_are_objective() {
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
assert_eq!(
rect.visible_fraction(Some(Rect::new(0.0, 0.0, 50.0, 100.0))),
0.5
);
assert!(rect.intersects(Rect::new(99.0, 99.0, 5.0, 5.0)));
}
#[test]
fn rejects_hierarchy_cycles() {
let a = UiNode::new("a", Role::Application, Rect::new(0.0, 0.0, 10.0, 10.0)).parent("b");
let b = UiNode::new("b", Role::Group, Rect::new(0.0, 0.0, 10.0, 10.0)).parent("a");
let tree = UiTree::new(Viewport::new(10, 10), "a", vec![a, b]);
assert!(tree.validate().is_err());
}
}