Files
cthulhu/internal/wine/review_snapshot.go

332 lines
12 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"encoding/binary"
"fmt"
"reflect"
"strings"
"unicode/utf16"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
"github.com/godbus/dbus/v5"
)
const maxReviewSourceUnits uint64 = 1000000
func reviewString(value string, limit int, empty bool) bool {
return (empty || value != "") && len(value) <= limit && utf8.ValidString(value) && !strings.ContainsRune(value, 0)
}
func reviewField(fields map[string]dbus.Variant, key, signature string, destination any) error {
value, found := fields[key]
if !found || value.Signature().String() != signature {
return fmt.Errorf("%w: field %s signature %s (expected %s)", ErrReviewInvalid, key, value.Signature(), signature)
}
if !copyReviewValue(reflect.ValueOf(destination).Elem(), reflect.ValueOf(value.Value())) {
return fmt.Errorf("%w: field %s", ErrReviewInvalid, key)
}
return nil
}
// D-Bus tuples arrive as []interface{}. Accept that representation and typed
// producer records, but never coerce a signed integer or a different wire type.
func copyReviewValue(dst, src reflect.Value) bool {
for src.IsValid() && src.Kind() == reflect.Interface {
src = src.Elem()
}
if !src.IsValid() {
return false
}
switch dst.Kind() {
case reflect.Struct:
if src.Kind() != reflect.Struct && src.Type() != reflect.TypeOf([]any{}) {
return false
}
n := src.Len
if src.Kind() == reflect.Struct {
n = src.NumField
}
if n() != dst.NumField() {
return false
}
for i := 0; i < dst.NumField(); i++ {
var field reflect.Value
if src.Kind() == reflect.Struct {
field = src.Field(i)
} else {
field = src.Index(i)
}
if !copyReviewValue(dst.Field(i), field) {
return false
}
}
return true
case reflect.Slice:
if src.Kind() != reflect.Slice || src.Len() > 256 {
return false
}
result := reflect.MakeSlice(dst.Type(), src.Len(), src.Len())
for i := 0; i < src.Len(); i++ {
if !copyReviewValue(result.Index(i), src.Index(i)) {
return false
}
}
dst.Set(result)
return true
default:
if src.Type() != dst.Type() {
return false
}
dst.Set(src)
return true
}
}
func copyReviewFields(fields map[string]dbus.Variant) map[string]dbus.Variant {
copy := make(map[string]dbus.Variant, len(fields))
for key, value := range fields {
copy[key] = value
}
return copy
}
func DecodeReviewSnapshot(fields map[string]dbus.Variant) (ReviewSnapshot, error) {
var snapshot ReviewSnapshot
if len(fields) != 8 {
return ReviewSnapshot{}, ErrReviewInvalid
}
reads := []struct {
key, signature string
destination any
}{
{"version", "u", &snapshot.Version}, {"instance", "s", &snapshot.Instance}, {"window", "s", &snapshot.Window},
{"focus", "s", &snapshot.Focus}, {"revision", "t", &snapshot.Revision}, {"status", "s", &snapshot.Status}, {"bounds", "(iiii)", &snapshot.Bounds},
}
for _, read := range reads {
if err := reviewField(fields, read.key, read.signature, read.destination); err != nil {
return ReviewSnapshot{}, err
}
}
var nodes []map[string]dbus.Variant
// Check the outer shape and count before copying nested records.
value, found := fields["nodes"]
if !found || value.Signature().String() != "aa{sv}" {
return ReviewSnapshot{}, ErrReviewInvalid
}
nodes, found = value.Value().([]map[string]dbus.Variant)
if !found || len(nodes) > 4096 {
return ReviewSnapshot{}, ErrReviewInvalid
}
canonical := copyReviewFields(fields)
canonical["bounds"] = dbus.MakeVariant(snapshot.Bounds)
canonicalNodes := make([]map[string]dbus.Variant, 0, len(nodes))
for _, fields := range nodes {
if len(fields) != 12 {
return ReviewSnapshot{}, ErrReviewInvalid
}
var node ReviewNode
reads := []struct {
key, signature string
destination any
}{
{"id", "s", &node.ID}, {"parent", "s", &node.Parent}, {"role", "s", &node.Role}, {"name", "s", &node.Name},
{"protected", "b", &node.Protected}, {"visible", "b", &node.Visible}, {"text_known", "b", &node.TextKnown},
{"caret_known", "b", &node.CaretKnown}, {"caret", "t", &node.Caret}, {"caret_utf16", "t", &node.CaretUTF16},
{"bounds", "(iiii)", &node.Bounds}, {"ranges", "a(tttts(iiii))", &node.Ranges},
}
for _, read := range reads {
if err := reviewField(fields, read.key, read.signature, read.destination); err != nil {
return ReviewSnapshot{}, err
}
}
snapshot.Nodes = append(snapshot.Nodes, node)
encoded := copyReviewFields(fields)
encoded["bounds"] = dbus.MakeVariant(node.Bounds)
encoded["ranges"] = dbus.MakeVariant(node.Ranges)
canonicalNodes = append(canonicalNodes, encoded)
}
if err := validateReviewSnapshot(snapshot); err != nil {
snapshot.Nodes = nil
return snapshot, err
}
// Exact D-Bus encoding catches metadata overhead after the independent
// record/text budgets bound the encoder's temporary allocation.
counter := reviewByteCounter{}
canonical["nodes"] = dbus.MakeVariant(canonicalNodes)
message := dbus.Message{Type: dbus.TypeMethodReply, Headers: map[dbus.HeaderField]dbus.Variant{
dbus.FieldReplySerial: dbus.MakeVariant(uint32(1)), dbus.FieldSignature: dbus.MakeVariant(dbus.SignatureOf(fields)),
}, Body: []any{canonical}}
if err := message.EncodeTo(&counter, binary.LittleEndian); err != nil || counter.count > 2<<20 {
return ReviewSnapshot{}, ErrReviewInvalid
}
return snapshot, nil
}
type reviewByteCounter struct{ count int }
func (counter *reviewByteCounter) Write(data []byte) (int, error) {
counter.count += len(data)
return len(data), nil
}
func validateReviewSnapshot(snapshot ReviewSnapshot) error {
if snapshot.Version != ReviewProtocolVersion || !reviewString(snapshot.Instance, 256, false) ||
!reviewString(snapshot.Window, 256, false) || !reviewString(snapshot.Focus, 256, false) || snapshot.Revision == 0 || !snapshot.Bounds.Valid() || len(snapshot.Nodes) > 4096 {
return ErrReviewInvalid
}
switch snapshot.Status {
case "ok":
case "unavailable", "stale", "busy", "limited":
if len(snapshot.Nodes) != 0 {
return ErrReviewInvalid
}
switch snapshot.Status {
case "stale":
return ErrReviewStale
case "busy":
return ErrReviewBusy
case "limited":
return ErrReviewLimited
default:
return ErrReviewUnavailable
}
default:
return ErrReviewInvalid
}
if len(snapshot.Nodes) == 0 {
return ErrReviewInvalid
}
nodes := make(map[string]ReviewNode, len(snapshot.Nodes))
textBytes, metadataFloor := 0, 256
for _, node := range snapshot.Nodes {
if !reviewString(node.ID, 256, false) || !reviewString(node.Parent, 256, true) || !reviewString(node.Role, 4096, false) ||
!reviewString(node.Name, 4096, true) || !node.Bounds.Valid() || len(node.Ranges) > 256 {
return ErrReviewInvalid
}
if _, exists := nodes[node.ID]; exists {
return ErrReviewInvalid
}
nodes[node.ID] = node
if node.Protected && (node.Name != "" || node.TextKnown || node.CaretKnown || len(node.Ranges) != 0 || node.Caret != 0 || node.CaretUTF16 != 0) {
return ErrReviewInvalid
}
if !node.Visible && (node.Name != "" || node.TextKnown || node.CaretKnown || len(node.Ranges) != 0) {
return ErrReviewInvalid
}
if !node.TextKnown && (len(node.Ranges) != 0 || node.CaretKnown) || !node.CaretKnown && (node.Caret != 0 || node.CaretUTF16 != 0) {
return ErrReviewInvalid
}
if node.Caret > maxReviewSourceUnits || node.CaretUTF16 > maxReviewSourceUnits || node.CaretUTF16 < node.Caret || node.CaretUTF16 > 2*node.Caret {
return ErrReviewInvalid
}
previous, nativePrevious, characters := uint64(0), uint64(0), uint64(0)
textBytes += len(node.Name)
metadataFloor += 200 + len(node.ID) + len(node.Parent) + len(node.Role) + len(node.Name)
for _, r := range node.Ranges {
if !utf8.ValidString(r.Text) || strings.ContainsAny(r.Text, "\x00\ufffc") || r.Start < previous || r.End <= r.Start ||
r.UTF16Start < nativePrevious || r.UTF16End <= r.UTF16Start || r.End > maxReviewSourceUnits || r.UTF16End > maxReviewSourceUnits ||
r.UTF16Start < r.Start || r.UTF16Start > 2*r.Start || r.UTF16End < r.End || r.UTF16End > 2*r.End {
return ErrReviewInvalid
}
count := uint64(utf8.RuneCountInString(r.Text))
if count != r.End-r.Start || count > 4096 || uint64(len(utf16.Encode([]rune(r.Text)))) != r.UTF16End-r.UTF16Start {
return ErrReviewInvalid
}
if strings.ContainsAny(r.Text, "\r\n") && strings.Trim(r.Text, "\r\n") != "" {
return ErrReviewInvalid
}
if clipped, ok := r.Bounds.Intersection(node.Bounds); !ok || clipped != r.Bounds {
return ErrReviewInvalid
}
previous, nativePrevious = r.End, r.UTF16End
characters += count
textBytes += len(r.Text)
metadataFloor += 64 + len(r.Text)
}
if characters > 65536 || textBytes > 1<<20 || metadataFloor > 2<<20 {
return ErrReviewInvalid
}
}
root, exists := nodes[snapshot.Window]
if !exists || root.Parent != "" || (root.Role != "window" && root.Role != "dialog") || !root.Visible || root.Protected || root.Bounds != snapshot.Bounds {
return ErrReviewInvalid
}
if _, exists := nodes[snapshot.Focus]; !exists {
return ErrReviewInvalid
}
// Check each ancestry chain: this bounds depth and detects disconnected
// cycles, which a traversal starting only at the root would miss.
for id, node := range nodes {
if id == snapshot.Window {
continue
}
seen := map[string]bool{id: true}
for depth := 1; ; depth++ {
if depth > 64 {
return ErrReviewInvalid
}
parent, exists := nodes[node.Parent]
if !exists || seen[node.Parent] {
return ErrReviewInvalid
}
if clipped, ok := node.Bounds.Intersection(parent.Bounds); !ok || clipped != node.Bounds {
return ErrReviewInvalid
}
if parent.ID == snapshot.Window {
break
}
seen[parent.ID] = true
node = parent
}
}
return nil
}
func ApplyReviewSnapshot(tree *atspi.AccessibleTree, snapshot ReviewSnapshot, sender string) (*atspi.AccessibleTree, atspi.ObjectID, error) {
if err := validateReviewSnapshot(snapshot); err != nil {
return nil, atspi.ObjectID{}, err
}
root := ReviewID(sender, snapshot.Instance, snapshot.Window, snapshot.Window)
if !IsReviewID(root) {
return nil, atspi.ObjectID{}, ErrReviewInvalid
}
nodes := make([]atspi.TreeNode, len(snapshot.Nodes))
index := make(map[string]int, len(nodes))
for i, node := range snapshot.Nodes {
id := ReviewID(sender, snapshot.Instance, snapshot.Window, node.ID)
index[node.ID] = i
role := node.Role
if node.Protected {
role = "password text"
}
geometry := atspi.Geometry{VisibilityKnown: true, Visible: node.Visible, Showing: node.Visible}
if node.Visible {
geometry.BoundsKnown = true
geometry.Bounds = node.Bounds
}
text := atspi.VisibleText{Known: node.TextKnown, CaretKnown: node.CaretKnown, CaretOffset: int(node.Caret)}
for _, r := range node.Ranges {
text.Ranges = append(text.Ranges, atspi.TextRange{Start: int(r.Start), End: int(r.End), Text: r.Text, Bounds: r.Bounds})
}
nodes[i] = atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: id.BusName, Path: id.Path, Name: node.Name, Role: role,
StatesKnown: true, Visible: node.Visible, Showing: node.Visible, Focused: node.ID == snapshot.Focus, Focusable: node.ID == snapshot.Focus}, Geometry: geometry, Text: text}
}
for _, node := range snapshot.Nodes {
if node.Parent != "" {
parent := index[node.Parent]
nodes[parent].Children = append(nodes[parent].Children, ReviewID(sender, snapshot.Instance, snapshot.Window, node.ID))
}
}
var err error
if tree == nil {
tree, err = atspi.NewAccessibleTree(root, nodes)
} else {
err = tree.Replace(root, nodes)
}
if err != nil {
return nil, atspi.ObjectID{}, fmt.Errorf("%w: %v", ErrReviewInvalid, err)
}
return tree, ReviewID(sender, snapshot.Instance, snapshot.Window, snapshot.Focus), nil
}