proto_convert_test.go RawHistoryFolder

1package sessionwire
2
3import (
4	"bytes"
5	"reflect"
6	"testing"
7	"time"
8
9	"google.golang.org/protobuf/encoding/protowire"
10	"google.golang.org/protobuf/proto"
11
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/protocol"
14	"github.com/bnema/vev/internal/protocol/wire"
15	"github.com/stretchr/testify/require"
16)
17
18func protoTestTarget() domain.RemoteSessionTarget {
19	return domain.RemoteSessionTarget{
20		Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},
21		SessionName: "work", LiveTabID: "tab-1",
22	}
23}
24
25func protoTestContext() *protocol.ViewContext {
26	lifecycle := domain.SessionLifecycleID{1}
27	return &protocol.ViewContext{
28		Publication:   3,
29		Route:         protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}},
30		TabID:         "tab-1",
31		FocusedPaneID: "pane-1",
32	}
33}
34
35// TestProtoClientRoundTrips exercises every client converter pair through
36// scan + generated marshal + generated unmarshal + semantic validation.
37func TestProtoClientRoundTrips(t *testing.T) {
38	target := protoTestTarget()
39	messages := []protocol.ClientMessage{
40		protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
41		protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
42		protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242},
43		protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
44		protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
45		protocol.Detach{},
46		protocol.Ping{},
47		protocol.List{},
48		protocol.Kill{RequestID: 1, Name: "work"},
49		protocol.Theme{TrueColor: true},
50		protocol.Ack{Epoch: 1, State: 1},
51		protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}},
52		protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected},
53		protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"},
54		protocol.OutputResetRequest{},
55		protocol.UIFence{ActionID: 7},
56		protocol.SelectTab{TabID: "tab-2"},
57		protocol.RemotePreviewWatch{Request: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, MinInterval: 33 * time.Millisecond},
58		protocol.RouteAttentionSubscription{},
59		protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}},
60		protocol.RecentRouteSnapshot{},
61		protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable},
62		protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable},
63		protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot},
64		protocol.NavigationInventoryPublication{InteractionGeneration: 1, PublicationGeneration: 1},
65		protocol.NavigationInventoryFailure{CauseActionID: 1, InteractionGeneration: 1, SourceKey: "local", EntryKey: "a", Code: protocol.NavigationInventoryStaleIdentity},
66		protocol.PickerClose{InteractionID: 1},
67		protocol.PickerSelection{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Key: "k", Action: protocol.PickerActionNavigate},
68	}
69	for _, message := range messages {
70		t.Run(protoMessageName(message), func(t *testing.T) {
71			envelope, err := encodeProtoClient(message)
72			require.NoError(t, err)
73			raw, err := proto.Marshal(envelope)
74			require.NoError(t, err)
75			require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
76			decoded := &wire.ClientEnvelope{}
77			require.NoError(t, proto.Unmarshal(raw, decoded))
78			got, err := decodeProtoClient(decoded)
79			require.NoError(t, err)
80			require.Equal(t, message, got)
81		})
82	}
83}
84
85// TestProtoServerRoundTrips exercises every server converter pair.
86func TestProtoServerRoundTrips(t *testing.T) {
87	lifecycle := domain.SessionLifecycleID{1}
88	exact := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}
89	context := protoTestContext()
90	messages := []protocol.ServerMessage{
91		protocol.Welcome{SessionID: "session"},
92		protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"},
93		protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: context},
94		protocol.Detached{Reason: protocol.ReasonDetach},
95		protocol.Pong{},
96		protocol.Sessions{},
97		protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded},
98		protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded},
99		protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: work", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "delete failed"}}},
100		protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach},
101		protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable},
102		protocol.CommittedRouteIdentity{Target: exact},
103		protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1},
104		protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"},
105		protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable},
106		protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"},
107		protocol.RouteRetired{Ref: protocol.RouteRef{Key: 1, Generation: 1}, Target: exact},
108		protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable},
109		protocol.UIReceipt{ActionID: 1, Outcome: protocol.UIReceiptUnavailable},
110		protocol.UIViewUpdate{Epoch: 1, State: 1, Context: *context},
111		protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK},
112		protocol.NavigationInventoryDemand{InteractionGeneration: 1, Open: true},
113		protocol.NavigationInventorySelection{CauseActionID: 1, InteractionGeneration: 1, PublicationGeneration: 1, SourceKey: "local", EntryKey: "a"},
114		protocol.PickerOffer{InteractionID: 1, Intent: protocol.PickerIntentNavigation, Title: "pick"},
115		protocol.PickerSnapshot{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: protocol.PickerProjection{}, Grouped: protocol.PickerProjection{}},
116		protocol.PickerClosed{InteractionID: 1},
117		protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "k", Action: protocol.PickerActionNavigate},
118		protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionNavigate, Code: protocol.PickerStaleRevision},
119	}
120	for _, message := range messages {
121		t.Run(protoMessageName(message), func(t *testing.T) {
122			envelope, err := encodeProtoServer(message)
123			require.NoError(t, err)
124			raw, err := proto.Marshal(envelope)
125			require.NoError(t, err)
126			require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw))
127			decoded := &wire.ServerEnvelope{}
128			require.NoError(t, proto.Unmarshal(raw, decoded))
129			got, err := decodeProtoServer(decoded)
130			require.NoError(t, err)
131			require.Equal(t, message, got)
132		})
133	}
134}
135
136func TestSessionAttachTargetFromWireRejectsInvalidRawName(t *testing.T) {
137	lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
138	for _, rawName := range []string{string(make([]byte, 257)), "bad\nname"} {
139		_, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: rawName, TabExpectedCount: 1, Stopped: true})
140		require.Error(t, err)
141	}
142}
143
144func TestSessionAttachTargetTabIndexPresence(t *testing.T) {
145	lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
146
147	zero, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: "first", TabExpectedCount: 1, Stopped: true})
148	require.NoError(t, err)
149	require.Equal(t, int32(0), zero.TabIndex)
150
151	absent, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", Stopped: true})
152	require.NoError(t, err)
153	require.Equal(t, protocol.NoTabIndex, absent.TabIndex)
154
155	encoded, err := sessionAttachTargetToWire(zero)
156	require.NoError(t, err)
157	require.Zero(t, encoded.GetTabIndex())
158}
159
160// TestProtoWrongDirectionRejects proves a server payload in a client
161// envelope (and vice versa) fails before any mutation.
162func TestProtoWrongDirectionRejects(t *testing.T) {
163	// Direction is enforced by Go types at encode time (a server-only
164	// message does not implement protocol.ClientMessage, so misuse does
165	// not compile) and by exhaustive decode switches at decode time.
166	// Here: an empty client envelope and an unknown wrapper fail closed.
167	_, err := decodeProtoClient(&wire.ClientEnvelope{Payload: nil})
168	require.ErrorIs(t, err, ErrWrongDirection)
169	_, err = decodeProtoClient(nil)
170	require.ErrorIs(t, err, ErrInvalidMessage)
171	_, err = decodeProtoServer(nil)
172	require.ErrorIs(t, err, ErrInvalidMessage)
173}
174
175// TestProtoOutputCompressionRoundTrip keeps the frozen policy observable:
176// large full snapshots compress, small/incremental stay raw, and zip bombs
177// fail closed.
178func TestProtoOutputCompressionRoundTrip(t *testing.T) {
179	context := protoTestContext()
180	full := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 120, Rows: 40}, Context: context,
181		Data: bytes.Repeat([]byte("\x1b[38;2;120;180;240mstyled viewport row\x1b[0m\r\n"), 128)}
182	converted, err := outputToWire(full)
183	require.NoError(t, err)
184	require.Equal(t, uint32(1), converted.GetEncoding())
185	back, err := outputFromWire(converted)
186	require.NoError(t, err)
187	require.Equal(t, full.Data, back.Data)
188
189	small := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: context, Data: []byte("small")}
190	converted, err = outputToWire(small)
191	require.NoError(t, err)
192	require.Equal(t, uint32(0), converted.GetEncoding())
193
194	incremental := protocol.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 80, Rows: 24}, Context: context, Data: bytes.Repeat([]byte("x"), 2048)}
195	converted, err = outputToWire(incremental)
196	require.NoError(t, err)
197	require.Equal(t, uint32(0), converted.GetEncoding())
198
199	bomb := proto.Clone(converted).(*wire.Output)
200	bomb.UncompressedLength = uint64(protocol.MaxOutputDataLen) + 1
201	_, err = outputFromWire(bomb)
202	require.Error(t, err)
203}
204
205// TestProtoOutputRejectsCompressedTrailingBytes restores the legacy
206// trailing-data guard: bytes appended after the zlib stream end must
207// fail closed (the zlib reader stops at stream end and would otherwise
208// swallow them).
209func TestProtoOutputRejectsCompressedTrailingBytes(t *testing.T) {
210	context := protoTestContext()
211	full := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 120, Rows: 40}, Context: context,
212		Data: bytes.Repeat([]byte("\x1b[38;2;120;180;240mstyled viewport row\x1b[0m\r\n"), 128)}
213	converted, err := outputToWire(full)
214	require.NoError(t, err)
215	require.Equal(t, uint32(1), converted.GetEncoding())
216
217	trailing := proto.Clone(converted).(*wire.Output)
218	trailing.Data = append(append([]byte(nil), converted.GetData()...), []byte("trailing-garbage")...)
219	_, err = outputFromWire(trailing)
220	require.Error(t, err)
221}
222
223// TestProtoScanNegatives fuzzes the scanner contract: unknown fields,
224// duplicate singulars, empty envelopes, truncation, trailing garbage.
225func TestProtoScanNegatives(t *testing.T) {
226	valid, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Ping{Ping: &wire.Ping{}}})
227	require.NoError(t, err)
228	require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, valid))
229
230	truncated := valid[:len(valid)-1]
231	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, truncated))
232	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(valid, 0xFF)))
233	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, nil))
234	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, []byte{}))
235	require.ErrorIs(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, []byte{}), wire.ErrScanEmpty)
236
237	// Unknown top-level field 99 (varint).
238	unknown := append([]byte(nil), valid...)
239	unknown = protowire.AppendTag(unknown, 99, protowire.VarintType)
240	unknown = protowire.AppendVarint(unknown, 1)
241	require.ErrorIs(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, unknown), wire.ErrScanUnknown)
242
243	// Duplicate top-level variant: two ping occurrences.
244	duplicate := append([]byte(nil), valid...)
245	duplicate = append(duplicate, valid...)
246	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, duplicate))
247
248	// Concatenated envelopes: generated unmarshal alone takes last-wins,
249	// which is why the scanner must reject them first.
250	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, duplicate))
251	envelope := &wire.ClientEnvelope{}
252	require.NoError(t, proto.Unmarshal(duplicate, envelope))
253}
254
255// TestProtoSelectTabWire pins the SelectTab payload byte for byte and refuses
256// a malformed tab identity, a truncated prefix, and trailing garbage.
257func TestProtoSelectTabWire(t *testing.T) {
258	envelope, err := encodeProtoClient(protocol.SelectTab{TabID: "t1"})
259	require.NoError(t, err)
260	raw, err := proto.Marshal(envelope)
261	require.NoError(t, err)
262	// field 31 (length-delimited) -> SelectTab{field 1 = "t1"}.
263	require.Equal(t, []byte{0xFA, 0x01, 0x04, 0x0A, 0x02, 't', '1'}, raw)
264
265	tests := []struct {
266		name    string
267		raw     []byte
268		wantErr bool
269	}{
270		{name: "exact", raw: raw},
271		{name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true},
272		{name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true},
273	}
274	for _, tt := range tests {
275		t.Run(tt.name, func(t *testing.T) {
276			err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw)
277			if tt.wantErr {
278				require.Error(t, err)
279				return
280			}
281			require.NoError(t, err)
282			decoded := &wire.ClientEnvelope{}
283			require.NoError(t, proto.Unmarshal(tt.raw, decoded))
284			got, err := decodeProtoClient(decoded)
285			require.NoError(t, err)
286			require.Equal(t, protocol.SelectTab{TabID: "t1"}, got)
287		})
288	}
289
290	_, err = encodeProtoClient(protocol.SelectTab{})
291	require.Error(t, err, "an empty tab identity is never encoded")
292	_, err = decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: &wire.SelectTab{}}})
293	require.Error(t, err, "an empty tab identity is never decoded")
294}
295
296// TestProtoTerminalFocusWire pins the TerminalFocus payload byte for byte and
297// refuses unknown focus, a truncated prefix, and trailing garbage.
298func TestProtoTerminalFocusWire(t *testing.T) {
299	tests := []struct {
300		name  string
301		focus domain.TerminalFocus
302		want  []byte
303	}{
304		// field 33 (length-delimited) -> TerminalFocus{field 1 = focus}.
305		{name: "focused", focus: domain.TerminalFocusFocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x01}},
306		{name: "unfocused", focus: domain.TerminalFocusUnfocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x02}},
307	}
308	for _, tt := range tests {
309		t.Run(tt.name, func(t *testing.T) {
310			message := protocol.TerminalFocus{Focus: tt.focus}
311			envelope, err := encodeProtoClient(message)
312			require.NoError(t, err)
313			raw, err := proto.Marshal(envelope)
314			require.NoError(t, err)
315			require.Equal(t, tt.want, raw)
316
317			require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
318			require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
319			require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
320			decoded := &wire.ClientEnvelope{}
321			require.NoError(t, proto.Unmarshal(raw, decoded))
322			got, err := decodeProtoClient(decoded)
323			require.NoError(t, err)
324			require.Equal(t, message, got)
325		})
326	}
327
328	invalid := []struct {
329		name  string
330		focus uint32
331	}{
332		{name: "unknown is never reported", focus: 0},
333		{name: "out of taxonomy", focus: 3},
334		{name: "out of range", focus: 256},
335	}
336	for _, tt := range invalid {
337		t.Run(tt.name, func(t *testing.T) {
338			_, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: &wire.TerminalFocus{Focus: tt.focus}}})
339			require.Error(t, err)
340		})
341	}
342	_, err := encodeProtoClient(protocol.TerminalFocus{})
343	require.Error(t, err, "unknown focus is never encoded")
344}
345
346func protoMessageName(message any) string {
347	return protoShortName(message)
348}
349
350func protoShortName(message any) string {
351	if message == nil {
352		return "nil"
353	}
354	return reflect.TypeOf(message).Name()
355}

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