1package sessionwire
2
3// Kill/KillResult wire contract: a Kill request carries a unique nonzero
4// RequestID, exactly one correlated KillResult answers it, and both directions
5// fail closed on malformed, wrong-direction, truncated, trailing, or oversize
6// bytes. The result is a bounded control envelope: it never rides the
7// Output/bulk ceiling exemption.
8
9import (
10 "testing"
11
12 "google.golang.org/protobuf/proto"
13
14 "github.com/bnema/vev/internal/protocol"
15 "github.com/bnema/vev/internal/protocol/wire"
16 "github.com/stretchr/testify/require"
17)
18
19func testKillResult() protocol.KillResult {
20 return protocol.KillResult{
21 RequestID: 0x1122334455,
22 Outcome: protocol.KillFailed,
23 Code: protocol.ErrNoSuchSession,
24 Text: "no such session: work",
25 Failures: []protocol.KillFailure{
26 {Class: "live", Name: "one", Text: "boom"},
27 {Class: "stopped", Name: "two", Text: "gone"},
28 },
29 }
30}
31
32// TestKillResultByteRoundTrip proves one semantic KillResult re-encodes to
33// byte-identical bytes after a scan + generated unmarshal + semantic decode.
34func TestKillResultByteRoundTrip(t *testing.T) {
35 message := testKillResult()
36 raw, err := EncodeServerMessage(message)
37 require.NoError(t, err)
38 require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw))
39
40 decoded, err := DecodeServerEnvelope(raw)
41 require.NoError(t, err)
42 require.Equal(t, message, decoded)
43
44 reencoded, err := EncodeServerMessage(decoded)
45 require.NoError(t, err)
46 require.Equal(t, raw, reencoded, "KillResult must re-encode byte-for-byte")
47}
48
49// TestKillRequestByteRoundTrip proves one Kill request with a unique nonzero
50// RequestID survives the client direction byte-for-byte.
51func TestKillRequestByteRoundTrip(t *testing.T) {
52 message := protocol.Kill{RequestID: 0xDEADBEEF, Name: "work", Scope: protocol.KillSession}
53 raw, err := EncodeClientMessage(message)
54 require.NoError(t, err)
55 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
56
57 decoded, err := DecodeClientEnvelope(raw)
58 require.NoError(t, err)
59 require.Equal(t, message, decoded)
60
61 reencoded, err := EncodeClientMessage(decoded)
62 require.NoError(t, err)
63 require.Equal(t, raw, reencoded, "Kill must re-encode byte-for-byte")
64}
65
66// TestKillResultWrongDirectionFailsClosed proves a server-only KillResult
67// payload is refused on the client direction and the server decoder rejects a
68// nil envelope, so a result can never be consumed as a request.
69func TestKillResultWrongDirectionFailsClosed(t *testing.T) {
70 raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_KillResult{KillResult: killResultToWire(testKillResult())}})
71 require.NoError(t, err)
72
73 var clientEnvelope wire.ClientEnvelope
74 err = wire.ScanEnvelope(&clientEnvelope, raw)
75 require.Error(t, err, "a server KillResult must not scan as a client envelope")
76
77 _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: nil})
78 require.ErrorIs(t, err, ErrWrongDirection)
79 _, err = decodeProtoServer(nil)
80 require.ErrorIs(t, err, ErrInvalidMessage)
81}
82
83// TestKillAndKillResultRejectMissingOrOutOfRangeIdentity proves the decode
84// boundary refuses a zero RequestID, an unknown outcome, an out-of-range code,
85// and a nil failure entry instead of aliasing them into valid values.
86func TestKillAndKillResultRejectMissingOrOutOfRangeIdentity(t *testing.T) {
87 tests := []struct {
88 name string
89 run func(t *testing.T) error
90 wantErr error
91 }{
92 {
93 name: "kill zero request id",
94 run: func(t *testing.T) error {
95 _, err := killFromWire(&wire.Kill{Name: "work", Scope: uint32(protocol.KillSession)})
96 return err
97 },
98 wantErr: errProtoConvertRange,
99 },
100 {
101 name: "kill nil",
102 run: func(t *testing.T) error { _, err := killFromWire(nil); return err },
103 wantErr: errProtoConvertRange,
104 },
105 {
106 name: "kill result nil",
107 run: func(t *testing.T) error { _, err := killResultFromWire(nil); return err },
108 wantErr: errProtoConvertRange,
109 },
110 {
111 name: "kill result zero request id",
112 run: func(t *testing.T) error {
113 _, err := killResultFromWire(&wire.KillResult{RequestId: 0, Outcome: uint32(protocol.KillSucceeded)})
114 return err
115 },
116 wantErr: errProtoConvertRange,
117 },
118 {
119 name: "kill result unknown outcome below range",
120 run: func(t *testing.T) error {
121 _, err := killResultFromWire(&wire.KillResult{RequestId: 1, Outcome: 0})
122 return err
123 },
124 wantErr: errProtoConvertRange,
125 },
126 {
127 name: "kill result unknown outcome above range",
128 run: func(t *testing.T) error {
129 _, err := killResultFromWire(&wire.KillResult{RequestId: 1, Outcome: uint32(protocol.KillOutcomeUnknown) + 1})
130 return err
131 },
132 wantErr: errProtoConvertRange,
133 },
134 {
135 name: "kill result code overflow",
136 run: func(t *testing.T) error {
137 _, err := killResultFromWire(&wire.KillResult{RequestId: 1, Outcome: uint32(protocol.KillFailed), Code: 1 << 16})
138 return err
139 },
140 wantErr: errProtoConvertRange,
141 },
142 {
143 name: "kill result nil failure entry",
144 run: func(t *testing.T) error {
145 _, err := killResultFromWire(&wire.KillResult{RequestId: 1, Outcome: uint32(protocol.KillFailed), Failures: []*wire.KillFailure{nil}})
146 return err
147 },
148 wantErr: errProtoConvertRange,
149 },
150 }
151 for _, tt := range tests {
152 t.Run(tt.name, func(t *testing.T) {
153 require.ErrorIs(t, tt.run(t), tt.wantErr)
154 })
155 }
156}
157
158// TestKillResultConversionArmInventory is the sessionwire arm inventory for the
159// explicit kill result: every arm of the kill encoders is exercised (server
160// result value/pointer, client request value/pointer), each arm shares the
161// value arm's bytes, and a typed nil pointer arm fails closed with
162// ErrInvalidMessage instead of panicking. A new arm added without coverage fails
163// here.
164func TestKillResultConversionArmInventory(t *testing.T) {
165 result := testKillResult()
166 resultRaw, err := EncodeServerMessage(result)
167 require.NoError(t, err)
168 request := protocol.Kill{RequestID: 0xDEADBEEF, Name: "work", Scope: protocol.KillSession}
169 requestRaw, err := EncodeClientMessage(request)
170 require.NoError(t, err)
171
172 tests := []struct {
173 name string
174 // run encodes one arm and returns the bytes and any error.
175 run func() ([]byte, error)
176 wantRaw []byte
177 // wantErr, when non-nil, is the required failure; otherwise run must
178 // succeed with wantRaw.
179 wantErr error
180 }{
181 {name: "result value arm", run: func() ([]byte, error) { return EncodeServerMessage(result) }, wantRaw: resultRaw},
182 {name: "result pointer arm", run: func() ([]byte, error) { return EncodeServerMessage(&result) }, wantRaw: resultRaw},
183 {name: "result typed nil pointer arm", run: func() ([]byte, error) { return EncodeServerMessage((*protocol.KillResult)(nil)) }, wantErr: ErrInvalidMessage},
184 {name: "request value arm", run: func() ([]byte, error) { return EncodeClientMessage(request) }, wantRaw: requestRaw},
185 {name: "request pointer arm", run: func() ([]byte, error) { return EncodeClientMessage(&request) }, wantRaw: requestRaw},
186 {name: "request typed nil pointer arm", run: func() ([]byte, error) { return EncodeClientMessage((*protocol.Kill)(nil)) }, wantErr: ErrInvalidMessage},
187 }
188 for _, tt := range tests {
189 t.Run(tt.name, func(t *testing.T) {
190 raw, err := tt.run()
191 if tt.wantErr != nil {
192 require.ErrorIs(t, err, tt.wantErr)
193 return
194 }
195 require.NoError(t, err)
196 require.Equal(t, tt.wantRaw, raw, "every arm must share the value arm's bytes")
197 })
198 }
199
200 // The pointer arm decodes back to the same semantic value, so it is not a
201 // side channel around validation.
202 decoded, err := DecodeServerEnvelope(resultRaw)
203 require.NoError(t, err)
204 require.Equal(t, result, decoded)
205}
206
207// TestKillResultEnvelopeStrictScan proves the scan boundary rejects a
208// truncated payload, trailing bytes after a complete variant, and a
209// concatenated duplicate before any semantic decode.
210func TestKillResultEnvelopeStrictScan(t *testing.T) {
211 valid, err := EncodeServerMessage(testKillResult())
212 require.NoError(t, err)
213 require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid))
214
215 require.Error(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid[:len(valid)-1]))
216 require.Error(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, append(append([]byte(nil), valid...), 0xFF)))
217 require.Error(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, append(append([]byte(nil), valid...), valid...)))
218
219 _, err = DecodeServerEnvelope(valid[:len(valid)-1])
220 require.Error(t, err)
221 var failure *protocol.DecodeFailure
222 require.ErrorAs(t, err, &failure)
223 require.Equal(t, protocol.DecodeMalformed, failure.Category)
224}
225
226// TestKillResultIsBoundedControlEnvelope proves a KillResult rides the strict
227// control ceiling, never the bulk exemption, on both send and receive.
228func TestKillResultIsBoundedControlEnvelope(t *testing.T) {
229 message := testKillResult()
230
231 raw, err := EncodeServerMessage(message)
232 require.NoError(t, err)
233 require.Equal(t, categoryControl, serverEnvelopeCategory(raw), "a KillResult is a control envelope")
234
235 // A text sized so the serialized control envelope crosses the 1 MiB
236 // ceiling while the semantic value itself is only the declared cap.
237 oversize, err := EncodeServerMessage(protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded, Text: string(make([]byte, wire.ControlEnvelopeLimit))})
238 require.NoError(t, err)
239 require.ErrorIs(t, checkCategoryCeiling(oversize, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,
240 "a KillResult above the control ceiling must fail even under an absolute negotiated ceiling")
241
242 send := &serverConnection{handshake: handshake{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}}
243 require.NoError(t, send.SendServer(message))
244 require.ErrorIs(t, send.SendServer(protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded, Text: string(make([]byte, wire.ControlEnvelopeLimit))}), wire.ErrScanLength)
245
246 recv := &clientConnection{handshake: handshake{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversize}}}, ceilings: defaultProtoCeilings()}}
247 recv.preambleOnce.Do(func() {})
248 _, err = recv.ReceiveServer()
249 require.Error(t, err)
250}