main ece7682

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/domain/terminalcap"
14	"github.com/bnema/vev/internal/protocol"
15	"github.com/bnema/vev/internal/protocol/wire"
16	"github.com/stretchr/testify/require"
17)
18
19func protoTestTarget() domain.RemoteSessionTarget {
20	return domain.RemoteSessionTarget{
21		Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},
22		SessionName: "work", LiveTabID: "tab-1",
23	}
24}
25
26func protoTestContext() *protocol.ViewContext {
27	lifecycle := domain.SessionLifecycleID{1}
28	return &protocol.ViewContext{
29		Publication:   3,
30		Route:         protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}},
31		TabID:         "tab-1",
32		FocusedPaneID: "pane-1",
33	}
34}
35
36// TestProtoClientRoundTrips exercises every client converter pair through
37// scan + generated marshal + generated unmarshal + semantic validation.
38func TestProtoClientRoundTrips(t *testing.T) {
39	target := protoTestTarget()
40	messages := []protocol.ClientMessage{
41		protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
42		protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
43		protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242},
44		protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
45		protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
46		protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
47		protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
48		protocol.Detach{},
49		protocol.Ping{},
50		protocol.List{},
51		protocol.Kill{RequestID: 1, Name: "work"},
52		protocol.Theme{SchemeKnown: true},
53		protocol.Ack{Epoch: 1, State: 1},
54		protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}},
55		protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected},
56		protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"},
57		protocol.OutputResetRequest{},
58		protocol.UIFence{ActionID: 7},
59		protocol.SelectTab{TabID: "tab-2"},
60		protocol.RemotePreviewWatch{Request: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, MinInterval: 33 * time.Millisecond},
61		protocol.RouteAttentionSubscription{},
62		protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}},
63		protocol.RecentRouteSnapshot{},
64		protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable},
65		protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable},
66		protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot},
67		protocol.NavigationInventoryPublication{InteractionGeneration: 1, PublicationGeneration: 1},
68		protocol.NavigationInventoryFailure{CauseActionID: 1, InteractionGeneration: 1, SourceKey: "local", EntryKey: "a", Code: protocol.NavigationInventoryStaleIdentity},
69		protocol.PickerClose{InteractionID: 1},
70		protocol.PickerSelection{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Key: "k", Action: protocol.PickerActionNavigate},
71	}
72	for _, message := range messages {
73		t.Run(protoMessageName(message), func(t *testing.T) {
74			envelope, err := encodeProtoClient(message)
75			require.NoError(t, err)
76			raw, err := proto.Marshal(envelope)
77			require.NoError(t, err)
78			require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
79			decoded := &wire.ClientEnvelope{}
80			require.NoError(t, proto.Unmarshal(raw, decoded))
81			got, err := decodeProtoClient(decoded)
82			require.NoError(t, err)
83			require.Equal(t, message, got)
84		})
85	}
86}
87
88// TestProtoServerRoundTrips exercises every server converter pair.
89func TestProtoServerRoundTrips(t *testing.T) {
90	lifecycle := domain.SessionLifecycleID{1}
91	exact := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}
92	context := protoTestContext()
93	messages := []protocol.ServerMessage{
94		protocol.Welcome{SessionID: "session"},
95		protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"},
96		protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: context},
97		protocol.Detached{Reason: protocol.ReasonDetach},
98		protocol.Pong{},
99		protocol.Sessions{},
100		protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded},
101		protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded},
102		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"}}},
103		protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach},
104		protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable},
105		protocol.CommittedRouteIdentity{Target: exact},
106		protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1},
107		protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"},
108		protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable},
109		protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"},
110		protocol.RouteRetired{Ref: protocol.RouteRef{Key: 1, Generation: 1}, Target: exact},
111		protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable},
112		protocol.UIReceipt{ActionID: 1, Outcome: protocol.UIReceiptUnavailable},
113		protocol.UIViewUpdate{Epoch: 1, State: 1, Context: *context},
114		protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK},
115		protocol.NavigationInventoryDemand{InteractionGeneration: 1, Open: true},
116		protocol.NavigationInventorySelection{CauseActionID: 1, InteractionGeneration: 1, PublicationGeneration: 1, SourceKey: "local", EntryKey: "a"},
117		protocol.PickerOffer{InteractionID: 1, Intent: protocol.PickerIntentNavigation, Title: "pick"},
118		protocol.PickerSnapshot{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: protocol.PickerProjection{}, Grouped: protocol.PickerProjection{}},
119		protocol.PickerClosed{InteractionID: 1},
120		protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "k", Action: protocol.PickerActionNavigate},
121		protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionNavigate, Code: protocol.PickerStaleRevision},
122	}
123	for _, message := range messages {
124		t.Run(protoMessageName(message), func(t *testing.T) {
125			envelope, err := encodeProtoServer(message)
126			require.NoError(t, err)
127			raw, err := proto.Marshal(envelope)
128			require.NoError(t, err)
129			require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw))
130			decoded := &wire.ServerEnvelope{}
131			require.NoError(t, proto.Unmarshal(raw, decoded))
132			got, err := decodeProtoServer(decoded)
133			require.NoError(t, err)
134			require.Equal(t, message, got)
135		})
136	}
137}
138
139func TestSessionAttachTargetFromWireRejectsInvalidRawName(t *testing.T) {
140	lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
141	for _, rawName := range []string{string(make([]byte, 257)), "bad\nname"} {
142		_, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: rawName, TabExpectedCount: 1, Stopped: true})
143		require.Error(t, err)
144	}
145}
146
147func TestSessionAttachTargetTabIndexPresence(t *testing.T) {
148	lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
149
150	zero, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: "first", TabExpectedCount: 1, Stopped: true})
151	require.NoError(t, err)
152	require.Equal(t, int32(0), zero.TabIndex)
153
154	absent, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", Stopped: true})
155	require.NoError(t, err)
156	require.Equal(t, protocol.NoTabIndex, absent.TabIndex)
157
158	encoded, err := sessionAttachTargetToWire(zero)
159	require.NoError(t, err)
160	require.Zero(t, encoded.GetTabIndex())
161}
162
163// TestProtoWrongDirectionRejects proves a server payload in a client
164// envelope (and vice versa) fails before any mutation.
165func TestProtoWrongDirectionRejects(t *testing.T) {
166	// Direction is enforced by Go types at encode time (a server-only
167	// message does not implement protocol.ClientMessage, so misuse does
168	// not compile) and by exhaustive decode switches at decode time.
169	// Here: an empty client envelope and an unknown wrapper fail closed.
170	_, err := decodeProtoClient(&wire.ClientEnvelope{Payload: nil})
171	require.ErrorIs(t, err, ErrWrongDirection)
172	_, err = decodeProtoClient(nil)
173	require.ErrorIs(t, err, ErrInvalidMessage)
174	_, err = decodeProtoServer(nil)
175	require.ErrorIs(t, err, ErrInvalidMessage)
176}
177
178// TestProtoOutputCompressionRoundTrip keeps the frozen policy observable:
179// large full snapshots compress, small/incremental stay raw, and zip bombs
180// fail closed.
181func TestProtoOutputCompressionRoundTrip(t *testing.T) {
182	context := protoTestContext()
183	full := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 120, Rows: 40}, Context: context,
184		Data: bytes.Repeat([]byte("\x1b[38;2;120;180;240mstyled viewport row\x1b[0m\r\n"), 128)}
185	converted, err := outputToWire(full)
186	require.NoError(t, err)
187	require.Equal(t, uint32(1), converted.GetEncoding())
188	back, err := outputFromWire(converted)
189	require.NoError(t, err)
190	require.Equal(t, full.Data, back.Data)
191
192	small := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: context, Data: []byte("small")}
193	converted, err = outputToWire(small)
194	require.NoError(t, err)
195	require.Equal(t, uint32(0), converted.GetEncoding())
196
197	incremental := protocol.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 80, Rows: 24}, Context: context, Data: bytes.Repeat([]byte("x"), 2048)}
198	converted, err = outputToWire(incremental)
199	require.NoError(t, err)
200	require.Equal(t, uint32(0), converted.GetEncoding())
201
202	bomb := proto.Clone(converted).(*wire.Output)
203	bomb.UncompressedLength = uint64(protocol.MaxOutputDataLen) + 1
204	_, err = outputFromWire(bomb)
205	require.Error(t, err)
206}
207
208// TestProtoOutputRejectsCompressedTrailingBytes restores the legacy
209// trailing-data guard: bytes appended after the zlib stream end must
210// fail closed (the zlib reader stops at stream end and would otherwise
211// swallow them).
212func TestProtoOutputRejectsCompressedTrailingBytes(t *testing.T) {
213	context := protoTestContext()
214	full := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 120, Rows: 40}, Context: context,
215		Data: bytes.Repeat([]byte("\x1b[38;2;120;180;240mstyled viewport row\x1b[0m\r\n"), 128)}
216	converted, err := outputToWire(full)
217	require.NoError(t, err)
218	require.Equal(t, uint32(1), converted.GetEncoding())
219
220	trailing := proto.Clone(converted).(*wire.Output)
221	trailing.Data = append(append([]byte(nil), converted.GetData()...), []byte("trailing-garbage")...)
222	_, err = outputFromWire(trailing)
223	require.Error(t, err)
224}
225
226// TestProtoScanNegatives fuzzes the scanner contract: unknown fields,
227// duplicate singulars, empty envelopes, truncation, trailing garbage.
228func TestProtoScanNegatives(t *testing.T) {
229	valid, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Ping{Ping: &wire.Ping{}}})
230	require.NoError(t, err)
231	require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, valid))
232
233	truncated := valid[:len(valid)-1]
234	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, truncated))
235	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(valid, 0xFF)))
236	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, nil))
237	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, []byte{}))
238	require.ErrorIs(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, []byte{}), wire.ErrScanEmpty)
239
240	// Unknown top-level field 99 (varint).
241	unknown := append([]byte(nil), valid...)
242	unknown = protowire.AppendTag(unknown, 99, protowire.VarintType)
243	unknown = protowire.AppendVarint(unknown, 1)
244	require.ErrorIs(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, unknown), wire.ErrScanUnknown)
245
246	// Duplicate top-level variant: two ping occurrences.
247	duplicate := append([]byte(nil), valid...)
248	duplicate = append(duplicate, valid...)
249	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, duplicate))
250
251	// Concatenated envelopes: generated unmarshal alone takes last-wins,
252	// which is why the scanner must reject them first.
253	require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, duplicate))
254	envelope := &wire.ClientEnvelope{}
255	require.NoError(t, proto.Unmarshal(duplicate, envelope))
256}
257
258// TestProtoSelectTabWire pins the SelectTab payload byte for byte and refuses
259// a malformed tab identity, a truncated prefix, and trailing garbage.
260func TestProtoSelectTabWire(t *testing.T) {
261	envelope, err := encodeProtoClient(protocol.SelectTab{TabID: "t1"})
262	require.NoError(t, err)
263	raw, err := proto.Marshal(envelope)
264	require.NoError(t, err)
265	// field 31 (length-delimited) -> SelectTab{field 1 = "t1"}.
266	require.Equal(t, []byte{0xFA, 0x01, 0x04, 0x0A, 0x02, 't', '1'}, raw)
267
268	tests := []struct {
269		name    string
270		raw     []byte
271		wantErr bool
272	}{
273		{name: "exact", raw: raw},
274		{name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true},
275		{name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true},
276	}
277	for _, tt := range tests {
278		t.Run(tt.name, func(t *testing.T) {
279			err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw)
280			if tt.wantErr {
281				require.Error(t, err)
282				return
283			}
284			require.NoError(t, err)
285			decoded := &wire.ClientEnvelope{}
286			require.NoError(t, proto.Unmarshal(tt.raw, decoded))
287			got, err := decodeProtoClient(decoded)
288			require.NoError(t, err)
289			require.Equal(t, protocol.SelectTab{TabID: "t1"}, got)
290		})
291	}
292
293	_, err = encodeProtoClient(protocol.SelectTab{})
294	require.Error(t, err, "an empty tab identity is never encoded")
295	_, err = decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: &wire.SelectTab{}}})
296	require.Error(t, err, "an empty tab identity is never decoded")
297}
298
299// TestProtoTerminalFocusWire pins the TerminalFocus payload byte for byte and
300// refuses unknown focus, a truncated prefix, and trailing garbage.
301func TestProtoTerminalFocusWire(t *testing.T) {
302	tests := []struct {
303		name  string
304		focus domain.TerminalFocus
305		want  []byte
306	}{
307		// field 33 (length-delimited) -> TerminalFocus{field 1 = focus}.
308		{name: "focused", focus: domain.TerminalFocusFocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x01}},
309		{name: "unfocused", focus: domain.TerminalFocusUnfocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x02}},
310	}
311	for _, tt := range tests {
312		t.Run(tt.name, func(t *testing.T) {
313			message := protocol.TerminalFocus{Focus: tt.focus}
314			envelope, err := encodeProtoClient(message)
315			require.NoError(t, err)
316			raw, err := proto.Marshal(envelope)
317			require.NoError(t, err)
318			require.Equal(t, tt.want, raw)
319
320			require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
321			require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
322			require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
323			decoded := &wire.ClientEnvelope{}
324			require.NoError(t, proto.Unmarshal(raw, decoded))
325			got, err := decodeProtoClient(decoded)
326			require.NoError(t, err)
327			require.Equal(t, message, got)
328		})
329	}
330
331	invalid := []struct {
332		name  string
333		focus uint32
334	}{
335		{name: "unknown is never reported", focus: 0},
336		{name: "out of taxonomy", focus: 3},
337		{name: "out of range", focus: 256},
338	}
339	for _, tt := range invalid {
340		t.Run(tt.name, func(t *testing.T) {
341			_, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: &wire.TerminalFocus{Focus: tt.focus}}})
342			require.Error(t, err)
343		})
344	}
345	_, err := encodeProtoClient(protocol.TerminalFocus{})
346	require.Error(t, err, "unknown focus is never encoded")
347}
348
349// TestProtoModifierStateWire pins the ModifierState payload byte for byte and
350// refuses a truncated prefix and trailing garbage.
351func TestProtoModifierStateWire(t *testing.T) {
352	tests := []struct {
353		name string
354		ctrl bool
355		want []byte
356	}{
357		// field 34 (length-delimited) -> ModifierState{field 1 = ctrl}.
358		{name: "ctrl held", ctrl: true, want: []byte{0x92, 0x02, 0x02, 0x08, 0x01}},
359		{name: "released", ctrl: false, want: []byte{0x92, 0x02, 0x00}},
360	}
361	for _, tt := range tests {
362		t.Run(tt.name, func(t *testing.T) {
363			message := protocol.ModifierState{Ctrl: tt.ctrl}
364			envelope, err := encodeProtoClient(message)
365			require.NoError(t, err)
366			raw, err := proto.Marshal(envelope)
367			require.NoError(t, err)
368			require.Equal(t, tt.want, raw)
369
370			require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
371			require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
372			require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
373			decoded := &wire.ClientEnvelope{}
374			require.NoError(t, proto.Unmarshal(raw, decoded))
375			got, err := decodeProtoClient(decoded)
376			require.NoError(t, err)
377			require.Equal(t, message, got)
378		})
379	}
380	_, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{}})
381	require.Error(t, err, "a missing payload is never decoded")
382}
383
384func protoMessageName(message any) string {
385	return protoShortName(message)
386}
387
388func protoShortName(message any) string {
389	if message == nil {
390		return "nil"
391	}
392	return reflect.TypeOf(message).Name()
393}
394
395// TestProtoHelloColorWire pins the Hello color capability byte for byte for
396// every mode and source, and refuses truncated prefixes, trailing garbage,
397// and values outside the closed enums.
398func TestProtoHelloColorWire(t *testing.T) {
399	modes := []struct {
400		name string
401		mode terminalcap.ColorMode
402		code byte
403	}{
404		{name: "truecolor", mode: terminalcap.TrueColor, code: 1},
405		{name: "ansi256", mode: terminalcap.ANSI256, code: 2},
406		{name: "ansi16", mode: terminalcap.ANSI16, code: 3},
407		{name: "monochrome", mode: terminalcap.Monochrome, code: 4},
408	}
409	sources := []struct {
410		name   string
411		source terminalcap.Source
412		code   byte
413	}{
414		{name: "unknown", source: terminalcap.SourceUnknown, code: 0},
415		{name: "heuristic", source: terminalcap.SourceHeuristic, code: 1},
416		{name: "declared", source: terminalcap.SourceDeclared, code: 2},
417		{name: "forced", source: terminalcap.SourceForced, code: 3},
418	}
419	for _, m := range modes {
420		for _, src := range sources {
421			t.Run(m.name+"/"+src.name, func(t *testing.T) {
422				color := terminalcap.ColorCapabilities{Mode: m.mode, Source: src.source}
423				// The minimal attach Hello: version, intent, empty client id, cols,
424				// rows, then color (field 26, length-delimited), preceded by the
425				// oneof tag for Hello (field 1).
426				colorBytes := []byte{0xD2, 0x01}
427				if src.code == 0 {
428					colorBytes = append(colorBytes, 0x02, 0x08, m.code)
429				} else {
430					colorBytes = append(colorBytes, 0x04, 0x08, m.code, 0x10, src.code)
431				}
432				hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, ClientID: [16]byte{1}, Size: domain.Size{Cols: 80, Rows: 24}, Color: color}
433				envelope, err := encodeProtoClient(hello)
434				require.NoError(t, err)
435				raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(envelope)
436				require.NoError(t, err)
437				require.True(t, bytes.HasSuffix(raw, colorBytes), "color is the last Hello field on the wire: % x", raw)
438				// ClientEnvelope tag 1 (Hello), then the Hello length.
439				require.Equal(t, byte(0x0A), raw[0])
440
441				require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
442				require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
443				require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
444				decoded := &wire.ClientEnvelope{}
445				require.NoError(t, proto.Unmarshal(raw, decoded))
446				got, err := decodeProtoClient(decoded)
447				require.NoError(t, err)
448				require.Equal(t, hello, got)
449			})
450		}
451	}
452
453	t.Run("missing color is rejected", func(t *testing.T) {
454		message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24}
455		raw, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
456		require.NoError(t, err)
457		require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw), "structurally valid")
458		decoded := &wire.ClientEnvelope{}
459		require.NoError(t, proto.Unmarshal(raw, decoded))
460		_, err = decodeProtoClient(decoded)
461		require.Error(t, err)
462	})
463
464	t.Run("unspecified mode on the wire is rejected", func(t *testing.T) {
465		message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: &wire.ColorCapabilities{Source: wire.ColorSource_COLOR_SOURCE_DECLARED}}
466		_, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
467		require.Error(t, err)
468	})
469
470	t.Run("unknown enum values are rejected", func(t *testing.T) {
471		for _, color := range []*wire.ColorCapabilities{
472			{Mode: wire.ColorMode(5)},
473			{Mode: wire.ColorMode_COLOR_MODE_TRUE_COLOR, Source: wire.ColorSource(4)},
474		} {
475			message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: color}
476			_, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
477			require.Error(t, err)
478		}
479	})
480
481	t.Run("invalid semantic color is never encoded", func(t *testing.T) {
482		_, err := encodeProtoClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}})
483		require.Error(t, err)
484	})
485}

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