detached ece7682

command_result_test.go RawHistoryFolder

1package sessionwire
2
3// CommandResult wire contract: one command request carries exactly one closed
4// terminal outcome (Succeeded, Failed, or Unknown), correlation is preserved
5// byte-for-byte, and the decode boundary refuses a malformed, unspecified, or
6// out-of-range outcome instead of aliasing it into a valid one. The result is a
7// bounded control envelope: a failure's Code and Text can never ride the
8// Output/bulk ceiling exemption.
9
10import (
11	"encoding/hex"
12	"math"
13	"testing"
14
15	"google.golang.org/protobuf/proto"
16
17	"github.com/bnema/vev/internal/protocol"
18	"github.com/bnema/vev/internal/protocol/wire"
19	"github.com/stretchr/testify/require"
20)
21
22func testCommandResults() []protocol.CommandResult {
23	return []protocol.CommandResult{
24		{RequestID: 0x1122334455, Outcome: protocol.CommandSucceeded, Output: "work\tsh\n"},
25		{RequestID: 7, Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
26		{RequestID: 8, Outcome: protocol.CommandOutcomeUnknown, Text: "command request timed out"},
27	}
28}
29
30// commandResultPinnedBytes pins the exact serialized bytes of each fixture in
31// testCommandResults order, so a schema, encoder, or envelope-variant change
32// that silently re-lays-out a CommandResult fails loudly here.
33var commandResultPinnedBytes = []string{
34	"3a1308d588cd9192022a08776f726b0973680a3001",
35	"3a180807180922106e6f206c6976652073657373696f6e733002",
36	"3a1f08082219636f6d6d616e6420726571756573742074696d6564206f75743003",
37}
38
39// TestCommandResultByteRoundTrip proves every closed outcome has pinned
40// serialized bytes and re-encodes to byte-identical bytes after a strict scan +
41// generated unmarshal + semantic decode, with the request correlation id and the
42// bounded text/output intact.
43func TestCommandResultByteRoundTrip(t *testing.T) {
44	for index, message := range testCommandResults() {
45		require.True(t, message.Valid(), "fixture %+v must obey the outcome contract", message)
46		raw, err := EncodeServerMessage(message)
47		require.NoError(t, err)
48		require.Equal(t, commandResultPinnedBytes[index], hex.EncodeToString(raw), "CommandResult %d must keep its pinned bytes", index)
49		require.Equal(t, categoryControl, serverEnvelopeCategory(raw), "a CommandResult is a control envelope")
50		require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw))
51
52		decoded, err := DecodeServerEnvelope(raw)
53		require.NoError(t, err)
54		require.Equal(t, message, decoded)
55
56		reencoded, err := EncodeServerMessage(decoded)
57		require.NoError(t, err)
58		require.Equal(t, raw, reencoded, "CommandResult must re-encode byte-for-byte")
59	}
60}
61
62// TestCommandResultEnvelopeStrictScan proves the scan boundary rejects a
63// truncated prefix, a truncated payload, trailing garbage, and a concatenated
64// duplicate before any semantic decode, and that each rejected frame surfaces
65// as a malformed decode failure rather than a valid result.
66func TestCommandResultEnvelopeStrictScan(t *testing.T) {
67	valid, err := EncodeServerMessage(testCommandResults()[0])
68	require.NoError(t, err)
69	require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid))
70
71	clone := func(raw []byte) []byte { return append([]byte(nil), raw...) }
72	tests := []struct {
73		name    string
74		raw     []byte
75		wantErr error
76	}{
77		{name: "truncated tag prefix", raw: valid[:1], wantErr: wire.ErrScanTruncated},
78		{name: "truncated length prefix", raw: valid[:3], wantErr: wire.ErrScanTruncated},
79		{name: "truncated payload", raw: valid[:len(valid)-1], wantErr: wire.ErrScanTruncated},
80		{name: "trailing garbage", raw: append(clone(valid), 0xFF), wantErr: wire.ErrScanTruncated},
81		{name: "concatenated duplicate", raw: append(clone(valid), valid...), wantErr: wire.ErrScanDuplicate},
82	}
83	for _, tt := range tests {
84		t.Run(tt.name, func(t *testing.T) {
85			require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, tt.raw), tt.wantErr)
86
87			_, decodeErr := DecodeServerEnvelope(tt.raw)
88			var failure *protocol.DecodeFailure
89			require.ErrorAs(t, decodeErr, &failure)
90			require.Equal(t, protocol.DecodeMalformed, failure.Category, "a corrupt CommandResult frame is malformed, never a result")
91		})
92	}
93
94	// Every proper prefix of a valid frame is a truncated prefix in one of the
95	// leading tag, length, or nested-payload bytes; none may scan or decode.
96	t.Run("every truncated prefix", func(t *testing.T) {
97		for length := 1; length < len(valid); length++ {
98			require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid[:length]), wire.ErrScanTruncated, "prefix %d", length)
99			_, decodeErr := DecodeServerEnvelope(valid[:length])
100			var failure *protocol.DecodeFailure
101			require.ErrorAs(t, decodeErr, &failure, "prefix %d", length)
102			require.Equal(t, protocol.DecodeMalformed, failure.Category, "prefix %d", length)
103		}
104	})
105}
106
107// TestCommandResultWrongDirectionFailsClosed proves a server-only CommandResult
108// payload is refused on the client direction and the server decoder rejects a
109// nil envelope, so a result can never be consumed as a request.
110func TestCommandResultWrongDirectionFailsClosed(t *testing.T) {
111	converted, err := commandResultToWire(testCommandResults()[0])
112	require.NoError(t, err)
113	raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{
114		CommandResult: converted,
115	}})
116	require.NoError(t, err)
117
118	var clientEnvelope wire.ClientEnvelope
119	err = wire.ScanEnvelope(&clientEnvelope, raw)
120	require.Error(t, err, "a server CommandResult must not scan as a client envelope")
121
122	_, err = decodeProtoClient(&wire.ClientEnvelope{Payload: nil})
123	require.ErrorIs(t, err, ErrWrongDirection)
124	_, err = decodeProtoServer(nil)
125	require.ErrorIs(t, err, ErrInvalidMessage)
126}
127
128// TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome proves the decode
129// boundary refuses an unspecified (zero) outcome, an outcome outside the closed
130// enumeration, and every outcome/code/text combination the contract forbids.
131// The out-of-range cases include values whose low byte would otherwise truncate
132// into a valid outcome (257 -> 1, 259 -> 3, 65537 -> 1), so a hostile or corrupt
133// frame can never alias success or unknown.
134func TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome(t *testing.T) {
135	failed := wire.CommandOutcome_COMMAND_OUTCOME_FAILED
136	tests := []struct {
137		name string
138		raw  *wire.CommandResult
139	}{
140		{name: "nil result"},
141		{name: "unspecified zero outcome", raw: &wire.CommandResult{RequestId: 1}},
142		{name: "outcome one above range", raw: &wire.CommandResult{RequestId: 1, Outcome: failed + 2}},
143		{name: "outcome far above range", raw: &wire.CommandResult{RequestId: 1, Outcome: 255}},
144		{name: "outcome truncating into succeeded", raw: &wire.CommandResult{RequestId: 1, Outcome: 257}},
145		{name: "outcome truncating into unknown", raw: &wire.CommandResult{RequestId: 1, Outcome: 259}},
146		{name: "outcome truncating at 16 bits", raw: &wire.CommandResult{RequestId: 1, Outcome: 65537}},
147		{name: "outcome at int32 maximum", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome(math.MaxInt32)}},
148		{name: "negative outcome", raw: &wire.CommandResult{RequestId: 1, Outcome: -1}},
149		{name: "code overflows uint16", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: 1 << 16}},
150		{name: "succeeded with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: uint32(protocol.ErrInternal)}},
151		{name: "succeeded with text", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Text: "boom"}},
152		{name: "failed without a code", raw: &wire.CommandResult{RequestId: 1, Outcome: failed}},
153		{name: "failed with output", raw: &wire.CommandResult{RequestId: 1, Outcome: failed, Code: uint32(protocol.ErrInternal), Output: "leak"}},
154		{name: "unknown with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Code: uint32(protocol.ErrInternal)}},
155		{name: "unknown with output", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Output: "leak"}},
156	}
157	for _, tt := range tests {
158		t.Run(tt.name, func(t *testing.T) {
159			_, err := commandResultFromWire(tt.raw)
160			require.ErrorIs(t, err, errProtoConvertRange)
161
162			if tt.raw == nil {
163				return
164			}
165			// The serialized boundary must refuse the same frame end to end:
166			// a well-formed protobuf with a forbidden value is still refused.
167			payload, marshalErr := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: tt.raw}})
168			require.NoError(t, marshalErr)
169			_, decodeErr := DecodeServerEnvelope(payload)
170			require.Error(t, decodeErr, "a forbidden outcome must never decode into a semantic result")
171			var failure *protocol.DecodeFailure
172			require.ErrorAs(t, decodeErr, &failure)
173		})
174	}
175}
176
177// TestCommandResultConversionArmInventory is the sessionwire arm inventory for
178// the explicit command result: value and pointer arms share the value arm's
179// bytes, and a typed nil pointer arm fails closed with ErrInvalidMessage instead
180// of panicking. A new arm added without coverage fails here.
181func TestCommandResultConversionArmInventory(t *testing.T) {
182	message := testCommandResults()[0]
183	valueRaw, err := EncodeServerMessage(message)
184	require.NoError(t, err)
185
186	tests := []struct {
187		name    string
188		run     func() ([]byte, error)
189		wantRaw []byte
190		wantErr error
191	}{
192		{name: "value arm", run: func() ([]byte, error) { return EncodeServerMessage(message) }, wantRaw: valueRaw},
193		{name: "pointer arm", run: func() ([]byte, error) { return EncodeServerMessage(&message) }, wantRaw: valueRaw},
194		{name: "typed nil pointer arm", run: func() ([]byte, error) { return EncodeServerMessage((*protocol.CommandResult)(nil)) }, wantErr: ErrInvalidMessage},
195	}
196	for _, tt := range tests {
197		t.Run(tt.name, func(t *testing.T) {
198			raw, err := tt.run()
199			if tt.wantErr != nil {
200				require.ErrorIs(t, err, tt.wantErr)
201				return
202			}
203			require.NoError(t, err)
204			require.Equal(t, tt.wantRaw, raw, "every arm must share the value arm's bytes")
205		})
206	}
207
208	decoded, err := DecodeServerEnvelope(valueRaw)
209	require.NoError(t, err)
210	require.Equal(t, message, decoded)
211}
212
213// TestCommandResultIsBoundedControlEnvelope proves a CommandResult rides the
214// strict control ceiling, never the bulk exemption, on send and receive, so a
215// failure's error text stays inside the documented 1 MiB control bound.
216func TestCommandResultIsBoundedControlEnvelope(t *testing.T) {
217	message := testCommandResults()[1]
218
219	oversize := protocol.CommandResult{
220		RequestID: 1,
221		Outcome:   protocol.CommandFailed,
222		Code:      protocol.ErrInternal,
223		Text:      string(make([]byte, wire.ControlEnvelopeLimit)),
224	}
225	oversizeRaw, err := EncodeServerMessage(oversize)
226	require.NoError(t, err)
227	require.ErrorIs(t, checkCategoryCeiling(oversizeRaw, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,
228		"a CommandResult above the control ceiling must fail even under an absolute negotiated ceiling")
229
230	send := &serverConnection{handshake: handshake{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}}
231	require.NoError(t, send.SendServer(message))
232	require.ErrorIs(t, send.SendServer(oversize), wire.ErrScanLength)
233
234	recv := &clientConnection{handshake: handshake{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversizeRaw}}}, ceilings: defaultProtoCeilings()}}
235	recv.preambleOnce.Do(func() {})
236	_, err = recv.ReceiveServer()
237	require.Error(t, err)
238}

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