detached 1d8d6c0

fix(cef): harden the vim page bridge

End the page interaction when the renderer terminates, clear it on
Destroy and ignore results for destroyed views. Dispatch visual and yank
clipboard writes to GTK instead of the CEF IO thread, and accept a visual
copy only for a y/Enter key Go forwarded so a stolen token cannot rewrite
the clipboard. Ship the runtime only on start; key and cancel call the
installed runtime or report the end when it is gone. Answer oversized
payloads with 413, hide decode details behind a generic message and send
only hex accent colors to the page.
commit
1d8d6c086d6ede5a21c17e61627c21a6d2518575
author
brice · · 2026-10-04 16:04 UTC
committer
brice · · 2026-10-10 08:27 UTC
parent
a86e62f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +334 -39
M internal/infrastructure/cef/scheme_handler.go +5 -1
@@ -7,6 +7,7 @@ import (
77 "encoding/base64"
88 "encoding/hex"
99 "encoding/json"
10+ "errors"
1011 "fmt"
1112 "html"
1213 "io/fs"
@@ -492,8 +493,11 @@ func (h *dumbSchemeHandler) handleVimPage(request purecef.Request, browser purec
492493 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
493494 }
494495 payload, err := decodeVimPageBridgePayload(body)
496+ if errors.Is(err, errVimPagePayloadTooLarge) {
497+ return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
498+ }
495499 if err != nil {
496- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
500+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
497501 }
498502 if h.onVimPage == nil {
499503 h.logger.Warn().Msg("cef: vim-page — callback not wired")
@@ -7,6 +7,7 @@ import (
7 "encoding/base64"7 "encoding/base64"
8 "encoding/hex"8 "encoding/hex"
9 "encoding/json"9 "encoding/json"
10+ "errors"
10 "fmt"11 "fmt"
11 "html"12 "html"
12 "io/fs"13 "io/fs"
@@ -492,8 +493,11 @@ func (h *dumbSchemeHandler) handleVimPage(request purecef.Request, browser purec
492 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})493 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
493 }494 }
494 payload, err := decodeVimPageBridgePayload(body)495 payload, err := decodeVimPageBridgePayload(body)
496+ if errors.Is(err, errVimPagePayloadTooLarge) {
497+ return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
498+ }
495 if err != nil {499 if err != nil {
496- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})500+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
497 }501 }
498 if h.onVimPage == nil {502 if h.onVimPage == nil {
499 h.logger.Warn().Msg("cef: vim-page — callback not wired")503 h.logger.Warn().Msg("cef: vim-page — callback not wired")
M internal/infrastructure/cef/vim_page.go +103 -23
@@ -4,10 +4,12 @@ import (
44 "context"
55 "crypto/subtle"
66 _ "embed"
7+ "encoding/base64"
78 "encoding/json"
89 "errors"
910 "fmt"
1011 "net/url"
12+ "regexp"
1113 "strings"
1214
1315 purecef "github.com/bnema/purego-cef/cef"
@@ -22,7 +24,13 @@ var _ port.VimPageInteractor = (*WebView)(nil)
2224 //go:embed vim_page_runtime.js
2325 var vimPageRuntimeTemplateJS string
2426
25-var errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable")
27+var (
28+ errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable")
29+ errVimPagePayloadTooLarge = errors.New("vim page: payload too large")
30+ // vimPageAccentColor admits the CSS hex colors the runtime paints with;
31+ // anything else is replaced by the runtime default before reaching JS.
32+ vimPageAccentColor = regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
33+)
2634
2735 // vimPageBridgePayload is one result reported by the page runtime. Token is
2836 // the per-interaction secret issued by StartVimPageInteraction.
@@ -47,7 +55,7 @@ const (
4755 func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) {
4856 var payload vimPageBridgePayload
4957 if len(body) > maxClipboardBytes {
50- return payload, errors.New("payload too large")
58+ return payload, errVimPagePayloadTooLarge
5159 }
5260 if err := json.Unmarshal(body, &payload); err != nil {
5361 return payload, err
@@ -140,11 +148,15 @@ func vimPageRequestJSON(request dto.VimPageInteractionRequest) (string, error) {
140148 return "", fmt.Errorf("unsupported vim text object: %d", request.Object)
141149 }
142150 }
151+ color := request.HighlightColor
152+ if !vimPageAccentColor.MatchString(color) {
153+ color = ""
154+ }
143155 encoded, err := json.Marshal(struct {
144156 Kind string `json:"kind"`
145157 Object string `json:"object,omitempty"`
146158 Color string `json:"color,omitempty"`
147- }{Kind: kind, Object: object, Color: request.HighlightColor})
159+ }{Kind: kind, Object: object, Color: color})
148160 if err != nil {
149161 return "", err
150162 }
@@ -157,13 +169,28 @@ func jsString(s string) string {
157169 return string(encoded)
158170 }
159171
160-// vimPageScript installs the runtime when absent and then invokes one method
161-// with the interaction token. The token is a per-interaction secret, not the
162-// shared bridge nonce, so a page that intercepts the call only learns a token
163-// valid for the interaction the user just started.
164-func vimPageScript(method, token string, args ...string) string {
172+// vimPageStartScript installs the runtime when absent and starts an
173+// interaction. The token is a per-interaction secret, not the shared bridge
174+// nonce, so a page that intercepts the call only learns a token valid for the
175+// interaction the user just started.
176+func vimPageStartScript(token, request string) string {
177+ return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage.start(" + jsString(token) + ", " + request + ");"
178+}
179+
180+// vimPageCallScript invokes key or cancel on the installed runtime without
181+// shipping it again. When the runtime is gone (the document was replaced), it
182+// reports the end of the token so Go releases the key capture.
183+func vimPageCallScript(method, token string, args ...string) string {
165184 call := append([]string{jsString(token)}, args...)
166- return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ");"
185+ return "window.__dumberVimPage ? window.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ") : " + vimPageEndScript(token)
186+}
187+
188+// vimPageEndScript posts an end message for token over the same bridge the
189+// runtime uses.
190+func vimPageEndScript(token string) string {
191+ body, _ := json.Marshal(map[string]string{"token": token, "type": vimPageMessageEnd})
192+ return `window.fetch("dumb:///api/vim-page", {method: "POST", headers: {"X-Dumber-Body": ` +
193+ jsString(base64.StdEncoding.EncodeToString(body)) + `}}).catch(() => {});`
167194 }
168195
169196 // armVimPageInteraction issues a fresh token for a new interaction and
@@ -176,6 +203,7 @@ func (wv *WebView) armVimPageInteraction(kind dto.VimPageInteractionKind) (strin
176203 wv.mu.Lock()
177204 wv.vimPageToken = token
178205 wv.vimPageKind = kind
206+ wv.vimPageCopyAllowance = 0
179207 wv.vimPageGeneration++
180208 wv.mu.Unlock()
181209 return token, nil
@@ -195,6 +223,7 @@ func (wv *WebView) disarmVimPageInteraction() (bool, uint64) {
195223 captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys()
196224 wv.vimPageToken = ""
197225 wv.vimPageKind = 0
226+ wv.vimPageCopyAllowance = 0
198227 return captured, wv.vimPageGeneration
199228 }
200229
@@ -220,8 +249,9 @@ func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) {
220249
221250 // vimPageResultEndsInteraction reports whether a result spends the token.
222251 // The caret and visual flow is long-lived: its mode changes and copies leave
223-// the interaction running so copies can be chained; every other result is
224-// terminal, so a token grants exactly one of them.
252+// the interaction running so copies can be chained. Copies stay bounded by
253+// the user's own y/Enter presses (see vimPageCopyAllowance), not by the
254+// token. Every other result is terminal, so a token grants exactly one.
225255 func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool {
226256 if kind != dto.VimPageVisual {
227257 return true
@@ -279,13 +309,32 @@ func (wv *WebView) StartVimPageInteraction(_ context.Context, request dto.VimPag
279309 if err != nil {
280310 return err
281311 }
282- if err := wv.scheduleJavaScript(vimPageScript("start", token, encoded)); err != nil {
312+ if err := wv.scheduleJavaScript(vimPageStartScript(token, encoded)); err != nil {
283313 wv.disarmVimPageInteraction()
284314 return err
285315 }
286316 return nil
287317 }
288318
319+// vimPageKeyRequestsCopy reports whether a forwarded key may make a visual
320+// selection copy: y and Enter yank in the caret and visual flow.
321+func vimPageKeyRequestsCopy(key string) bool {
322+ return key == "y" || key == "<Return>"
323+}
324+
325+// grantVimPageCopyAllowance records that the user pressed a copy key for the
326+// armed visual interaction, so one visual copy result will be accepted.
327+func (wv *WebView) grantVimPageCopyAllowance(token, key string) {
328+ if !vimPageKeyRequestsCopy(key) {
329+ return
330+ }
331+ wv.mu.Lock()
332+ if wv.vimPageToken == token && wv.vimPageKind == dto.VimPageVisual {
333+ wv.vimPageCopyAllowance++
334+ }
335+ wv.mu.Unlock()
336+}
337+
289338 // SendVimPageKey forwards one canonical key to the active page interaction.
290339 func (wv *WebView) SendVimPageKey(_ context.Context, key string) error {
291340 if wv == nil || wv.destroyed.Load() {
@@ -295,7 +344,8 @@ func (wv *WebView) SendVimPageKey(_ context.Context, key string) error {
295344 if key == "" || token == "" {
296345 return nil
297346 }
298- return wv.scheduleJavaScript(vimPageScript("key", token, jsString(key)))
347+ wv.grantVimPageCopyAllowance(token, key)
348+ return wv.scheduleJavaScript(vimPageCallScript("key", token, jsString(key)))
299349 }
300350
301351 // CancelVimPageInteraction removes any hint overlay or visual selection.
@@ -308,7 +358,7 @@ func (wv *WebView) CancelVimPageInteraction(_ context.Context) error {
308358 if token == "" {
309359 return nil
310360 }
311- return wv.scheduleJavaScript(vimPageScript("cancel", token))
361+ return wv.scheduleJavaScript(vimPageCallScript("cancel", token))
312362 }
313363
314364 // endVimPageInteractionOnNavigation drops an interaction whose document is
@@ -326,26 +376,54 @@ func (e *Engine) handleVimPageBridge(browser purecef.Browser, payload vimPageBri
326376 })
327377 }
328378
379+// spendVimPageResultLocked validates the token of one result and applies its
380+// effect on the armed state: a terminal result disarms the interaction, and a
381+// visual copy spends one allowance. A visual copy is non-terminal, so the
382+// token alone would let a page that read it write the clipboard repeatedly;
383+// each accepted copy needs a y/Enter key the user pressed. wv.mu must be held.
384+func (wv *WebView) spendVimPageResultLocked(payload vimPageBridgePayload) (valid, ends, copyDenied bool) {
385+ armed := wv.vimPageToken
386+ valid = armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1
387+ if !valid {
388+ return false, false, false
389+ }
390+ kind := wv.vimPageKind
391+ if payload.Type == vimPageMessageCopy && kind == dto.VimPageVisual {
392+ if wv.vimPageCopyAllowance <= 0 {
393+ return true, false, true
394+ }
395+ wv.vimPageCopyAllowance--
396+ }
397+ ends = vimPageResultEndsInteraction(kind, payload.Type)
398+ if ends {
399+ wv.vimPageToken = ""
400+ wv.vimPageKind = 0
401+ wv.vimPageCopyAllowance = 0
402+ }
403+ return true, ends, false
404+}
405+
329406 // handleVimPageResult accepts a result only for the interaction the user
330407 // armed; results from stale or forged interactions are dropped.
331408 func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
409+ if wv == nil || wv.destroyed.Load() {
410+ return
411+ }
332412 wv.mu.Lock()
333413 cb := wv.callbacks
334414 sourceURI := wv.uri
335- armed := wv.vimPageToken
336415 kind := wv.vimPageKind
337416 generation := wv.vimPageGeneration
338- valid := armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1
339- ends := vimPageResultEndsInteraction(kind, payload.Type)
340- if valid && ends {
341- wv.vimPageToken = ""
342- wv.vimPageKind = 0
343- }
417+ valid, ends, copyDenied := wv.spendVimPageResultLocked(payload)
344418 wv.mu.Unlock()
345419 if !valid {
346420 logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token")
347421 return
348422 }
423+ if copyDenied {
424+ logging.FromContext(wv.ctx).Debug().Msg("cef: vim page copy rejected — no user copy key pending")
425+ return
426+ }
349427
350428 if ends {
351429 logging.FromContext(wv.ctx).Debug().
@@ -365,8 +443,10 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
365443 case vimPageMessageMode:
366444 wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode))
367445 case vimPageMessageCopy:
368- if wv.engine != nil {
369- wv.engine.handleExplicitClipboardBridgeText(wv.id, "copy", payload.Text)
446+ // Results arrive on the CEF IO thread; the clipboard write belongs on GTK.
447+ if engine := wv.engine; engine != nil {
448+ id, text := wv.id, payload.Text
449+ wv.runOnGTK(func() { engine.handleExplicitClipboardBridgeText(id, "copy", text) })
370450 }
371451 case vimPageMessageOpenNew:
372452 if !isNavigableVimHintURL(payload.URL, sourceURI) {
@@ -4,10 +4,12 @@ import (
4 "context"4 "context"
5 "crypto/subtle"5 "crypto/subtle"
6 _ "embed"6 _ "embed"
7+ "encoding/base64"
7 "encoding/json"8 "encoding/json"
8 "errors"9 "errors"
9 "fmt"10 "fmt"
10 "net/url"11 "net/url"
12+ "regexp"
11 "strings"13 "strings"
12 14
13 purecef "github.com/bnema/purego-cef/cef"15 purecef "github.com/bnema/purego-cef/cef"
@@ -22,7 +24,13 @@ var _ port.VimPageInteractor = (*WebView)(nil)
22 //go:embed vim_page_runtime.js24 //go:embed vim_page_runtime.js
23 var vimPageRuntimeTemplateJS string25 var vimPageRuntimeTemplateJS string
24 26
25-var errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable")27+var (
28+ errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable")
29+ errVimPagePayloadTooLarge = errors.New("vim page: payload too large")
30+ // vimPageAccentColor admits the CSS hex colors the runtime paints with;
31+ // anything else is replaced by the runtime default before reaching JS.
32+ vimPageAccentColor = regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
33+)
26 34
27 // vimPageBridgePayload is one result reported by the page runtime. Token is35 // vimPageBridgePayload is one result reported by the page runtime. Token is
28 // the per-interaction secret issued by StartVimPageInteraction.36 // the per-interaction secret issued by StartVimPageInteraction.
@@ -47,7 +55,7 @@ const (
47 func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) {55 func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) {
48 var payload vimPageBridgePayload56 var payload vimPageBridgePayload
49 if len(body) > maxClipboardBytes {57 if len(body) > maxClipboardBytes {
50- return payload, errors.New("payload too large")58+ return payload, errVimPagePayloadTooLarge
51 }59 }
52 if err := json.Unmarshal(body, &payload); err != nil {60 if err := json.Unmarshal(body, &payload); err != nil {
53 return payload, err61 return payload, err
@@ -140,11 +148,15 @@ func vimPageRequestJSON(request dto.VimPageInteractionRequest) (string, error) {
140 return "", fmt.Errorf("unsupported vim text object: %d", request.Object)148 return "", fmt.Errorf("unsupported vim text object: %d", request.Object)
141 }149 }
142 }150 }
151+ color := request.HighlightColor
152+ if !vimPageAccentColor.MatchString(color) {
153+ color = ""
154+ }
143 encoded, err := json.Marshal(struct {155 encoded, err := json.Marshal(struct {
144 Kind string `json:"kind"`156 Kind string `json:"kind"`
145 Object string `json:"object,omitempty"`157 Object string `json:"object,omitempty"`
146 Color string `json:"color,omitempty"`158 Color string `json:"color,omitempty"`
147- }{Kind: kind, Object: object, Color: request.HighlightColor})159+ }{Kind: kind, Object: object, Color: color})
148 if err != nil {160 if err != nil {
149 return "", err161 return "", err
150 }162 }
@@ -157,13 +169,28 @@ func jsString(s string) string {
157 return string(encoded)169 return string(encoded)
158 }170 }
159 171
160-// vimPageScript installs the runtime when absent and then invokes one method172+// vimPageStartScript installs the runtime when absent and starts an
161-// with the interaction token. The token is a per-interaction secret, not the173+// interaction. The token is a per-interaction secret, not the shared bridge
162-// shared bridge nonce, so a page that intercepts the call only learns a token174+// nonce, so a page that intercepts the call only learns a token valid for the
163-// valid for the interaction the user just started.175+// interaction the user just started.
164-func vimPageScript(method, token string, args ...string) string {176+func vimPageStartScript(token, request string) string {
177+ return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage.start(" + jsString(token) + ", " + request + ");"
178+}
179+
180+// vimPageCallScript invokes key or cancel on the installed runtime without
181+// shipping it again. When the runtime is gone (the document was replaced), it
182+// reports the end of the token so Go releases the key capture.
183+func vimPageCallScript(method, token string, args ...string) string {
165 call := append([]string{jsString(token)}, args...)184 call := append([]string{jsString(token)}, args...)
166- return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ");"185+ return "window.__dumberVimPage ? window.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ") : " + vimPageEndScript(token)
186+}
187+
188+// vimPageEndScript posts an end message for token over the same bridge the
189+// runtime uses.
190+func vimPageEndScript(token string) string {
191+ body, _ := json.Marshal(map[string]string{"token": token, "type": vimPageMessageEnd})
192+ return `window.fetch("dumb:///api/vim-page", {method: "POST", headers: {"X-Dumber-Body": ` +
193+ jsString(base64.StdEncoding.EncodeToString(body)) + `}}).catch(() => {});`
167 }194 }
168 195
169 // armVimPageInteraction issues a fresh token for a new interaction and196 // armVimPageInteraction issues a fresh token for a new interaction and
@@ -176,6 +203,7 @@ func (wv *WebView) armVimPageInteraction(kind dto.VimPageInteractionKind) (strin
176 wv.mu.Lock()203 wv.mu.Lock()
177 wv.vimPageToken = token204 wv.vimPageToken = token
178 wv.vimPageKind = kind205 wv.vimPageKind = kind
206+ wv.vimPageCopyAllowance = 0
179 wv.vimPageGeneration++207 wv.vimPageGeneration++
180 wv.mu.Unlock()208 wv.mu.Unlock()
181 return token, nil209 return token, nil
@@ -195,6 +223,7 @@ func (wv *WebView) disarmVimPageInteraction() (bool, uint64) {
195 captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys()223 captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys()
196 wv.vimPageToken = ""224 wv.vimPageToken = ""
197 wv.vimPageKind = 0225 wv.vimPageKind = 0
226+ wv.vimPageCopyAllowance = 0
198 return captured, wv.vimPageGeneration227 return captured, wv.vimPageGeneration
199 }228 }
200 229
@@ -220,8 +249,9 @@ func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) {
220 249
221 // vimPageResultEndsInteraction reports whether a result spends the token.250 // vimPageResultEndsInteraction reports whether a result spends the token.
222 // The caret and visual flow is long-lived: its mode changes and copies leave251 // The caret and visual flow is long-lived: its mode changes and copies leave
223-// the interaction running so copies can be chained; every other result is252+// the interaction running so copies can be chained. Copies stay bounded by
224-// terminal, so a token grants exactly one of them.253+// the user's own y/Enter presses (see vimPageCopyAllowance), not by the
254+// token. Every other result is terminal, so a token grants exactly one.
225 func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool {255 func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool {
226 if kind != dto.VimPageVisual {256 if kind != dto.VimPageVisual {
227 return true257 return true
@@ -279,13 +309,32 @@ func (wv *WebView) StartVimPageInteraction(_ context.Context, request dto.VimPag
279 if err != nil {309 if err != nil {
280 return err310 return err
281 }311 }
282- if err := wv.scheduleJavaScript(vimPageScript("start", token, encoded)); err != nil {312+ if err := wv.scheduleJavaScript(vimPageStartScript(token, encoded)); err != nil {
283 wv.disarmVimPageInteraction()313 wv.disarmVimPageInteraction()
284 return err314 return err
285 }315 }
286 return nil316 return nil
287 }317 }
288 318
319+// vimPageKeyRequestsCopy reports whether a forwarded key may make a visual
320+// selection copy: y and Enter yank in the caret and visual flow.
321+func vimPageKeyRequestsCopy(key string) bool {
322+ return key == "y" || key == "<Return>"
323+}
324+
325+// grantVimPageCopyAllowance records that the user pressed a copy key for the
326+// armed visual interaction, so one visual copy result will be accepted.
327+func (wv *WebView) grantVimPageCopyAllowance(token, key string) {
328+ if !vimPageKeyRequestsCopy(key) {
329+ return
330+ }
331+ wv.mu.Lock()
332+ if wv.vimPageToken == token && wv.vimPageKind == dto.VimPageVisual {
333+ wv.vimPageCopyAllowance++
334+ }
335+ wv.mu.Unlock()
336+}
337+
289 // SendVimPageKey forwards one canonical key to the active page interaction.338 // SendVimPageKey forwards one canonical key to the active page interaction.
290 func (wv *WebView) SendVimPageKey(_ context.Context, key string) error {339 func (wv *WebView) SendVimPageKey(_ context.Context, key string) error {
291 if wv == nil || wv.destroyed.Load() {340 if wv == nil || wv.destroyed.Load() {
@@ -295,7 +344,8 @@ func (wv *WebView) SendVimPageKey(_ context.Context, key string) error {
295 if key == "" || token == "" {344 if key == "" || token == "" {
296 return nil345 return nil
297 }346 }
298- return wv.scheduleJavaScript(vimPageScript("key", token, jsString(key)))347+ wv.grantVimPageCopyAllowance(token, key)
348+ return wv.scheduleJavaScript(vimPageCallScript("key", token, jsString(key)))
299 }349 }
300 350
301 // CancelVimPageInteraction removes any hint overlay or visual selection.351 // CancelVimPageInteraction removes any hint overlay or visual selection.
@@ -308,7 +358,7 @@ func (wv *WebView) CancelVimPageInteraction(_ context.Context) error {
308 if token == "" {358 if token == "" {
309 return nil359 return nil
310 }360 }
311- return wv.scheduleJavaScript(vimPageScript("cancel", token))361+ return wv.scheduleJavaScript(vimPageCallScript("cancel", token))
312 }362 }
313 363
314 // endVimPageInteractionOnNavigation drops an interaction whose document is364 // endVimPageInteractionOnNavigation drops an interaction whose document is
@@ -326,26 +376,54 @@ func (e *Engine) handleVimPageBridge(browser purecef.Browser, payload vimPageBri
326 })376 })
327 }377 }
328 378
379+// spendVimPageResultLocked validates the token of one result and applies its
380+// effect on the armed state: a terminal result disarms the interaction, and a
381+// visual copy spends one allowance. A visual copy is non-terminal, so the
382+// token alone would let a page that read it write the clipboard repeatedly;
383+// each accepted copy needs a y/Enter key the user pressed. wv.mu must be held.
384+func (wv *WebView) spendVimPageResultLocked(payload vimPageBridgePayload) (valid, ends, copyDenied bool) {
385+ armed := wv.vimPageToken
386+ valid = armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1
387+ if !valid {
388+ return false, false, false
389+ }
390+ kind := wv.vimPageKind
391+ if payload.Type == vimPageMessageCopy && kind == dto.VimPageVisual {
392+ if wv.vimPageCopyAllowance <= 0 {
393+ return true, false, true
394+ }
395+ wv.vimPageCopyAllowance--
396+ }
397+ ends = vimPageResultEndsInteraction(kind, payload.Type)
398+ if ends {
399+ wv.vimPageToken = ""
400+ wv.vimPageKind = 0
401+ wv.vimPageCopyAllowance = 0
402+ }
403+ return true, ends, false
404+}
405+
329 // handleVimPageResult accepts a result only for the interaction the user406 // handleVimPageResult accepts a result only for the interaction the user
330 // armed; results from stale or forged interactions are dropped.407 // armed; results from stale or forged interactions are dropped.
331 func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {408 func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
409+ if wv == nil || wv.destroyed.Load() {
410+ return
411+ }
332 wv.mu.Lock()412 wv.mu.Lock()
333 cb := wv.callbacks413 cb := wv.callbacks
334 sourceURI := wv.uri414 sourceURI := wv.uri
335- armed := wv.vimPageToken
336 kind := wv.vimPageKind415 kind := wv.vimPageKind
337 generation := wv.vimPageGeneration416 generation := wv.vimPageGeneration
338- valid := armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1417+ valid, ends, copyDenied := wv.spendVimPageResultLocked(payload)
339- ends := vimPageResultEndsInteraction(kind, payload.Type)
340- if valid && ends {
341- wv.vimPageToken = ""
342- wv.vimPageKind = 0
343- }
344 wv.mu.Unlock()418 wv.mu.Unlock()
345 if !valid {419 if !valid {
346 logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token")420 logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token")
347 return421 return
348 }422 }
423+ if copyDenied {
424+ logging.FromContext(wv.ctx).Debug().Msg("cef: vim page copy rejected — no user copy key pending")
425+ return
426+ }
349 427
350 if ends {428 if ends {
351 logging.FromContext(wv.ctx).Debug().429 logging.FromContext(wv.ctx).Debug().
@@ -365,8 +443,10 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
365 case vimPageMessageMode:443 case vimPageMessageMode:
366 wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode))444 wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode))
367 case vimPageMessageCopy:445 case vimPageMessageCopy:
368- if wv.engine != nil {446+ // Results arrive on the CEF IO thread; the clipboard write belongs on GTK.
369- wv.engine.handleExplicitClipboardBridgeText(wv.id, "copy", payload.Text)447+ if engine := wv.engine; engine != nil {
448+ id, text := wv.id, payload.Text
449+ wv.runOnGTK(func() { engine.handleExplicitClipboardBridgeText(id, "copy", text) })
370 }450 }
371 case vimPageMessageOpenNew:451 case vimPageMessageOpenNew:
372 if !isNavigableVimHintURL(payload.URL, sourceURI) {452 if !isNavigableVimHintURL(payload.URL, sourceURI) {
M internal/infrastructure/cef/vim_page_test.go +210 -13
@@ -4,6 +4,8 @@ import (
44 "context"
55 "encoding/base64"
66 "net/http"
7+ "regexp"
8+ "strings"
79 "testing"
810
911 purecef "github.com/bnema/purego-cef/cef"
@@ -16,21 +18,20 @@ import (
1618 "github.com/bnema/dumber/internal/application/port"
1719 )
1820
19-func expectVimPageScript(t *testing.T, fragments ...string) *WebView {
21+// expectVimPageScript expects one ExecuteJavaScript call and captures the
22+// script that was actually sent to the page.
23+func expectVimPageScript(t *testing.T) (*WebView, *string) {
2024 t.Helper()
2125 browser := cefmocks.NewMockBrowser(t)
2226 frame := cefmocks.NewMockFrame(t)
27+ var script string
2328 browser.EXPECT().GetMainFrame().Return(frame).Once()
24- frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, fragments...)), "", int32(0)).Once()
25- return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"}
29+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(code, _ string, _ int32) { script = code }).Once()
30+ return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"}, &script
2631 }
2732
2833 func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) {
29- wv := expectVimPageScript(t,
30- "if (window.__dumberVimPage) return;",
31- `dumb:///api/vim-page`,
32- `{"kind":"yank","object":"code","color":"#123456"});`,
33- )
34+ wv, script := expectVimPageScript(t)
3435
3536 err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{
3637 Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456",
@@ -39,16 +40,38 @@ func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testi
3940 token := wv.currentVimPageToken()
4041 require.NotEmpty(t, token)
4142 assert.NotEqual(t, "shared-bridge-nonce", token)
42- assert.NotContains(t, vimPageScript("start", token, "{}"), "shared-bridge-nonce")
43- assert.Contains(t, vimPageScript("start", token, "{}"), `window.__dumberVimPage.start("`+token+`", {});`)
43+ assert.Contains(t, *script, "if (window.__dumberVimPage) return;", "start ships the runtime")
44+ assert.Contains(t, *script, `dumb:///api/vim-page`)
45+ assert.NotContains(t, *script, "shared-bridge-nonce")
46+ assert.Contains(t, *script, `window.__dumberVimPage.start("`+token+`", {"kind":"yank","object":"code","color":"#123456"});`)
4447 }
4548
46-func TestWebViewSendVimPageKeyEncodesKeyAsJSString(t *testing.T) {
47- wv := expectVimPageScript(t, `window.__dumberVimPage.key("tok", "\"\u0007");`)
49+// Keys and cancels run on the already installed runtime: shipping the whole
50+// runtime for every key press would cost tens of kilobytes per keystroke.
51+func TestWebViewSendVimPageKeyDoesNotShipRuntime(t *testing.T) {
52+ wv, script := expectVimPageScript(t)
4853 wv.vimPageToken = "tok"
4954
5055 require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a"))
5156 require.NoError(t, wv.SendVimPageKey(context.Background(), ""))
57+
58+ assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.key("tok", "\"\u0007") : `), *script)
59+ assert.NotContains(t, *script, "if (window.__dumberVimPage) return;")
60+ assert.Less(t, len(*script), 1024)
61+}
62+
63+// A document replaced between start and key leaves no runtime; the script must
64+// then report the end of that token so the UI releases its key capture.
65+func TestVimPageCallScriptReportsEndWhenRuntimeMissing(t *testing.T) {
66+ script := vimPageCallScript("cancel", "tok")
67+
68+ assert.Contains(t, script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `)
69+ assert.Contains(t, script, `dumb:///api/vim-page`)
70+ match := regexp.MustCompile(`"X-Dumber-Body": "([^"]+)"`).FindStringSubmatch(script)
71+ require.Len(t, match, 2)
72+ body, err := base64.StdEncoding.DecodeString(match[1])
73+ require.NoError(t, err)
74+ assert.JSONEq(t, `{"token":"tok","type":"end"}`, string(body))
5275 }
5376
5477 func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) {
@@ -58,10 +81,11 @@ func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) {
5881 }
5982
6083 func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) {
61- wv := expectVimPageScript(t, `window.__dumberVimPage.cancel("tok");`)
84+ wv, script := expectVimPageScript(t)
6285 wv.vimPageToken = "tok"
6386
6487 require.NoError(t, wv.CancelVimPageInteraction(context.Background()))
88+ assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `), *script)
6589 assert.Empty(t, wv.currentVimPageToken())
6690 require.NoError(t, wv.CancelVimPageInteraction(context.Background()))
6791 }
@@ -77,6 +101,26 @@ func TestVimPageRequestJSONRejectsUnknownRequests(t *testing.T) {
77101 assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got)
78102 }
79103
104+// The accent reaches the page as a CSS color; only hex colors are sent.
105+func TestVimPageRequestJSONValidatesAccentColor(t *testing.T) {
106+ for color, want := range map[string]string{
107+ "#fbbf24": `{"kind":"visual","color":"#fbbf24"}`,
108+ "#FFF": `{"kind":"visual","color":"#FFF"}`,
109+ "#aabbccdd": `{"kind":"visual","color":"#aabbccdd"}`,
110+ "": `{"kind":"visual"}`,
111+ "red": `{"kind":"visual"}`,
112+ "#12": `{"kind":"visual"}`,
113+ "#123456789": `{"kind":"visual"}`,
114+ "#abc;} body{display:none": `{"kind":"visual"}`,
115+ "url(javascript:alert(1))": `{"kind":"visual"}`,
116+ "#abc\"; alert(1); //": `{"kind":"visual"}`,
117+ } {
118+ got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageVisual, HighlightColor: color})
119+ require.NoError(t, err)
120+ assert.JSONEq(t, want, got, "color %q", color)
121+ }
122+}
123+
80124 func TestDecodeVimPageBridgePayload(t *testing.T) {
81125 tests := []struct {
82126 name string
@@ -134,6 +178,7 @@ func TestWebViewHandleVimPageResultVisualFlowStaysArmedAcrossModeAndCopy(t *test
134178 wv := newVimResultWebView(&opened, &ended)
135179 wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) }
136180 wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
181+ wv.vimPageCopyAllowance = 1
137182
138183 wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
139184 wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"})
@@ -301,3 +346,155 @@ func TestWebViewVimPageEndedCallbackSkippedAfterRearm(t *testing.T) {
301346
302347 assert.Zero(t, ended)
303348 }
349+
350+// handleVimPageResult runs on the CEF IO thread. The clipboard write must be
351+// handed to GTK instead of running there.
352+func newVimClipboardWebView(t *testing.T, kind dto.VimPageInteractionKind) (*WebView, *recordingClipboardTextOrchestrator, *[]func()) {
353+ t.Helper()
354+ orchestrator := &recordingClipboardTextOrchestrator{}
355+ queued := &[]func(){}
356+ wv := &WebView{
357+ ctx: context.Background(), id: 7, uri: "https://site.test/",
358+ engine: &Engine{clipboardTextOrchestrator: orchestrator},
359+ gtkDispatch: func(fn func()) { *queued = append(*queued, fn) },
360+ callbacks: &port.WebViewCallbacks{},
361+ }
362+ wv.vimPageToken, wv.vimPageKind = "tok", kind
363+ return wv, orchestrator, queued
364+}
365+
366+func TestWebViewHandleVimPageResultCopyRunsOnGTK(t *testing.T) {
367+ wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject)
368+
369+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "yanked"})
370+
371+ assert.Empty(t, orchestrator.explicit.Text, "the clipboard must not be written on the IO thread")
372+ require.Len(t, *queued, 1)
373+ (*queued)[0]()
374+ assert.Equal(t, dto.ExplicitClipboardInput{
375+ Text: "yanked", Action: "copy", SourceEngine: dto.ClipboardSourceCEF, ViewID: 7,
376+ }, orchestrator.explicit)
377+}
378+
379+// A hostile page that read the token must not be able to write the clipboard
380+// again and again: visual copies are non-terminal, so each needs a y/Enter the
381+// user pressed and Go forwarded.
382+func TestWebViewHandleVimPageResultVisualCopyNeedsForwardedCopyKey(t *testing.T) {
383+ wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageVisual)
384+ copyResult := vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "stolen"}
385+
386+ wv.handleVimPageResult(copyResult)
387+ assert.Empty(t, *queued, "no key was forwarded: the copy is refused")
388+ assert.Equal(t, "tok", wv.currentVimPageToken(), "a refused copy leaves the interaction running")
389+
390+ wv.grantVimPageCopyAllowance("tok", "j")
391+ wv.handleVimPageResult(copyResult)
392+ assert.Empty(t, *queued, "motion keys grant no copy")
393+
394+ wv.grantVimPageCopyAllowance("tok", "y")
395+ wv.handleVimPageResult(copyResult)
396+ wv.handleVimPageResult(copyResult)
397+ require.Len(t, *queued, 1, "one forwarded key allows exactly one copy")
398+ (*queued)[0]()
399+ assert.Equal(t, "stolen", orchestrator.explicit.Text)
400+
401+ wv.grantVimPageCopyAllowance("tok", "<Return>")
402+ wv.grantVimPageCopyAllowance("other", "y")
403+ wv.handleVimPageResult(copyResult)
404+ assert.Len(t, *queued, 2)
405+}
406+
407+func TestWebViewSendVimPageKeyGrantsCopyAllowanceOnlyForVisual(t *testing.T) {
408+ for kind, want := range map[dto.VimPageInteractionKind]int{dto.VimPageVisual: 2, dto.VimPageHintYankURL: 0} {
409+ browser := cefmocks.NewMockBrowser(t)
410+ frame := cefmocks.NewMockFrame(t)
411+ browser.EXPECT().GetMainFrame().Return(frame).Times(3)
412+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Times(3)
413+ wv := &WebView{browser: browser, vimPageToken: "tok", vimPageKind: kind}
414+
415+ require.NoError(t, wv.SendVimPageKey(context.Background(), "y"))
416+ require.NoError(t, wv.SendVimPageKey(context.Background(), "j"))
417+ require.NoError(t, wv.SendVimPageKey(context.Background(), "<Return>"))
418+
419+ assert.Equal(t, want, wv.vimPageCopyAllowance, "kind %d", kind)
420+ }
421+}
422+
423+func TestWebViewArmVimPageInteractionResetsCopyAllowance(t *testing.T) {
424+ wv := &WebView{vimPageToken: "old", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 3}
425+
426+ _, err := wv.armVimPageInteraction(dto.VimPageVisual)
427+ require.NoError(t, err)
428+ assert.Zero(t, wv.vimPageCopyAllowance)
429+ wv.vimPageCopyAllowance = 2
430+ wv.disarmVimPageInteraction()
431+ assert.Zero(t, wv.vimPageCopyAllowance)
432+}
433+
434+func TestWebViewHandleVimPageResultIgnoredAfterDestroy(t *testing.T) {
435+ wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject)
436+ ended := 0
437+ wv.callbacks.OnVimPageInteractionEnded = func() { ended++ }
438+ wv.destroyed.Store(true)
439+
440+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "late"})
441+
442+ assert.Empty(t, *queued)
443+ assert.Empty(t, orchestrator.explicit.Text)
444+ assert.Zero(t, ended)
445+}
446+
447+func TestWebViewDestroyClearsVimPageInteraction(t *testing.T) {
448+ wv := &WebView{ctx: context.Background(), vimPageToken: "tok", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 1}
449+
450+ wv.Destroy()
451+
452+ assert.Empty(t, wv.currentVimPageToken())
453+ assert.Zero(t, wv.vimPageKind)
454+ assert.Zero(t, wv.vimPageCopyAllowance)
455+}
456+
457+// A crashed renderer takes the page runtime with it, so it can never report
458+// the end; the render-process-terminated handler must release the capture.
459+func TestHandlerSetOnRenderProcessTerminatedEndsVimPageInteraction(t *testing.T) {
460+ var opened string
461+ ended := 0
462+ wv := newVimResultWebView(&opened, &ended)
463+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
464+ h := &handlerSet{wv: wv}
465+
466+ h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "")
467+
468+ assert.Equal(t, 1, ended)
469+ assert.Empty(t, wv.currentVimPageToken())
470+}
471+
472+func TestSchemeHandler_APIVimPageRejectsOversizedPayloadWith413(t *testing.T) {
473+ oldNewResourceHandler := cefNewResourceHandler
474+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
475+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
476+
477+ h := newTestDumbSchemeHandler(t)
478+ h.onVimPage = func(purecef.Browser, vimPageBridgePayload) { t.Fatal("oversized payload must not reach the handler") }
479+ request := cefmocks.NewMockRequest(t)
480+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"type":"copy","token":"t","text":"` + strings.Repeat("a", maxClipboardBytes) + `"}`))
481+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
482+ response := cefmocks.NewMockResponse(t)
483+ response.EXPECT().SetStatus(int32(http.StatusRequestEntityTooLarge)).Once()
484+ response.EXPECT().SetStatusText(http.StatusText(http.StatusRequestEntityTooLarge)).Once()
485+ response.EXPECT().SetMimeType("application/json").Once()
486+ expectAPIResponseHeaders(response)
487+
488+ handler := h.handleAPI(cefmocks.NewMockBrowser(t), http.MethodPost, "/api/vim-page", request)
489+ require.NotNil(t, handler)
490+ var length int64
491+ handler.GetResponseHeaders(response, &length, 0)
492+}
493+
494+func TestDecodeVimPageBridgePayloadOversizedIsSentinel(t *testing.T) {
495+ _, err := decodeVimPageBridgePayload(make([]byte, maxClipboardBytes+1))
496+ require.ErrorIs(t, err, errVimPagePayloadTooLarge)
497+ _, err = decodeVimPageBridgePayload([]byte(`{`))
498+ require.Error(t, err)
499+ require.NotErrorIs(t, err, errVimPagePayloadTooLarge)
500+}
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6 "net/http"6 "net/http"
7+ "regexp"
8+ "strings"
7 "testing"9 "testing"
8 10
9 purecef "github.com/bnema/purego-cef/cef"11 purecef "github.com/bnema/purego-cef/cef"
@@ -16,21 +18,20 @@ import (
16 "github.com/bnema/dumber/internal/application/port"18 "github.com/bnema/dumber/internal/application/port"
17 )19 )
18 20
19-func expectVimPageScript(t *testing.T, fragments ...string) *WebView {21+// expectVimPageScript expects one ExecuteJavaScript call and captures the
22+// script that was actually sent to the page.
23+func expectVimPageScript(t *testing.T) (*WebView, *string) {
20 t.Helper()24 t.Helper()
21 browser := cefmocks.NewMockBrowser(t)25 browser := cefmocks.NewMockBrowser(t)
22 frame := cefmocks.NewMockFrame(t)26 frame := cefmocks.NewMockFrame(t)
27+ var script string
23 browser.EXPECT().GetMainFrame().Return(frame).Once()28 browser.EXPECT().GetMainFrame().Return(frame).Once()
24- frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, fragments...)), "", int32(0)).Once()29+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(code, _ string, _ int32) { script = code }).Once()
25- return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"}30+ return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"}, &script
26 }31 }
27 32
28 func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) {33 func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) {
29- wv := expectVimPageScript(t,34+ wv, script := expectVimPageScript(t)
30- "if (window.__dumberVimPage) return;",
31- `dumb:///api/vim-page`,
32- `{"kind":"yank","object":"code","color":"#123456"});`,
33- )
34 35
35 err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{36 err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{
36 Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456",37 Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456",
@@ -39,16 +40,38 @@ func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testi
39 token := wv.currentVimPageToken()40 token := wv.currentVimPageToken()
40 require.NotEmpty(t, token)41 require.NotEmpty(t, token)
41 assert.NotEqual(t, "shared-bridge-nonce", token)42 assert.NotEqual(t, "shared-bridge-nonce", token)
42- assert.NotContains(t, vimPageScript("start", token, "{}"), "shared-bridge-nonce")43+ assert.Contains(t, *script, "if (window.__dumberVimPage) return;", "start ships the runtime")
43- assert.Contains(t, vimPageScript("start", token, "{}"), `window.__dumberVimPage.start("`+token+`", {});`)44+ assert.Contains(t, *script, `dumb:///api/vim-page`)
45+ assert.NotContains(t, *script, "shared-bridge-nonce")
46+ assert.Contains(t, *script, `window.__dumberVimPage.start("`+token+`", {"kind":"yank","object":"code","color":"#123456"});`)
44 }47 }
45 48
46-func TestWebViewSendVimPageKeyEncodesKeyAsJSString(t *testing.T) {49+// Keys and cancels run on the already installed runtime: shipping the whole
47- wv := expectVimPageScript(t, `window.__dumberVimPage.key("tok", "\"\u0007");`)50+// runtime for every key press would cost tens of kilobytes per keystroke.
51+func TestWebViewSendVimPageKeyDoesNotShipRuntime(t *testing.T) {
52+ wv, script := expectVimPageScript(t)
48 wv.vimPageToken = "tok"53 wv.vimPageToken = "tok"
49 54
50 require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a"))55 require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a"))
51 require.NoError(t, wv.SendVimPageKey(context.Background(), ""))56 require.NoError(t, wv.SendVimPageKey(context.Background(), ""))
57+
58+ assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.key("tok", "\"\u0007") : `), *script)
59+ assert.NotContains(t, *script, "if (window.__dumberVimPage) return;")
60+ assert.Less(t, len(*script), 1024)
61+}
62+
63+// A document replaced between start and key leaves no runtime; the script must
64+// then report the end of that token so the UI releases its key capture.
65+func TestVimPageCallScriptReportsEndWhenRuntimeMissing(t *testing.T) {
66+ script := vimPageCallScript("cancel", "tok")
67+
68+ assert.Contains(t, script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `)
69+ assert.Contains(t, script, `dumb:///api/vim-page`)
70+ match := regexp.MustCompile(`"X-Dumber-Body": "([^"]+)"`).FindStringSubmatch(script)
71+ require.Len(t, match, 2)
72+ body, err := base64.StdEncoding.DecodeString(match[1])
73+ require.NoError(t, err)
74+ assert.JSONEq(t, `{"token":"tok","type":"end"}`, string(body))
52 }75 }
53 76
54 func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) {77 func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) {
@@ -58,10 +81,11 @@ func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) {
58 }81 }
59 82
60 func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) {83 func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) {
61- wv := expectVimPageScript(t, `window.__dumberVimPage.cancel("tok");`)84+ wv, script := expectVimPageScript(t)
62 wv.vimPageToken = "tok"85 wv.vimPageToken = "tok"
63 86
64 require.NoError(t, wv.CancelVimPageInteraction(context.Background()))87 require.NoError(t, wv.CancelVimPageInteraction(context.Background()))
88+ assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `), *script)
65 assert.Empty(t, wv.currentVimPageToken())89 assert.Empty(t, wv.currentVimPageToken())
66 require.NoError(t, wv.CancelVimPageInteraction(context.Background()))90 require.NoError(t, wv.CancelVimPageInteraction(context.Background()))
67 }91 }
@@ -77,6 +101,26 @@ func TestVimPageRequestJSONRejectsUnknownRequests(t *testing.T) {
77 assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got)101 assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got)
78 }102 }
79 103
104+// The accent reaches the page as a CSS color; only hex colors are sent.
105+func TestVimPageRequestJSONValidatesAccentColor(t *testing.T) {
106+ for color, want := range map[string]string{
107+ "#fbbf24": `{"kind":"visual","color":"#fbbf24"}`,
108+ "#FFF": `{"kind":"visual","color":"#FFF"}`,
109+ "#aabbccdd": `{"kind":"visual","color":"#aabbccdd"}`,
110+ "": `{"kind":"visual"}`,
111+ "red": `{"kind":"visual"}`,
112+ "#12": `{"kind":"visual"}`,
113+ "#123456789": `{"kind":"visual"}`,
114+ "#abc;} body{display:none": `{"kind":"visual"}`,
115+ "url(javascript:alert(1))": `{"kind":"visual"}`,
116+ "#abc\"; alert(1); //": `{"kind":"visual"}`,
117+ } {
118+ got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageVisual, HighlightColor: color})
119+ require.NoError(t, err)
120+ assert.JSONEq(t, want, got, "color %q", color)
121+ }
122+}
123+
80 func TestDecodeVimPageBridgePayload(t *testing.T) {124 func TestDecodeVimPageBridgePayload(t *testing.T) {
81 tests := []struct {125 tests := []struct {
82 name string126 name string
@@ -134,6 +178,7 @@ func TestWebViewHandleVimPageResultVisualFlowStaysArmedAcrossModeAndCopy(t *test
134 wv := newVimResultWebView(&opened, &ended)178 wv := newVimResultWebView(&opened, &ended)
135 wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) }179 wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) }
136 wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual180 wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
181+ wv.vimPageCopyAllowance = 1
137 182
138 wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})183 wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
139 wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"})184 wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"})
@@ -301,3 +346,155 @@ func TestWebViewVimPageEndedCallbackSkippedAfterRearm(t *testing.T) {
301 346
302 assert.Zero(t, ended)347 assert.Zero(t, ended)
303 }348 }
349+
350+// handleVimPageResult runs on the CEF IO thread. The clipboard write must be
351+// handed to GTK instead of running there.
352+func newVimClipboardWebView(t *testing.T, kind dto.VimPageInteractionKind) (*WebView, *recordingClipboardTextOrchestrator, *[]func()) {
353+ t.Helper()
354+ orchestrator := &recordingClipboardTextOrchestrator{}
355+ queued := &[]func(){}
356+ wv := &WebView{
357+ ctx: context.Background(), id: 7, uri: "https://site.test/",
358+ engine: &Engine{clipboardTextOrchestrator: orchestrator},
359+ gtkDispatch: func(fn func()) { *queued = append(*queued, fn) },
360+ callbacks: &port.WebViewCallbacks{},
361+ }
362+ wv.vimPageToken, wv.vimPageKind = "tok", kind
363+ return wv, orchestrator, queued
364+}
365+
366+func TestWebViewHandleVimPageResultCopyRunsOnGTK(t *testing.T) {
367+ wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject)
368+
369+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "yanked"})
370+
371+ assert.Empty(t, orchestrator.explicit.Text, "the clipboard must not be written on the IO thread")
372+ require.Len(t, *queued, 1)
373+ (*queued)[0]()
374+ assert.Equal(t, dto.ExplicitClipboardInput{
375+ Text: "yanked", Action: "copy", SourceEngine: dto.ClipboardSourceCEF, ViewID: 7,
376+ }, orchestrator.explicit)
377+}
378+
379+// A hostile page that read the token must not be able to write the clipboard
380+// again and again: visual copies are non-terminal, so each needs a y/Enter the
381+// user pressed and Go forwarded.
382+func TestWebViewHandleVimPageResultVisualCopyNeedsForwardedCopyKey(t *testing.T) {
383+ wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageVisual)
384+ copyResult := vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "stolen"}
385+
386+ wv.handleVimPageResult(copyResult)
387+ assert.Empty(t, *queued, "no key was forwarded: the copy is refused")
388+ assert.Equal(t, "tok", wv.currentVimPageToken(), "a refused copy leaves the interaction running")
389+
390+ wv.grantVimPageCopyAllowance("tok", "j")
391+ wv.handleVimPageResult(copyResult)
392+ assert.Empty(t, *queued, "motion keys grant no copy")
393+
394+ wv.grantVimPageCopyAllowance("tok", "y")
395+ wv.handleVimPageResult(copyResult)
396+ wv.handleVimPageResult(copyResult)
397+ require.Len(t, *queued, 1, "one forwarded key allows exactly one copy")
398+ (*queued)[0]()
399+ assert.Equal(t, "stolen", orchestrator.explicit.Text)
400+
401+ wv.grantVimPageCopyAllowance("tok", "<Return>")
402+ wv.grantVimPageCopyAllowance("other", "y")
403+ wv.handleVimPageResult(copyResult)
404+ assert.Len(t, *queued, 2)
405+}
406+
407+func TestWebViewSendVimPageKeyGrantsCopyAllowanceOnlyForVisual(t *testing.T) {
408+ for kind, want := range map[dto.VimPageInteractionKind]int{dto.VimPageVisual: 2, dto.VimPageHintYankURL: 0} {
409+ browser := cefmocks.NewMockBrowser(t)
410+ frame := cefmocks.NewMockFrame(t)
411+ browser.EXPECT().GetMainFrame().Return(frame).Times(3)
412+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Times(3)
413+ wv := &WebView{browser: browser, vimPageToken: "tok", vimPageKind: kind}
414+
415+ require.NoError(t, wv.SendVimPageKey(context.Background(), "y"))
416+ require.NoError(t, wv.SendVimPageKey(context.Background(), "j"))
417+ require.NoError(t, wv.SendVimPageKey(context.Background(), "<Return>"))
418+
419+ assert.Equal(t, want, wv.vimPageCopyAllowance, "kind %d", kind)
420+ }
421+}
422+
423+func TestWebViewArmVimPageInteractionResetsCopyAllowance(t *testing.T) {
424+ wv := &WebView{vimPageToken: "old", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 3}
425+
426+ _, err := wv.armVimPageInteraction(dto.VimPageVisual)
427+ require.NoError(t, err)
428+ assert.Zero(t, wv.vimPageCopyAllowance)
429+ wv.vimPageCopyAllowance = 2
430+ wv.disarmVimPageInteraction()
431+ assert.Zero(t, wv.vimPageCopyAllowance)
432+}
433+
434+func TestWebViewHandleVimPageResultIgnoredAfterDestroy(t *testing.T) {
435+ wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject)
436+ ended := 0
437+ wv.callbacks.OnVimPageInteractionEnded = func() { ended++ }
438+ wv.destroyed.Store(true)
439+
440+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "late"})
441+
442+ assert.Empty(t, *queued)
443+ assert.Empty(t, orchestrator.explicit.Text)
444+ assert.Zero(t, ended)
445+}
446+
447+func TestWebViewDestroyClearsVimPageInteraction(t *testing.T) {
448+ wv := &WebView{ctx: context.Background(), vimPageToken: "tok", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 1}
449+
450+ wv.Destroy()
451+
452+ assert.Empty(t, wv.currentVimPageToken())
453+ assert.Zero(t, wv.vimPageKind)
454+ assert.Zero(t, wv.vimPageCopyAllowance)
455+}
456+
457+// A crashed renderer takes the page runtime with it, so it can never report
458+// the end; the render-process-terminated handler must release the capture.
459+func TestHandlerSetOnRenderProcessTerminatedEndsVimPageInteraction(t *testing.T) {
460+ var opened string
461+ ended := 0
462+ wv := newVimResultWebView(&opened, &ended)
463+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
464+ h := &handlerSet{wv: wv}
465+
466+ h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "")
467+
468+ assert.Equal(t, 1, ended)
469+ assert.Empty(t, wv.currentVimPageToken())
470+}
471+
472+func TestSchemeHandler_APIVimPageRejectsOversizedPayloadWith413(t *testing.T) {
473+ oldNewResourceHandler := cefNewResourceHandler
474+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
475+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
476+
477+ h := newTestDumbSchemeHandler(t)
478+ h.onVimPage = func(purecef.Browser, vimPageBridgePayload) { t.Fatal("oversized payload must not reach the handler") }
479+ request := cefmocks.NewMockRequest(t)
480+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"type":"copy","token":"t","text":"` + strings.Repeat("a", maxClipboardBytes) + `"}`))
481+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
482+ response := cefmocks.NewMockResponse(t)
483+ response.EXPECT().SetStatus(int32(http.StatusRequestEntityTooLarge)).Once()
484+ response.EXPECT().SetStatusText(http.StatusText(http.StatusRequestEntityTooLarge)).Once()
485+ response.EXPECT().SetMimeType("application/json").Once()
486+ expectAPIResponseHeaders(response)
487+
488+ handler := h.handleAPI(cefmocks.NewMockBrowser(t), http.MethodPost, "/api/vim-page", request)
489+ require.NotNil(t, handler)
490+ var length int64
491+ handler.GetResponseHeaders(response, &length, 0)
492+}
493+
494+func TestDecodeVimPageBridgePayloadOversizedIsSentinel(t *testing.T) {
495+ _, err := decodeVimPageBridgePayload(make([]byte, maxClipboardBytes+1))
496+ require.ErrorIs(t, err, errVimPagePayloadTooLarge)
497+ _, err = decodeVimPageBridgePayload([]byte(`{`))
498+ require.Error(t, err)
499+ require.NotErrorIs(t, err, errVimPagePayloadTooLarge)
500+}
M internal/infrastructure/cef/webview.go +14 -2
@@ -203,8 +203,10 @@ type WebView struct {
203203 // GTK sync dispatch hooks are injectable for tests. Production uses the GTK
204204 // default main context through runOnGTK and isOnGTKThread.
205205 gtkSyncDispatch func(func())
206- gtkSyncIsOwner func() bool
207- gtkSyncTimeout time.Duration
206+ // gtkDispatch replaces the async GTK idle hop in tests.
207+ gtkDispatch func(func())
208+ gtkSyncIsOwner func() bool
209+ gtkSyncTimeout time.Duration
208210
209211 // scrollCancelSeam overrides the adapter for scroll cancellation in
210212 // tests. Production leaves it nil so the live view bridge is used.
@@ -277,6 +279,9 @@ type WebView struct {
277279 // carrying any other token are rejected. Empty when none is armed.
278280 vimPageToken string
279281 vimPageKind dto.VimPageInteractionKind
282+ // vimPageCopyAllowance counts y/Enter keys Go forwarded in a visual
283+ // interaction that no accepted copy result has consumed yet.
284+ vimPageCopyAllowance int
280285 // vimPageGeneration increments on every arm so deferred end callbacks
281286 // from an older interaction cannot release a newer one's key capture.
282287 vimPageGeneration uint64
@@ -1360,6 +1365,9 @@ func (wv *WebView) Destroy() {
13601365 wv.nativePopupFallbackStarted = false
13611366 wv.popupOpenerBridgeParent = nil
13621367 wv.popupOpenerBridgeParentURI = ""
1368+ wv.vimPageToken = ""
1369+ wv.vimPageKind = 0
1370+ wv.vimPageCopyAllowance = 0
13631371 wv.mu.Unlock()
13641372 wv.stopNativePopupFallbackTimer()
13651373 wv.stopRenderStallWatchdog()
@@ -2625,6 +2633,10 @@ func (wv *WebView) runOnGTK(fn func()) {
26252633 if fn == nil {
26262634 return
26272635 }
2636+ if wv.gtkDispatch != nil {
2637+ wv.gtkDispatch(fn)
2638+ return
2639+ }
26282640 // When engine is nil (test/bootstrap), call directly.
26292641 if wv.engine == nil {
26302642 fn()
@@ -203,8 +203,10 @@ type WebView struct {
203 // GTK sync dispatch hooks are injectable for tests. Production uses the GTK203 // GTK sync dispatch hooks are injectable for tests. Production uses the GTK
204 // default main context through runOnGTK and isOnGTKThread.204 // default main context through runOnGTK and isOnGTKThread.
205 gtkSyncDispatch func(func())205 gtkSyncDispatch func(func())
206- gtkSyncIsOwner func() bool206+ // gtkDispatch replaces the async GTK idle hop in tests.
207- gtkSyncTimeout time.Duration207+ gtkDispatch func(func())
208+ gtkSyncIsOwner func() bool
209+ gtkSyncTimeout time.Duration
208 210
209 // scrollCancelSeam overrides the adapter for scroll cancellation in211 // scrollCancelSeam overrides the adapter for scroll cancellation in
210 // tests. Production leaves it nil so the live view bridge is used.212 // tests. Production leaves it nil so the live view bridge is used.
@@ -277,6 +279,9 @@ type WebView struct {
277 // carrying any other token are rejected. Empty when none is armed.279 // carrying any other token are rejected. Empty when none is armed.
278 vimPageToken string280 vimPageToken string
279 vimPageKind dto.VimPageInteractionKind281 vimPageKind dto.VimPageInteractionKind
282+ // vimPageCopyAllowance counts y/Enter keys Go forwarded in a visual
283+ // interaction that no accepted copy result has consumed yet.
284+ vimPageCopyAllowance int
280 // vimPageGeneration increments on every arm so deferred end callbacks285 // vimPageGeneration increments on every arm so deferred end callbacks
281 // from an older interaction cannot release a newer one's key capture.286 // from an older interaction cannot release a newer one's key capture.
282 vimPageGeneration uint64287 vimPageGeneration uint64
@@ -1360,6 +1365,9 @@ func (wv *WebView) Destroy() {
1360 wv.nativePopupFallbackStarted = false1365 wv.nativePopupFallbackStarted = false
1361 wv.popupOpenerBridgeParent = nil1366 wv.popupOpenerBridgeParent = nil
1362 wv.popupOpenerBridgeParentURI = ""1367 wv.popupOpenerBridgeParentURI = ""
1368+ wv.vimPageToken = ""
1369+ wv.vimPageKind = 0
1370+ wv.vimPageCopyAllowance = 0
1363 wv.mu.Unlock()1371 wv.mu.Unlock()
1364 wv.stopNativePopupFallbackTimer()1372 wv.stopNativePopupFallbackTimer()
1365 wv.stopRenderStallWatchdog()1373 wv.stopRenderStallWatchdog()
@@ -2625,6 +2633,10 @@ func (wv *WebView) runOnGTK(fn func()) {
2625 if fn == nil {2633 if fn == nil {
2626 return2634 return
2627 }2635 }
2636+ if wv.gtkDispatch != nil {
2637+ wv.gtkDispatch(fn)
2638+ return
2639+ }
2628 // When engine is nil (test/bootstrap), call directly.2640 // When engine is nil (test/bootstrap), call directly.
2629 if wv.engine == nil {2641 if wv.engine == nil {
2630 fn()2642 fn()
M internal/infrastructure/cef/webview_handlers.go +2 -0
@@ -1308,6 +1308,8 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef
13081308 // (and the dead page's callback) hanging.
13091309 h.wv.cancelJSDialogs()
13101310
1311+ // The page runtime died with the renderer and can no longer report its end.
1312+ h.wv.endVimPageInteractionOnNavigation()
13111313 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
13121314 return // suppress to break the loop
13131315 }
@@ -1308,6 +1308,8 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef
1308 // (and the dead page's callback) hanging.1308 // (and the dead page's callback) hanging.
1309 h.wv.cancelJSDialogs()1309 h.wv.cancelJSDialogs()
1310 1310
1311+ // The page runtime died with the renderer and can no longer report its end.
1312+ h.wv.endVimPageInteractionOnNavigation()
1311 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {1313 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
1312 return // suppress to break the loop1314 return // suppress to break the loop
1313 }1315 }

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 8

Tags 42

↑↓ moveenter openesc close