1// Package uidriver exposes attachment UI primitives through bounded JSONL.
2package uidriver
3
4import (
5 "bytes"
6 "encoding/json"
7 "io"
8 "time"
9 "unicode/utf8"
10
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/protocol"
14)
15
16const (
17 apiVersion = 1
18 maxRequestBytes = 64 << 10
19 maxResponseBytes = 48 << 20
20 maxSerializedBytes = 128 << 20
21 maxConnections = 4
22 maxColumns = 512
23 maxRows = 256
24 writeTimeout = 5 * time.Second
25)
26
27type operation string
28
29const (
30 opCapture operation = "capture"
31 opKeys operation = "keys"
32 opText operation = "text"
33 opWait operation = "wait"
34)
35
36type captureFormat string
37
38const (
39 formatText captureFormat = "text"
40 formatCells captureFormat = "cells"
41 formatBoth captureFormat = "both"
42)
43
44type request struct {
45 ID uint64
46 Op operation
47 Attachment string
48 Format captureFormat
49 Action ports.UIActionRequest
50 Wait ports.UIWaitRequest
51}
52
53func invalidRequest() error { return &ports.UIError{Code: ports.UIErrInvalidRequest} }
54
55// decodeRequest validates the exact operation schema before touching a service.
56// The standard decoder's case-insensitive struct matching is deliberately not
57// used for member names; null is not a substitute for an omitted option.
58func decodeRequest(data []byte) (request, error) {
59 var result request
60 if len(data) > maxRequestBytes || !utf8.Valid(data) || !uniqueJSON(data) {
61 return result, invalidRequest()
62 }
63 var fields map[string]json.RawMessage
64 if json.Unmarshal(data, &fields) != nil || fields == nil {
65 return result, invalidRequest()
66 }
67 field := func(name string, out any, required bool) bool {
68 raw, exists := fields[name]
69 if !exists {
70 return !required
71 }
72 return !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) && json.Unmarshal(raw, out) == nil
73 }
74 var version uint16
75 if !field("id", &result.ID, true) || result.ID == 0 || !field("version", &version, true) {
76 return result, invalidRequest()
77 }
78 if version != apiVersion {
79 return result, &ports.UIError{Code: ports.UIErrUnsupportedVersion}
80 }
81 if !field("op", &result.Op, true) || !field("attachment", &result.Attachment, true) || result.Attachment == "" {
82 return result, invalidRequest()
83 }
84 allowed := map[string]bool{"version": true, "id": true, "op": true, "attachment": true}
85 allow := func(names ...string) {
86 for _, name := range names {
87 allowed[name] = true
88 }
89 }
90 var timeoutMS int64
91 switch result.Op {
92 case opCapture:
93 allow("format")
94 result.Format = formatText
95 if !field("format", &result.Format, false) || result.Format != formatText && result.Format != formatCells && result.Format != formatBoth {
96 return result, invalidRequest()
97 }
98 case opKeys, opText:
99 allow("generation", "timeout_ms")
100 result.Action.Attachment = result.Attachment
101 if !field("generation", &result.Action.Generation, true) || result.Action.Generation == 0 {
102 return result, invalidRequest()
103 }
104 if result.Op == opKeys {
105 allow("keys")
106 if !field("keys", &result.Action.Keys, true) || len(result.Action.Keys) == 0 || len(result.Action.Keys) > 256 {
107 return result, invalidRequest()
108 }
109 } else {
110 allow("text")
111 if !field("text", &result.Action.Text, true) || result.Action.Text == "" {
112 return result, invalidRequest()
113 }
114 }
115 case opWait:
116 allow("after_action", "timeout_ms", "expect")
117 result.Wait.Attachment = result.Attachment
118 if !field("after_action", &result.Wait.AfterAction, false) {
119 return result, invalidRequest()
120 }
121 if _, exists := fields["after_action"]; exists && result.Wait.AfterAction == 0 {
122 return result, invalidRequest()
123 }
124 expect, err := decodeExpect(fields["expect"])
125 if err != nil {
126 return result, err
127 }
128 result.Wait.Expect = expect
129 default:
130 return result, invalidRequest()
131 }
132 for name := range fields {
133 if !allowed[name] {
134 return result, invalidRequest()
135 }
136 }
137 if raw, exists := fields["timeout_ms"]; exists {
138 if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) || json.Unmarshal(raw, &timeoutMS) != nil || timeoutMS < 1 || timeoutMS > 30000 {
139 return result, invalidRequest()
140 }
141 }
142 result.Action.Timeout = time.Duration(timeoutMS) * time.Millisecond
143 result.Wait.Timeout = result.Action.Timeout
144 return result, nil
145}
146
147func decodeExpect(data []byte) (ports.UIExpect, error) {
148 var result ports.UIExpect
149 var fields map[string]json.RawMessage
150 if json.Unmarshal(data, &fields) != nil || len(fields) == 0 {
151 return result, invalidRequest()
152 }
153 for name, raw := range fields {
154 if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
155 return result, invalidRequest()
156 }
157 switch name {
158 case "text_contains":
159 var text string
160 if json.Unmarshal(raw, &text) != nil || len(text) > 4096 {
161 return result, invalidRequest()
162 }
163 result.TextContains = &text
164 case "status":
165 var status ports.UIPresentationStatus
166 if json.Unmarshal(raw, &status) != nil || !status.Valid() {
167 return result, invalidRequest()
168 }
169 result.Status = &status
170 case "session":
171 object, ok := exactStringObject(raw, "lifecycle_id", "session_name")
172 if !ok {
173 return result, invalidRequest()
174 }
175 target := protocol.ExactSessionTarget{SessionName: object["session_name"]}
176 if target.LifecycleID.UnmarshalText([]byte(object["lifecycle_id"])) != nil || target.Validate() != nil {
177 return result, invalidRequest()
178 }
179 result.Session = &target
180 case "focus":
181 object, ok := exactStringObject(raw, "tab_id", "pane_id")
182 if !ok {
183 return result, invalidRequest()
184 }
185 focus := &ports.UIFocus{TabID: domain.TabStableID(object["tab_id"]), PaneID: domain.PaneStableID(object["pane_id"])}
186 if domain.ValidateTabStableID(focus.TabID) != nil || domain.ValidatePaneStableID(focus.PaneID) != nil {
187 return result, invalidRequest()
188 }
189 result.Focus = focus
190 default:
191 return result, invalidRequest()
192 }
193 }
194 return result, nil
195}
196
197func exactStringObject(data []byte, names ...string) (map[string]string, bool) {
198 var object map[string]string
199 if json.Unmarshal(data, &object) != nil || len(object) != len(names) {
200 return nil, false
201 }
202 for _, name := range names {
203 if object[name] == "" {
204 return nil, false
205 }
206 }
207 return object, true
208}
209
210// uniqueJSON rejects duplicate members at every depth, including escaped aliases.
211// Input is already byte-bounded. A small depth cap exceeds every API schema.
212func uniqueJSON(data []byte) bool {
213 decoder := json.NewDecoder(bytes.NewReader(data))
214 decoder.UseNumber()
215 var value func(int) bool
216 value = func(depth int) bool {
217 if depth > 8 {
218 return false
219 }
220 token, err := decoder.Token()
221 if err != nil {
222 return false
223 }
224 delimiter, ok := token.(json.Delim)
225 if !ok {
226 return true
227 }
228 switch delimiter {
229 case '{':
230 seen := make(map[string]bool)
231 for decoder.More() {
232 token, err := decoder.Token()
233 if err != nil {
234 return false
235 }
236 name, ok := token.(string)
237 if !ok || seen[name] {
238 return false
239 }
240 seen[name] = true
241 if !value(depth + 1) {
242 return false
243 }
244 }
245 case '[':
246 for decoder.More() {
247 if !value(depth + 1) {
248 return false
249 }
250 }
251 default:
252 return false
253 }
254 _, err = decoder.Token()
255 return err == nil
256 }
257 if !value(0) {
258 return false
259 }
260 _, err := decoder.Token()
261 return err == io.EOF
262}