1package sessionwire
2
3import (
4 "bytes"
5 "compress/zlib"
6 "errors"
7 "io"
8 "math"
9 "sync"
10
11 "google.golang.org/protobuf/proto"
12
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/protocol"
16 "github.com/bnema/vev/internal/protocol/wire"
17)
18
19// outputCompressionThreshold preserves the small-snapshot compression threshold: small snapshots stay
20// on the canonical raw path.
21const protoOutputCompressionThreshold = 1024
22
23func helloToWire(message protocol.Hello) (*wire.Hello, error) {
24 cols, rows, pixelWidth, pixelHeight, err := geometryToWire(message.Size, message.PixelWidth, message.PixelHeight)
25 if err != nil {
26 return nil, err
27 }
28 sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget)
29 if err != nil {
30 return nil, err
31 }
32 exact := exactTargetToWire(message.ExactTarget)
33 color, err := colorToWire(message.Color)
34 if err != nil {
35 return nil, err
36 }
37 return &wire.Hello{
38 Version: uint32(message.Version),
39 Intent: uint32(message.Intent),
40 ClientId: append([]byte(nil), message.ClientID[:]...),
41 ResumeToken: message.ResumeToken,
42 Name: message.Name,
43 Cols: cols,
44 Rows: rows,
45 PixelWidth: pixelWidth,
46 PixelHeight: pixelHeight,
47 TermEnv: message.TermEnv,
48 Cwd: message.Cwd,
49 Color: color,
50 MaxOutputInFlight: uint32(message.MaxOutputInFlight),
51 Env: append([]string(nil), message.Env...),
52 SessionTarget: sessionTarget,
53 EnvironmentPolicy: uint32(message.EnvironmentPolicy),
54 ExactTarget: exact,
55 PreferredTabId: string(message.PreferredTabID),
56 NavigationCapabilities: uint32(message.NavigationCapabilities),
57 Remote: message.Remote,
58 KittyDirectGraphics: message.KittyDirectGraphics,
59 KittyKeyboard: message.KittyKeyboard,
60 TerminalFocus: uint32(message.TerminalFocus),
61 ClientPid: message.ClientPID,
62 }, nil
63}
64
65// colorToWire maps a color capability to its wire message. Unknown semantic
66// values are never encoded.
67func colorToWire(color terminalcap.ColorCapabilities) (*wire.ColorCapabilities, error) {
68 if !color.Valid() {
69 return nil, errProtoConvertRange
70 }
71 var mode wire.ColorMode
72 switch color.Mode {
73 case terminalcap.TrueColor:
74 mode = wire.ColorMode_COLOR_MODE_TRUE_COLOR
75 case terminalcap.ANSI256:
76 mode = wire.ColorMode_COLOR_MODE_ANSI256
77 case terminalcap.ANSI16:
78 mode = wire.ColorMode_COLOR_MODE_ANSI16
79 case terminalcap.Monochrome:
80 mode = wire.ColorMode_COLOR_MODE_MONOCHROME
81 default:
82 return nil, errProtoConvertRange
83 }
84 var source wire.ColorSource
85 switch color.Source {
86 case terminalcap.SourceUnknown:
87 source = wire.ColorSource_COLOR_SOURCE_UNSPECIFIED
88 case terminalcap.SourceHeuristic:
89 source = wire.ColorSource_COLOR_SOURCE_HEURISTIC
90 case terminalcap.SourceDeclared:
91 source = wire.ColorSource_COLOR_SOURCE_DECLARED
92 case terminalcap.SourceForced:
93 source = wire.ColorSource_COLOR_SOURCE_FORCED
94 default:
95 return nil, errProtoConvertRange
96 }
97 return &wire.ColorCapabilities{Mode: mode, Source: source}, nil
98}
99
100// colorFromWire is strict: Hello must claim a color capability, so a missing
101// message, an UNSPECIFIED mode, or any value outside the closed enums is a
102// decode error. An UNSPECIFIED source is the explicit "unknown" source.
103func colorFromWire(message *wire.ColorCapabilities) (terminalcap.ColorCapabilities, error) {
104 if message == nil {
105 return terminalcap.ColorCapabilities{}, errProtoConvertRange
106 }
107 var color terminalcap.ColorCapabilities
108 switch message.GetMode() {
109 case wire.ColorMode_COLOR_MODE_TRUE_COLOR:
110 color.Mode = terminalcap.TrueColor
111 case wire.ColorMode_COLOR_MODE_ANSI256:
112 color.Mode = terminalcap.ANSI256
113 case wire.ColorMode_COLOR_MODE_ANSI16:
114 color.Mode = terminalcap.ANSI16
115 case wire.ColorMode_COLOR_MODE_MONOCHROME:
116 color.Mode = terminalcap.Monochrome
117 default:
118 return terminalcap.ColorCapabilities{}, errProtoConvertRange
119 }
120 switch message.GetSource() {
121 case wire.ColorSource_COLOR_SOURCE_UNSPECIFIED:
122 color.Source = terminalcap.SourceUnknown
123 case wire.ColorSource_COLOR_SOURCE_HEURISTIC:
124 color.Source = terminalcap.SourceHeuristic
125 case wire.ColorSource_COLOR_SOURCE_DECLARED:
126 color.Source = terminalcap.SourceDeclared
127 case wire.ColorSource_COLOR_SOURCE_FORCED:
128 color.Source = terminalcap.SourceForced
129 default:
130 return terminalcap.ColorCapabilities{}, errProtoConvertRange
131 }
132 return color, nil
133}
134
135func geometryToWire(size domain.Size, pixelWidth, pixelHeight int) (uint32, uint32, uint32, uint32, error) {
136 for _, value := range []int{size.Cols, size.Rows, pixelWidth, pixelHeight} {
137 if value < 0 || value > math.MaxUint16 {
138 return 0, 0, 0, 0, errProtoConvertRange
139 }
140 }
141 return uint32(size.Cols), uint32(size.Rows), uint32(pixelWidth), uint32(pixelHeight), nil
142}
143
144func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
145 var hello protocol.Hello
146 if message == nil {
147 return hello, errProtoConvertRange
148 }
149 version, err := mustUint16(message.GetVersion())
150 if err != nil {
151 return protocol.Hello{}, err
152 }
153 hello.Version = version
154 intent, err := mustUint8(message.GetIntent())
155 if err != nil {
156 return protocol.Hello{}, err
157 }
158 hello.Intent = intent
159 if len(message.GetClientId()) != len(hello.ClientID) {
160 return protocol.Hello{}, errProtoConvertRange
161 }
162 copy(hello.ClientID[:], message.GetClientId())
163 hello.ResumeToken = message.GetResumeToken()
164 hello.Name = message.GetName()
165 cols, err := mustUint16(message.GetCols())
166 if err != nil {
167 return protocol.Hello{}, err
168 }
169 rows, err := mustUint16(message.GetRows())
170 if err != nil {
171 return protocol.Hello{}, err
172 }
173 pixelWidth, err := mustUint16(message.GetPixelWidth())
174 if err != nil {
175 return protocol.Hello{}, err
176 }
177 pixelHeight, err := mustUint16(message.GetPixelHeight())
178 if err != nil {
179 return protocol.Hello{}, err
180 }
181 hello.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
182 hello.PixelWidth = int(pixelWidth)
183 hello.PixelHeight = int(pixelHeight)
184 hello.TermEnv = message.GetTermEnv()
185 hello.Cwd = message.GetCwd()
186 hello.Color, err = colorFromWire(message.GetColor())
187 if err != nil {
188 return protocol.Hello{}, err
189 }
190 window, err := mustUint8(message.GetMaxOutputInFlight())
191 if err != nil {
192 return protocol.Hello{}, err
193 }
194 hello.MaxOutputInFlight = window
195 hello.Env = append([]string(nil), message.GetEnv()...)
196 sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget())
197 if err != nil {
198 return protocol.Hello{}, err
199 }
200 hello.SessionTarget = sessionTarget
201 hello.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())
202 if err != nil {
203 return protocol.Hello{}, err
204 }
205 exact, err := exactTargetFromWire(message.GetExactTarget())
206 if err != nil {
207 return protocol.Hello{}, err
208 }
209 hello.ExactTarget = exact
210 hello.PreferredTabID = domain.TabStableID(message.GetPreferredTabId())
211 hello.NavigationCapabilities, err = enum8[protocol.NavigationCapabilities](message.GetNavigationCapabilities())
212 if err != nil {
213 return protocol.Hello{}, err
214 }
215 hello.Remote = message.GetRemote()
216 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()
217 hello.KittyKeyboard = message.GetKittyKeyboard()
218 hello.ClientPID = message.GetClientPid()
219 hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus())
220 if err != nil {
221 return protocol.Hello{}, err
222 }
223 if err := protocol.ValidateHello(hello); err != nil {
224 return protocol.Hello{}, err
225 }
226 return hello, nil
227}
228
229func welcomeToWire(message protocol.Welcome) (*wire.Welcome, error) {
230 var identity *wire.CommittedRouteIdentity
231 if message.CommittedIdentity != nil {
232 converted, err := committedIdentityToWire(*message.CommittedIdentity)
233 if err != nil {
234 return nil, err
235 }
236 identity = converted
237 }
238 return &wire.Welcome{
239 SessionId: message.SessionID,
240 SessionName: message.SessionName,
241 Ephemeral: message.Ephemeral,
242 ResumeToken: message.ResumeToken,
243 Capabilities: message.Capabilities,
244 CommittedIdentity: identity,
245 }, nil
246}
247
248func welcomeFromWire(message *wire.Welcome) (protocol.Welcome, error) {
249 var welcome protocol.Welcome
250 if message == nil {
251 return welcome, errProtoConvertRange
252 }
253 welcome.SessionID = message.GetSessionId()
254 welcome.SessionName = message.GetSessionName()
255 welcome.Ephemeral = message.GetEphemeral()
256 welcome.ResumeToken = message.GetResumeToken()
257 welcome.Capabilities = message.GetCapabilities()
258 if message.GetCommittedIdentity() != nil {
259 identity, err := committedIdentityFromWire(message.GetCommittedIdentity())
260 if err != nil {
261 return protocol.Welcome{}, err
262 }
263 welcome.CommittedIdentity = &identity
264 if welcome.CommittedIdentity.Target.SessionName != welcome.SessionName || welcome.CommittedIdentity.Ephemeral != welcome.Ephemeral {
265 return protocol.Welcome{}, protocol.ErrInvalidRouteWire
266 }
267 }
268 return welcome, nil
269}
270
271func committedIdentityToWire(identity protocol.CommittedRouteIdentity) (*wire.CommittedRouteIdentity, error) {
272 if err := identity.Validate(); err != nil {
273 return nil, err
274 }
275 return &wire.CommittedRouteIdentity{Target: exactTargetToWire(&identity.Target), Ephemeral: identity.Ephemeral}, nil
276}
277
278func committedIdentityFromWire(message *wire.CommittedRouteIdentity) (protocol.CommittedRouteIdentity, error) {
279 var identity protocol.CommittedRouteIdentity
280 if message == nil {
281 return identity, protocol.ErrInvalidRouteWire
282 }
283 target, err := exactTargetFromWire(message.GetTarget())
284 if err != nil || target == nil {
285 return protocol.CommittedRouteIdentity{}, protocol.ErrInvalidRouteWire
286 }
287 identity.Target = *target
288 identity.Ephemeral = message.GetEphemeral()
289 if err := identity.Validate(); err != nil {
290 return protocol.CommittedRouteIdentity{}, err
291 }
292 return identity, nil
293}
294
295func sessionAttachTargetToWire(target *protocol.SessionAttachTarget) (*wire.SessionAttachTarget, error) {
296 if target == nil {
297 return nil, nil
298 }
299 if err := target.Validate(); err != nil {
300 return nil, err
301 }
302 message := &wire.SessionAttachTarget{SessionId: string(target.SessionID), LifecycleId: lifecycleToWire(target.LifecycleID), SessionName: target.SessionName, TabId: string(target.TabID), TabRawName: target.TabRawName, TabExpectedCount: uint32(target.TabExpectedCount), Stopped: target.Stopped}
303 if target.TabIndex != protocol.NoTabIndex {
304 message.TabIndex = proto.Int32(target.TabIndex)
305 }
306 return message, nil
307}
308
309func sessionAttachTargetFromWire(message *wire.SessionAttachTarget) (*protocol.SessionAttachTarget, error) {
310 if message == nil {
311 return nil, nil
312 }
313 if message.GetTabExpectedCount() > math.MaxUint16 {
314 return nil, protocol.ErrInvalidAttachTarget
315 }
316 lifecycle, err := lifecycleFromWire(message.GetLifecycleId())
317 if err != nil {
318 return nil, err
319 }
320 tabIndex := protocol.NoTabIndex
321 if message.TabIndex != nil {
322 tabIndex = message.GetTabIndex()
323 }
324 target := &protocol.SessionAttachTarget{SessionID: domain.SessionID(message.GetSessionId()), LifecycleID: lifecycle, SessionName: message.GetSessionName(), TabID: domain.TabStableID(message.GetTabId()), TabIndex: tabIndex, TabRawName: message.GetTabRawName(), TabExpectedCount: uint16(message.GetTabExpectedCount()), Stopped: message.GetStopped()}
325 if err := target.Validate(); err != nil {
326 return nil, err
327 }
328 return target, nil
329}
330
331func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, error) {
332 if err := protocol.ValidateAttachTarget(message); err != nil {
333 return nil, err
334 }
335 sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget)
336 if err != nil {
337 return nil, err
338 }
339 return &wire.AttachTarget{
340 RequestId: message.RequestID,
341 Endpoint: message.Endpoint,
342 Session: message.Session,
343 Intent: uint32(message.Intent),
344 SessionTarget: sessionTarget,
345 EnvironmentPolicy: uint32(message.EnvironmentPolicy),
346 ExactTarget: exactTargetToWire(message.ExactTarget),
347 SamePeer: message.SamePeer,
348 PreferredTabId: string(message.PreferredTabID),
349 CauseActionId: message.CauseActionID,
350 }, nil
351}
352
353func attachTargetFromWire(message *wire.AttachTarget) (protocol.AttachTarget, error) {
354 var target protocol.AttachTarget
355 if message == nil {
356 return target, protocol.ErrInvalidAttachTarget
357 }
358 target.RequestID = message.GetRequestId()
359 target.Endpoint = message.GetEndpoint()
360 target.Session = message.GetSession()
361 intent, err := mustUint8(message.GetIntent())
362 if err != nil {
363 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
364 }
365 target.Intent = intent
366 sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget())
367 if err != nil {
368 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
369 }
370 target.SessionTarget = sessionTarget
371 target.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())
372 if err != nil {
373 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
374 }
375 exact, err := exactTargetFromWire(message.GetExactTarget())
376 if err != nil {
377 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
378 }
379 target.ExactTarget = exact
380 target.SamePeer = message.GetSamePeer()
381 target.PreferredTabID = domain.TabStableID(message.GetPreferredTabId())
382 target.CauseActionID = message.GetCauseActionId()
383 if err := protocol.ValidateAttachTarget(target); err != nil {
384 return protocol.AttachTarget{}, err
385 }
386 return target, nil
387}
388
389func outputToWire(message protocol.Output) (*wire.Output, error) {
390 if err := protocol.ValidateOutput(message); err != nil {
391 return nil, err
392 }
393 cols, rows, _, _, err := geometryToWire(message.Size, 0, 0)
394 if err != nil {
395 return nil, err
396 }
397 var context *wire.ViewContext
398 if message.Context != nil {
399 converted, err := viewContextToWire(*message.Context)
400 if err != nil {
401 return nil, err
402 }
403 context = converted
404 }
405 encoding, data, err := compressProtoOutput(message)
406 if err != nil {
407 return nil, err
408 }
409 return &wire.Output{
410 Epoch: message.Epoch,
411 Base: message.Base,
412 NewState: message.New,
413 Echo: message.Echo,
414 ViewRevision: message.ViewRevision,
415 Cols: cols,
416 Rows: rows,
417 Full: message.Full,
418 Context: context,
419 Encoding: encoding,
420 UncompressedLength: uint64(len(message.Data)),
421 Data: data,
422 }, nil
423}
424
425var protoOutputCompressorPool = sync.Pool{New: func() any {
426 writer, err := zlib.NewWriterLevel(io.Discard, zlib.BestSpeed)
427 if err != nil {
428 panic(err)
429 }
430 return writer
431}}
432
433// compressProtoOutput keeps the frozen policy: only full snapshots at or
434// above the threshold are zlib candidates, retained only when smaller.
435// Callers (use cases) always see uncompressed terminal data.
436func compressProtoOutput(message protocol.Output) (uint32, []byte, error) {
437 if !message.Full || len(message.Data) < protoOutputCompressionThreshold {
438 return 0, append([]byte(nil), message.Data...), nil
439 }
440 var compressed bytes.Buffer
441 writer := protoOutputCompressorPool.Get().(*zlib.Writer)
442 writer.Reset(&compressed)
443 defer func() {
444 writer.Reset(io.Discard)
445 protoOutputCompressorPool.Put(writer)
446 }()
447 if _, err := writer.Write(message.Data); err != nil {
448 return 0, nil, err
449 }
450 if err := writer.Close(); err != nil {
451 return 0, nil, err
452 }
453 if compressed.Len()+5 >= len(message.Data) {
454 return 0, append([]byte(nil), message.Data...), nil
455 }
456 return 1, compressed.Bytes(), nil
457}
458
459func outputFromWire(message *wire.Output) (protocol.Output, error) {
460 var output protocol.Output
461 if message == nil {
462 return output, protocol.ErrInvalidOutput
463 }
464 output.Epoch = message.GetEpoch()
465 output.Base = message.GetBase()
466 output.New = message.GetNewState()
467 output.Echo = message.GetEcho()
468 output.ViewRevision = message.GetViewRevision()
469 cols, err := mustUint16(message.GetCols())
470 if err != nil {
471 return protocol.Output{}, protocol.ErrInvalidOutput
472 }
473 rows, err := mustUint16(message.GetRows())
474 if err != nil {
475 return protocol.Output{}, protocol.ErrInvalidOutput
476 }
477 output.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
478 output.Full = message.GetFull()
479 if message.GetContext() != nil {
480 context, err := viewContextFromWire(message.GetContext())
481 if err != nil {
482 return protocol.Output{}, protocol.ErrInvalidOutput
483 }
484 output.Context = &context
485 }
486 if err := protocol.ValidateOutput(output); err != nil {
487 return protocol.Output{}, err
488 }
489 decodedLen := message.GetUncompressedLength()
490 if decodedLen > uint64(protocol.MaxOutputDataLen) {
491 return protocol.Output{}, protocol.ErrInvalidOutput
492 }
493 switch message.GetEncoding() {
494 case 0:
495 if uint64(len(message.GetData())) != decodedLen {
496 return protocol.Output{}, protocol.ErrInvalidOutput
497 }
498 output.Data = append([]byte(nil), message.GetData()...)
499 case 1:
500 if !output.Full {
501 return protocol.Output{}, protocol.ErrInvalidOutput
502 }
503 decoded, err := decompressProtoOutput(message.GetData(), int(decodedLen))
504 if err != nil {
505 return protocol.Output{}, protocol.ErrInvalidOutput
506 }
507 output.Data = decoded
508 default:
509 return protocol.Output{}, protocol.ErrInvalidOutput
510 }
511 if err := protocol.ValidateOutput(output); err != nil {
512 return protocol.Output{}, err
513 }
514 return output, nil
515}
516
517func decompressProtoOutput(data []byte, decodedLen int) ([]byte, error) {
518 source := bytes.NewReader(data)
519 reader, err := zlib.NewReader(source)
520 if err != nil {
521 return nil, err
522 }
523 decoded := make([]byte, decodedLen)
524 if _, err := io.ReadFull(reader, decoded); err != nil {
525 _ = reader.Close()
526 return nil, err
527 }
528 var extra [1]byte
529 if n, err := reader.Read(extra[:]); n != 0 || !errors.Is(err, io.EOF) {
530 _ = reader.Close()
531 if err == nil || errors.Is(err, io.EOF) {
532 return nil, errors.New("sessionwire: compressed output exceeds declared length")
533 }
534 return nil, err
535 }
536 if err := reader.Close(); err != nil {
537 return nil, err
538 }
539 // The zlib reader stops at the stream end without consuming
540 // appended bytes, so trailing garbage must be rejected explicitly.
541 if source.Len() != 0 {
542 return nil, errors.New("sessionwire: compressed output has trailing bytes")
543 }
544 return decoded, nil
545}
546
547func viewContextToWire(context protocol.ViewContext) (*wire.ViewContext, error) {
548 if err := context.Validate(); err != nil {
549 return nil, err
550 }
551 route, err := committedIdentityToWire(context.Route)
552 if err != nil {
553 return nil, err
554 }
555 return &wire.ViewContext{
556 Publication: context.Publication,
557 Route: route,
558 TabId: string(context.TabID),
559 FocusedPaneId: string(context.FocusedPaneID),
560 }, nil
561}
562
563func viewContextFromWire(message *wire.ViewContext) (protocol.ViewContext, error) {
564 var context protocol.ViewContext
565 if message == nil {
566 return context, protocol.ErrInvalidOutput
567 }
568 context.Publication = message.GetPublication()
569 route, err := committedIdentityFromWire(message.GetRoute())
570 if err != nil {
571 return protocol.ViewContext{}, err
572 }
573 context.Route = route
574 context.TabID = domain.TabStableID(message.GetTabId())
575 context.FocusedPaneID = domain.PaneStableID(message.GetFocusedPaneId())
576 if err := context.Validate(); err != nil {
577 return protocol.ViewContext{}, err
578 }
579 return context, nil
580}
581
582func commandRequestToWire(message protocol.CommandRequest) (*wire.CommandRequest, error) {
583 if len(message.Args) > math.MaxUint16 {
584 return nil, errProtoConvertRange
585 }
586 return &wire.CommandRequest{
587 Version: uint32(message.Version),
588 RequestId: message.RequestID,
589 Attached: message.Attached,
590 Self: message.Self,
591 Slug: message.Slug,
592 Args: append([]string(nil), message.Args...),
593 TargetSession: message.TargetSession,
594 TargetTab: message.TargetTab,
595 TargetPane: message.TargetPane,
596 Json: message.JSON,
597 }, nil
598}
599
600func commandRequestFromWire(message *wire.CommandRequest) (protocol.CommandRequest, error) {
601 var request protocol.CommandRequest
602 if message == nil {
603 return request, errProtoConvertRange
604 }
605 version, err := mustUint16(message.GetVersion())
606 if err != nil {
607 return protocol.CommandRequest{}, err
608 }
609 request.Version = version
610 request.RequestID = message.GetRequestId()
611 request.Attached = message.GetAttached()
612 request.Self = message.GetSelf()
613 request.Slug = message.GetSlug()
614 request.Args = append([]string(nil), message.GetArgs()...)
615 request.TargetSession = message.GetTargetSession()
616 request.TargetTab = message.GetTargetTab()
617 request.TargetPane = message.GetTargetPane()
618 request.JSON = message.GetJson()
619 return request, nil
620}