From d1c889938b80901842f123ee58637b8a338bb4d6 Mon Sep 17 00:00:00 2001 From: youdie006 Date: Tue, 18 Aug 2026 09:02:15 +0900 Subject: [PATCH] fix(vt): keep OSC 8 hyperlinks when the URL contains semicolons An OSC 8 hyperlink was silently dropped when its URI contained a semicolon (e.g. https://example.com/f?a=1;b=2). handleHyperlink split the payload on every semicolon with bytes.Split, but the OSC 8 wire format is '8;params;uri' where only the first two semicolons are field separators; a URI with a semicolon produced four or more parts, so the len(parts) != 3 guard rejected the whole command. Split on only the first two semicolons with bytes.SplitN(data, ';', 3) so the URI keeps its own semicolons. The guard still rejects malformed payloads, and the reset sequence (8;;) and ordinary links are unaffected. Orthogonal to #868 (the params/uri field mapping); the added test is swap-agnostic. Fixes #937 --- vt/hyperlink_test.go | 36 ++++++++++++++++++++++++++++++++++++ vt/osc.go | 6 +++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 vt/hyperlink_test.go diff --git a/vt/hyperlink_test.go b/vt/hyperlink_test.go new file mode 100644 index 000000000..bba8be6e2 --- /dev/null +++ b/vt/hyperlink_test.go @@ -0,0 +1,36 @@ +package vt + +import "testing" + +// TestHyperlinkURLWithSemicolon verifies that an OSC 8 hyperlink whose URI +// contains a semicolon is kept and attached to the cells, rather than being +// silently dropped. URLs may legally contain semicolons (for example in query +// strings or path parameters), and only the first two semicolons of the OSC 8 +// payload are field separators. +// +// The assertion is agnostic to whether the URI lands in Link.URL or +// Link.Params: the exchange of those two fields is a separate bug tracked by +// PR #868, and this test only checks that the URI survives the parser at all. +func TestHyperlinkURLWithSemicolon(t *testing.T) { + const uri = "https://example.com/f?a=1;b=2" + + term := newTestTerminal(t, 10, 1) + // Set the hyperlink, write "AB", then reset the hyperlink, matching the + // reproduction from the issue: OSC 8 ; ; ST AB OSC 8 ; ; ST. + term.Write([]byte("\x1b]8;;" + uri + "\x1b\\AB\x1b]8;;\x1b\\")) + + for _, x := range []int{0, 1} { + cell := term.CellAt(x, 0) + if cell == nil { + t.Fatalf("expected a cell at (%d, 0)", x) + } + got := cell.Link.URL + if got != uri { + got = cell.Link.Params + } + if got != uri { + t.Errorf("OSC 8 hyperlink with a semicolon in the URI was dropped at (%d, 0):\nwant URI %q in Link.URL or Link.Params\ngot URL=%q Params=%q", + x, uri, cell.Link.URL, cell.Link.Params) + } + } +} diff --git a/vt/osc.go b/vt/osc.go index 5f2f5859a..c102c8c91 100644 --- a/vt/osc.go +++ b/vt/osc.go @@ -125,7 +125,11 @@ func (e *Emulator) handleWorkingDirectory(cmd int, data []byte) { } func (e *Emulator) handleHyperlink(cmd int, data []byte) { - parts := bytes.Split(data, []byte{';'}) + // An OSC 8 payload is "8;params;uri". Only the first two semicolons are + // field separators; any later semicolons are part of the URI (URLs may + // legally contain them, e.g. in query strings or path parameters). Use + // SplitN so the URI keeps its own semicolons instead of being rejected. + parts := bytes.SplitN(data, []byte{';'}, 3) if len(parts) != 3 || cmd != 8 { // Invalid, ignore return