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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +627 -61
M internal/ports/frame.go +2 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 21 | |
| 4 | + | const ProtocolVersion uint16 = 22 | |
| 5 | 5 | ||
| 6 | 6 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 7 | 7 | // and excluding the four-byte length prefix. | |
| @@ -37,6 +37,7 @@ const ( | |||
| 37 | 37 | MsgCommandResult MsgType = 22 | |
| 38 | 38 | MsgSessionMeta MsgType = 23 | |
| 39 | 39 | MsgScreenUpdate MsgType = 24 | |
| 40 | + | MsgAttachTarget MsgType = 25 | |
| 40 | 41 | ) | |
| 41 | 42 | ||
| 42 | 43 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | 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 = 21 | 4 | + | const ProtocolVersion uint16 = 22 |
| 5 | 5 | ||||
| 6 | // MaxFrameLen is the largest permitted frame length, including the type byte | 6 | // 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 = 22 | 37 | MsgCommandResult MsgType = 22 | ||
| 38 | MsgSessionMeta MsgType = 23 | 38 | MsgSessionMeta MsgType = 23 | ||
| 39 | MsgScreenUpdate MsgType = 24 | 39 | MsgScreenUpdate MsgType = 24 | ||
| 40 | + | MsgAttachTarget MsgType = 25 | |||
| 40 | ) | 41 | ) | ||
| 41 | 42 | ||||
| 42 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 43 | // 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 | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | 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) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -62,6 +62,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 62 | 62 | {"MsgCommandResult", MsgCommandResult}, | |
| 63 | 63 | {"MsgSessionMeta", MsgSessionMeta}, | |
| 64 | 64 | {"MsgScreenUpdate", MsgScreenUpdate}, | |
| 65 | + | {"MsgAttachTarget", MsgAttachTarget}, | |
| 65 | 66 | } | |
| 66 | 67 | ||
| 67 | 68 | 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 | |||
| 9 | 9 | import ( | |
| 10 | 10 | "encoding/binary" | |
| 11 | 11 | "errors" | |
| 12 | + | "fmt" | |
| 12 | 13 | "math" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/vev/internal/domain" | |
| @@ -26,6 +27,22 @@ var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload") | |||
| 26 | 27 | ||
| 27 | 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 | 46 | // ErrInvalidSessionMeta reports metadata that cannot be represented by the | |
| 30 | 47 | // protocol's ordered uint16-indexed tab layout. | |
| 31 | 48 | var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata") | |
| @@ -160,8 +177,14 @@ type Ping struct{} | |||
| 160 | 177 | // Pong answers a Ping. | |
| 161 | 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 | 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 | 188 | AckedStateNum uint64 | |
| 166 | 189 | } | |
| 167 | 190 | ||
| @@ -180,12 +203,31 @@ type ErrorMsg struct { | |||
| 180 | 203 | 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 | 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 | 220 | BaseStateNum uint64 | |
| 186 | 221 | NewStateNum uint64 | |
| 187 | 222 | 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 | |
| 189 | 231 | } | |
| 190 | 232 | ||
| 191 | 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 | 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 | 346 | // payloadReader consumes a message payload field by field in wire order, | |
| 300 | 347 | // erroring instead of panicking on any short read. | |
| 301 | 348 | type payloadReader struct { | |
| @@ -384,6 +431,43 @@ func (r *payloadReader) getLongString() (string, error) { | |||
| 384 | 431 | 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 | 471 | // rest consumes and returns all remaining bytes, copied so the result is | |
| 388 | 472 | // independent of the reader's backing array. | |
| 389 | 473 | func (r *payloadReader) rest() []byte { | |
| @@ -408,6 +492,67 @@ func PeekHelloVersion(b []byte) (uint16, bool) { | |||
| 408 | 492 | 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 | 556 | // MarshalHello encodes h into a Hello message payload. | |
| 412 | 557 | func MarshalHello(h Hello) []byte { | |
| 413 | 558 | w := payloadWriter{} | |
| @@ -430,8 +575,81 @@ func MarshalHello(h Hello) []byte { | |||
| 430 | 575 | 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 | 645 | // UnmarshalHello decodes a Hello message payload. | |
| 434 | 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 | 653 | r := payloadReader{b: b} | |
| 436 | 654 | var h Hello | |
| 437 | 655 | var err error | |
| @@ -439,6 +657,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 439 | 657 | if h.Version, err = r.getUint16(); err != nil { | |
| 440 | 658 | return Hello{}, err | |
| 441 | 659 | } | |
| 660 | + | if h.Version == 21 { | |
| 661 | + | return Hello{}, ErrInvalidHello | |
| 662 | + | } | |
| 442 | 663 | if h.Intent, err = r.getUint8(); err != nil { | |
| 443 | 664 | return Hello{}, err | |
| 444 | 665 | } | |
| @@ -465,17 +686,21 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 465 | 686 | return Hello{}, err | |
| 466 | 687 | } | |
| 467 | 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 | 695 | if h.TermEnv, err = r.getString(); err != nil { | |
| 469 | 696 | return Hello{}, err | |
| 470 | 697 | } | |
| 471 | 698 | if h.Cwd, err = r.getString(); err != nil { | |
| 472 | 699 | return Hello{}, err | |
| 473 | 700 | } | |
| 474 | - | trueColor, err := r.getUint8() | |
| 475 | - | if err != nil { | |
| 701 | + | if h.TrueColor, err = r.getBool(); err != nil { | |
| 476 | 702 | return Hello{}, err | |
| 477 | 703 | } | |
| 478 | - | h.TrueColor = trueColor != 0 | |
| 479 | 704 | if h.MaxOutputInFlight, err = r.getUint8(); err != nil { | |
| 480 | 705 | return Hello{}, err | |
| 481 | 706 | } | |
| @@ -501,6 +726,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 501 | 726 | if err := r.done(); err != nil { | |
| 502 | 727 | return Hello{}, err | |
| 503 | 728 | } | |
| 729 | + | if err := ValidateHello(h); err != nil { | |
| 730 | + | return Hello{}, err | |
| 731 | + | } | |
| 504 | 732 | return h, nil | |
| 505 | 733 | } | |
| 506 | 734 | ||
| @@ -572,6 +800,9 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) { | |||
| 572 | 800 | ||
| 573 | 801 | // MarshalResize encodes m into a Resize message payload. | |
| 574 | 802 | func MarshalResize(m Resize) []byte { | |
| 803 | + | if err := ValidateSize(m.Size); err != nil { | |
| 804 | + | return nil | |
| 805 | + | } | |
| 575 | 806 | w := payloadWriter{} | |
| 576 | 807 | w.putUint16(uint16(m.Size.Cols)) | |
| 577 | 808 | w.putUint16(uint16(m.Size.Rows)) | |
| @@ -691,7 +922,11 @@ func UnmarshalResize(b []byte) (Resize, error) { | |||
| 691 | 922 | if err := r.done(); err != nil { | |
| 692 | 923 | return Resize{}, err | |
| 693 | 924 | } | |
| 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 | |
| 695 | 930 | } | |
| 696 | 931 | ||
| 697 | 932 | // MarshalDetach encodes a Detach message payload (always empty). | |
| @@ -736,24 +971,44 @@ func UnmarshalPong(b []byte) (Pong, error) { | |||
| 736 | 971 | 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 | 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 | 987 | w := payloadWriter{} | |
| 742 | - | w.putUint64(m.AckedStateNum) | |
| 988 | + | w.putUint64(m.Epoch) | |
| 989 | + | w.putUint64(m.State) | |
| 743 | 990 | return w.b | |
| 744 | 991 | } | |
| 745 | 992 | ||
| 746 | - | // UnmarshalAck decodes an Ack message payload. | |
| 993 | + | // UnmarshalAck decodes an epoch/state Ack payload. | |
| 747 | 994 | func UnmarshalAck(b []byte) (Ack, error) { | |
| 748 | 995 | 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 { | |
| 751 | 1002 | return Ack{}, err | |
| 752 | 1003 | } | |
| 753 | 1004 | if err := r.done(); err != nil { | |
| 754 | 1005 | return Ack{}, err | |
| 755 | 1006 | } | |
| 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 | |
| 757 | 1012 | } | |
| 758 | 1013 | ||
| 759 | 1014 | // MarshalWelcome encodes m into a Welcome message payload. | |
| @@ -826,33 +1081,159 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) { | |||
| 826 | 1081 | 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 | 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 | 1105 | 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) | |
| 836 | 1115 | return w.b | |
| 837 | 1116 | } | |
| 838 | 1117 | ||
| 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. | |
| 841 | 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 | 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 | 1147 | if err != nil { | |
| 845 | 1148 | return Output{}, err | |
| 846 | 1149 | } | |
| 847 | - | newState, err := r.getUint64() | |
| 1150 | + | rows, err := r.getUint16() | |
| 848 | 1151 | if err != nil { | |
| 849 | 1152 | 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 | 1172 | if err != nil { | |
| 853 | 1173 | return Output{}, err | |
| 854 | 1174 | } | |
| 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 | |
| 856 | 1237 | } | |
| 857 | 1238 | ||
| 858 | 1239 | // 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 the | 46 | // 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 uint64 | 188 | AckedStateNum uint64 | ||
| 166 | } | 189 | } | ||
| 167 | 190 | ||||
| @@ -180,12 +203,31 @@ type ErrorMsg struct { | |||||
| 180 | Text string | 203 | 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 uint64 | 220 | BaseStateNum uint64 | ||
| 186 | NewStateNum uint64 | 221 | NewStateNum uint64 | ||
| 187 | EchoAck uint64 | 222 | 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 | |||
| 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, nil | 431 | 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 is | 471 | // 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), true | 492 | 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.b | 575 | 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 Hello | 654 | var h Hello | ||
| 437 | var err error | 655 | 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{}, err | 658 | 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{}, err | 664 | return Hello{}, err | ||
| 444 | } | 665 | } | ||
| @@ -465,17 +686,21 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 465 | return Hello{}, err | 686 | 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{}, err | 696 | 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{}, err | 699 | 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{}, err | 702 | 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{}, err | 705 | 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{}, err | 727 | return Hello{}, err | ||
| 503 | } | 728 | } | ||
| 729 | + | if err := ValidateHello(h); err != nil { | |||
| 730 | + | return Hello{}, err | |||
| 731 | + | } | |||
| 504 | return h, nil | 732 | 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{}, err | 923 | return Resize{}, err | ||
| 693 | } | 924 | } | ||
| 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 | |||
| 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{}, nil | 971 | 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.b | 990 | 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{}, err | 1002 | return Ack{}, err | ||
| 752 | } | 1003 | } | ||
| 753 | if err := r.done(); err != nil { | 1004 | if err := r.done(); err != nil { | ||
| 754 | return Ack{}, err | 1005 | return Ack{}, err | ||
| 755 | } | 1006 | } | ||
| 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 | |||
| 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, nil | 1081 | 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.b | 1115 | return w.b | ||
| 837 | } | 1116 | } | ||
| 838 | 1117 | ||||
| 839 | - | // UnmarshalOutput decodes an Output message payload. After fixed state | 1118 | + | // 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{}, err | 1148 | 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{}, err | 1152 | 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{}, err | 1173 | return Output{}, err | ||
| 854 | } | 1174 | } | ||
| 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 | |||
| 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 ( | |||
| 60 | 60 | ) | |
| 61 | 61 | ||
| 62 | 62 | const ( | |
| 63 | - | maxScreenCells = 1 << 18 | |
| 64 | - | ||
| 65 | 63 | screenHeaderLen = 40 | |
| 66 | 64 | screenStyleLen = 18 | |
| 67 | 65 | screenSpanLimit = 4096 | |
| @@ -386,7 +384,7 @@ func validateScreenUpdate(m ScreenUpdate) error { | |||
| 386 | 384 | } | |
| 387 | 385 | ||
| 388 | 386 | func screenAreaWithinLimit(size domain.Size) bool { | |
| 389 | - | return size.Cols > 0 && size.Rows > 0 && size.Rows <= maxScreenCells/size.Cols | |
| 387 | + | return validWireSize(size) | |
| 390 | 388 | } | |
| 391 | 389 | ||
| 392 | 390 | 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 = 40 | 63 | screenHeaderLen = 40 | ||
| 66 | screenStyleLen = 18 | 64 | screenStyleLen = 18 | ||
| 67 | screenSpanLimit = 4096 | 65 | 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.Cols | 387 | + | 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) { | |||
| 81 | 81 | Version: 1, | |
| 82 | 82 | Intent: IntentEphemeral, | |
| 83 | 83 | Name: "", | |
| 84 | - | Size: domain.Size{Cols: 0, Rows: 0}, | |
| 84 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |
| 85 | 85 | TermEnv: "", | |
| 86 | 86 | Cwd: "", | |
| 87 | 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 | 122 | Env: []string{"A=B", "XY=123"}, | |
| 123 | 123 | }) | |
| 124 | 124 | want := []byte{ | |
| 125 | - | 0x00, 0x15, 0x00, 0x00, // version, intent, proxied | |
| 125 | + | 0x00, 0x16, 0x00, 0x00, // version, intent, proxied | |
| 126 | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 127 | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 128 | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -143,20 +143,20 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 143 | 143 | t.Run("lossless order and values", func(t *testing.T) { | |
| 144 | 144 | entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536)) | |
| 145 | 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 | 147 | got, err := UnmarshalHello(payload) | |
| 148 | 148 | require.NoError(t, err) | |
| 149 | 149 | require.Equal(t, want, got.Env) | |
| 150 | 150 | }) | |
| 151 | 151 | ||
| 152 | 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 | 154 | got, err := UnmarshalHello(payload) | |
| 155 | 155 | require.NoError(t, err) | |
| 156 | 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 | 160 | withCount := func(count byte) []byte { | |
| 161 | 161 | payload := append([]byte(nil), base...) | |
| 162 | 162 | payload[len(payload)-1] = count | |
| @@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 336 | 336 | } | |
| 337 | 337 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 338 | 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 | 342 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -461,7 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 461 | 461 | name: "minimal", | |
| 462 | 462 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 463 | 463 | want: []byte{ | |
| 464 | - | 0x00, 0x15, | |
| 464 | + | 0x00, 0x16, | |
| 465 | 465 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 466 | 466 | 0x00, // attached | |
| 467 | 467 | 0x00, // self | |
| @@ -484,7 +484,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 484 | 484 | JSON: true, | |
| 485 | 485 | }, | |
| 486 | 486 | want: []byte{ | |
| 487 | - | 0x00, 0x15, | |
| 487 | + | 0x00, 0x16, | |
| 488 | 488 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 489 | 489 | 0x00, // attached | |
| 490 | 490 | 0x01, // self | |
| @@ -609,8 +609,8 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | |||
| 609 | 609 | } | |
| 610 | 610 | ||
| 611 | 611 | 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} | |
| 614 | 614 | ||
| 615 | 615 | got := MarshalOutput(msg) | |
| 616 | 616 | if !bytes.Equal(got, want) { | |
| @@ -624,23 +624,13 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) { | |||
| 624 | 624 | t.Fatalf("round trip = %#v, want %#v", back, msg) | |
| 625 | 625 | } | |
| 626 | 626 | ||
| 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) | |
| 639 | 629 | } | |
| 640 | 630 | ||
| 641 | 631 | 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} | |
| 644 | 634 | ||
| 645 | 635 | got := MarshalAck(msg) | |
| 646 | 636 | if !bytes.Equal(got, want) { | |
| @@ -884,11 +874,11 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||
| 884 | 874 | func TestHelloOutputWindowByteExactValues(t *testing.T) { | |
| 885 | 875 | for _, window := range []uint8{0, 1, 8} { | |
| 886 | 876 | 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. | |
| 890 | 879 | want := append(make([]byte, 39), window, 0, 0, 0, 0) | |
| 891 | 880 | want[1] = 14 | |
| 881 | + | want[31], want[33] = 1, 1 | |
| 892 | 882 | got := MarshalHello(hello) | |
| 893 | 883 | requireBytesEqual(t, want, got) | |
| 894 | 884 | back, err := UnmarshalHello(got) | |
| @@ -921,7 +911,7 @@ func TestProxiedHelloGoldenAndStrict(t *testing.T) { | |||
| 921 | 911 | MaxOutputInFlight: 8, | |
| 922 | 912 | } | |
| 923 | 913 | want := []byte{ | |
| 924 | - | 0x00, 0x15, 0x02, 0x01, | |
| 914 | + | 0x00, 0x16, 0x02, 0x01, | |
| 925 | 915 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |
| 926 | 916 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, | |
| 927 | 917 | 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e', | |
| @@ -954,7 +944,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 954 | 944 | name: "attached", | |
| 955 | 945 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 956 | 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 | 948 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 959 | 949 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 960 | 950 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -964,7 +954,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 964 | 954 | name: "control", | |
| 965 | 955 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 966 | 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 | 958 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 969 | 959 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 970 | 960 | }, | |
| @@ -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, proxied | 125 | + | 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 ID | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 128 | 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] = count | 162 | 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 ID | 465 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 466 | 0x00, // attached | 466 | 0x00, // attached | ||
| 467 | 0x00, // self | 467 | 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 ID | 488 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 489 | 0x00, // attached | 489 | 0x00, // attached | ||
| 490 | 0x01, // self | 490 | 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] = 14 | 880 | 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 | }, | ||