fix(snapshot): enforce terminal history ownership
- commit
- 7cf3f77258e19a1bb794181e8ade3d3e792b50f0
- author
- brice · · 2026-07-12 17:31 UTC
- parent
- 46dfc77
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 49 | 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}) | |
| 53 | 51 | return &pane{ | |
| 54 | 52 | id: id, | |
| 55 | 53 | stableID: stableID, | |
| 56 | 54 | pty: pty, | |
| 57 | 55 | screen: screen, | |
| 58 | - | history: history, | |
| 56 | + | history: screen.History(), | |
| 59 | 57 | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |
| 60 | 58 | title: paneTitleState{displayFallback: "sh"}, | |
| 61 | 59 | } | |
| @@ -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) { | |||
| 14 | 14 | 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 | 18 | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |
| 19 | 19 | wantLen: 1, | |
| 20 | 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 | 30 | for _, tt := range tests { | |
| @@ -27,6 +33,9 @@ func TestNewTabInitializesScrollback(t *testing.T) { | |||
| 27 | 33 | if win.focusedPane().history == nil { | |
| 28 | 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 | 39 | if win.focusedPane().history.Cap() != defaultScrollbackRows { | |
| 31 | 40 | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows) | |
| 32 | 41 | } | |
| @@ -14,11 +14,17 @@ func TestNewTabInitializesScrollback(t *testing.T) { | |||||
| 14 | wantRow string | 14 | 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 | |||
| 87 | 87 | if err := ctx.Err(); err != nil { | |
| 88 | 88 | 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 | 97 | if snap.Name == "" { | |
| 91 | 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 | 214 | createdAt := int64(snap.CreatedAt) | |
| 208 | 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 | 216 | sess.snapEligible.Store(true) | |
| 213 | 217 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |
| 214 | 218 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | |
| @@ -261,15 +265,15 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |||
| 261 | 265 | } | |
| 262 | 266 | p.mu.Lock() | |
| 263 | 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 | 269 | if err != nil { | |
| 266 | 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 | 273 | return fmt.Errorf("snapshot visible: %w", err) | |
| 270 | 274 | } | |
| 271 | - | p.history = history | |
| 272 | - | p.screen.OnLineEvicted = history.Append | |
| 275 | + | p.screen = screen | |
| 276 | + | p.history = screen.History() | |
| 273 | 277 | return nil | |
| 274 | 278 | } | |
| 275 | 279 | ||
| @@ -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 err | 88 | 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].Cwd | 218 | 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 = history | 275 | + | p.screen = screen |
| 272 | - | p.screen.OnLineEvicted = history.Append | 276 | + | p.history = screen.History() |
| 273 | return nil | 277 | 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 | |||
| 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 | 74 | func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) { | |
| 63 | 75 | factory := &restorePTYFactory{} | |
| 64 | 76 | d := newTestDaemon(t, factory, stubClock{}) | |
| @@ -131,6 +143,7 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 131 | 143 | p := tb.panes["pane-2"] | |
| 132 | 144 | tb.mu.Unlock() | |
| 133 | 145 | p.mu.Lock() | |
| 146 | + | require.Same(t, p.screen.History(), p.history) | |
| 134 | 147 | require.Equal(t, "old2", rowText(p.history.View().Row(0))) | |
| 135 | 148 | require.Equal(t, 1, p.history.Len()) | |
| 136 | 149 | 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 { | |||
| 228 | 228 | } | |
| 229 | 229 | ||
| 230 | 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 | 237 | if err := w.putString(s.Name); err != nil { | |
| 232 | 238 | return err | |
| 233 | 239 | } | |
| 234 | 240 | w.putUint64(s.CreatedAt) | |
| 235 | 241 | w.putUint16(s.Active) | |
| 236 | - | if len(s.Tabs) > math.MaxUint16 { | |
| 237 | - | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |
| 238 | - | } | |
| 239 | 242 | w.putUint16(uint16(len(s.Tabs))) | |
| 240 | 243 | for _, t := range s.Tabs { | |
| 241 | 244 | if err := writeTab(w, t); err != nil { | |
| @@ -262,6 +265,9 @@ func readSession(r *payloadReader) (Session, error) { | |||
| 262 | 265 | if err != nil { | |
| 263 | 266 | return Session{}, err | |
| 264 | 267 | } | |
| 268 | + | if err := validateActive(active, int(nt)); err != nil { | |
| 269 | + | return Session{}, err | |
| 270 | + | } | |
| 265 | 271 | s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)} | |
| 266 | 272 | for i := range s.Tabs { | |
| 267 | 273 | t, err := readTab(r) | |
| @@ -556,9 +562,30 @@ func readProcess(r *payloadReader) (*Process, error) { | |||
| 556 | 562 | 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 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. | |
| 561 | 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 | 589 | r := payloadReader{b: body} | |
| 563 | 590 | if err := skipString(&r); err != nil { | |
| 564 | 591 | return err | |
| @@ -566,21 +593,26 @@ func preflightSession(body []byte) error { | |||
| 566 | 593 | if _, err := r.getUint64(); err != nil { | |
| 567 | 594 | return err | |
| 568 | 595 | } | |
| 569 | - | if _, err := r.getUint16(); err != nil { | |
| 596 | + | active, err := r.getUint16() | |
| 597 | + | if err != nil { | |
| 570 | 598 | return err | |
| 571 | 599 | } | |
| 572 | 600 | tabs, err := r.getUint16() | |
| 573 | 601 | if err != nil { | |
| 574 | 602 | return err | |
| 575 | 603 | } | |
| 604 | + | if err := validateActive(active, int(tabs)); err != nil { | |
| 605 | + | return err | |
| 606 | + | } | |
| 576 | 607 | if uint64(tabs) > uint64(len(r.b))/2 { | |
| 577 | 608 | return ErrShortPayload | |
| 578 | 609 | } | |
| 610 | + | ||
| 579 | 611 | var totals vt.DecodeStats | |
| 580 | 612 | blobs := uint64(0) | |
| 581 | 613 | budget := preflightBudget{} | |
| 582 | 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 | 616 | return fmt.Errorf("preflight tab: %w", err) | |
| 585 | 617 | } | |
| 586 | 618 | } | |
| @@ -633,7 +665,7 @@ func (b *preflightBudget) addNode() bool { | |||
| 633 | 665 | 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 | 669 | if !budget.addAllocation(256) { | |
| 638 | 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 | 681 | if _, err := r.getUint64(); err != nil { | |
| 650 | 682 | return err | |
| 651 | 683 | } | |
| 652 | - | focus, err := r.getString() | |
| 653 | - | if err != nil { | |
| 684 | + | if err := skipString(r); err != nil { | |
| 654 | 685 | return err | |
| 655 | 686 | } | |
| 656 | - | references := preflightTreeReferences{} | |
| 657 | - | if err := preflightNode(r, 0, budget, &references); err != nil { | |
| 687 | + | if err := preflightNodeStructure(r, 0, true, budget); err != nil { | |
| 658 | 688 | return err | |
| 659 | 689 | } | |
| 660 | 690 | panes, err := r.getUint16() | |
| @@ -664,34 +694,15 @@ func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budge | |||
| 664 | 694 | if uint64(panes) > uint64(len(r.b))/2 { | |
| 665 | 695 | return ErrShortPayload | |
| 666 | 696 | } | |
| 667 | - | ids := make(map[layout.PaneID]struct{}) | |
| 668 | 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 | 699 | 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 | 702 | return nil | |
| 682 | 703 | } | |
| 683 | 704 | ||
| 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 { | |
| 695 | 706 | if depth > 64 { | |
| 696 | 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 | 716 | switch kind { | |
| 706 | 717 | case 0: | |
| 707 | - | leaf, err := r.getString() | |
| 718 | + | return skipString(r) | |
| 719 | + | case 1: | |
| 720 | + | dir, err := r.getUint8() | |
| 708 | 721 | if err != nil { | |
| 709 | 722 | return err | |
| 710 | 723 | } | |
| 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) | |
| 715 | 726 | } | |
| 716 | 727 | case 2: | |
| 717 | - | expanded, err := r.getString() | |
| 718 | - | if err != nil { | |
| 728 | + | if err := skipString(r); err != nil { | |
| 719 | 729 | return err | |
| 720 | 730 | } | |
| 721 | - | if expanded != "" { | |
| 722 | - | if err := references.add(layout.PaneID(expanded), budget); err != nil { | |
| 723 | - | return err | |
| 724 | - | } | |
| 725 | - | } | |
| 726 | 731 | case 3: | |
| 732 | + | if !root { | |
| 733 | + | return fmt.Errorf("%w: nil child node", ErrInvalidData) | |
| 734 | + | } | |
| 727 | 735 | return nil | |
| 728 | 736 | default: | |
| 729 | 737 | return fmt.Errorf("%w: node kind", ErrInvalidData) | |
| @@ -736,29 +744,20 @@ func preflightNode(r *payloadReader, depth int, budget *preflightBudget, referen | |||
| 736 | 744 | return ErrShortPayload | |
| 737 | 745 | } | |
| 738 | 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 | 748 | return err | |
| 741 | 749 | } | |
| 742 | 750 | } | |
| 743 | 751 | 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 | 755 | if !budget.addAllocation(256) { | |
| 748 | 756 | return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData) | |
| 749 | 757 | } | |
| 750 | - | id, err := r.getString() | |
| 751 | - | if err != nil { | |
| 758 | + | if err := skipString(r); err != nil { | |
| 752 | 759 | 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 | 761 | if err := skipString(r); err != nil { | |
| 763 | 762 | return err | |
| 764 | 763 | } | |
| @@ -792,8 +791,6 @@ func preflightPane(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, ids | |||
| 792 | 791 | if err != nil { | |
| 793 | 792 | 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 | 794 | if visible.Rows == 0 || visible.Cells == 0 { | |
| 798 | 795 | return fmt.Errorf("%w: visible geometry", ErrInvalidData) | |
| 799 | 796 | } | |
| @@ -861,6 +858,166 @@ func preflightProcess(r *payloadReader) error { | |||
| 861 | 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 | 1021 | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | |
| 865 | 1022 | if t == nil || t.Root == nil { | |
| 866 | 1023 | 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 err | 238 | 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{}, err | 266 | 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}}, nil | 562 | 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 a | 565 | + | 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 err | 591 | 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 err | 594 | return err | ||
| 568 | } | 595 | } | ||
| 569 | - | if _, err := r.getUint16(); err != nil { | 596 | + | active, err := r.getUint16() |
| 597 | + | if err != nil { | |||
| 570 | return err | 598 | return err | ||
| 571 | } | 599 | } | ||
| 572 | tabs, err := r.getUint16() | 600 | tabs, err := r.getUint16() | ||
| 573 | if err != nil { | 601 | if err != nil { | ||
| 574 | return err | 602 | 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 ErrShortPayload | 608 | return ErrShortPayload | ||
| 578 | } | 609 | } | ||
| 610 | + | ||||
| 579 | var totals vt.DecodeStats | 611 | 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 true | 665 | 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 err | 682 | return err | ||
| 651 | } | 683 | } | ||
| 652 | - | focus, err := r.getString() | 684 | + | if err := skipString(r); err != nil { |
| 653 | - | if err != nil { | |||
| 654 | return err | 685 | 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 err | 688 | 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 ErrShortPayload | 695 | 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 err | 699 | 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 nil | 702 | 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 err | 722 | 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 err | 729 | 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 nil | 735 | 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 ErrShortPayload | 744 | 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 err | 748 | return err | ||
| 741 | } | 749 | } | ||
| 742 | } | 750 | } | ||
| 743 | return nil | 751 | 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 err | 759 | 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 err | 762 | 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 err | 792 | 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 nil | 1023 | return nil | ||
M internal/usecase/snapshot/codec_test.go +85 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "hash/crc32" | |
| 8 | 8 | "math" | |
| 9 | + | "strconv" | |
| 9 | 10 | "testing" | |
| 10 | 11 | ||
| 11 | 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 | 113 | func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) { | |
| 76 | 114 | for _, version := range []uint16{1, 2} { | |
| 77 | 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 | 352 | func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) { | |
| 285 | 353 | budget := preflightBudget{alloc: maxSnapshotDecodedAllocation - 1} | |
| 286 | 354 | if budget.addProduct(1, 2) { | |
| @@ -376,6 +444,23 @@ func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { | |||
| 376 | 444 | 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 | 464 | // v3Manifest creates just enough session/tab/pane framing for hostile declarations. | |
| 380 | 465 | // v3WideTreeManifest declares a broad but compact tree without allocating a | |
| 381 | 466 | // 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 out | 444 | 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 a | 465 | // 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 | |||
| 99 | 99 | 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 | 115 | // MarshalVisible encodes the exact visible primary frame, including every | |
| 103 | 116 | // blank, row width, and terminal cell attribute. | |
| 104 | 117 | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |
| @@ -99,6 +99,19 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||||
| 99 | return h, nil | 99 | 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 every | 115 | // 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) { | ||