// 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 }