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{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}
231 require.NoError(t, send.SendServer(message))
232 require.ErrorIs(t, send.SendServer(oversize), wire.ErrScanLength)
233
234 recv := &clientConnection{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversizeRaw}}}, ceilings: defaultProtoCeilings()}
235 recv.preambleOnce.Do(func() {})
236 _, err = recv.ReceiveServer()
237 require.Error(t, err)
238}