detached 7cf3f77

fix(snapshot): enforce terminal history ownership

commit
7cf3f77258e19a1bb794181e8ade3d3e792b50f0
author
brice · · 2026-07-12 17:31 UTC
parent
46dfc77
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +350 -71
M internal/usecase/daemon/pane.go +2 -4
@@ -47,15 +47,13 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
4747 }
4848
4949 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
50- history := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
51- screen := vt.NewScreen(sz.Cols, sz.Rows)
52- screen.OnLineEvicted = history.Append
50+ screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows})
5351 return &pane{
5452 id: id,
5553 stableID: stableID,
5654 pty: pty,
5755 screen: screen,
58- history: history,
56+ history: screen.History(),
5957 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
6058 title: paneTitleState{displayFallback: "sh"},
6159 }
@@ -47,15 +47,13 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
47 }47 }
48 48
49 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {49 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
50- history := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})50+ screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows})
51- screen := vt.NewScreen(sz.Cols, sz.Rows)
52- screen.OnLineEvicted = history.Append
53 return &pane{51 return &pane{
54 id: id,52 id: id,
55 stableID: stableID,53 stableID: stableID,
56 pty: pty,54 pty: pty,
57 screen: screen,55 screen: screen,
58- history: history,56+ history: screen.History(),
59 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},57 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
60 title: paneTitleState{displayFallback: "sh"},58 title: paneTitleState{displayFallback: "sh"},
61 }59 }
M internal/usecase/daemon/scrollback_test.go +10 -1
@@ -14,11 +14,17 @@ func TestNewTabInitializesScrollback(t *testing.T) {
1414 wantRow string
1515 }{
1616 {
17- name: "tab owns scrollback wired to vt eviction callback",
17+ name: "primary evictions enter screen owned history",
1818 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
1919 wantLen: 1,
2020 wantRow: "AAAA",
2121 },
22+ {
23+ name: "alternate screen evictions do not enter pane history",
24+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"),
25+ wantLen: 1,
26+ wantRow: "AAAA",
27+ },
2228 }
2329
2430 for _, tt := range tests {
@@ -27,6 +33,9 @@ func TestNewTabInitializesScrollback(t *testing.T) {
2733 if win.focusedPane().history == nil {
2834 t.Fatal("scrollback is nil")
2935 }
36+ if win.focusedPane().history != win.focusedPane().screen.History() {
37+ t.Fatal("pane history is not owned by its screen")
38+ }
3039 if win.focusedPane().history.Cap() != defaultScrollbackRows {
3140 t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows)
3241 }
@@ -14,11 +14,17 @@ func TestNewTabInitializesScrollback(t *testing.T) {
14 wantRow string14 wantRow string
15 }{15 }{
16 {16 {
17- name: "tab owns scrollback wired to vt eviction callback",17+ name: "primary evictions enter screen owned history",
18 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),18 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
19 wantLen: 1,19 wantLen: 1,
20 wantRow: "AAAA",20 wantRow: "AAAA",
21 },21 },
22+ {
23+ name: "alternate screen evictions do not enter pane history",
24+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"),
25+ wantLen: 1,
26+ wantRow: "AAAA",
27+ },
22 }28 }
23 29
24 for _, tt := range tests {30 for _, tt := range tests {
@@ -27,6 +33,9 @@ func TestNewTabInitializesScrollback(t *testing.T) {
27 if win.focusedPane().history == nil {33 if win.focusedPane().history == nil {
28 t.Fatal("scrollback is nil")34 t.Fatal("scrollback is nil")
29 }35 }
36+ if win.focusedPane().history != win.focusedPane().screen.History() {
37+ t.Fatal("pane history is not owned by its screen")
38+ }
30 if win.focusedPane().history.Cap() != defaultScrollbackRows {39 if win.focusedPane().history.Cap() != defaultScrollbackRows {
31 t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows)40 t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows)
32 }41 }
M internal/usecase/daemon/snapshot.go +11 -7
@@ -87,6 +87,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
8787 if err := ctx.Err(); err != nil {
8888 return err
8989 }
90+ if len(snap.Tabs) == 0 {
91+ if snap.Active != 0 {
92+ return fmt.Errorf("snapshot: active tab out of range")
93+ }
94+ } else if int(snap.Active) >= len(snap.Tabs) {
95+ return fmt.Errorf("snapshot: active tab out of range")
96+ }
9097 if snap.Name == "" {
9198 return fmt.Errorf("snapshot: empty session name")
9299 }
@@ -206,9 +213,6 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
206213 }
207214 createdAt := int64(snap.CreatedAt)
208215 sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}
209- if sess.active < 0 || sess.active >= len(sess.tabs) {
210- sess.active = 0
211- }
212216 sess.snapEligible.Store(true)
213217 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
214218 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -261,15 +265,15 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
261265 }
262266 p.mu.Lock()
263267 defer p.mu.Unlock()
264- history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
268+ screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
265269 if err != nil {
266270 return fmt.Errorf("snapshot history: %w", err)
267271 }
268- if err := p.screen.RestorePrimaryVisible(snap.Visible); err != nil {
272+ if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
269273 return fmt.Errorf("snapshot visible: %w", err)
270274 }
271- p.history = history
272- p.screen.OnLineEvicted = history.Append
275+ p.screen = screen
276+ p.history = screen.History()
273277 return nil
274278 }
275279
@@ -87,6 +87,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
87 if err := ctx.Err(); err != nil {87 if err := ctx.Err(); err != nil {
88 return err88 return err
89 }89 }
90+ if len(snap.Tabs) == 0 {
91+ if snap.Active != 0 {
92+ return fmt.Errorf("snapshot: active tab out of range")
93+ }
94+ } else if int(snap.Active) >= len(snap.Tabs) {
95+ return fmt.Errorf("snapshot: active tab out of range")
96+ }
90 if snap.Name == "" {97 if snap.Name == "" {
91 return fmt.Errorf("snapshot: empty session name")98 return fmt.Errorf("snapshot: empty session name")
92 }99 }
@@ -206,9 +213,6 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
206 }213 }
207 createdAt := int64(snap.CreatedAt)214 createdAt := int64(snap.CreatedAt)
208 sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}215 sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}
209- if sess.active < 0 || sess.active >= len(sess.tabs) {
210- sess.active = 0
211- }
212 sess.snapEligible.Store(true)216 sess.snapEligible.Store(true)
213 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {217 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
214 sess.cwd = snap.Tabs[0].Panes[0].Cwd218 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -261,15 +265,15 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
261 }265 }
262 p.mu.Lock()266 p.mu.Lock()
263 defer p.mu.Unlock()267 defer p.mu.Unlock()
264- history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)268+ screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
265 if err != nil {269 if err != nil {
266 return fmt.Errorf("snapshot history: %w", err)270 return fmt.Errorf("snapshot history: %w", err)
267 }271 }
268- if err := p.screen.RestorePrimaryVisible(snap.Visible); err != nil {272+ if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
269 return fmt.Errorf("snapshot visible: %w", err)273 return fmt.Errorf("snapshot visible: %w", err)
270 }274 }
271- p.history = history275+ p.screen = screen
272- p.screen.OnLineEvicted = history.Append276+ p.history = screen.History()
273 return nil277 return nil
274 }278 }
275 279
M internal/usecase/daemon/snapshot_restore_test.go +13 -0
@@ -59,6 +59,18 @@ func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T
5959 }
6060 }
6161
62+func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) {
63+ factory := &restorePTYFactory{}
64+ d := newTestDaemon(t, factory, stubClock{})
65+
66+ err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{
67+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
68+ }}})
69+
70+ require.Error(t, err)
71+ require.Empty(t, factory.opens)
72+}
73+
6274 func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {
6375 factory := &restorePTYFactory{}
6476 d := newTestDaemon(t, factory, stubClock{})
@@ -131,6 +143,7 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
131143 p := tb.panes["pane-2"]
132144 tb.mu.Unlock()
133145 p.mu.Lock()
146+ require.Same(t, p.screen.History(), p.history)
134147 require.Equal(t, "old2", rowText(p.history.View().Row(0)))
135148 require.Equal(t, 1, p.history.Len())
136149 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
@@ -59,6 +59,18 @@ func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T
59 }59 }
60 }60 }
61 61
62+func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) {
63+ factory := &restorePTYFactory{}
64+ d := newTestDaemon(t, factory, stubClock{})
65+
66+ err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{
67+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
68+ }}})
69+
70+ require.Error(t, err)
71+ require.Empty(t, factory.opens)
72+}
73+
62 func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {74 func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {
63 factory := &restorePTYFactory{}75 factory := &restorePTYFactory{}
64 d := newTestDaemon(t, factory, stubClock{})76 d := newTestDaemon(t, factory, stubClock{})
@@ -131,6 +143,7 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
131 p := tb.panes["pane-2"]143 p := tb.panes["pane-2"]
132 tb.mu.Unlock()144 tb.mu.Unlock()
133 p.mu.Lock()145 p.mu.Lock()
146+ require.Same(t, p.screen.History(), p.history)
134 require.Equal(t, "old2", rowText(p.history.View().Row(0)))147 require.Equal(t, "old2", rowText(p.history.View().Row(0)))
135 require.Equal(t, 1, p.history.Len())148 require.Equal(t, 1, p.history.Len())
136 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))149 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
M internal/usecase/snapshot/codec.go +216 -59
@@ -228,14 +228,17 @@ func (r *payloadReader) done() error {
228228 }
229229
230230 func writeSession(w *payloadWriter, s Session) error {
231+ if len(s.Tabs) > math.MaxUint16 {
232+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
233+ }
234+ if err := validateActive(s.Active, len(s.Tabs)); err != nil {
235+ return err
236+ }
231237 if err := w.putString(s.Name); err != nil {
232238 return err
233239 }
234240 w.putUint64(s.CreatedAt)
235241 w.putUint16(s.Active)
236- if len(s.Tabs) > math.MaxUint16 {
237- return fmt.Errorf("%w: too many tabs", ErrInvalidData)
238- }
239242 w.putUint16(uint16(len(s.Tabs)))
240243 for _, t := range s.Tabs {
241244 if err := writeTab(w, t); err != nil {
@@ -262,6 +265,9 @@ func readSession(r *payloadReader) (Session, error) {
262265 if err != nil {
263266 return Session{}, err
264267 }
268+ if err := validateActive(active, int(nt)); err != nil {
269+ return Session{}, err
270+ }
265271 s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)}
266272 for i := range s.Tabs {
267273 t, err := readTab(r)
@@ -556,9 +562,30 @@ func readProcess(r *payloadReader) (*Process, error) {
556562 return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
557563 }
558564
559-// preflightSession walks framing and opaque VT blobs without constructing a
560-// Session. All count-derived allocation happens only in the second pass.
565+func validateActive(active uint16, tabCount int) error {
566+ if tabCount == 0 {
567+ if active == 0 {
568+ return nil
569+ }
570+ return fmt.Errorf("%w: active tab", ErrInvalidData)
571+ }
572+ if int(active) >= tabCount {
573+ return fmt.Errorf("%w: active tab", ErrInvalidData)
574+ }
575+ return nil
576+}
577+
578+// preflightSession first validates the full structural and aggregate resource
579+// shape without materializing strings, maps, or reference lists. Only once
580+// that pass has accepted global budgets does it allocate to check references.
561581 func preflightSession(body []byte) error {
582+ if err := preflightSessionStructure(body); err != nil {
583+ return err
584+ }
585+ return preflightSessionReferences(body)
586+}
587+
588+func preflightSessionStructure(body []byte) error {
562589 r := payloadReader{b: body}
563590 if err := skipString(&r); err != nil {
564591 return err
@@ -566,21 +593,26 @@ func preflightSession(body []byte) error {
566593 if _, err := r.getUint64(); err != nil {
567594 return err
568595 }
569- if _, err := r.getUint16(); err != nil {
596+ active, err := r.getUint16()
597+ if err != nil {
570598 return err
571599 }
572600 tabs, err := r.getUint16()
573601 if err != nil {
574602 return err
575603 }
604+ if err := validateActive(active, int(tabs)); err != nil {
605+ return err
606+ }
576607 if uint64(tabs) > uint64(len(r.b))/2 {
577608 return ErrShortPayload
578609 }
610+
579611 var totals vt.DecodeStats
580612 blobs := uint64(0)
581613 budget := preflightBudget{}
582614 for range tabs {
583- if err := preflightTab(&r, &totals, &blobs, &budget); err != nil {
615+ if err := preflightTabStructure(&r, &totals, &blobs, &budget); err != nil {
584616 return fmt.Errorf("preflight tab: %w", err)
585617 }
586618 }
@@ -633,7 +665,7 @@ func (b *preflightBudget) addNode() bool {
633665 return true
634666 }
635667
636-func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error {
668+func preflightTabStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error {
637669 if !budget.addAllocation(256) {
638670 return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
639671 }
@@ -649,12 +681,10 @@ func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budge
649681 if _, err := r.getUint64(); err != nil {
650682 return err
651683 }
652- focus, err := r.getString()
653- if err != nil {
684+ if err := skipString(r); err != nil {
654685 return err
655686 }
656- references := preflightTreeReferences{}
657- if err := preflightNode(r, 0, budget, &references); err != nil {
687+ if err := preflightNodeStructure(r, 0, true, budget); err != nil {
658688 return err
659689 }
660690 panes, err := r.getUint16()
@@ -664,34 +694,15 @@ func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budge
664694 if uint64(panes) > uint64(len(r.b))/2 {
665695 return ErrShortPayload
666696 }
667- ids := make(map[layout.PaneID]struct{})
668697 for range panes {
669- if err := preflightPane(r, totals, blobs, ids, budget); err != nil {
698+ if err := preflightPaneStructure(r, totals, blobs, budget); err != nil {
670699 return err
671700 }
672701 }
673- if focus != "" {
674- references.ids = append(references.ids, layout.PaneID(focus))
675- }
676- for _, id := range references.ids {
677- if _, ok := ids[id]; !ok {
678- return ErrUnknownPane
679- }
680- }
681702 return nil
682703 }
683704
684-type preflightTreeReferences struct{ ids []layout.PaneID }
685-
686-func (r *preflightTreeReferences) add(id layout.PaneID, budget *preflightBudget) error {
687- if !budget.addAllocation(32) {
688- return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
689- }
690- r.ids = append(r.ids, id)
691- return nil
692-}
693-
694-func preflightNode(r *payloadReader, depth int, budget *preflightBudget, references *preflightTreeReferences) error {
705+func preflightNodeStructure(r *payloadReader, depth int, root bool, budget *preflightBudget) error {
695706 if depth > 64 {
696707 return fmt.Errorf("%w: tree depth", ErrInvalidData)
697708 }
@@ -704,26 +715,23 @@ func preflightNode(r *payloadReader, depth int, budget *preflightBudget, referen
704715 }
705716 switch kind {
706717 case 0:
707- leaf, err := r.getString()
718+ return skipString(r)
719+ case 1:
720+ dir, err := r.getUint8()
708721 if err != nil {
709722 return err
710723 }
711- return references.add(layout.PaneID(leaf), budget)
712- case 1:
713- if _, err := r.getUint8(); err != nil {
714- return err
724+ if layout.SplitDir(dir) != layout.Horizontal && layout.SplitDir(dir) != layout.Vertical {
725+ return fmt.Errorf("%w: split dir", ErrInvalidData)
715726 }
716727 case 2:
717- expanded, err := r.getString()
718- if err != nil {
728+ if err := skipString(r); err != nil {
719729 return err
720730 }
721- if expanded != "" {
722- if err := references.add(layout.PaneID(expanded), budget); err != nil {
723- return err
724- }
725- }
726731 case 3:
732+ if !root {
733+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
734+ }
727735 return nil
728736 default:
729737 return fmt.Errorf("%w: node kind", ErrInvalidData)
@@ -736,29 +744,20 @@ func preflightNode(r *payloadReader, depth int, budget *preflightBudget, referen
736744 return ErrShortPayload
737745 }
738746 for range children {
739- if err := preflightNode(r, depth+1, budget, references); err != nil {
747+ if err := preflightNodeStructure(r, depth+1, false, budget); err != nil {
740748 return err
741749 }
742750 }
743751 return nil
744752 }
745753
746-func preflightPane(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, ids map[layout.PaneID]struct{}, budget *preflightBudget) error {
754+func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error {
747755 if !budget.addAllocation(256) {
748756 return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
749757 }
750- id, err := r.getString()
751- if err != nil {
758+ if err := skipString(r); err != nil {
752759 return err
753760 }
754- if id == "" {
755- return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
756- }
757- paneID := layout.PaneID(id)
758- if _, exists := ids[paneID]; exists {
759- return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
760- }
761- ids[paneID] = struct{}{}
762761 if err := skipString(r); err != nil {
763762 return err
764763 }
@@ -792,8 +791,6 @@ func preflightPane(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, ids
792791 if err != nil {
793792 return err
794793 }
795- // PreflightVisibleBlob above validates width×height and exact payload
796- // cardinality. A visible terminal frame itself must also be nonempty.
797794 if visible.Rows == 0 || visible.Cells == 0 {
798795 return fmt.Errorf("%w: visible geometry", ErrInvalidData)
799796 }
@@ -861,6 +858,166 @@ func preflightProcess(r *payloadReader) error {
861858 return skipString(r)
862859 }
863860
861+// preflightSessionReferences validates duplicate, empty, and dangling pane
862+// references only after the structural pass has admitted the complete payload.
863+func preflightSessionReferences(body []byte) error {
864+ r := payloadReader{b: body}
865+ if err := skipString(&r); err != nil {
866+ return err
867+ }
868+ if _, err := r.getUint64(); err != nil {
869+ return err
870+ }
871+ if _, err := r.getUint16(); err != nil {
872+ return err
873+ }
874+ tabs, err := r.getUint16()
875+ if err != nil {
876+ return err
877+ }
878+ for range tabs {
879+ if err := preflightTabReferences(&r); err != nil {
880+ return fmt.Errorf("preflight tab references: %w", err)
881+ }
882+ }
883+ return r.done()
884+}
885+
886+func preflightTabReferences(r *payloadReader) error {
887+ if err := skipString(r); err != nil {
888+ return err
889+ }
890+ if _, err := r.getUint16(); err != nil {
891+ return err
892+ }
893+ if _, err := r.getUint16(); err != nil {
894+ return err
895+ }
896+ if _, err := r.getUint64(); err != nil {
897+ return err
898+ }
899+ focus, err := r.getString()
900+ if err != nil {
901+ return err
902+ }
903+ references := preflightTreeReferences{}
904+ if err := preflightNodeReferences(r, &references); err != nil {
905+ return err
906+ }
907+ panes, err := r.getUint16()
908+ if err != nil {
909+ return err
910+ }
911+ ids := make(map[layout.PaneID]struct{}, panes)
912+ for range panes {
913+ id, err := r.getString()
914+ if err != nil {
915+ return err
916+ }
917+ if id == "" {
918+ return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
919+ }
920+ paneID := layout.PaneID(id)
921+ if _, exists := ids[paneID]; exists {
922+ return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
923+ }
924+ ids[paneID] = struct{}{}
925+ if err := skipString(r); err != nil {
926+ return err
927+ }
928+ if err := skipString(r); err != nil {
929+ return err
930+ }
931+ n, err := r.getUint32()
932+ if err != nil {
933+ return err
934+ }
935+ for range n {
936+ if err := skipBlob(r); err != nil {
937+ return err
938+ }
939+ }
940+ if err := skipBlob(r); err != nil {
941+ return err
942+ }
943+ if err := skipBlob(r); err != nil {
944+ return err
945+ }
946+ if err := preflightProcess(r); err != nil {
947+ return err
948+ }
949+ }
950+ if focus != "" {
951+ references.ids = append(references.ids, layout.PaneID(focus))
952+ }
953+ for _, id := range references.ids {
954+ if _, ok := ids[id]; !ok {
955+ return ErrUnknownPane
956+ }
957+ }
958+ return nil
959+}
960+
961+func skipBlob(r *payloadReader) error {
962+ n, err := r.getUint32()
963+ if err != nil {
964+ return err
965+ }
966+ if uint64(n) > uint64(len(r.b)) {
967+ return ErrShortPayload
968+ }
969+ r.b = r.b[n:]
970+ return nil
971+}
972+
973+type preflightTreeReferences struct{ ids []layout.PaneID }
974+
975+func (r *preflightTreeReferences) add(id layout.PaneID) {
976+ r.ids = append(r.ids, id)
977+}
978+
979+func preflightNodeReferences(r *payloadReader, references *preflightTreeReferences) error {
980+ kind, err := r.getUint8()
981+ if err != nil {
982+ return err
983+ }
984+ switch kind {
985+ case 0:
986+ leaf, err := r.getString()
987+ if err != nil {
988+ return err
989+ }
990+ references.add(layout.PaneID(leaf))
991+ return nil
992+ case 1:
993+ if _, err := r.getUint8(); err != nil {
994+ return err
995+ }
996+ case 2:
997+ expanded, err := r.getString()
998+ if err != nil {
999+ return err
1000+ }
1001+ if expanded != "" {
1002+ references.add(layout.PaneID(expanded))
1003+ }
1004+ case 3:
1005+ return nil
1006+ default:
1007+ return fmt.Errorf("%w: node kind", ErrInvalidData)
1008+ }
1009+ children, err := r.getUint16()
1010+ if err != nil {
1011+ return err
1012+ }
1013+ for range children {
1014+ if err := preflightNodeReferences(r, references); err != nil {
1015+ return err
1016+ }
1017+ }
1018+ return nil
1019+}
1020+
8641021 func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
8651022 if t == nil || t.Root == nil {
8661023 return nil
@@ -228,14 +228,17 @@ func (r *payloadReader) done() error {
228 }228 }
229 229
230 func writeSession(w *payloadWriter, s Session) error {230 func writeSession(w *payloadWriter, s Session) error {
231+ if len(s.Tabs) > math.MaxUint16 {
232+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
233+ }
234+ if err := validateActive(s.Active, len(s.Tabs)); err != nil {
235+ return err
236+ }
231 if err := w.putString(s.Name); err != nil {237 if err := w.putString(s.Name); err != nil {
232 return err238 return err
233 }239 }
234 w.putUint64(s.CreatedAt)240 w.putUint64(s.CreatedAt)
235 w.putUint16(s.Active)241 w.putUint16(s.Active)
236- if len(s.Tabs) > math.MaxUint16 {
237- return fmt.Errorf("%w: too many tabs", ErrInvalidData)
238- }
239 w.putUint16(uint16(len(s.Tabs)))242 w.putUint16(uint16(len(s.Tabs)))
240 for _, t := range s.Tabs {243 for _, t := range s.Tabs {
241 if err := writeTab(w, t); err != nil {244 if err := writeTab(w, t); err != nil {
@@ -262,6 +265,9 @@ func readSession(r *payloadReader) (Session, error) {
262 if err != nil {265 if err != nil {
263 return Session{}, err266 return Session{}, err
264 }267 }
268+ if err := validateActive(active, int(nt)); err != nil {
269+ return Session{}, err
270+ }
265 s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)}271 s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)}
266 for i := range s.Tabs {272 for i := range s.Tabs {
267 t, err := readTab(r)273 t, err := readTab(r)
@@ -556,9 +562,30 @@ func readProcess(r *payloadReader) (*Process, error) {
556 return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil562 return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
557 }563 }
558 564
559-// preflightSession walks framing and opaque VT blobs without constructing a565+func validateActive(active uint16, tabCount int) error {
560-// Session. All count-derived allocation happens only in the second pass.566+ if tabCount == 0 {
567+ if active == 0 {
568+ return nil
569+ }
570+ return fmt.Errorf("%w: active tab", ErrInvalidData)
571+ }
572+ if int(active) >= tabCount {
573+ return fmt.Errorf("%w: active tab", ErrInvalidData)
574+ }
575+ return nil
576+}
577+
578+// preflightSession first validates the full structural and aggregate resource
579+// shape without materializing strings, maps, or reference lists. Only once
580+// that pass has accepted global budgets does it allocate to check references.
561 func preflightSession(body []byte) error {581 func preflightSession(body []byte) error {
582+ if err := preflightSessionStructure(body); err != nil {
583+ return err
584+ }
585+ return preflightSessionReferences(body)
586+}
587+
588+func preflightSessionStructure(body []byte) error {
562 r := payloadReader{b: body}589 r := payloadReader{b: body}
563 if err := skipString(&r); err != nil {590 if err := skipString(&r); err != nil {
564 return err591 return err
@@ -566,21 +593,26 @@ func preflightSession(body []byte) error {
566 if _, err := r.getUint64(); err != nil {593 if _, err := r.getUint64(); err != nil {
567 return err594 return err
568 }595 }
569- if _, err := r.getUint16(); err != nil {596+ active, err := r.getUint16()
597+ if err != nil {
570 return err598 return err
571 }599 }
572 tabs, err := r.getUint16()600 tabs, err := r.getUint16()
573 if err != nil {601 if err != nil {
574 return err602 return err
575 }603 }
604+ if err := validateActive(active, int(tabs)); err != nil {
605+ return err
606+ }
576 if uint64(tabs) > uint64(len(r.b))/2 {607 if uint64(tabs) > uint64(len(r.b))/2 {
577 return ErrShortPayload608 return ErrShortPayload
578 }609 }
610+
579 var totals vt.DecodeStats611 var totals vt.DecodeStats
580 blobs := uint64(0)612 blobs := uint64(0)
581 budget := preflightBudget{}613 budget := preflightBudget{}
582 for range tabs {614 for range tabs {
583- if err := preflightTab(&r, &totals, &blobs, &budget); err != nil {615+ if err := preflightTabStructure(&r, &totals, &blobs, &budget); err != nil {
584 return fmt.Errorf("preflight tab: %w", err)616 return fmt.Errorf("preflight tab: %w", err)
585 }617 }
586 }618 }
@@ -633,7 +665,7 @@ func (b *preflightBudget) addNode() bool {
633 return true665 return true
634 }666 }
635 667
636-func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error {668+func preflightTabStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error {
637 if !budget.addAllocation(256) {669 if !budget.addAllocation(256) {
638 return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)670 return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
639 }671 }
@@ -649,12 +681,10 @@ func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budge
649 if _, err := r.getUint64(); err != nil {681 if _, err := r.getUint64(); err != nil {
650 return err682 return err
651 }683 }
652- focus, err := r.getString()684+ if err := skipString(r); err != nil {
653- if err != nil {
654 return err685 return err
655 }686 }
656- references := preflightTreeReferences{}687+ if err := preflightNodeStructure(r, 0, true, budget); err != nil {
657- if err := preflightNode(r, 0, budget, &references); err != nil {
658 return err688 return err
659 }689 }
660 panes, err := r.getUint16()690 panes, err := r.getUint16()
@@ -664,34 +694,15 @@ func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budge
664 if uint64(panes) > uint64(len(r.b))/2 {694 if uint64(panes) > uint64(len(r.b))/2 {
665 return ErrShortPayload695 return ErrShortPayload
666 }696 }
667- ids := make(map[layout.PaneID]struct{})
668 for range panes {697 for range panes {
669- if err := preflightPane(r, totals, blobs, ids, budget); err != nil {698+ if err := preflightPaneStructure(r, totals, blobs, budget); err != nil {
670 return err699 return err
671 }700 }
672 }701 }
673- if focus != "" {
674- references.ids = append(references.ids, layout.PaneID(focus))
675- }
676- for _, id := range references.ids {
677- if _, ok := ids[id]; !ok {
678- return ErrUnknownPane
679- }
680- }
681 return nil702 return nil
682 }703 }
683 704
684-type preflightTreeReferences struct{ ids []layout.PaneID }705+func preflightNodeStructure(r *payloadReader, depth int, root bool, budget *preflightBudget) error {
685-
686-func (r *preflightTreeReferences) add(id layout.PaneID, budget *preflightBudget) error {
687- if !budget.addAllocation(32) {
688- return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
689- }
690- r.ids = append(r.ids, id)
691- return nil
692-}
693-
694-func preflightNode(r *payloadReader, depth int, budget *preflightBudget, references *preflightTreeReferences) error {
695 if depth > 64 {706 if depth > 64 {
696 return fmt.Errorf("%w: tree depth", ErrInvalidData)707 return fmt.Errorf("%w: tree depth", ErrInvalidData)
697 }708 }
@@ -704,26 +715,23 @@ func preflightNode(r *payloadReader, depth int, budget *preflightBudget, referen
704 }715 }
705 switch kind {716 switch kind {
706 case 0:717 case 0:
707- leaf, err := r.getString()718+ return skipString(r)
719+ case 1:
720+ dir, err := r.getUint8()
708 if err != nil {721 if err != nil {
709 return err722 return err
710 }723 }
711- return references.add(layout.PaneID(leaf), budget)724+ if layout.SplitDir(dir) != layout.Horizontal && layout.SplitDir(dir) != layout.Vertical {
712- case 1:725+ return fmt.Errorf("%w: split dir", ErrInvalidData)
713- if _, err := r.getUint8(); err != nil {
714- return err
715 }726 }
716 case 2:727 case 2:
717- expanded, err := r.getString()728+ if err := skipString(r); err != nil {
718- if err != nil {
719 return err729 return err
720 }730 }
721- if expanded != "" {
722- if err := references.add(layout.PaneID(expanded), budget); err != nil {
723- return err
724- }
725- }
726 case 3:731 case 3:
732+ if !root {
733+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
734+ }
727 return nil735 return nil
728 default:736 default:
729 return fmt.Errorf("%w: node kind", ErrInvalidData)737 return fmt.Errorf("%w: node kind", ErrInvalidData)
@@ -736,29 +744,20 @@ func preflightNode(r *payloadReader, depth int, budget *preflightBudget, referen
736 return ErrShortPayload744 return ErrShortPayload
737 }745 }
738 for range children {746 for range children {
739- if err := preflightNode(r, depth+1, budget, references); err != nil {747+ if err := preflightNodeStructure(r, depth+1, false, budget); err != nil {
740 return err748 return err
741 }749 }
742 }750 }
743 return nil751 return nil
744 }752 }
745 753
746-func preflightPane(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, ids map[layout.PaneID]struct{}, budget *preflightBudget) error {754+func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error {
747 if !budget.addAllocation(256) {755 if !budget.addAllocation(256) {
748 return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)756 return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
749 }757 }
750- id, err := r.getString()758+ if err := skipString(r); err != nil {
751- if err != nil {
752 return err759 return err
753 }760 }
754- if id == "" {
755- return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
756- }
757- paneID := layout.PaneID(id)
758- if _, exists := ids[paneID]; exists {
759- return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
760- }
761- ids[paneID] = struct{}{}
762 if err := skipString(r); err != nil {761 if err := skipString(r); err != nil {
763 return err762 return err
764 }763 }
@@ -792,8 +791,6 @@ func preflightPane(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, ids
792 if err != nil {791 if err != nil {
793 return err792 return err
794 }793 }
795- // PreflightVisibleBlob above validates width×height and exact payload
796- // cardinality. A visible terminal frame itself must also be nonempty.
797 if visible.Rows == 0 || visible.Cells == 0 {794 if visible.Rows == 0 || visible.Cells == 0 {
798 return fmt.Errorf("%w: visible geometry", ErrInvalidData)795 return fmt.Errorf("%w: visible geometry", ErrInvalidData)
799 }796 }
@@ -861,6 +858,166 @@ func preflightProcess(r *payloadReader) error {
861 return skipString(r)858 return skipString(r)
862 }859 }
863 860
861+// preflightSessionReferences validates duplicate, empty, and dangling pane
862+// references only after the structural pass has admitted the complete payload.
863+func preflightSessionReferences(body []byte) error {
864+ r := payloadReader{b: body}
865+ if err := skipString(&r); err != nil {
866+ return err
867+ }
868+ if _, err := r.getUint64(); err != nil {
869+ return err
870+ }
871+ if _, err := r.getUint16(); err != nil {
872+ return err
873+ }
874+ tabs, err := r.getUint16()
875+ if err != nil {
876+ return err
877+ }
878+ for range tabs {
879+ if err := preflightTabReferences(&r); err != nil {
880+ return fmt.Errorf("preflight tab references: %w", err)
881+ }
882+ }
883+ return r.done()
884+}
885+
886+func preflightTabReferences(r *payloadReader) error {
887+ if err := skipString(r); err != nil {
888+ return err
889+ }
890+ if _, err := r.getUint16(); err != nil {
891+ return err
892+ }
893+ if _, err := r.getUint16(); err != nil {
894+ return err
895+ }
896+ if _, err := r.getUint64(); err != nil {
897+ return err
898+ }
899+ focus, err := r.getString()
900+ if err != nil {
901+ return err
902+ }
903+ references := preflightTreeReferences{}
904+ if err := preflightNodeReferences(r, &references); err != nil {
905+ return err
906+ }
907+ panes, err := r.getUint16()
908+ if err != nil {
909+ return err
910+ }
911+ ids := make(map[layout.PaneID]struct{}, panes)
912+ for range panes {
913+ id, err := r.getString()
914+ if err != nil {
915+ return err
916+ }
917+ if id == "" {
918+ return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
919+ }
920+ paneID := layout.PaneID(id)
921+ if _, exists := ids[paneID]; exists {
922+ return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
923+ }
924+ ids[paneID] = struct{}{}
925+ if err := skipString(r); err != nil {
926+ return err
927+ }
928+ if err := skipString(r); err != nil {
929+ return err
930+ }
931+ n, err := r.getUint32()
932+ if err != nil {
933+ return err
934+ }
935+ for range n {
936+ if err := skipBlob(r); err != nil {
937+ return err
938+ }
939+ }
940+ if err := skipBlob(r); err != nil {
941+ return err
942+ }
943+ if err := skipBlob(r); err != nil {
944+ return err
945+ }
946+ if err := preflightProcess(r); err != nil {
947+ return err
948+ }
949+ }
950+ if focus != "" {
951+ references.ids = append(references.ids, layout.PaneID(focus))
952+ }
953+ for _, id := range references.ids {
954+ if _, ok := ids[id]; !ok {
955+ return ErrUnknownPane
956+ }
957+ }
958+ return nil
959+}
960+
961+func skipBlob(r *payloadReader) error {
962+ n, err := r.getUint32()
963+ if err != nil {
964+ return err
965+ }
966+ if uint64(n) > uint64(len(r.b)) {
967+ return ErrShortPayload
968+ }
969+ r.b = r.b[n:]
970+ return nil
971+}
972+
973+type preflightTreeReferences struct{ ids []layout.PaneID }
974+
975+func (r *preflightTreeReferences) add(id layout.PaneID) {
976+ r.ids = append(r.ids, id)
977+}
978+
979+func preflightNodeReferences(r *payloadReader, references *preflightTreeReferences) error {
980+ kind, err := r.getUint8()
981+ if err != nil {
982+ return err
983+ }
984+ switch kind {
985+ case 0:
986+ leaf, err := r.getString()
987+ if err != nil {
988+ return err
989+ }
990+ references.add(layout.PaneID(leaf))
991+ return nil
992+ case 1:
993+ if _, err := r.getUint8(); err != nil {
994+ return err
995+ }
996+ case 2:
997+ expanded, err := r.getString()
998+ if err != nil {
999+ return err
1000+ }
1001+ if expanded != "" {
1002+ references.add(layout.PaneID(expanded))
1003+ }
1004+ case 3:
1005+ return nil
1006+ default:
1007+ return fmt.Errorf("%w: node kind", ErrInvalidData)
1008+ }
1009+ children, err := r.getUint16()
1010+ if err != nil {
1011+ return err
1012+ }
1013+ for range children {
1014+ if err := preflightNodeReferences(r, references); err != nil {
1015+ return err
1016+ }
1017+ }
1018+ return nil
1019+}
1020+
864 func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {1021 func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
865 if t == nil || t.Root == nil {1022 if t == nil || t.Root == nil {
866 return nil1023 return nil
M internal/usecase/snapshot/codec_test.go +85 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "hash/crc32"
88 "math"
9+ "strconv"
910 "testing"
1011
1112 "github.com/bnema/vev/internal/usecase/layout"
@@ -72,6 +73,43 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
7273 }
7374 }
7475
76+func TestActiveTabReferenceMustBeExact(t *testing.T) {
77+ validTab := Tab{}
78+ for _, tc := range []struct {
79+ name string
80+ s Session
81+ valid bool
82+ }{
83+ {name: "zero tabs active zero", s: Session{Active: 0}, valid: true},
84+ {name: "zero tabs active nonzero", s: Session{Active: 1}},
85+ {name: "nonempty tabs active in range", s: Session{Active: 0, Tabs: []Tab{validTab}}, valid: true},
86+ {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},
87+ } {
88+ t.Run("marshal "+tc.name, func(t *testing.T) {
89+ _, err := Marshal(tc.s)
90+ if tc.valid {
91+ requireNoError(t, err)
92+ return
93+ }
94+ if !errors.Is(err, ErrInvalidData) {
95+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
96+ }
97+ })
98+ }
99+
100+ for _, tc := range []struct {
101+ name string
102+ data []byte
103+ }{
104+ {name: "zero tabs active nonzero", data: v3ActiveManifest(1, 0)},
105+ {name: "nonempty tabs active past end", data: v3ActiveManifest(1, 1)},
106+ } {
107+ t.Run("unmarshal "+tc.name, func(t *testing.T) {
108+ reject(t, v3Envelope(version, 0, tc.data), ErrInvalidData)
109+ })
110+ }
111+}
112+
75113 func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {
76114 for _, version := range []uint16{1, 2} {
77115 if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
@@ -281,6 +319,36 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
281319 }
282320 }
283321
322+func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
323+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
324+ for range 256 {
325+ history.Append([]renderer.Cell{{Rune: 'x'}})
326+ }
327+ sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
328+ requireNoError(t, err)
329+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
330+
331+ const paneCount = maxSnapshotRows/256 + 1
332+ tabs := make([]Tab, paneCount)
333+ for i := range tabs {
334+ tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}
335+ }
336+ data, err := Marshal(Session{Name: "over-budget", Tabs: tabs})
337+ requireNoError(t, err)
338+
339+ allocs := testing.AllocsPerRun(3, func() {
340+ _, err := Unmarshal(data)
341+ if !errors.Is(err, ErrInvalidData) {
342+ panic(err)
343+ }
344+ })
345+ // The error and testing harness may allocate a small fixed amount. Rejecting
346+ // before semantic maps/slices are built must not scale with paneCount.
347+ if allocs > 8 {
348+ t.Fatalf("Unmarshal() allocations = %f, want fixed overhead only", allocs)
349+ }
350+}
351+
284352 func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) {
285353 budget := preflightBudget{alloc: maxSnapshotDecodedAllocation - 1}
286354 if budget.addProduct(1, 2) {
@@ -376,6 +444,23 @@ func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
376444 return out
377445 }
378446
447+func v3ActiveManifest(active, tabs uint16) []byte {
448+ var b []byte
449+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
450+ putS("s")
451+ b = append(b, make([]byte, 8)...)
452+ b = binary.BigEndian.AppendUint16(b, active)
453+ b = binary.BigEndian.AppendUint16(b, tabs)
454+ for range tabs {
455+ putS("")
456+ b = append(b, make([]byte, 2+2+8)...)
457+ putS("")
458+ b = append(b, 3) // nil root
459+ b = binary.BigEndian.AppendUint16(b, 0)
460+ }
461+ return b
462+}
463+
379464 // v3Manifest creates just enough session/tab/pane framing for hostile declarations.
380465 // v3WideTreeManifest declares a broad but compact tree without allocating a
381466 // matching layout.Node graph. It exercises preflight's manifest budget.
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "hash/crc32"7 "hash/crc32"
8 "math"8 "math"
9+ "strconv"
9 "testing"10 "testing"
10 11
11 "github.com/bnema/vev/internal/usecase/layout"12 "github.com/bnema/vev/internal/usecase/layout"
@@ -72,6 +73,43 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
72 }73 }
73 }74 }
74 75
76+func TestActiveTabReferenceMustBeExact(t *testing.T) {
77+ validTab := Tab{}
78+ for _, tc := range []struct {
79+ name string
80+ s Session
81+ valid bool
82+ }{
83+ {name: "zero tabs active zero", s: Session{Active: 0}, valid: true},
84+ {name: "zero tabs active nonzero", s: Session{Active: 1}},
85+ {name: "nonempty tabs active in range", s: Session{Active: 0, Tabs: []Tab{validTab}}, valid: true},
86+ {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},
87+ } {
88+ t.Run("marshal "+tc.name, func(t *testing.T) {
89+ _, err := Marshal(tc.s)
90+ if tc.valid {
91+ requireNoError(t, err)
92+ return
93+ }
94+ if !errors.Is(err, ErrInvalidData) {
95+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
96+ }
97+ })
98+ }
99+
100+ for _, tc := range []struct {
101+ name string
102+ data []byte
103+ }{
104+ {name: "zero tabs active nonzero", data: v3ActiveManifest(1, 0)},
105+ {name: "nonempty tabs active past end", data: v3ActiveManifest(1, 1)},
106+ } {
107+ t.Run("unmarshal "+tc.name, func(t *testing.T) {
108+ reject(t, v3Envelope(version, 0, tc.data), ErrInvalidData)
109+ })
110+ }
111+}
112+
75 func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {113 func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {
76 for _, version := range []uint16{1, 2} {114 for _, version := range []uint16{1, 2} {
77 if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {115 if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
@@ -281,6 +319,36 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
281 }319 }
282 }320 }
283 321
322+func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
323+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
324+ for range 256 {
325+ history.Append([]renderer.Cell{{Rune: 'x'}})
326+ }
327+ sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
328+ requireNoError(t, err)
329+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
330+
331+ const paneCount = maxSnapshotRows/256 + 1
332+ tabs := make([]Tab, paneCount)
333+ for i := range tabs {
334+ tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}
335+ }
336+ data, err := Marshal(Session{Name: "over-budget", Tabs: tabs})
337+ requireNoError(t, err)
338+
339+ allocs := testing.AllocsPerRun(3, func() {
340+ _, err := Unmarshal(data)
341+ if !errors.Is(err, ErrInvalidData) {
342+ panic(err)
343+ }
344+ })
345+ // The error and testing harness may allocate a small fixed amount. Rejecting
346+ // before semantic maps/slices are built must not scale with paneCount.
347+ if allocs > 8 {
348+ t.Fatalf("Unmarshal() allocations = %f, want fixed overhead only", allocs)
349+ }
350+}
351+
284 func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) {352 func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) {
285 budget := preflightBudget{alloc: maxSnapshotDecodedAllocation - 1}353 budget := preflightBudget{alloc: maxSnapshotDecodedAllocation - 1}
286 if budget.addProduct(1, 2) {354 if budget.addProduct(1, 2) {
@@ -376,6 +444,23 @@ func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
376 return out444 return out
377 }445 }
378 446
447+func v3ActiveManifest(active, tabs uint16) []byte {
448+ var b []byte
449+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
450+ putS("s")
451+ b = append(b, make([]byte, 8)...)
452+ b = binary.BigEndian.AppendUint16(b, active)
453+ b = binary.BigEndian.AppendUint16(b, tabs)
454+ for range tabs {
455+ putS("")
456+ b = append(b, make([]byte, 2+2+8)...)
457+ putS("")
458+ b = append(b, 3) // nil root
459+ b = binary.BigEndian.AppendUint16(b, 0)
460+ }
461+ return b
462+}
463+
379 // v3Manifest creates just enough session/tab/pane framing for hostile declarations.464 // v3Manifest creates just enough session/tab/pane framing for hostile declarations.
380 // v3WideTreeManifest declares a broad but compact tree without allocating a465 // v3WideTreeManifest declares a broad but compact tree without allocating a
381 // matching layout.Node graph. It exercises preflight's manifest budget.466 // matching layout.Node graph. It exercises preflight's manifest budget.
M pkg/vt/snapshot.go +13 -0
@@ -99,6 +99,19 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
9999 return h, nil
100100 }
101101
102+// NewScreenWithRestoredHistory constructs a screen that owns history restored
103+// directly from canonical blobs. Call RestorePrimaryVisible to install a
104+// persisted primary frame before making the screen live.
105+func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, sealed [][]byte, tail []byte) (*Screen, error) {
106+ history, err := HistoryFromBlobs(config, sealed, tail)
107+ if err != nil {
108+ return nil, err
109+ }
110+ screen := NewScreenWithHistory(width, height, config)
111+ screen.history = history
112+ return screen, nil
113+}
114+
102115 // MarshalVisible encodes the exact visible primary frame, including every
103116 // blank, row width, and terminal cell attribute.
104117 func MarshalVisible(frame renderer.Frame) ([]byte, error) {
@@ -99,6 +99,19 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
99 return h, nil99 return h, nil
100 }100 }
101 101
102+// NewScreenWithRestoredHistory constructs a screen that owns history restored
103+// directly from canonical blobs. Call RestorePrimaryVisible to install a
104+// persisted primary frame before making the screen live.
105+func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, sealed [][]byte, tail []byte) (*Screen, error) {
106+ history, err := HistoryFromBlobs(config, sealed, tail)
107+ if err != nil {
108+ return nil, err
109+ }
110+ screen := NewScreenWithHistory(width, height, config)
111+ screen.history = history
112+ return screen, nil
113+}
114+
102 // MarshalVisible encodes the exact visible primary frame, including every115 // MarshalVisible encodes the exact visible primary frame, including every
103 // blank, row width, and terminal cell attribute.116 // blank, row width, and terminal cell attribute.
104 func MarshalVisible(frame renderer.Frame) ([]byte, error) {117 func MarshalVisible(frame renderer.Frame) ([]byte, error) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close