v0.8.1 1f54f61

request.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close