332 lines
12 KiB
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
|
|
}
|