detached 0929f70

feat(ports): finalize attachment wire codecs

commit
0929f7051bb2e636f1fb7137a5d6ab28d9316dfd
author
brice · · 2026-08-03 07:05 UTC
committer
brice · · 2026-08-04 03:19 UTC
parent
6a384ad
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +627 -61
M internal/ports/frame.go +2 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 21
4+const ProtocolVersion uint16 = 22
55
66 // MaxFrameLen is the largest permitted frame length, including the type byte
77 // and excluding the four-byte length prefix.
@@ -37,6 +37,7 @@ const (
3737 MsgCommandResult MsgType = 22
3838 MsgSessionMeta MsgType = 23
3939 MsgScreenUpdate MsgType = 24
40+ MsgAttachTarget MsgType = 25
4041 )
4142
4243 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 214+const ProtocolVersion uint16 = 22
5 5
6 // MaxFrameLen is the largest permitted frame length, including the type byte6 // MaxFrameLen is the largest permitted frame length, including the type byte
7 // and excluding the four-byte length prefix.7 // and excluding the four-byte length prefix.
@@ -37,6 +37,7 @@ const (
37 MsgCommandResult MsgType = 2237 MsgCommandResult MsgType = 22
38 MsgSessionMeta MsgType = 2338 MsgSessionMeta MsgType = 23
39 MsgScreenUpdate MsgType = 2439 MsgScreenUpdate MsgType = 24
40+ MsgAttachTarget MsgType = 25
40 )41 )
41 42
42 // Frame is the unit of exchange over a Transport: a typed, length-delimited43 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/ports/frame_test.go +3 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 21 {
7- t.Fatalf("ProtocolVersion = %d, want 21", ProtocolVersion)
6+ if ProtocolVersion != 22 {
7+ t.Fatalf("ProtocolVersion = %d, want 22", ProtocolVersion)
88 }
99 }
1010
@@ -62,6 +62,7 @@ func TestMsgTypeUnique(t *testing.T) {
6262 {"MsgCommandResult", MsgCommandResult},
6363 {"MsgSessionMeta", MsgSessionMeta},
6464 {"MsgScreenUpdate", MsgScreenUpdate},
65+ {"MsgAttachTarget", MsgAttachTarget},
6566 }
6667
6768 seen := make(map[MsgType]string, len(tests))
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 21 {6+ if ProtocolVersion != 22 {
7- t.Fatalf("ProtocolVersion = %d, want 21", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 22", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -62,6 +62,7 @@ func TestMsgTypeUnique(t *testing.T) {
62 {"MsgCommandResult", MsgCommandResult},62 {"MsgCommandResult", MsgCommandResult},
63 {"MsgSessionMeta", MsgSessionMeta},63 {"MsgSessionMeta", MsgSessionMeta},
64 {"MsgScreenUpdate", MsgScreenUpdate},64 {"MsgScreenUpdate", MsgScreenUpdate},
65+ {"MsgAttachTarget", MsgAttachTarget},
65 }66 }
66 67
67 seen := make(map[MsgType]string, len(tests))68 seen := make(map[MsgType]string, len(tests))
M internal/ports/wire.go +405 -24
@@ -9,6 +9,7 @@ package ports
99 import (
1010 "encoding/binary"
1111 "errors"
12+ "fmt"
1213 "math"
1314
1415 "github.com/bnema/vev/internal/domain"
@@ -26,6 +27,22 @@ var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
2627
2728 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
2829
30+// ErrInvalidSize reports a terminal size that cannot be represented by the
31+// protocol or would exceed the bounded screen area.
32+var ErrInvalidSize = errors.New("ports: invalid terminal size")
33+
34+// ErrInvalidHello reports a malformed Hello semantic value.
35+var ErrInvalidHello = errors.New("ports: invalid hello")
36+
37+// ErrInvalidOutput reports a malformed Output semantic value.
38+var ErrInvalidOutput = errors.New("ports: invalid output")
39+
40+// ErrInvalidAck reports a malformed Ack semantic value.
41+var ErrInvalidAck = errors.New("ports: invalid ack")
42+
43+// ErrInvalidAttachTarget reports a malformed attach target.
44+var ErrInvalidAttachTarget = errors.New("ports: invalid attach target")
45+
2946 // ErrInvalidSessionMeta reports metadata that cannot be represented by the
3047 // protocol's ordered uint16-indexed tab layout.
3148 var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata")
@@ -160,8 +177,14 @@ type Ping struct{}
160177 // Pong answers a Ping.
161178 type Pong struct{}
162179
163-// Ack acknowledges receipt/application of an output state number.
180+// Ack acknowledges receipt/application of an output state in one output epoch.
164181 type Ack struct {
182+ Epoch uint64
183+ State uint64
184+
185+ // AckedStateNum is retained for the structured-proxy deletion task. It is
186+ // populated from State when decoding and accepted as an input alias only
187+ // when the final fields are otherwise unset.
165188 AckedStateNum uint64
166189 }
167190
@@ -180,12 +203,31 @@ type ErrorMsg struct {
180203 Text string
181204 }
182205
183-// Output carries raw PTY output, destined for the client's terminal.
206+// Output carries one ordered output publication for an attachment.
184207 type Output struct {
208+ Epoch uint64
209+ Base uint64
210+ New uint64
211+ Echo uint64
212+ ViewRevision uint64
213+ Size domain.Size
214+ Full bool
215+ Data []byte
216+
217+ // The old state names remain until the structured-proxy deletion task. They
218+ // are input/output aliases for the final fields and are not encoded as
219+ // separate wire fields.
185220 BaseStateNum uint64
186221 NewStateNum uint64
187222 EchoAck uint64
188- Data []byte
223+}
224+
225+// AttachTarget identifies the endpoint and session selected for a new
226+// attachment. It deliberately carries no viewport, resume, or proxy state.
227+type AttachTarget struct {
228+ Endpoint string
229+ Session string
230+ Intent uint8
189231 }
190232
191233 // Detached tells a client it has been disconnected from its session and why.
@@ -296,6 +338,11 @@ func (w *payloadWriter) putLongString(s string) {
296338 w.b = append(w.b, s...)
297339 }
298340
341+func (w *payloadWriter) putLongBytes(b []byte) {
342+ w.putUint32(uint32(len(b)))
343+ w.b = append(w.b, b...)
344+}
345+
299346 // payloadReader consumes a message payload field by field in wire order,
300347 // erroring instead of panicking on any short read.
301348 type payloadReader struct {
@@ -384,6 +431,43 @@ func (r *payloadReader) getLongString() (string, error) {
384431 return s, nil
385432 }
386433
434+func (r *payloadReader) getLongBytes() ([]byte, error) {
435+ n, err := r.getUint32()
436+ if err != nil {
437+ return nil, err
438+ }
439+ if uint64(n) > uint64(len(r.b)) {
440+ return nil, errShortPayload
441+ }
442+ b := append([]byte(nil), r.b[:int(n)]...)
443+ r.b = r.b[int(n):]
444+ return b, nil
445+}
446+
447+func (r *payloadReader) skipString() error {
448+ n, err := r.getUint16()
449+ if err != nil {
450+ return err
451+ }
452+ if int(n) > len(r.b) {
453+ return errShortPayload
454+ }
455+ r.b = r.b[n:]
456+ return nil
457+}
458+
459+func (r *payloadReader) skipLongString() error {
460+ n, err := r.getUint32()
461+ if err != nil {
462+ return err
463+ }
464+ if uint64(n) > uint64(len(r.b)) {
465+ return errShortPayload
466+ }
467+ r.b = r.b[n:]
468+ return nil
469+}
470+
387471 // rest consumes and returns all remaining bytes, copied so the result is
388472 // independent of the reader's backing array.
389473 func (r *payloadReader) rest() []byte {
@@ -408,6 +492,67 @@ func PeekHelloVersion(b []byte) (uint16, bool) {
408492 return binary.BigEndian.Uint16(b), true
409493 }
410494
495+// ValidateHello validates Hello semantics without retaining or allocating any
496+// payload data.
497+func ValidateHello(h Hello) error {
498+ if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
499+ return ErrInvalidHello
500+ }
501+ if err := ValidateSize(h.Size); err != nil {
502+ return fmt.Errorf("%w: size", ErrInvalidHello)
503+ }
504+ if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {
505+ return ErrInvalidHello
506+ }
507+ for _, entry := range h.Env {
508+ if uint64(len(entry)) > math.MaxUint32 {
509+ return ErrInvalidHello
510+ }
511+ }
512+ return nil
513+}
514+
515+// ValidateOutput validates the final output state before data allocation.
516+func ValidateOutput(m Output) error {
517+ if m.Epoch == 0 {
518+ return ErrInvalidOutput
519+ }
520+ if m.New == 0 {
521+ if m.Base != 0 || m.Full {
522+ return ErrInvalidOutput
523+ }
524+ } else if (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
525+ return ErrInvalidOutput
526+ }
527+ if err := ValidateSize(m.Size); err != nil {
528+ return fmt.Errorf("%w: size", ErrInvalidOutput)
529+ }
530+ if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxFrameLen-1-45-4 {
531+ return ErrInvalidOutput
532+ }
533+ return nil
534+}
535+
536+// ValidateAck validates an epoch-scoped output acknowledgement.
537+func ValidateAck(m Ack) error {
538+ if m.Epoch == 0 {
539+ return ErrInvalidAck
540+ }
541+ return nil
542+}
543+
544+// ValidateAttachTarget validates only the endpoint, session, and intent
545+// fields carried by the server attach-target message.
546+func ValidateAttachTarget(m AttachTarget) error {
547+ if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
548+ return ErrInvalidAttachTarget
549+ }
550+ if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
551+ return ErrInvalidAttachTarget
552+ }
553+ return nil
554+}
555+
411556 // MarshalHello encodes h into a Hello message payload.
412557 func MarshalHello(h Hello) []byte {
413558 w := payloadWriter{}
@@ -430,8 +575,81 @@ func MarshalHello(h Hello) []byte {
430575 return w.b
431576 }
432577
578+func preflightHello(b []byte) error {
579+ r := payloadReader{b: b}
580+ version, err := r.getUint16()
581+ if err != nil {
582+ return err
583+ }
584+ if version == 21 {
585+ return ErrInvalidHello
586+ }
587+ intent, err := r.getUint8()
588+ if err != nil {
589+ return err
590+ }
591+ if intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume {
592+ return ErrInvalidHello
593+ }
594+ if _, err := r.getBool(); err != nil {
595+ return err
596+ }
597+ if len(r.b) < 16 {
598+ return errShortPayload
599+ }
600+ r.b = r.b[16:]
601+ if _, err := r.getUint64(); err != nil {
602+ return err
603+ }
604+ if err := r.skipString(); err != nil {
605+ return err
606+ }
607+ cols, err := r.getUint16()
608+ if err != nil {
609+ return err
610+ }
611+ rows, err := r.getUint16()
612+ if err != nil {
613+ return err
614+ }
615+ if err := ValidateSize(domain.Size{Cols: int(cols), Rows: int(rows)}); err != nil {
616+ return fmt.Errorf("%w: size", ErrInvalidHello)
617+ }
618+ if err := r.skipString(); err != nil {
619+ return err
620+ }
621+ if err := r.skipString(); err != nil {
622+ return err
623+ }
624+ if _, err := r.getBool(); err != nil {
625+ return err
626+ }
627+ if _, err := r.getUint8(); err != nil {
628+ return err
629+ }
630+ envCount, err := r.getUint32()
631+ if err != nil {
632+ return err
633+ }
634+ if uint64(envCount) > uint64(len(r.b)/4) {
635+ return errShortPayload
636+ }
637+ for range int(envCount) {
638+ if err := r.skipLongString(); err != nil {
639+ return err
640+ }
641+ }
642+ return r.done()
643+}
644+
433645 // UnmarshalHello decodes a Hello message payload.
434646 func UnmarshalHello(b []byte) (Hello, error) {
647+ if len(b) > MaxFrameLen-1 {
648+ return Hello{}, ErrInvalidHello
649+ }
650+ if err := preflightHello(b); err != nil {
651+ return Hello{}, err
652+ }
435653 r := payloadReader{b: b}
436654 var h Hello
437655 var err error
@@ -439,6 +657,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
439657 if h.Version, err = r.getUint16(); err != nil {
440658 return Hello{}, err
441659 }
660+ if h.Version == 21 {
661+ return Hello{}, ErrInvalidHello
662+ }
442663 if h.Intent, err = r.getUint8(); err != nil {
443664 return Hello{}, err
444665 }
@@ -465,17 +686,21 @@ func UnmarshalHello(b []byte) (Hello, error) {
465686 return Hello{}, err
466687 }
467688 h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
689+ if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
690+ return Hello{}, ErrInvalidHello
691+ }
692+ if err := ValidateSize(h.Size); err != nil {
693+ return Hello{}, fmt.Errorf("%w: size", ErrInvalidHello)
694+ }
468695 if h.TermEnv, err = r.getString(); err != nil {
469696 return Hello{}, err
470697 }
471698 if h.Cwd, err = r.getString(); err != nil {
472699 return Hello{}, err
473700 }
474- trueColor, err := r.getUint8()
475- if err != nil {
701+ if h.TrueColor, err = r.getBool(); err != nil {
476702 return Hello{}, err
477703 }
478- h.TrueColor = trueColor != 0
479704 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {
480705 return Hello{}, err
481706 }
@@ -501,6 +726,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
501726 if err := r.done(); err != nil {
502727 return Hello{}, err
503728 }
729+ if err := ValidateHello(h); err != nil {
730+ return Hello{}, err
731+ }
504732 return h, nil
505733 }
506734
@@ -572,6 +800,9 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
572800
573801 // MarshalResize encodes m into a Resize message payload.
574802 func MarshalResize(m Resize) []byte {
803+ if err := ValidateSize(m.Size); err != nil {
804+ return nil
805+ }
575806 w := payloadWriter{}
576807 w.putUint16(uint16(m.Size.Cols))
577808 w.putUint16(uint16(m.Size.Rows))
@@ -691,7 +922,11 @@ func UnmarshalResize(b []byte) (Resize, error) {
691922 if err := r.done(); err != nil {
692923 return Resize{}, err
693924 }
694- return Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}, nil
925+ m := Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}
926+ if err := ValidateSize(m.Size); err != nil {
927+ return Resize{}, err
928+ }
929+ return m, nil
695930 }
696931
697932 // MarshalDetach encodes a Detach message payload (always empty).
@@ -736,24 +971,44 @@ func UnmarshalPong(b []byte) (Pong, error) {
736971 return Pong{}, nil
737972 }
738973
739-// MarshalAck encodes m into an Ack message payload.
974+// MarshalAck encodes m into an epoch/state Ack payload.
740975 func MarshalAck(m Ack) []byte {
976+ if m.Epoch == 0 && m.State == 0 {
977+ // Keep old in-tree callers source-compatible until the structured proxy
978+ // deletion task moves them to the final names.
979+ m.Epoch = 1
980+ m.State = m.AckedStateNum
981+ } else if m.AckedStateNum != 0 && m.State != m.AckedStateNum {
982+ return nil
983+ }
984+ if err := ValidateAck(m); err != nil {
985+ return nil
986+ }
741987 w := payloadWriter{}
742- w.putUint64(m.AckedStateNum)
988+ w.putUint64(m.Epoch)
989+ w.putUint64(m.State)
743990 return w.b
744991 }
745992
746-// UnmarshalAck decodes an Ack message payload.
993+// UnmarshalAck decodes an epoch/state Ack payload.
747994 func UnmarshalAck(b []byte) (Ack, error) {
748995 r := payloadReader{b: b}
749- acked, err := r.getUint64()
750- if err != nil {
996+ var m Ack
997+ var err error
998+ if m.Epoch, err = r.getUint64(); err != nil {
999+ return Ack{}, err
1000+ }
1001+ if m.State, err = r.getUint64(); err != nil {
7511002 return Ack{}, err
7521003 }
7531004 if err := r.done(); err != nil {
7541005 return Ack{}, err
7551006 }
756- return Ack{AckedStateNum: acked}, nil
1007+ if err := ValidateAck(m); err != nil {
1008+ return Ack{}, err
1009+ }
1010+ m.AckedStateNum = m.State
1011+ return m, nil
7571012 }
7581013
7591014 // MarshalWelcome encodes m into a Welcome message payload.
@@ -826,33 +1081,159 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
8261081 return m, nil
8271082 }
8281083
829-// MarshalOutput encodes m into an Output message payload.
1084+// MarshalOutput encodes m into the final epoch/base/new/echo/viewRevision/
1085+// size/full/data message layout.
8301086 func MarshalOutput(m Output) []byte {
1087+ legacy := m.Epoch == 0 && m.Base == 0 && m.New == 0 && m.Echo == 0 && m.ViewRevision == 0 && m.Size == (domain.Size{}) && !m.Full
1088+ if legacy {
1089+ m.Epoch = 1
1090+ m.Base = m.BaseStateNum
1091+ m.New = m.NewStateNum
1092+ m.Echo = m.EchoAck
1093+ m.Full = m.New != 0 && m.Base == 0
1094+ m.Size = domain.Size{Cols: 1, Rows: 1}
1095+ } else {
1096+ if (m.BaseStateNum != 0 && m.BaseStateNum != m.Base) ||
1097+ (m.NewStateNum != 0 && m.NewStateNum != m.New) ||
1098+ (m.EchoAck != 0 && m.EchoAck != m.Echo) {
1099+ return nil
1100+ }
1101+ }
1102+ if err := ValidateOutput(m); err != nil {
1103+ return nil
1104+ }
8311105 w := payloadWriter{}
832- w.putUint64(m.BaseStateNum)
833- w.putUint64(m.NewStateNum)
834- w.putUint64(m.EchoAck)
835- w.putBytes(m.Data)
1106+ w.putUint64(m.Epoch)
1107+ w.putUint64(m.Base)
1108+ w.putUint64(m.New)
1109+ w.putUint64(m.Echo)
1110+ w.putUint64(m.ViewRevision)
1111+ w.putUint16(uint16(m.Size.Cols))
1112+ w.putUint16(uint16(m.Size.Rows))
1113+ w.putBool(m.Full)
1114+ w.putLongBytes(m.Data)
8361115 return w.b
8371116 }
8381117
839-// UnmarshalOutput decodes an Output message payload. After fixed state
840-// numbers, the rest of the payload is data; there is no length prefix.
1118+// UnmarshalOutput strictly decodes one final Output payload. Header semantics
1119+// are validated before the length-prefixed data is copied.
8411120 func UnmarshalOutput(b []byte) (Output, error) {
1121+ const outputHeaderLen = 45
1122+ if len(b) > MaxFrameLen-1 {
1123+ return Output{}, ErrInvalidOutput
1124+ }
1125+ if len(b) < outputHeaderLen+4 {
1126+ return Output{}, errShortPayload
1127+ }
8421128 r := payloadReader{b: b}
843- base, err := r.getUint64()
1129+ var m Output
1130+ var err error
1131+ if m.Epoch, err = r.getUint64(); err != nil {
1132+ return Output{}, err
1133+ }
1134+ if m.Base, err = r.getUint64(); err != nil {
1135+ return Output{}, err
1136+ }
1137+ if m.New, err = r.getUint64(); err != nil {
1138+ return Output{}, err
1139+ }
1140+ if m.Echo, err = r.getUint64(); err != nil {
1141+ return Output{}, err
1142+ }
1143+ if m.ViewRevision, err = r.getUint64(); err != nil {
1144+ return Output{}, err
1145+ }
1146+ cols, err := r.getUint16()
8441147 if err != nil {
8451148 return Output{}, err
8461149 }
847- newState, err := r.getUint64()
1150+ rows, err := r.getUint16()
8481151 if err != nil {
8491152 return Output{}, err
8501153 }
851- echoAck, err := r.getUint64()
1154+ m.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
1155+ if m.Full, err = r.getBool(); err != nil {
1156+ return Output{}, err
1157+ }
1158+ if err := ValidateOutput(m); err != nil {
1159+ return Output{}, err
1160+ }
1161+ if len(r.b) < 4 {
1162+ return Output{}, errShortPayload
1163+ }
1164+ dataLen := binary.BigEndian.Uint32(r.b)
1165+ if uint64(dataLen) > uint64(len(r.b)-4) {
1166+ return Output{}, errShortPayload
1167+ }
1168+ if uint64(dataLen) < uint64(len(r.b)-4) {
1169+ return Output{}, errTrailingBytes
1170+ }
1171+ data, err := r.getLongBytes()
8521172 if err != nil {
8531173 return Output{}, err
8541174 }
855- return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil
1175+ if err := r.done(); err != nil {
1176+ return Output{}, err
1177+ }
1178+ m.Data = data
1179+ m.BaseStateNum = m.Base
1180+ m.NewStateNum = m.New
1181+ m.EchoAck = m.Echo
1182+ return m, nil
1183+}
1184+
1185+// MarshalAttachTarget encodes a strict server attach-target payload.
1186+func MarshalAttachTarget(m AttachTarget) []byte {
1187+ if err := ValidateAttachTarget(m); err != nil {
1188+ return nil
1189+ }
1190+ w := payloadWriter{}
1191+ w.putString(m.Endpoint)
1192+ w.putString(m.Session)
1193+ w.putUint8(m.Intent)
1194+ return w.b
1195+}
1196+
1197+// UnmarshalAttachTarget decodes a strict server attach-target payload.
1198+func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1199+ // Preflight lengths and intent before getString can allocate either value.
1200+ probe := payloadReader{b: b}
1201+ endpointLen, err := probe.getUint16()
1202+ if err != nil || endpointLen == 0 || int(endpointLen) > len(probe.b) {
1203+ return AttachTarget{}, ErrInvalidAttachTarget
1204+ }
1205+ probe.b = probe.b[endpointLen:]
1206+ sessionLen, err := probe.getUint16()
1207+ if err != nil || sessionLen == 0 || int(sessionLen) > len(probe.b) {
1208+ return AttachTarget{}, ErrInvalidAttachTarget
1209+ }
1210+ probe.b = probe.b[sessionLen:]
1211+ intent, err := probe.getUint8()
1212+ if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {
1213+ return AttachTarget{}, ErrInvalidAttachTarget
1214+ }
1215+ if err := probe.done(); err != nil {
1216+ return AttachTarget{}, ErrInvalidAttachTarget
1217+ }
1218+
1219+ r := payloadReader{b: b}
1220+ var m AttachTarget
1221+ if m.Endpoint, err = r.getString(); err != nil {
1222+ return AttachTarget{}, err
1223+ }
1224+ if m.Session, err = r.getString(); err != nil {
1225+ return AttachTarget{}, err
1226+ }
1227+ if m.Intent, err = r.getUint8(); err != nil {
1228+ return AttachTarget{}, err
1229+ }
1230+ if err := r.done(); err != nil {
1231+ return AttachTarget{}, err
1232+ }
1233+ if err := ValidateAttachTarget(m); err != nil {
1234+ return AttachTarget{}, err
1235+ }
1236+ return m, nil
8561237 }
8571238
8581239 // MarshalDetached encodes m into a Detached message payload.
@@ -9,6 +9,7 @@ package ports
9 import (9 import (
10 "encoding/binary"10 "encoding/binary"
11 "errors"11 "errors"
12+ "fmt"
12 "math"13 "math"
13 14
14 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
@@ -26,6 +27,22 @@ var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
26 27
27 var errInvalidBoolean = errors.New("ports: invalid boolean flag")28 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
28 29
30+// ErrInvalidSize reports a terminal size that cannot be represented by the
31+// protocol or would exceed the bounded screen area.
32+var ErrInvalidSize = errors.New("ports: invalid terminal size")
33+
34+// ErrInvalidHello reports a malformed Hello semantic value.
35+var ErrInvalidHello = errors.New("ports: invalid hello")
36+
37+// ErrInvalidOutput reports a malformed Output semantic value.
38+var ErrInvalidOutput = errors.New("ports: invalid output")
39+
40+// ErrInvalidAck reports a malformed Ack semantic value.
41+var ErrInvalidAck = errors.New("ports: invalid ack")
42+
43+// ErrInvalidAttachTarget reports a malformed attach target.
44+var ErrInvalidAttachTarget = errors.New("ports: invalid attach target")
45+
29 // ErrInvalidSessionMeta reports metadata that cannot be represented by the46 // ErrInvalidSessionMeta reports metadata that cannot be represented by the
30 // protocol's ordered uint16-indexed tab layout.47 // protocol's ordered uint16-indexed tab layout.
31 var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata")48 var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata")
@@ -160,8 +177,14 @@ type Ping struct{}
160 // Pong answers a Ping.177 // Pong answers a Ping.
161 type Pong struct{}178 type Pong struct{}
162 179
163-// Ack acknowledges receipt/application of an output state number.180+// Ack acknowledges receipt/application of an output state in one output epoch.
164 type Ack struct {181 type Ack struct {
182+ Epoch uint64
183+ State uint64
184+
185+ // AckedStateNum is retained for the structured-proxy deletion task. It is
186+ // populated from State when decoding and accepted as an input alias only
187+ // when the final fields are otherwise unset.
165 AckedStateNum uint64188 AckedStateNum uint64
166 }189 }
167 190
@@ -180,12 +203,31 @@ type ErrorMsg struct {
180 Text string203 Text string
181 }204 }
182 205
183-// Output carries raw PTY output, destined for the client's terminal.206+// Output carries one ordered output publication for an attachment.
184 type Output struct {207 type Output struct {
208+ Epoch uint64
209+ Base uint64
210+ New uint64
211+ Echo uint64
212+ ViewRevision uint64
213+ Size domain.Size
214+ Full bool
215+ Data []byte
216+
217+ // The old state names remain until the structured-proxy deletion task. They
218+ // are input/output aliases for the final fields and are not encoded as
219+ // separate wire fields.
185 BaseStateNum uint64220 BaseStateNum uint64
186 NewStateNum uint64221 NewStateNum uint64
187 EchoAck uint64222 EchoAck uint64
188- Data []byte223+}
224+
225+// AttachTarget identifies the endpoint and session selected for a new
226+// attachment. It deliberately carries no viewport, resume, or proxy state.
227+type AttachTarget struct {
228+ Endpoint string
229+ Session string
230+ Intent uint8
189 }231 }
190 232
191 // Detached tells a client it has been disconnected from its session and why.233 // Detached tells a client it has been disconnected from its session and why.
@@ -296,6 +338,11 @@ func (w *payloadWriter) putLongString(s string) {
296 w.b = append(w.b, s...)338 w.b = append(w.b, s...)
297 }339 }
298 340
341+func (w *payloadWriter) putLongBytes(b []byte) {
342+ w.putUint32(uint32(len(b)))
343+ w.b = append(w.b, b...)
344+}
345+
299 // payloadReader consumes a message payload field by field in wire order,346 // payloadReader consumes a message payload field by field in wire order,
300 // erroring instead of panicking on any short read.347 // erroring instead of panicking on any short read.
301 type payloadReader struct {348 type payloadReader struct {
@@ -384,6 +431,43 @@ func (r *payloadReader) getLongString() (string, error) {
384 return s, nil431 return s, nil
385 }432 }
386 433
434+func (r *payloadReader) getLongBytes() ([]byte, error) {
435+ n, err := r.getUint32()
436+ if err != nil {
437+ return nil, err
438+ }
439+ if uint64(n) > uint64(len(r.b)) {
440+ return nil, errShortPayload
441+ }
442+ b := append([]byte(nil), r.b[:int(n)]...)
443+ r.b = r.b[int(n):]
444+ return b, nil
445+}
446+
447+func (r *payloadReader) skipString() error {
448+ n, err := r.getUint16()
449+ if err != nil {
450+ return err
451+ }
452+ if int(n) > len(r.b) {
453+ return errShortPayload
454+ }
455+ r.b = r.b[n:]
456+ return nil
457+}
458+
459+func (r *payloadReader) skipLongString() error {
460+ n, err := r.getUint32()
461+ if err != nil {
462+ return err
463+ }
464+ if uint64(n) > uint64(len(r.b)) {
465+ return errShortPayload
466+ }
467+ r.b = r.b[n:]
468+ return nil
469+}
470+
387 // rest consumes and returns all remaining bytes, copied so the result is471 // rest consumes and returns all remaining bytes, copied so the result is
388 // independent of the reader's backing array.472 // independent of the reader's backing array.
389 func (r *payloadReader) rest() []byte {473 func (r *payloadReader) rest() []byte {
@@ -408,6 +492,67 @@ func PeekHelloVersion(b []byte) (uint16, bool) {
408 return binary.BigEndian.Uint16(b), true492 return binary.BigEndian.Uint16(b), true
409 }493 }
410 494
495+// ValidateHello validates Hello semantics without retaining or allocating any
496+// payload data.
497+func ValidateHello(h Hello) error {
498+ if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
499+ return ErrInvalidHello
500+ }
501+ if err := ValidateSize(h.Size); err != nil {
502+ return fmt.Errorf("%w: size", ErrInvalidHello)
503+ }
504+ if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {
505+ return ErrInvalidHello
506+ }
507+ for _, entry := range h.Env {
508+ if uint64(len(entry)) > math.MaxUint32 {
509+ return ErrInvalidHello
510+ }
511+ }
512+ return nil
513+}
514+
515+// ValidateOutput validates the final output state before data allocation.
516+func ValidateOutput(m Output) error {
517+ if m.Epoch == 0 {
518+ return ErrInvalidOutput
519+ }
520+ if m.New == 0 {
521+ if m.Base != 0 || m.Full {
522+ return ErrInvalidOutput
523+ }
524+ } else if (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
525+ return ErrInvalidOutput
526+ }
527+ if err := ValidateSize(m.Size); err != nil {
528+ return fmt.Errorf("%w: size", ErrInvalidOutput)
529+ }
530+ if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxFrameLen-1-45-4 {
531+ return ErrInvalidOutput
532+ }
533+ return nil
534+}
535+
536+// ValidateAck validates an epoch-scoped output acknowledgement.
537+func ValidateAck(m Ack) error {
538+ if m.Epoch == 0 {
539+ return ErrInvalidAck
540+ }
541+ return nil
542+}
543+
544+// ValidateAttachTarget validates only the endpoint, session, and intent
545+// fields carried by the server attach-target message.
546+func ValidateAttachTarget(m AttachTarget) error {
547+ if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
548+ return ErrInvalidAttachTarget
549+ }
550+ if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
551+ return ErrInvalidAttachTarget
552+ }
553+ return nil
554+}
555+
411 // MarshalHello encodes h into a Hello message payload.556 // MarshalHello encodes h into a Hello message payload.
412 func MarshalHello(h Hello) []byte {557 func MarshalHello(h Hello) []byte {
413 w := payloadWriter{}558 w := payloadWriter{}
@@ -430,8 +575,81 @@ func MarshalHello(h Hello) []byte {
430 return w.b575 return w.b
431 }576 }
432 577
578+func preflightHello(b []byte) error {
579+ r := payloadReader{b: b}
580+ version, err := r.getUint16()
581+ if err != nil {
582+ return err
583+ }
584+ if version == 21 {
585+ return ErrInvalidHello
586+ }
587+ intent, err := r.getUint8()
588+ if err != nil {
589+ return err
590+ }
591+ if intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume {
592+ return ErrInvalidHello
593+ }
594+ if _, err := r.getBool(); err != nil {
595+ return err
596+ }
597+ if len(r.b) < 16 {
598+ return errShortPayload
599+ }
600+ r.b = r.b[16:]
601+ if _, err := r.getUint64(); err != nil {
602+ return err
603+ }
604+ if err := r.skipString(); err != nil {
605+ return err
606+ }
607+ cols, err := r.getUint16()
608+ if err != nil {
609+ return err
610+ }
611+ rows, err := r.getUint16()
612+ if err != nil {
613+ return err
614+ }
615+ if err := ValidateSize(domain.Size{Cols: int(cols), Rows: int(rows)}); err != nil {
616+ return fmt.Errorf("%w: size", ErrInvalidHello)
617+ }
618+ if err := r.skipString(); err != nil {
619+ return err
620+ }
621+ if err := r.skipString(); err != nil {
622+ return err
623+ }
624+ if _, err := r.getBool(); err != nil {
625+ return err
626+ }
627+ if _, err := r.getUint8(); err != nil {
628+ return err
629+ }
630+ envCount, err := r.getUint32()
631+ if err != nil {
632+ return err
633+ }
634+ if uint64(envCount) > uint64(len(r.b)/4) {
635+ return errShortPayload
636+ }
637+ for range int(envCount) {
638+ if err := r.skipLongString(); err != nil {
639+ return err
640+ }
641+ }
642+ return r.done()
643+}
644+
433 // UnmarshalHello decodes a Hello message payload.645 // UnmarshalHello decodes a Hello message payload.
434 func UnmarshalHello(b []byte) (Hello, error) {646 func UnmarshalHello(b []byte) (Hello, error) {
647+ if len(b) > MaxFrameLen-1 {
648+ return Hello{}, ErrInvalidHello
649+ }
650+ if err := preflightHello(b); err != nil {
651+ return Hello{}, err
652+ }
435 r := payloadReader{b: b}653 r := payloadReader{b: b}
436 var h Hello654 var h Hello
437 var err error655 var err error
@@ -439,6 +657,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
439 if h.Version, err = r.getUint16(); err != nil {657 if h.Version, err = r.getUint16(); err != nil {
440 return Hello{}, err658 return Hello{}, err
441 }659 }
660+ if h.Version == 21 {
661+ return Hello{}, ErrInvalidHello
662+ }
442 if h.Intent, err = r.getUint8(); err != nil {663 if h.Intent, err = r.getUint8(); err != nil {
443 return Hello{}, err664 return Hello{}, err
444 }665 }
@@ -465,17 +686,21 @@ func UnmarshalHello(b []byte) (Hello, error) {
465 return Hello{}, err686 return Hello{}, err
466 }687 }
467 h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}688 h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
689+ if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
690+ return Hello{}, ErrInvalidHello
691+ }
692+ if err := ValidateSize(h.Size); err != nil {
693+ return Hello{}, fmt.Errorf("%w: size", ErrInvalidHello)
694+ }
468 if h.TermEnv, err = r.getString(); err != nil {695 if h.TermEnv, err = r.getString(); err != nil {
469 return Hello{}, err696 return Hello{}, err
470 }697 }
471 if h.Cwd, err = r.getString(); err != nil {698 if h.Cwd, err = r.getString(); err != nil {
472 return Hello{}, err699 return Hello{}, err
473 }700 }
474- trueColor, err := r.getUint8()701+ if h.TrueColor, err = r.getBool(); err != nil {
475- if err != nil {
476 return Hello{}, err702 return Hello{}, err
477 }703 }
478- h.TrueColor = trueColor != 0
479 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {704 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {
480 return Hello{}, err705 return Hello{}, err
481 }706 }
@@ -501,6 +726,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
501 if err := r.done(); err != nil {726 if err := r.done(); err != nil {
502 return Hello{}, err727 return Hello{}, err
503 }728 }
729+ if err := ValidateHello(h); err != nil {
730+ return Hello{}, err
731+ }
504 return h, nil732 return h, nil
505 }733 }
506 734
@@ -572,6 +800,9 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
572 800
573 // MarshalResize encodes m into a Resize message payload.801 // MarshalResize encodes m into a Resize message payload.
574 func MarshalResize(m Resize) []byte {802 func MarshalResize(m Resize) []byte {
803+ if err := ValidateSize(m.Size); err != nil {
804+ return nil
805+ }
575 w := payloadWriter{}806 w := payloadWriter{}
576 w.putUint16(uint16(m.Size.Cols))807 w.putUint16(uint16(m.Size.Cols))
577 w.putUint16(uint16(m.Size.Rows))808 w.putUint16(uint16(m.Size.Rows))
@@ -691,7 +922,11 @@ func UnmarshalResize(b []byte) (Resize, error) {
691 if err := r.done(); err != nil {922 if err := r.done(); err != nil {
692 return Resize{}, err923 return Resize{}, err
693 }924 }
694- return Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}, nil925+ m := Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}
926+ if err := ValidateSize(m.Size); err != nil {
927+ return Resize{}, err
928+ }
929+ return m, nil
695 }930 }
696 931
697 // MarshalDetach encodes a Detach message payload (always empty).932 // MarshalDetach encodes a Detach message payload (always empty).
@@ -736,24 +971,44 @@ func UnmarshalPong(b []byte) (Pong, error) {
736 return Pong{}, nil971 return Pong{}, nil
737 }972 }
738 973
739-// MarshalAck encodes m into an Ack message payload.974+// MarshalAck encodes m into an epoch/state Ack payload.
740 func MarshalAck(m Ack) []byte {975 func MarshalAck(m Ack) []byte {
976+ if m.Epoch == 0 && m.State == 0 {
977+ // Keep old in-tree callers source-compatible until the structured proxy
978+ // deletion task moves them to the final names.
979+ m.Epoch = 1
980+ m.State = m.AckedStateNum
981+ } else if m.AckedStateNum != 0 && m.State != m.AckedStateNum {
982+ return nil
983+ }
984+ if err := ValidateAck(m); err != nil {
985+ return nil
986+ }
741 w := payloadWriter{}987 w := payloadWriter{}
742- w.putUint64(m.AckedStateNum)988+ w.putUint64(m.Epoch)
989+ w.putUint64(m.State)
743 return w.b990 return w.b
744 }991 }
745 992
746-// UnmarshalAck decodes an Ack message payload.993+// UnmarshalAck decodes an epoch/state Ack payload.
747 func UnmarshalAck(b []byte) (Ack, error) {994 func UnmarshalAck(b []byte) (Ack, error) {
748 r := payloadReader{b: b}995 r := payloadReader{b: b}
749- acked, err := r.getUint64()996+ var m Ack
750- if err != nil {997+ var err error
998+ if m.Epoch, err = r.getUint64(); err != nil {
999+ return Ack{}, err
1000+ }
1001+ if m.State, err = r.getUint64(); err != nil {
751 return Ack{}, err1002 return Ack{}, err
752 }1003 }
753 if err := r.done(); err != nil {1004 if err := r.done(); err != nil {
754 return Ack{}, err1005 return Ack{}, err
755 }1006 }
756- return Ack{AckedStateNum: acked}, nil1007+ if err := ValidateAck(m); err != nil {
1008+ return Ack{}, err
1009+ }
1010+ m.AckedStateNum = m.State
1011+ return m, nil
757 }1012 }
758 1013
759 // MarshalWelcome encodes m into a Welcome message payload.1014 // MarshalWelcome encodes m into a Welcome message payload.
@@ -826,33 +1081,159 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
826 return m, nil1081 return m, nil
827 }1082 }
828 1083
829-// MarshalOutput encodes m into an Output message payload.1084+// MarshalOutput encodes m into the final epoch/base/new/echo/viewRevision/
1085+// size/full/data message layout.
830 func MarshalOutput(m Output) []byte {1086 func MarshalOutput(m Output) []byte {
1087+ legacy := m.Epoch == 0 && m.Base == 0 && m.New == 0 && m.Echo == 0 && m.ViewRevision == 0 && m.Size == (domain.Size{}) && !m.Full
1088+ if legacy {
1089+ m.Epoch = 1
1090+ m.Base = m.BaseStateNum
1091+ m.New = m.NewStateNum
1092+ m.Echo = m.EchoAck
1093+ m.Full = m.New != 0 && m.Base == 0
1094+ m.Size = domain.Size{Cols: 1, Rows: 1}
1095+ } else {
1096+ if (m.BaseStateNum != 0 && m.BaseStateNum != m.Base) ||
1097+ (m.NewStateNum != 0 && m.NewStateNum != m.New) ||
1098+ (m.EchoAck != 0 && m.EchoAck != m.Echo) {
1099+ return nil
1100+ }
1101+ }
1102+ if err := ValidateOutput(m); err != nil {
1103+ return nil
1104+ }
831 w := payloadWriter{}1105 w := payloadWriter{}
832- w.putUint64(m.BaseStateNum)1106+ w.putUint64(m.Epoch)
833- w.putUint64(m.NewStateNum)1107+ w.putUint64(m.Base)
834- w.putUint64(m.EchoAck)1108+ w.putUint64(m.New)
835- w.putBytes(m.Data)1109+ w.putUint64(m.Echo)
1110+ w.putUint64(m.ViewRevision)
1111+ w.putUint16(uint16(m.Size.Cols))
1112+ w.putUint16(uint16(m.Size.Rows))
1113+ w.putBool(m.Full)
1114+ w.putLongBytes(m.Data)
836 return w.b1115 return w.b
837 }1116 }
838 1117
839-// UnmarshalOutput decodes an Output message payload. After fixed state1118+// UnmarshalOutput strictly decodes one final Output payload. Header semantics
840-// numbers, the rest of the payload is data; there is no length prefix.1119+// are validated before the length-prefixed data is copied.
841 func UnmarshalOutput(b []byte) (Output, error) {1120 func UnmarshalOutput(b []byte) (Output, error) {
1121+ const outputHeaderLen = 45
1122+ if len(b) > MaxFrameLen-1 {
1123+ return Output{}, ErrInvalidOutput
1124+ }
1125+ if len(b) < outputHeaderLen+4 {
1126+ return Output{}, errShortPayload
1127+ }
842 r := payloadReader{b: b}1128 r := payloadReader{b: b}
843- base, err := r.getUint64()1129+ var m Output
1130+ var err error
1131+ if m.Epoch, err = r.getUint64(); err != nil {
1132+ return Output{}, err
1133+ }
1134+ if m.Base, err = r.getUint64(); err != nil {
1135+ return Output{}, err
1136+ }
1137+ if m.New, err = r.getUint64(); err != nil {
1138+ return Output{}, err
1139+ }
1140+ if m.Echo, err = r.getUint64(); err != nil {
1141+ return Output{}, err
1142+ }
1143+ if m.ViewRevision, err = r.getUint64(); err != nil {
1144+ return Output{}, err
1145+ }
1146+ cols, err := r.getUint16()
844 if err != nil {1147 if err != nil {
845 return Output{}, err1148 return Output{}, err
846 }1149 }
847- newState, err := r.getUint64()1150+ rows, err := r.getUint16()
848 if err != nil {1151 if err != nil {
849 return Output{}, err1152 return Output{}, err
850 }1153 }
851- echoAck, err := r.getUint64()1154+ m.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
1155+ if m.Full, err = r.getBool(); err != nil {
1156+ return Output{}, err
1157+ }
1158+ if err := ValidateOutput(m); err != nil {
1159+ return Output{}, err
1160+ }
1161+ if len(r.b) < 4 {
1162+ return Output{}, errShortPayload
1163+ }
1164+ dataLen := binary.BigEndian.Uint32(r.b)
1165+ if uint64(dataLen) > uint64(len(r.b)-4) {
1166+ return Output{}, errShortPayload
1167+ }
1168+ if uint64(dataLen) < uint64(len(r.b)-4) {
1169+ return Output{}, errTrailingBytes
1170+ }
1171+ data, err := r.getLongBytes()
852 if err != nil {1172 if err != nil {
853 return Output{}, err1173 return Output{}, err
854 }1174 }
855- return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil1175+ if err := r.done(); err != nil {
1176+ return Output{}, err
1177+ }
1178+ m.Data = data
1179+ m.BaseStateNum = m.Base
1180+ m.NewStateNum = m.New
1181+ m.EchoAck = m.Echo
1182+ return m, nil
1183+}
1184+
1185+// MarshalAttachTarget encodes a strict server attach-target payload.
1186+func MarshalAttachTarget(m AttachTarget) []byte {
1187+ if err := ValidateAttachTarget(m); err != nil {
1188+ return nil
1189+ }
1190+ w := payloadWriter{}
1191+ w.putString(m.Endpoint)
1192+ w.putString(m.Session)
1193+ w.putUint8(m.Intent)
1194+ return w.b
1195+}
1196+
1197+// UnmarshalAttachTarget decodes a strict server attach-target payload.
1198+func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1199+ // Preflight lengths and intent before getString can allocate either value.
1200+ probe := payloadReader{b: b}
1201+ endpointLen, err := probe.getUint16()
1202+ if err != nil || endpointLen == 0 || int(endpointLen) > len(probe.b) {
1203+ return AttachTarget{}, ErrInvalidAttachTarget
1204+ }
1205+ probe.b = probe.b[endpointLen:]
1206+ sessionLen, err := probe.getUint16()
1207+ if err != nil || sessionLen == 0 || int(sessionLen) > len(probe.b) {
1208+ return AttachTarget{}, ErrInvalidAttachTarget
1209+ }
1210+ probe.b = probe.b[sessionLen:]
1211+ intent, err := probe.getUint8()
1212+ if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {
1213+ return AttachTarget{}, ErrInvalidAttachTarget
1214+ }
1215+ if err := probe.done(); err != nil {
1216+ return AttachTarget{}, ErrInvalidAttachTarget
1217+ }
1218+
1219+ r := payloadReader{b: b}
1220+ var m AttachTarget
1221+ if m.Endpoint, err = r.getString(); err != nil {
1222+ return AttachTarget{}, err
1223+ }
1224+ if m.Session, err = r.getString(); err != nil {
1225+ return AttachTarget{}, err
1226+ }
1227+ if m.Intent, err = r.getUint8(); err != nil {
1228+ return AttachTarget{}, err
1229+ }
1230+ if err := r.done(); err != nil {
1231+ return AttachTarget{}, err
1232+ }
1233+ if err := ValidateAttachTarget(m); err != nil {
1234+ return AttachTarget{}, err
1235+ }
1236+ return m, nil
856 }1237 }
857 1238
858 // MarshalDetached encodes m into a Detached message payload.1239 // MarshalDetached encodes m into a Detached message payload.
A internal/ports/wire_final_test.go +168 -0
@@ -0,0 +1,168 @@
1+package ports
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
13+ if ProtocolVersion != 22 {
14+ t.Fatalf("ProtocolVersion = %d, want 22", ProtocolVersion)
15+ }
16+ payload := MarshalHello(Hello{Version: 21, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
17+ if _, err := UnmarshalHello(payload); err == nil {
18+ t.Fatal("UnmarshalHello accepted protocol v21")
19+ }
20+}
21+
22+func TestFinalHelloGoldenStrict(t *testing.T) {
23+ msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
24+ want := append([]byte{0, 22, 2, 0}, make([]byte, 16+8)...)
25+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
26+ got := MarshalHello(msg)
27+ if !bytes.Equal(got, want) {
28+ t.Fatalf("Hello bytes = %x, want %x", got, want)
29+ }
30+ back, err := UnmarshalHello(got)
31+ if err != nil {
32+ t.Fatalf("UnmarshalHello() error = %v", err)
33+ }
34+ if !reflect.DeepEqual(back, msg) {
35+ t.Fatalf("Hello = %+v, want %+v", back, msg)
36+ }
37+ assertAllPrefixesFail(t, got, UnmarshalHello)
38+ assertTrailingGarbageFails(t, got, UnmarshalHello)
39+}
40+
41+func TestFinalHelloSemanticValidation(t *testing.T) {
42+ valid := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
43+ for _, tt := range []struct {
44+ name string
45+ mutate func(*Hello)
46+ }{
47+ {name: "intent", mutate: func(h *Hello) { h.Intent = 99 }},
48+ {name: "zero columns", mutate: func(h *Hello) { h.Size.Cols = 0 }},
49+ {name: "zero rows", mutate: func(h *Hello) { h.Size.Rows = 0 }},
50+ {name: "area", mutate: func(h *Hello) { h.Size = domain.Size{Cols: 513, Rows: 512} }},
51+ } {
52+ t.Run(tt.name, func(t *testing.T) {
53+ msg := valid
54+ tt.mutate(&msg)
55+ if err := ValidateHello(msg); err == nil {
56+ t.Fatal("ValidateHello accepted malformed semantics")
57+ }
58+ })
59+ }
60+}
61+
62+func TestFinalResizeGoldenStrict(t *testing.T) {
63+ msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}}
64+ want := []byte{0, 80, 0, 24}
65+ got := MarshalResize(msg)
66+ if !bytes.Equal(got, want) {
67+ t.Fatalf("Resize bytes = %x, want %x", got, want)
68+ }
69+ back, err := UnmarshalResize(got)
70+ if err != nil || back != msg {
71+ t.Fatalf("Resize = %+v, error %v, want %+v", back, err, msg)
72+ }
73+ assertAllPrefixesFail(t, got, UnmarshalResize)
74+ assertTrailingGarbageFails(t, got, UnmarshalResize)
75+ for _, size := range []domain.Size{{}, {Cols: 513, Rows: 512}} {
76+ if got := MarshalResize(Resize{Size: size}); got != nil {
77+ t.Fatalf("MarshalResize(%+v) = %x, want nil", size, got)
78+ }
79+ }
80+}
81+
82+func TestFinalOutputGoldenStrict(t *testing.T) {
83+ msg := Output{
84+ Epoch: 1, Base: 0, New: 7, Echo: 8, ViewRevision: 9,
85+ Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("ok"),
86+ }
87+ want := []byte{
88+ 0, 0, 0, 0, 0, 0, 0, 1,
89+ 0, 0, 0, 0, 0, 0, 0, 0,
90+ 0, 0, 0, 0, 0, 0, 0, 7,
91+ 0, 0, 0, 0, 0, 0, 0, 8,
92+ 0, 0, 0, 0, 0, 0, 0, 9,
93+ 0, 80, 0, 24, 1,
94+ 0, 0, 0, 2, 'o', 'k',
95+ }
96+ got := MarshalOutput(msg)
97+ if !bytes.Equal(got, want) {
98+ t.Fatalf("Output bytes = %x, want %x", got, want)
99+ }
100+ back, err := UnmarshalOutput(got)
101+ if err != nil || back.Epoch != msg.Epoch || back.Base != msg.Base || back.New != msg.New || back.Echo != msg.Echo || back.ViewRevision != msg.ViewRevision || back.Size != msg.Size || back.Full != msg.Full || !bytes.Equal(back.Data, msg.Data) {
102+ t.Fatalf("Output = %+v, error %v, want %+v", back, err, msg)
103+ }
104+ assertAllPrefixesFail(t, got, UnmarshalOutput)
105+ assertTrailingGarbageFails(t, got, UnmarshalOutput)
106+}
107+
108+func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
109+ valid := Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true}
110+ payload := MarshalOutput(valid)
111+ for _, tt := range []struct {
112+ name string
113+ mutate func([]byte)
114+ }{
115+ {name: "zero epoch", mutate: func(b []byte) { b[7] = 0 }},
116+ {name: "new does not advance", mutate: func(b []byte) { binary.BigEndian.PutUint64(b[16:24], 0) }},
117+ {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }},
118+ {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }},
119+ {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }},
120+ {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[45:49], ^uint32(0)) }},
121+ } {
122+ t.Run(tt.name, func(t *testing.T) {
123+ bad := append([]byte(nil), payload...)
124+ tt.mutate(bad)
125+ if _, err := UnmarshalOutput(bad); err == nil {
126+ t.Fatal("UnmarshalOutput accepted malformed semantics")
127+ }
128+ })
129+ }
130+}
131+
132+func TestFinalAckGoldenStrict(t *testing.T) {
133+ msg := Ack{Epoch: 2, State: 7}
134+ want := []byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 7}
135+ got := MarshalAck(msg)
136+ if !bytes.Equal(got, want) {
137+ t.Fatalf("Ack bytes = %x, want %x", got, want)
138+ }
139+ back, err := UnmarshalAck(got)
140+ if err != nil || back != (Ack{Epoch: 2, State: 7, AckedStateNum: 7}) {
141+ t.Fatalf("Ack = %+v, error %v", back, err)
142+ }
143+ assertAllPrefixesFail(t, got, UnmarshalAck)
144+ assertTrailingGarbageFails(t, got, UnmarshalAck)
145+ if _, err := UnmarshalAck(make([]byte, 16)); err == nil {
146+ t.Fatal("UnmarshalAck accepted zero epoch")
147+ }
148+}
149+
150+func TestFinalAttachTargetGoldenStrict(t *testing.T) {
151+ msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}
152+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2}
153+ got := MarshalAttachTarget(msg)
154+ if !bytes.Equal(got, want) {
155+ t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
156+ }
157+ back, err := UnmarshalAttachTarget(got)
158+ if err != nil || back != msg {
159+ t.Fatalf("AttachTarget = %+v, error %v, want %+v", back, err, msg)
160+ }
161+ assertAllPrefixesFail(t, got, UnmarshalAttachTarget)
162+ assertTrailingGarbageFails(t, got, UnmarshalAttachTarget)
163+ for _, bad := range []AttachTarget{{Session: "work", Intent: IntentAttach}, {Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} {
164+ if got := MarshalAttachTarget(bad); got != nil {
165+ t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got)
166+ }
167+ }
168+}
@@ -0,0 +1,168 @@
1+package ports
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
13+ if ProtocolVersion != 22 {
14+ t.Fatalf("ProtocolVersion = %d, want 22", ProtocolVersion)
15+ }
16+ payload := MarshalHello(Hello{Version: 21, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
17+ if _, err := UnmarshalHello(payload); err == nil {
18+ t.Fatal("UnmarshalHello accepted protocol v21")
19+ }
20+}
21+
22+func TestFinalHelloGoldenStrict(t *testing.T) {
23+ msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
24+ want := append([]byte{0, 22, 2, 0}, make([]byte, 16+8)...)
25+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
26+ got := MarshalHello(msg)
27+ if !bytes.Equal(got, want) {
28+ t.Fatalf("Hello bytes = %x, want %x", got, want)
29+ }
30+ back, err := UnmarshalHello(got)
31+ if err != nil {
32+ t.Fatalf("UnmarshalHello() error = %v", err)
33+ }
34+ if !reflect.DeepEqual(back, msg) {
35+ t.Fatalf("Hello = %+v, want %+v", back, msg)
36+ }
37+ assertAllPrefixesFail(t, got, UnmarshalHello)
38+ assertTrailingGarbageFails(t, got, UnmarshalHello)
39+}
40+
41+func TestFinalHelloSemanticValidation(t *testing.T) {
42+ valid := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
43+ for _, tt := range []struct {
44+ name string
45+ mutate func(*Hello)
46+ }{
47+ {name: "intent", mutate: func(h *Hello) { h.Intent = 99 }},
48+ {name: "zero columns", mutate: func(h *Hello) { h.Size.Cols = 0 }},
49+ {name: "zero rows", mutate: func(h *Hello) { h.Size.Rows = 0 }},
50+ {name: "area", mutate: func(h *Hello) { h.Size = domain.Size{Cols: 513, Rows: 512} }},
51+ } {
52+ t.Run(tt.name, func(t *testing.T) {
53+ msg := valid
54+ tt.mutate(&msg)
55+ if err := ValidateHello(msg); err == nil {
56+ t.Fatal("ValidateHello accepted malformed semantics")
57+ }
58+ })
59+ }
60+}
61+
62+func TestFinalResizeGoldenStrict(t *testing.T) {
63+ msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}}
64+ want := []byte{0, 80, 0, 24}
65+ got := MarshalResize(msg)
66+ if !bytes.Equal(got, want) {
67+ t.Fatalf("Resize bytes = %x, want %x", got, want)
68+ }
69+ back, err := UnmarshalResize(got)
70+ if err != nil || back != msg {
71+ t.Fatalf("Resize = %+v, error %v, want %+v", back, err, msg)
72+ }
73+ assertAllPrefixesFail(t, got, UnmarshalResize)
74+ assertTrailingGarbageFails(t, got, UnmarshalResize)
75+ for _, size := range []domain.Size{{}, {Cols: 513, Rows: 512}} {
76+ if got := MarshalResize(Resize{Size: size}); got != nil {
77+ t.Fatalf("MarshalResize(%+v) = %x, want nil", size, got)
78+ }
79+ }
80+}
81+
82+func TestFinalOutputGoldenStrict(t *testing.T) {
83+ msg := Output{
84+ Epoch: 1, Base: 0, New: 7, Echo: 8, ViewRevision: 9,
85+ Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("ok"),
86+ }
87+ want := []byte{
88+ 0, 0, 0, 0, 0, 0, 0, 1,
89+ 0, 0, 0, 0, 0, 0, 0, 0,
90+ 0, 0, 0, 0, 0, 0, 0, 7,
91+ 0, 0, 0, 0, 0, 0, 0, 8,
92+ 0, 0, 0, 0, 0, 0, 0, 9,
93+ 0, 80, 0, 24, 1,
94+ 0, 0, 0, 2, 'o', 'k',
95+ }
96+ got := MarshalOutput(msg)
97+ if !bytes.Equal(got, want) {
98+ t.Fatalf("Output bytes = %x, want %x", got, want)
99+ }
100+ back, err := UnmarshalOutput(got)
101+ if err != nil || back.Epoch != msg.Epoch || back.Base != msg.Base || back.New != msg.New || back.Echo != msg.Echo || back.ViewRevision != msg.ViewRevision || back.Size != msg.Size || back.Full != msg.Full || !bytes.Equal(back.Data, msg.Data) {
102+ t.Fatalf("Output = %+v, error %v, want %+v", back, err, msg)
103+ }
104+ assertAllPrefixesFail(t, got, UnmarshalOutput)
105+ assertTrailingGarbageFails(t, got, UnmarshalOutput)
106+}
107+
108+func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
109+ valid := Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true}
110+ payload := MarshalOutput(valid)
111+ for _, tt := range []struct {
112+ name string
113+ mutate func([]byte)
114+ }{
115+ {name: "zero epoch", mutate: func(b []byte) { b[7] = 0 }},
116+ {name: "new does not advance", mutate: func(b []byte) { binary.BigEndian.PutUint64(b[16:24], 0) }},
117+ {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }},
118+ {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }},
119+ {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }},
120+ {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[45:49], ^uint32(0)) }},
121+ } {
122+ t.Run(tt.name, func(t *testing.T) {
123+ bad := append([]byte(nil), payload...)
124+ tt.mutate(bad)
125+ if _, err := UnmarshalOutput(bad); err == nil {
126+ t.Fatal("UnmarshalOutput accepted malformed semantics")
127+ }
128+ })
129+ }
130+}
131+
132+func TestFinalAckGoldenStrict(t *testing.T) {
133+ msg := Ack{Epoch: 2, State: 7}
134+ want := []byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 7}
135+ got := MarshalAck(msg)
136+ if !bytes.Equal(got, want) {
137+ t.Fatalf("Ack bytes = %x, want %x", got, want)
138+ }
139+ back, err := UnmarshalAck(got)
140+ if err != nil || back != (Ack{Epoch: 2, State: 7, AckedStateNum: 7}) {
141+ t.Fatalf("Ack = %+v, error %v", back, err)
142+ }
143+ assertAllPrefixesFail(t, got, UnmarshalAck)
144+ assertTrailingGarbageFails(t, got, UnmarshalAck)
145+ if _, err := UnmarshalAck(make([]byte, 16)); err == nil {
146+ t.Fatal("UnmarshalAck accepted zero epoch")
147+ }
148+}
149+
150+func TestFinalAttachTargetGoldenStrict(t *testing.T) {
151+ msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}
152+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2}
153+ got := MarshalAttachTarget(msg)
154+ if !bytes.Equal(got, want) {
155+ t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
156+ }
157+ back, err := UnmarshalAttachTarget(got)
158+ if err != nil || back != msg {
159+ t.Fatalf("AttachTarget = %+v, error %v, want %+v", back, err, msg)
160+ }
161+ assertAllPrefixesFail(t, got, UnmarshalAttachTarget)
162+ assertTrailingGarbageFails(t, got, UnmarshalAttachTarget)
163+ for _, bad := range []AttachTarget{{Session: "work", Intent: IntentAttach}, {Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} {
164+ if got := MarshalAttachTarget(bad); got != nil {
165+ t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got)
166+ }
167+ }
168+}
M internal/ports/wire_screen.go +1 -3
@@ -60,8 +60,6 @@ var (
6060 )
6161
6262 const (
63- maxScreenCells = 1 << 18
64-
6563 screenHeaderLen = 40
6664 screenStyleLen = 18
6765 screenSpanLimit = 4096
@@ -386,7 +384,7 @@ func validateScreenUpdate(m ScreenUpdate) error {
386384 }
387385
388386 func screenAreaWithinLimit(size domain.Size) bool {
389- return size.Cols > 0 && size.Rows > 0 && size.Rows <= maxScreenCells/size.Cols
387+ return validWireSize(size)
390388 }
391389
392390 func validScreenScroll(scroll ScreenScroll, rows uint16) bool {
@@ -60,8 +60,6 @@ var (
60 )60 )
61 61
62 const (62 const (
63- maxScreenCells = 1 << 18
64-
65 screenHeaderLen = 4063 screenHeaderLen = 40
66 screenStyleLen = 1864 screenStyleLen = 18
67 screenSpanLimit = 409665 screenSpanLimit = 4096
@@ -386,7 +384,7 @@ func validateScreenUpdate(m ScreenUpdate) error {
386 }384 }
387 385
388 func screenAreaWithinLimit(size domain.Size) bool {386 func screenAreaWithinLimit(size domain.Size) bool {
389- return size.Cols > 0 && size.Rows > 0 && size.Rows <= maxScreenCells/size.Cols387+ return validWireSize(size)
390 }388 }
391 389
392 func validScreenScroll(scroll ScreenScroll, rows uint16) bool {390 func validScreenScroll(scroll ScreenScroll, rows uint16) bool {
A internal/ports/wire_size.go +27 -0
@@ -0,0 +1,27 @@
1+package ports
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// MaxWireScreenCells bounds every terminal size carried by the final wire
10+// protocol. The uint16 dimension limit is checked separately.
11+const MaxWireScreenCells = 1 << 18
12+
13+// ValidateSize validates a terminal size before a codec allocates or mutates
14+// state.
15+func ValidateSize(size domain.Size) error {
16+ if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 {
17+ return ErrInvalidSize
18+ }
19+ if size.Rows > MaxWireScreenCells/size.Cols {
20+ return ErrInvalidSize
21+ }
22+ return nil
23+}
24+
25+func validWireSize(size domain.Size) bool {
26+ return ValidateSize(size) == nil
27+}
@@ -0,0 +1,27 @@
1+package ports
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// MaxWireScreenCells bounds every terminal size carried by the final wire
10+// protocol. The uint16 dimension limit is checked separately.
11+const MaxWireScreenCells = 1 << 18
12+
13+// ValidateSize validates a terminal size before a codec allocates or mutates
14+// state.
15+func ValidateSize(size domain.Size) error {
16+ if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 {
17+ return ErrInvalidSize
18+ }
19+ if size.Rows > MaxWireScreenCells/size.Cols {
20+ return ErrInvalidSize
21+ }
22+ return nil
23+}
24+
25+func validWireSize(size domain.Size) bool {
26+ return ValidateSize(size) == nil
27+}
M internal/ports/wire_test.go +21 -31
@@ -81,12 +81,12 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8181 Version: 1,
8282 Intent: IntentEphemeral,
8383 Name: "",
84- Size: domain.Size{Cols: 0, Rows: 0},
84+ Size: domain.Size{Cols: 1, Rows: 1},
8585 TermEnv: "",
8686 Cwd: "",
8787 TrueColor: false,
8888 },
89- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
89+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
9090 },
9191 }
9292
@@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
122122 Env: []string{"A=B", "XY=123"},
123123 })
124124 want := []byte{
125- 0x00, 0x15, 0x00, 0x00, // version, intent, proxied
125+ 0x00, 0x16, 0x00, 0x00, // version, intent, proxied
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -143,20 +143,20 @@ func TestHelloEnvironmentCodec(t *testing.T) {
143143 t.Run("lossless order and values", func(t *testing.T) {
144144 entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
145145 want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}
146- payload := MarshalHello(Hello{Version: ProtocolVersion, Env: want})
146+ payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: want})
147147 got, err := UnmarshalHello(payload)
148148 require.NoError(t, err)
149149 require.Equal(t, want, got.Env)
150150 })
151151
152152 t.Run("empty", func(t *testing.T) {
153- payload := MarshalHello(Hello{Version: ProtocolVersion, Env: []string{}})
153+ payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: []string{}})
154154 got, err := UnmarshalHello(payload)
155155 require.NoError(t, err)
156156 require.Empty(t, got.Env)
157157 })
158158
159- base := MarshalHello(Hello{Version: ProtocolVersion})
159+ base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
160160 withCount := func(count byte) []byte {
161161 payload := append([]byte(nil), base...)
162162 payload[len(payload)-1] = count
@@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336336 }
337337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338338 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(21), ProtocolVersion)
339+ require.Equal(t, uint16(22), ProtocolVersion)
340340 }
341341
342342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -461,7 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461461 name: "minimal",
462462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463463 want: []byte{
464- 0x00, 0x15,
464+ 0x00, 0x16,
465465 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
466466 0x00, // attached
467467 0x00, // self
@@ -484,7 +484,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
484484 JSON: true,
485485 },
486486 want: []byte{
487- 0x00, 0x15,
487+ 0x00, 0x16,
488488 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
489489 0x00, // attached
490490 0x01, // self
@@ -609,8 +609,8 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
609609 }
610610
611611 func TestOutputGoldenAndRoundTrip(t *testing.T) {
612- msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")}
613- want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
612+ msg := Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n"), NewStateNum: 2, EchoAck: 3}
613+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x06, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
614614
615615 got := MarshalOutput(msg)
616616 if !bytes.Equal(got, want) {
@@ -624,23 +624,13 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
624624 t.Fatalf("round trip = %#v, want %#v", back, msg)
625625 }
626626
627- assertAllPrefixesFail(t, got[:24], UnmarshalOutput)
628-
629- extra := []byte{0x00, 0xff, 0x7f}
630- withTrailing := append(append([]byte(nil), got...), extra...)
631- back, err = UnmarshalOutput(withTrailing)
632- if err != nil {
633- t.Fatalf("UnmarshalOutput() with trailing payload error = %v", err)
634- }
635- wantData := append(append([]byte(nil), msg.Data...), extra...)
636- if !bytes.Equal(back.Data, wantData) {
637- t.Fatalf("trailing payload data = %#v, want %#v", back.Data, wantData)
638- }
627+ assertAllPrefixesFail(t, got, UnmarshalOutput)
628+ assertTrailingGarbageFails(t, got, UnmarshalOutput)
639629 }
640630
641631 func TestAckGoldenAndRoundTrip(t *testing.T) {
642- msg := Ack{AckedStateNum: 0x0102030405060708}
643- want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
632+ msg := Ack{Epoch: 1, State: 0x0102030405060708, AckedStateNum: 0x0102030405060708}
633+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
644634
645635 got := MarshalAck(msg)
646636 if !bytes.Equal(got, want) {
@@ -884,11 +874,11 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
884874 func TestHelloOutputWindowByteExactValues(t *testing.T) {
885875 for _, window := range []uint8{0, 1, 8} {
886876 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
887- hello := Hello{Version: 14, MaxOutputInFlight: window}
888- // The empty Hello has 39 zero bytes before the negotiated output window,
889- // followed by a uint32 zero environment-entry count.
877+ hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
878+ // The empty Hello has a fixed 1x1 size before the negotiated output window.
890879 want := append(make([]byte, 39), window, 0, 0, 0, 0)
891880 want[1] = 14
881+ want[31], want[33] = 1, 1
892882 got := MarshalHello(hello)
893883 requireBytesEqual(t, want, got)
894884 back, err := UnmarshalHello(got)
@@ -921,7 +911,7 @@ func TestProxiedHelloGoldenAndStrict(t *testing.T) {
921911 MaxOutputInFlight: 8,
922912 }
923913 want := []byte{
924- 0x00, 0x15, 0x02, 0x01,
914+ 0x00, 0x16, 0x02, 0x01,
925915 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
926916 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
927917 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e',
@@ -954,7 +944,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
954944 name: "attached",
955945 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
956946 want: []byte{
957- 0x00, 0x15, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
947+ 0x00, 0x16, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
958948 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
959949 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
960950 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -964,7 +954,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
964954 name: "control",
965955 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
966956 want: []byte{
967- 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
957+ 0x00, 0x16, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
968958 0x00, 0x02, 'l', 's', 0x00, 0x00,
969959 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
970960 },
@@ -81,12 +81,12 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
81 Version: 1,81 Version: 1,
82 Intent: IntentEphemeral,82 Intent: IntentEphemeral,
83 Name: "",83 Name: "",
84- Size: domain.Size{Cols: 0, Rows: 0},84+ Size: domain.Size{Cols: 1, Rows: 1},
85 TermEnv: "",85 TermEnv: "",
86 Cwd: "",86 Cwd: "",
87 TrueColor: false,87 TrueColor: false,
88 },88 },
89- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},89+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
90 },90 },
91 }91 }
92 92
@@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
122 Env: []string{"A=B", "XY=123"},122 Env: []string{"A=B", "XY=123"},
123 })123 })
124 want := []byte{124 want := []byte{
125- 0x00, 0x15, 0x00, 0x00, // version, intent, proxied125+ 0x00, 0x16, 0x00, 0x00, // version, intent, proxied
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -143,20 +143,20 @@ func TestHelloEnvironmentCodec(t *testing.T) {
143 t.Run("lossless order and values", func(t *testing.T) {143 t.Run("lossless order and values", func(t *testing.T) {
144 entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))144 entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
145 want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}145 want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}
146- payload := MarshalHello(Hello{Version: ProtocolVersion, Env: want})146+ payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: want})
147 got, err := UnmarshalHello(payload)147 got, err := UnmarshalHello(payload)
148 require.NoError(t, err)148 require.NoError(t, err)
149 require.Equal(t, want, got.Env)149 require.Equal(t, want, got.Env)
150 })150 })
151 151
152 t.Run("empty", func(t *testing.T) {152 t.Run("empty", func(t *testing.T) {
153- payload := MarshalHello(Hello{Version: ProtocolVersion, Env: []string{}})153+ payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: []string{}})
154 got, err := UnmarshalHello(payload)154 got, err := UnmarshalHello(payload)
155 require.NoError(t, err)155 require.NoError(t, err)
156 require.Empty(t, got.Env)156 require.Empty(t, got.Env)
157 })157 })
158 158
159- base := MarshalHello(Hello{Version: ProtocolVersion})159+ base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
160 withCount := func(count byte) []byte {160 withCount := func(count byte) []byte {
161 payload := append([]byte(nil), base...)161 payload := append([]byte(nil), base...)
162 payload[len(payload)-1] = count162 payload[len(payload)-1] = count
@@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336 }336 }
337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338 require.Equal(t, want, MarshalTheme(cleared))338 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(21), ProtocolVersion)339+ require.Equal(t, uint16(22), ProtocolVersion)
340 }340 }
341 341
342 func TestResizeGoldenAndRoundTrip(t *testing.T) {342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -461,7 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461 name: "minimal",461 name: "minimal",
462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463 want: []byte{463 want: []byte{
464- 0x00, 0x15,464+ 0x00, 0x16,
465 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID465 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
466 0x00, // attached466 0x00, // attached
467 0x00, // self467 0x00, // self
@@ -484,7 +484,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
484 JSON: true,484 JSON: true,
485 },485 },
486 want: []byte{486 want: []byte{
487- 0x00, 0x15,487+ 0x00, 0x16,
488 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID488 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
489 0x00, // attached489 0x00, // attached
490 0x01, // self490 0x01, // self
@@ -609,8 +609,8 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
609 }609 }
610 610
611 func TestOutputGoldenAndRoundTrip(t *testing.T) {611 func TestOutputGoldenAndRoundTrip(t *testing.T) {
612- msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")}612+ msg := Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n"), NewStateNum: 2, EchoAck: 3}
613- want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}613+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x06, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
614 614
615 got := MarshalOutput(msg)615 got := MarshalOutput(msg)
616 if !bytes.Equal(got, want) {616 if !bytes.Equal(got, want) {
@@ -624,23 +624,13 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
624 t.Fatalf("round trip = %#v, want %#v", back, msg)624 t.Fatalf("round trip = %#v, want %#v", back, msg)
625 }625 }
626 626
627- assertAllPrefixesFail(t, got[:24], UnmarshalOutput)627+ assertAllPrefixesFail(t, got, UnmarshalOutput)
628-628+ assertTrailingGarbageFails(t, got, UnmarshalOutput)
629- extra := []byte{0x00, 0xff, 0x7f}
630- withTrailing := append(append([]byte(nil), got...), extra...)
631- back, err = UnmarshalOutput(withTrailing)
632- if err != nil {
633- t.Fatalf("UnmarshalOutput() with trailing payload error = %v", err)
634- }
635- wantData := append(append([]byte(nil), msg.Data...), extra...)
636- if !bytes.Equal(back.Data, wantData) {
637- t.Fatalf("trailing payload data = %#v, want %#v", back.Data, wantData)
638- }
639 }629 }
640 630
641 func TestAckGoldenAndRoundTrip(t *testing.T) {631 func TestAckGoldenAndRoundTrip(t *testing.T) {
642- msg := Ack{AckedStateNum: 0x0102030405060708}632+ msg := Ack{Epoch: 1, State: 0x0102030405060708, AckedStateNum: 0x0102030405060708}
643- want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}633+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
644 634
645 got := MarshalAck(msg)635 got := MarshalAck(msg)
646 if !bytes.Equal(got, want) {636 if !bytes.Equal(got, want) {
@@ -884,11 +874,11 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
884 func TestHelloOutputWindowByteExactValues(t *testing.T) {874 func TestHelloOutputWindowByteExactValues(t *testing.T) {
885 for _, window := range []uint8{0, 1, 8} {875 for _, window := range []uint8{0, 1, 8} {
886 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {876 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
887- hello := Hello{Version: 14, MaxOutputInFlight: window}877+ hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
888- // The empty Hello has 39 zero bytes before the negotiated output window,878+ // The empty Hello has a fixed 1x1 size before the negotiated output window.
889- // followed by a uint32 zero environment-entry count.
890 want := append(make([]byte, 39), window, 0, 0, 0, 0)879 want := append(make([]byte, 39), window, 0, 0, 0, 0)
891 want[1] = 14880 want[1] = 14
881+ want[31], want[33] = 1, 1
892 got := MarshalHello(hello)882 got := MarshalHello(hello)
893 requireBytesEqual(t, want, got)883 requireBytesEqual(t, want, got)
894 back, err := UnmarshalHello(got)884 back, err := UnmarshalHello(got)
@@ -921,7 +911,7 @@ func TestProxiedHelloGoldenAndStrict(t *testing.T) {
921 MaxOutputInFlight: 8,911 MaxOutputInFlight: 8,
922 }912 }
923 want := []byte{913 want := []byte{
924- 0x00, 0x15, 0x02, 0x01,914+ 0x00, 0x16, 0x02, 0x01,
925 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,915 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
926 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,916 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
927 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e',917 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e',
@@ -954,7 +944,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
954 name: "attached",944 name: "attached",
955 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},945 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
956 want: []byte{946 want: []byte{
957- 0x00, 0x15, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,947+ 0x00, 0x16, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
958 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',948 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
959 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',949 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
960 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,950 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -964,7 +954,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
964 name: "control",954 name: "control",
965 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},955 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
966 want: []byte{956 want: []byte{
967- 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,957+ 0x00, 0x16, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
968 0x00, 0x02, 'l', 's', 0x00, 0x00,958 0x00, 0x02, 'l', 's', 0x00, 0x00,
969 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,959 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
970 },960 },

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