1// Package browser provides the static DOM runtime and strictly validated,
2// transport-neutral browser event values for github.com/bnema/vev-vt/html.
3package browser
4
5import (
6 "bytes"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "math"
12 "unicode/utf8"
13
14 htmlrenderer "github.com/bnema/vev-vt/html"
15)
16
17// EventSchemaVersion identifies the runtime-to-Go event contract.
18const EventSchemaVersion = htmlrenderer.UpdateSchemaVersion
19
20var (
21 // ErrEventLimit reports that an event exceeded a configured resource bound.
22 ErrEventLimit = errors.New("html/browser: event resource limit exceeded")
23 // ErrInvalidEventLimits reports incompatible configured event bounds.
24 ErrInvalidEventLimits = errors.New("html/browser: invalid event limits")
25)
26
27const (
28 maxJSONEscapedByteLength = 6
29 eventEnvelopeOverhead = 64
30)
31
32// EventLimits bounds untrusted browser event decoding. Zero fields select safe
33// defaults.
34type EventLimits struct {
35 MaxEventBytes int
36 MaxTextBytes int
37 MaxPasteBytes int
38 MaxColumns int
39 MaxRows int
40}
41
42// DefaultEventLimits returns the bounds used for zero-valued event limits.
43func DefaultEventLimits() EventLimits {
44 return EventLimits{
45 MaxEventBytes: 8 << 20,
46 MaxTextBytes: 64 << 10,
47 MaxPasteBytes: 1 << 20,
48 MaxColumns: 10_000,
49 MaxRows: 10_000,
50 }
51}
52
53func normalizeEventLimits(limits EventLimits) (EventLimits, error) {
54 if limits.MaxEventBytes < 0 || limits.MaxTextBytes < 0 || limits.MaxPasteBytes < 0 || limits.MaxColumns < 0 || limits.MaxRows < 0 {
55 return EventLimits{}, fmt.Errorf("%w: event limits must not be negative", ErrInvalidEventLimits)
56 }
57 defaults := DefaultEventLimits()
58 if limits.MaxEventBytes == 0 {
59 limits.MaxEventBytes = defaults.MaxEventBytes
60 }
61 if limits.MaxTextBytes == 0 {
62 limits.MaxTextBytes = defaults.MaxTextBytes
63 }
64 if limits.MaxPasteBytes == 0 {
65 limits.MaxPasteBytes = defaults.MaxPasteBytes
66 }
67 if limits.MaxColumns == 0 {
68 limits.MaxColumns = defaults.MaxColumns
69 }
70 if limits.MaxRows == 0 {
71 limits.MaxRows = defaults.MaxRows
72 }
73 requiredEventBytes, representable := minimumEventBytes(max(limits.MaxTextBytes, limits.MaxPasteBytes))
74 if !representable || limits.MaxEventBytes < requiredEventBytes {
75 return EventLimits{}, fmt.Errorf("%w: MaxEventBytes %d cannot contain the largest payload limit", ErrInvalidEventLimits, limits.MaxEventBytes)
76 }
77 return limits, nil
78}
79
80func minimumEventBytes(payloadBytes int) (int, bool) {
81 maxInt := int(^uint(0) >> 1)
82 if payloadBytes > (maxInt-eventEnvelopeOverhead)/maxJSONEscapedByteLength {
83 return 0, false
84 }
85 return payloadBytes*maxJSONEscapedByteLength + eventEnvelopeOverhead, true
86}
87
88// EventKind identifies one closed browser event payload.
89type EventKind string
90
91// Supported browser event kinds.
92const (
93 EventText EventKind = "text"
94 EventKey EventKind = "key"
95 EventPaste EventKind = "paste"
96 EventPointer EventKind = "pointer"
97 EventWheel EventKind = "wheel"
98 EventResize EventKind = "resize"
99 EventFocus EventKind = "focus"
100)
101
102// Modifiers is the normalized browser modifier-key state.
103type Modifiers struct {
104 Alt bool `json:"alt"`
105 Ctrl bool `json:"ctrl"`
106 Meta bool `json:"meta"`
107 Shift bool `json:"shift"`
108}
109
110// TextEvent carries composed text input.
111type TextEvent struct{ Text string }
112
113// PasteEvent carries bounded plain-text clipboard input. DecodeEvent preserves
114// Text unchanged, including control bytes such as ESC and CR. Consumers that
115// forward it to a PTY must apply their required framing or filtering policy.
116type PasteEvent struct{ Text string }
117
118// KeyEvent carries a non-text key or modified key press.
119type KeyEvent struct {
120 Key string
121 Code string
122 Modifiers Modifiers
123 Repeat bool
124 Location int
125}
126
127// PointerEvent carries a pointer action mapped to terminal coordinates.
128type PointerEvent struct {
129 Action string
130 Button int
131 Buttons uint16
132 Row int
133 Column int
134 X float64
135 Y float64
136 Modifiers Modifiers
137}
138
139// WheelEvent carries browser wheel deltas and terminal coordinates.
140type WheelEvent struct {
141 DeltaX float64
142 DeltaY float64
143 DeltaMode int
144 Row int
145 Column int
146 Modifiers Modifiers
147}
148
149// ResizeEvent carries measured grid and CSS-pixel geometry.
150type ResizeEvent struct {
151 Columns int
152 Rows int
153 PixelWidth float64
154 PixelHeight float64
155 CellWidth float64
156 CellHeight float64
157 DevicePixelRatio float64
158}
159
160// FocusEvent reports input-proxy focus changes.
161type FocusEvent struct{ Focused bool }
162
163// Event is a closed tagged union. Exactly one payload matching Kind is set.
164type Event struct {
165 SchemaVersion uint16
166 Kind EventKind
167 Text *TextEvent
168 Key *KeyEvent
169 Paste *PasteEvent
170 Pointer *PointerEvent
171 Wheel *WheelEvent
172 Resize *ResizeEvent
173 Focus *FocusEvent
174}
175
176type envelope struct {
177 SchemaVersion uint16 `json:"schemaVersion"`
178 Kind EventKind `json:"type"`
179}
180
181type textWire struct {
182 SchemaVersion uint16 `json:"schemaVersion"`
183 Kind EventKind `json:"type"`
184 Text *string `json:"text"`
185}
186
187type keyWire struct {
188 SchemaVersion uint16 `json:"schemaVersion"`
189 Kind EventKind `json:"type"`
190 Key *string `json:"key"`
191 Code *string `json:"code"`
192 Alt bool `json:"alt"`
193 Ctrl bool `json:"ctrl"`
194 Meta bool `json:"meta"`
195 Shift bool `json:"shift"`
196 Repeat bool `json:"repeat"`
197 Location int `json:"location"`
198}
199
200type pointerWire struct {
201 SchemaVersion uint16 `json:"schemaVersion"`
202 Kind EventKind `json:"type"`
203 Action *string `json:"action"`
204 Button *int `json:"button"`
205 Buttons *uint16 `json:"buttons"`
206 Row *int `json:"row"`
207 Column *int `json:"column"`
208 X *float64 `json:"x"`
209 Y *float64 `json:"y"`
210 Alt bool `json:"alt"`
211 Ctrl bool `json:"ctrl"`
212 Meta bool `json:"meta"`
213 Shift bool `json:"shift"`
214}
215
216type wheelWire struct {
217 SchemaVersion uint16 `json:"schemaVersion"`
218 Kind EventKind `json:"type"`
219 DeltaX *float64 `json:"deltaX"`
220 DeltaY *float64 `json:"deltaY"`
221 DeltaMode *int `json:"deltaMode"`
222 Row *int `json:"row"`
223 Column *int `json:"column"`
224 Alt bool `json:"alt"`
225 Ctrl bool `json:"ctrl"`
226 Meta bool `json:"meta"`
227 Shift bool `json:"shift"`
228}
229
230type resizeWire struct {
231 SchemaVersion uint16 `json:"schemaVersion"`
232 Kind EventKind `json:"type"`
233 Columns *int `json:"columns"`
234 Rows *int `json:"rows"`
235 PixelWidth *float64 `json:"pixelWidth"`
236 PixelHeight *float64 `json:"pixelHeight"`
237 CellWidth *float64 `json:"cellWidth"`
238 CellHeight *float64 `json:"cellHeight"`
239 DevicePixelRatio *float64 `json:"devicePixelRatio"`
240}
241
242type focusWire struct {
243 SchemaVersion uint16 `json:"schemaVersion"`
244 Kind EventKind `json:"type"`
245 Focused *bool `json:"focused"`
246}
247
248// DecodeEvent strictly decodes one complete untrusted browser event.
249func DecodeEvent(data []byte, requested EventLimits) (Event, error) {
250 limits, err := normalizeEventLimits(requested)
251 if err != nil {
252 return Event{}, err
253 }
254 if len(data) > limits.MaxEventBytes {
255 return Event{}, fmt.Errorf("%w: event has %d bytes, limit is %d", ErrEventLimit, len(data), limits.MaxEventBytes)
256 }
257 if !utf8.Valid(data) {
258 return Event{}, fmt.Errorf("html/browser: event is not valid UTF-8")
259 }
260 var header envelope
261 if err := json.Unmarshal(data, &header); err != nil {
262 return Event{}, fmt.Errorf("html/browser: decode event envelope: %w", err)
263 }
264 if header.SchemaVersion != EventSchemaVersion {
265 return Event{}, fmt.Errorf("html/browser: unsupported event schema %d", header.SchemaVersion)
266 }
267
268 event := Event{SchemaVersion: header.SchemaVersion, Kind: header.Kind}
269 switch header.Kind {
270 case EventText, EventPaste:
271 var wire textWire
272 if err := decodeStrict(data, &wire); err != nil {
273 return Event{}, err
274 }
275 if wire.Text == nil {
276 return Event{}, fmt.Errorf("html/browser: event text is required")
277 }
278 if !utf8.ValidString(*wire.Text) {
279 return Event{}, fmt.Errorf("html/browser: event text is not valid UTF-8")
280 }
281 limit := limits.MaxTextBytes
282 if header.Kind == EventPaste {
283 limit = limits.MaxPasteBytes
284 }
285 if len(*wire.Text) > limit {
286 return Event{}, fmt.Errorf("%w: %s has %d bytes, limit is %d", ErrEventLimit, header.Kind, len(*wire.Text), limit)
287 }
288 if header.Kind == EventText {
289 event.Text = &TextEvent{Text: *wire.Text}
290 } else {
291 event.Paste = &PasteEvent{Text: *wire.Text}
292 }
293 case EventKey:
294 var wire keyWire
295 if err := decodeStrict(data, &wire); err != nil {
296 return Event{}, err
297 }
298 if wire.Key == nil || wire.Code == nil || *wire.Key == "" || *wire.Code == "" || len(*wire.Key) > 128 || len(*wire.Code) > 128 || wire.Location < 0 || wire.Location > 3 {
299 return Event{}, fmt.Errorf("html/browser: invalid key event")
300 }
301 event.Key = &KeyEvent{Key: *wire.Key, Code: *wire.Code, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift), Repeat: wire.Repeat, Location: wire.Location}
302 case EventPointer:
303 var wire pointerWire
304 if err := decodeStrict(data, &wire); err != nil {
305 return Event{}, err
306 }
307 if wire.Action == nil || wire.Button == nil || wire.Buttons == nil || wire.Row == nil || wire.Column == nil || wire.X == nil || wire.Y == nil || !oneOf(*wire.Action, "down", "up", "move", "cancel") || *wire.Button < -1 || *wire.Button > 4 || *wire.Buttons > 31 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.X) || !finiteBounded(*wire.Y) {
308 return Event{}, fmt.Errorf("html/browser: invalid pointer event")
309 }
310 event.Pointer = &PointerEvent{Action: *wire.Action, Button: *wire.Button, Buttons: *wire.Buttons, Row: *wire.Row, Column: *wire.Column, X: *wire.X, Y: *wire.Y, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)}
311 case EventWheel:
312 var wire wheelWire
313 if err := decodeStrict(data, &wire); err != nil {
314 return Event{}, err
315 }
316 if wire.DeltaX == nil || wire.DeltaY == nil || wire.DeltaMode == nil || wire.Row == nil || wire.Column == nil || *wire.DeltaMode < 0 || *wire.DeltaMode > 2 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.DeltaX) || !finiteBounded(*wire.DeltaY) {
317 return Event{}, fmt.Errorf("html/browser: invalid wheel event")
318 }
319 event.Wheel = &WheelEvent{DeltaX: *wire.DeltaX, DeltaY: *wire.DeltaY, DeltaMode: *wire.DeltaMode, Row: *wire.Row, Column: *wire.Column, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)}
320 case EventResize:
321 var wire resizeWire
322 if err := decodeStrict(data, &wire); err != nil {
323 return Event{}, err
324 }
325 if wire.Columns == nil || wire.Rows == nil || wire.PixelWidth == nil || wire.PixelHeight == nil || wire.CellWidth == nil || wire.CellHeight == nil || wire.DevicePixelRatio == nil || *wire.Columns <= 0 || *wire.Columns > limits.MaxColumns || *wire.Rows <= 0 || *wire.Rows > limits.MaxRows || !positiveFinite(*wire.PixelWidth) || !positiveFinite(*wire.PixelHeight) || !positiveFinite(*wire.CellWidth) || !positiveFinite(*wire.CellHeight) || !positiveFinite(*wire.DevicePixelRatio) {
326 return Event{}, fmt.Errorf("html/browser: invalid resize event")
327 }
328 event.Resize = &ResizeEvent{Columns: *wire.Columns, Rows: *wire.Rows, PixelWidth: *wire.PixelWidth, PixelHeight: *wire.PixelHeight, CellWidth: *wire.CellWidth, CellHeight: *wire.CellHeight, DevicePixelRatio: *wire.DevicePixelRatio}
329 case EventFocus:
330 var wire focusWire
331 if err := decodeStrict(data, &wire); err != nil {
332 return Event{}, err
333 }
334 if wire.Focused == nil {
335 return Event{}, fmt.Errorf("html/browser: invalid focus event")
336 }
337 event.Focus = &FocusEvent{Focused: *wire.Focused}
338 default:
339 return Event{}, fmt.Errorf("html/browser: unknown event kind %q", header.Kind)
340 }
341 return event, nil
342}
343
344func decodeStrict(data []byte, dst any) error {
345 decoder := json.NewDecoder(bytes.NewReader(data))
346 decoder.DisallowUnknownFields()
347 if err := decoder.Decode(dst); err != nil {
348 return fmt.Errorf("html/browser: decode event: %w", err)
349 }
350 if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
351 return fmt.Errorf("html/browser: event must contain one JSON value")
352 }
353 return nil
354}
355
356func modifiers(alt, ctrl, meta, shift bool) Modifiers {
357 return Modifiers{Alt: alt, Ctrl: ctrl, Meta: meta, Shift: shift}
358}
359
360func validCell(row, column int, limits EventLimits) bool {
361 return row >= 0 && row < limits.MaxRows && column >= 0 && column < limits.MaxColumns
362}
363
364func finiteBounded(value float64) bool {
365 return !math.IsNaN(value) && !math.IsInf(value, 0) && math.Abs(value) <= 10_000_000
366}
367
368func positiveFinite(value float64) bool { return value > 0 && finiteBounded(value) }
369
370func oneOf(value string, allowed ...string) bool {
371 for _, candidate := range allowed {
372 if value == candidate {
373 return true
374 }
375 }
376 return false
377}