detached 871c141

feat(snapshot): encode incremental generation manifests

commit
871c1418b5c8de067cf5a8154104f68ddeed0401
author
brice · · 2026-07-21 19:15 UTC
committer
brice · · 2026-07-21 19:16 UTC
parent
f2c8f76
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +942 -0
A internal/usecase/snapshot/manifest.go +819 -0
@@ -0,0 +1,819 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/binary"
6+ "fmt"
7+ "hash/crc32"
8+ "math"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+const (
15+ manifestMagic = "VEVM"
16+ headMagic = "VEVH"
17+ objectMagic = "VEVO"
18+ codecVersion = uint16(1)
19+)
20+
21+// SnapshotDigest is the shared content-address type used by the repository.
22+type SnapshotDigest = ports.SnapshotDigest
23+
24+// ManifestDigest returns the content address of a complete VEVM manifest.
25+func ManifestDigest(encoded []byte) SnapshotDigest { return sha256.Sum256(encoded) }
26+
27+// ObjectRole binds a referenced object to its position in a pane snapshot.
28+type ObjectRole uint8
29+
30+const (
31+ ObjectRoleHistory ObjectRole = iota + 1
32+ ObjectRoleTail
33+ ObjectRoleVisible
34+)
35+
36+// ObjectRef is a content-addressed object used by a reference-only manifest.
37+type ObjectRef struct {
38+ Role ObjectRole
39+ Digest SnapshotDigest
40+}
41+
42+// Manifest is the incremental durable representation of a named session. Its
43+// panes contain references only; terminal bytes belong exclusively in VEVO
44+// object envelopes.
45+type Manifest struct {
46+ Name string
47+ CreatedAt uint64
48+ Active uint16
49+ Tabs []Tab
50+}
51+
52+// Head is the atomically published name-to-manifest pointer.
53+type Head struct {
54+ Name string
55+ Generation uint64
56+ Manifest SnapshotDigest
57+}
58+
59+// Object is a complete typed object payload before VEVO framing.
60+type Object struct {
61+ Role ObjectRole
62+ Data []byte
63+}
64+
65+// MarshalManifest encodes a deterministic, strict VEVM v1 envelope.
66+func MarshalManifest(m Manifest) ([]byte, error) {
67+ if err := validateManifest(m); err != nil {
68+ return nil, err
69+ }
70+ var w payloadWriter
71+ if err := writeManifest(&w, m); err != nil {
72+ return nil, err
73+ }
74+ return marshalEnvelope(manifestMagic, w.b)
75+}
76+
77+// UnmarshalManifest validates a whole VEVM envelope before allocating its
78+// decoded graph. It rejects non-reference pane data, malformed layouts and
79+// noncanonical object roles.
80+func UnmarshalManifest(encoded []byte) (Manifest, error) {
81+ body, err := unmarshalEnvelope(encoded, manifestMagic)
82+ if err != nil {
83+ return Manifest{}, err
84+ }
85+ if err := preflightManifest(body); err != nil {
86+ return Manifest{}, err
87+ }
88+ r := payloadReader{b: body}
89+ m, err := readManifest(&r)
90+ if err != nil {
91+ return Manifest{}, err
92+ }
93+ if err := r.done(); err != nil {
94+ return Manifest{}, err
95+ }
96+ return m, nil
97+}
98+
99+// MarshalHead encodes a deterministic VEVH v1 publication pointer.
100+func MarshalHead(h Head) ([]byte, error) {
101+ if h.Name == "" || isZeroDigest(h.Manifest) {
102+ return nil, fmt.Errorf("%w: head name or manifest digest", ErrInvalidData)
103+ }
104+ var w payloadWriter
105+ if err := w.putString(h.Name); err != nil {
106+ return nil, err
107+ }
108+ w.putUint64(h.Generation)
109+ w.b = append(w.b, h.Manifest[:]...)
110+ return marshalEnvelope(headMagic, w.b)
111+}
112+
113+// UnmarshalHead decodes a complete VEVH envelope.
114+func UnmarshalHead(encoded []byte) (Head, error) {
115+ body, err := unmarshalEnvelope(encoded, headMagic)
116+ if err != nil {
117+ return Head{}, err
118+ }
119+ r := payloadReader{b: body}
120+ name, err := r.getString()
121+ if err != nil {
122+ return Head{}, err
123+ }
124+ generation, err := r.getUint64()
125+ if err != nil {
126+ return Head{}, err
127+ }
128+ if len(r.b) < sha256.Size {
129+ return Head{}, ErrShortPayload
130+ }
131+ var digest SnapshotDigest
132+ copy(digest[:], r.b[:sha256.Size])
133+ r.b = r.b[sha256.Size:]
134+ if err := r.done(); err != nil {
135+ return Head{}, err
136+ }
137+ if name == "" || isZeroDigest(digest) {
138+ return Head{}, fmt.Errorf("%w: head name or manifest digest", ErrInvalidData)
139+ }
140+ return Head{Name: name, Generation: generation, Manifest: digest}, nil
141+}
142+
143+// MarshalObject is the sole API that creates VEVO object envelopes.
144+func MarshalObject(object Object) ([]byte, error) {
145+ if !validObjectRole(object.Role) || len(object.Data) == 0 || len(object.Data) > maxSnapshotBytes {
146+ return nil, fmt.Errorf("%w: object", ErrInvalidData)
147+ }
148+ var w payloadWriter
149+ w.putUint8(uint8(object.Role))
150+ w.putUint32(uint32(len(object.Data)))
151+ w.b = append(w.b, object.Data...)
152+ return marshalEnvelope(objectMagic, w.b)
153+}
154+
155+// PreflightObject validates VEVO framing and all sizes without copying object
156+// bytes. Callers loading untrusted storage can use it before retaining data.
157+func PreflightObject(encoded []byte) error {
158+ body, err := unmarshalEnvelope(encoded, objectMagic)
159+ if err != nil {
160+ return err
161+ }
162+ if len(body) < 5 {
163+ return ErrShortPayload
164+ }
165+ if !validObjectRole(ObjectRole(body[0])) {
166+ return fmt.Errorf("%w: object role", ErrInvalidData)
167+ }
168+ n := binary.BigEndian.Uint32(body[1:5])
169+ if n == 0 || n > maxSnapshotBytes {
170+ return fmt.Errorf("%w: object length", ErrInvalidData)
171+ }
172+ if uint64(n) > uint64(len(body)-5) {
173+ return ErrShortPayload
174+ }
175+ if uint64(n) != uint64(len(body)-5) {
176+ return ErrTrailingBytes
177+ }
178+ return nil
179+}
180+
181+// UnmarshalObject validates and returns a caller-owned object payload.
182+func UnmarshalObject(encoded []byte) (Object, error) {
183+ if err := PreflightObject(encoded); err != nil {
184+ return Object{}, err
185+ }
186+ body := encoded[16:]
187+ return Object{Role: ObjectRole(body[0]), Data: append([]byte(nil), body[5:]...)}, nil
188+}
189+
190+func marshalEnvelope(magic string, body []byte) ([]byte, error) {
191+ if len(body) > maxDecodedBodySize || len(body) > math.MaxUint32 {
192+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
193+ }
194+ out := make([]byte, 16+len(body))
195+ copy(out[:4], magic)
196+ binary.BigEndian.PutUint16(out[4:6], codecVersion)
197+ // Flags [6:8] remain zero.
198+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
199+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
200+ copy(out[16:], body)
201+ return out, nil
202+}
203+
204+func unmarshalEnvelope(encoded []byte, expectedMagic string) ([]byte, error) {
205+ if len(encoded) < 16 {
206+ return nil, ErrShortPayload
207+ }
208+ if string(encoded[:4]) != expectedMagic {
209+ return nil, ErrBadMagic
210+ }
211+ if binary.BigEndian.Uint16(encoded[4:6]) != codecVersion {
212+ return nil, ErrBadVersion
213+ }
214+ if binary.BigEndian.Uint16(encoded[6:8]) != 0 {
215+ return nil, fmt.Errorf("%w: flags", ErrInvalidData)
216+ }
217+ bodyLen := binary.BigEndian.Uint32(encoded[8:12])
218+ if bodyLen > maxDecodedBodySize {
219+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
220+ }
221+ if uint64(len(encoded)) < 16+uint64(bodyLen) {
222+ return nil, ErrShortPayload
223+ }
224+ if uint64(len(encoded)) != 16+uint64(bodyLen) {
225+ return nil, ErrTrailingBytes
226+ }
227+ body := encoded[16:]
228+ if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(encoded[12:16]) {
229+ return nil, ErrBadCRC
230+ }
231+ return body, nil
232+}
233+
234+func validateManifest(m Manifest) error {
235+ if m.Name == "" {
236+ return fmt.Errorf("%w: manifest name", ErrInvalidData)
237+ }
238+ if err := validateActive(m.Active, len(m.Tabs)); err != nil {
239+ return err
240+ }
241+ for _, tab := range m.Tabs {
242+ if tab.Cols == 0 || tab.Rows == 0 {
243+ return fmt.Errorf("%w: dimensions", ErrInvalidData)
244+ }
245+ ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
246+ for _, pane := range tab.Panes {
247+ if pane.ID == "" {
248+ return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
249+ }
250+ if _, exists := ids[pane.ID]; exists {
251+ return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
252+ }
253+ if len(pane.SealedChunks) != 0 || len(pane.Tail) != 0 || len(pane.Visible) != 0 {
254+ return fmt.Errorf("%w: manifest pane contains inline object data", ErrInvalidData)
255+ }
256+ if err := validateObjectRefs(pane.Objects); err != nil {
257+ return err
258+ }
259+ ids[pane.ID] = struct{}{}
260+ }
261+ if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok {
262+ return ErrUnknownPane
263+ }
264+ if err := validateTree(tab.Tree, ids); err != nil {
265+ return err
266+ }
267+ }
268+ return nil
269+}
270+
271+func validateObjectRefs(refs []ObjectRef) error {
272+ if len(refs) < 2 {
273+ return fmt.Errorf("%w: missing tail or visible object", ErrInvalidData)
274+ }
275+ seenTail := false
276+ for i, ref := range refs {
277+ if isZeroDigest(ref.Digest) {
278+ return fmt.Errorf("%w: object digest", ErrInvalidData)
279+ }
280+ switch ref.Role {
281+ case ObjectRoleHistory:
282+ if seenTail {
283+ return fmt.Errorf("%w: object role order", ErrInvalidData)
284+ }
285+ case ObjectRoleTail:
286+ if seenTail || i+1 == len(refs) {
287+ return fmt.Errorf("%w: object role order", ErrInvalidData)
288+ }
289+ seenTail = true
290+ case ObjectRoleVisible:
291+ if !seenTail || i+1 != len(refs) {
292+ return fmt.Errorf("%w: object role order", ErrInvalidData)
293+ }
294+ return nil
295+ default:
296+ return fmt.Errorf("%w: object role", ErrInvalidData)
297+ }
298+ }
299+ return fmt.Errorf("%w: missing visible object", ErrInvalidData)
300+}
301+
302+func writeManifest(w *payloadWriter, m Manifest) error {
303+ if len(m.Tabs) > math.MaxUint16 {
304+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
305+ }
306+ if err := validateActive(m.Active, len(m.Tabs)); err != nil {
307+ return err
308+ }
309+ if err := w.putString(m.Name); err != nil {
310+ return err
311+ }
312+ w.putUint64(m.CreatedAt)
313+ w.putUint16(m.Active)
314+ w.putUint16(uint16(len(m.Tabs)))
315+ for _, tab := range m.Tabs {
316+ if err := writeManifestTab(w, tab); err != nil {
317+ return err
318+ }
319+ }
320+ return nil
321+}
322+
323+func writeManifestTab(w *payloadWriter, tab Tab) error {
324+ if err := w.putString(tab.StableID); err != nil {
325+ return err
326+ }
327+ w.putUint16(tab.Cols)
328+ w.putUint16(tab.Rows)
329+ w.putUint64(tab.NextPaneID)
330+ if err := w.putString(string(tab.Focus)); err != nil {
331+ return err
332+ }
333+ var root *layout.Node
334+ if tab.Tree != nil {
335+ root = tab.Tree.Root
336+ }
337+ if err := writeNode(w, root); err != nil {
338+ return err
339+ }
340+ if len(tab.Panes) > math.MaxUint16 {
341+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
342+ }
343+ w.putUint16(uint16(len(tab.Panes)))
344+ for _, pane := range tab.Panes {
345+ if err := writeManifestPane(w, pane); err != nil {
346+ return err
347+ }
348+ }
349+ return nil
350+}
351+
352+func writeManifestPane(w *payloadWriter, pane Pane) error {
353+ if err := w.putString(string(pane.ID)); err != nil {
354+ return err
355+ }
356+ if err := w.putString(pane.StableID); err != nil {
357+ return err
358+ }
359+ if err := w.putString(pane.Cwd); err != nil {
360+ return err
361+ }
362+ if len(pane.Objects) > math.MaxUint16 {
363+ return fmt.Errorf("%w: too many object references", ErrInvalidData)
364+ }
365+ w.putUint16(uint16(len(pane.Objects)))
366+ for _, ref := range pane.Objects {
367+ w.putUint8(uint8(ref.Role))
368+ w.b = append(w.b, ref.Digest[:]...)
369+ }
370+ return writeProcess(w, pane.Process)
371+}
372+
373+func readManifest(r *payloadReader) (Manifest, error) {
374+ name, err := r.getString()
375+ if err != nil {
376+ return Manifest{}, err
377+ }
378+ created, err := r.getUint64()
379+ if err != nil {
380+ return Manifest{}, err
381+ }
382+ active, err := r.getUint16()
383+ if err != nil {
384+ return Manifest{}, err
385+ }
386+ n, err := r.getUint16()
387+ if err != nil {
388+ return Manifest{}, err
389+ }
390+ m := Manifest{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, n)}
391+ for i := range m.Tabs {
392+ if m.Tabs[i], err = readManifestTab(r); err != nil {
393+ return Manifest{}, err
394+ }
395+ }
396+ return m, nil
397+}
398+
399+func readManifestTab(r *payloadReader) (Tab, error) {
400+ stableID, err := r.getString()
401+ if err != nil {
402+ return Tab{}, err
403+ }
404+ cols, err := r.getUint16()
405+ if err != nil {
406+ return Tab{}, err
407+ }
408+ rows, err := r.getUint16()
409+ if err != nil {
410+ return Tab{}, err
411+ }
412+ next, err := r.getUint64()
413+ if err != nil {
414+ return Tab{}, err
415+ }
416+ focus, err := r.getString()
417+ if err != nil {
418+ return Tab{}, err
419+ }
420+ root, err := readNode(r)
421+ if err != nil {
422+ return Tab{}, err
423+ }
424+ n, err := r.getUint16()
425+ if err != nil {
426+ return Tab{}, err
427+ }
428+ tab := Tab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, n)}
429+ for i := range tab.Panes {
430+ if tab.Panes[i], err = readManifestPane(r); err != nil {
431+ return Tab{}, err
432+ }
433+ }
434+ return tab, nil
435+}
436+
437+func readManifestPane(r *payloadReader) (Pane, error) {
438+ id, err := r.getString()
439+ if err != nil {
440+ return Pane{}, err
441+ }
442+ stableID, err := r.getString()
443+ if err != nil {
444+ return Pane{}, err
445+ }
446+ cwd, err := r.getString()
447+ if err != nil {
448+ return Pane{}, err
449+ }
450+ n, err := r.getUint16()
451+ if err != nil {
452+ return Pane{}, err
453+ }
454+ objects := make([]ObjectRef, n)
455+ for i := range objects {
456+ role, err := r.getUint8()
457+ if err != nil {
458+ return Pane{}, err
459+ }
460+ if len(r.b) < sha256.Size {
461+ return Pane{}, ErrShortPayload
462+ }
463+ objects[i].Role = ObjectRole(role)
464+ copy(objects[i].Digest[:], r.b[:sha256.Size])
465+ r.b = r.b[sha256.Size:]
466+ }
467+ process, err := readProcess(r)
468+ if err != nil {
469+ return Pane{}, err
470+ }
471+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Objects: objects, Process: process}, nil
472+}
473+
474+func preflightManifest(body []byte) error {
475+ if len(body) < 2 {
476+ return ErrShortPayload
477+ }
478+ if binary.BigEndian.Uint16(body[:2]) == 0 {
479+ return fmt.Errorf("%w: manifest name", ErrInvalidData)
480+ }
481+ r := payloadReader{b: body}
482+ budget := preflightBudget{}
483+ if err := skipManifestString(&r, &budget); err != nil {
484+ return err
485+ }
486+ if _, err := r.getUint64(); err != nil {
487+ return err
488+ }
489+ active, err := r.getUint16()
490+ if err != nil {
491+ return err
492+ }
493+ tabs, err := r.getUint16()
494+ if err != nil {
495+ return err
496+ }
497+ if err := validateActive(active, int(tabs)); err != nil {
498+ return err
499+ }
500+ if uint64(tabs) > uint64(len(r.b))/2 {
501+ return ErrShortPayload
502+ }
503+ for range tabs {
504+ if err := preflightManifestTab(&r, &budget); err != nil {
505+ return err
506+ }
507+ }
508+ if err := r.done(); err != nil {
509+ return err
510+ }
511+ return preflightManifestReferences(body)
512+}
513+
514+func preflightManifestTab(r *payloadReader, budget *preflightBudget) error {
515+ if !budget.addAllocation(256) {
516+ return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
517+ }
518+ if err := skipManifestString(r, budget); err != nil {
519+ return err
520+ }
521+ cols, err := r.getUint16()
522+ if err != nil {
523+ return err
524+ }
525+ rows, err := r.getUint16()
526+ if err != nil {
527+ return err
528+ }
529+ if cols == 0 || rows == 0 {
530+ return fmt.Errorf("%w: dimensions", ErrInvalidData)
531+ }
532+ if _, err := r.getUint64(); err != nil {
533+ return err
534+ }
535+ if err := skipManifestString(r, budget); err != nil {
536+ return err
537+ }
538+ if err := preflightManifestNode(r, 0, true, budget); err != nil {
539+ return err
540+ }
541+ panes, err := r.getUint16()
542+ if err != nil {
543+ return err
544+ }
545+ if uint64(panes) > uint64(len(r.b))/2 {
546+ return ErrShortPayload
547+ }
548+ for range panes {
549+ if err := preflightManifestPane(r, budget); err != nil {
550+ return err
551+ }
552+ }
553+ return nil
554+}
555+
556+func preflightManifestPane(r *payloadReader, budget *preflightBudget) error {
557+ if !budget.addAllocation(256) {
558+ return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
559+ }
560+ for range 3 {
561+ if err := skipManifestString(r, budget); err != nil {
562+ return err
563+ }
564+ }
565+ n, err := r.getUint16()
566+ if err != nil {
567+ return err
568+ }
569+ if uint64(n) > uint64(len(r.b))/33 || !budget.addProduct(uint64(n), 48) {
570+ return fmt.Errorf("%w: object references or decoded allocation budget", ErrInvalidData)
571+ }
572+ for range n {
573+ role, err := r.getUint8()
574+ if err != nil {
575+ return err
576+ }
577+ if !validObjectRole(ObjectRole(role)) {
578+ return fmt.Errorf("%w: object role", ErrInvalidData)
579+ }
580+ if len(r.b) < sha256.Size {
581+ return ErrShortPayload
582+ }
583+ if isZeroDigestBytes(r.b[:sha256.Size]) {
584+ return fmt.Errorf("%w: object digest", ErrInvalidData)
585+ }
586+ r.b = r.b[sha256.Size:]
587+ }
588+ return preflightManifestProcess(r, budget)
589+}
590+
591+func skipManifestString(r *payloadReader, budget *preflightBudget) error {
592+ n, err := r.getUint16()
593+ if err != nil {
594+ return err
595+ }
596+ if uint64(n) > uint64(len(r.b)) {
597+ return ErrShortPayload
598+ }
599+ if !budget.addAllocation(uint64(n) + 16) {
600+ return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
601+ }
602+ r.b = r.b[n:]
603+ return nil
604+}
605+
606+func preflightManifestNode(r *payloadReader, depth int, root bool, budget *preflightBudget) error {
607+ if depth > 64 || !budget.addNode() {
608+ return fmt.Errorf("%w: tree depth, node, or decoded allocation budget", ErrInvalidData)
609+ }
610+ kind, err := r.getUint8()
611+ if err != nil {
612+ return err
613+ }
614+ switch kind {
615+ case 0:
616+ return skipManifestString(r, budget)
617+ case 1:
618+ dir, err := r.getUint8()
619+ if err != nil {
620+ return err
621+ }
622+ if layout.SplitDir(dir) != layout.Horizontal && layout.SplitDir(dir) != layout.Vertical {
623+ return fmt.Errorf("%w: split dir", ErrInvalidData)
624+ }
625+ case 2:
626+ if err := skipManifestString(r, budget); err != nil {
627+ return err
628+ }
629+ case 3:
630+ if !root {
631+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
632+ }
633+ return nil
634+ default:
635+ return fmt.Errorf("%w: node kind", ErrInvalidData)
636+ }
637+ children, err := r.getUint16()
638+ if err != nil {
639+ return err
640+ }
641+ if uint64(children) > uint64(len(r.b)) {
642+ return ErrShortPayload
643+ }
644+ for range children {
645+ if err := preflightManifestNode(r, depth+1, false, budget); err != nil {
646+ return err
647+ }
648+ }
649+ return nil
650+}
651+
652+func preflightManifestProcess(r *payloadReader, budget *preflightBudget) error {
653+ present, err := r.getUint8()
654+ if err != nil {
655+ return err
656+ }
657+ if present == 0 {
658+ return nil
659+ }
660+ if present != 1 {
661+ return fmt.Errorf("%w: process presence", ErrInvalidData)
662+ }
663+ n, err := r.getUint16()
664+ if err != nil {
665+ return err
666+ }
667+ if n == 0 || !budget.addProduct(uint64(n), 16) {
668+ return fmt.Errorf("%w: process argv or decoded allocation budget", ErrInvalidData)
669+ }
670+ for range n {
671+ if err := skipManifestString(r, budget); err != nil {
672+ return err
673+ }
674+ }
675+ if err := skipManifestString(r, budget); err != nil {
676+ return err
677+ }
678+ return skipManifestString(r, budget)
679+}
680+
681+func preflightManifestReferences(body []byte) error {
682+ r := payloadReader{b: body}
683+ if err := skipString(&r); err != nil {
684+ return err
685+ }
686+ if _, err := r.getUint64(); err != nil {
687+ return err
688+ }
689+ if _, err := r.getUint16(); err != nil {
690+ return err
691+ }
692+ tabs, err := r.getUint16()
693+ if err != nil {
694+ return err
695+ }
696+ for range tabs {
697+ if err := preflightManifestTabReferences(&r); err != nil {
698+ return err
699+ }
700+ }
701+ return r.done()
702+}
703+
704+func preflightManifestTabReferences(r *payloadReader) error {
705+ if err := skipString(r); err != nil {
706+ return err
707+ }
708+ if _, err := r.getUint16(); err != nil {
709+ return err
710+ }
711+ if _, err := r.getUint16(); err != nil {
712+ return err
713+ }
714+ if _, err := r.getUint64(); err != nil {
715+ return err
716+ }
717+ focus, err := r.getString()
718+ if err != nil {
719+ return err
720+ }
721+ references := preflightTreeReferences{}
722+ if err := preflightNodeReferences(r, &references); err != nil {
723+ return err
724+ }
725+ n, err := r.getUint16()
726+ if err != nil {
727+ return err
728+ }
729+ ids := make(map[layout.PaneID]struct{}, n)
730+ for range n {
731+ id, err := r.getString()
732+ if err != nil {
733+ return err
734+ }
735+ if id == "" {
736+ return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
737+ }
738+ paneID := layout.PaneID(id)
739+ if _, exists := ids[paneID]; exists {
740+ return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
741+ }
742+ ids[paneID] = struct{}{}
743+ if err := skipString(r); err != nil {
744+ return err
745+ }
746+ if err := skipString(r); err != nil {
747+ return err
748+ }
749+ objects, err := r.getUint16()
750+ if err != nil {
751+ return err
752+ }
753+ if err := preflightObjectOrder(r, objects); err != nil {
754+ return err
755+ }
756+ if err := preflightProcess(r); err != nil {
757+ return err
758+ }
759+ }
760+ if focus != "" {
761+ references.add(layout.PaneID(focus))
762+ }
763+ for _, id := range references.ids {
764+ if _, ok := ids[id]; !ok {
765+ return ErrUnknownPane
766+ }
767+ }
768+ return nil
769+}
770+
771+func preflightObjectOrder(r *payloadReader, n uint16) error {
772+ if n < 2 {
773+ return fmt.Errorf("%w: missing tail or visible object", ErrInvalidData)
774+ }
775+ seenTail := false
776+ for i := uint16(0); i < n; i++ {
777+ role, err := r.getUint8()
778+ if err != nil {
779+ return err
780+ }
781+ if len(r.b) < sha256.Size {
782+ return ErrShortPayload
783+ }
784+ r.b = r.b[sha256.Size:]
785+ switch ObjectRole(role) {
786+ case ObjectRoleHistory:
787+ if seenTail {
788+ return fmt.Errorf("%w: object role order", ErrInvalidData)
789+ }
790+ case ObjectRoleTail:
791+ if seenTail || i+1 >= n {
792+ return fmt.Errorf("%w: object role order", ErrInvalidData)
793+ }
794+ seenTail = true
795+ case ObjectRoleVisible:
796+ if !seenTail || i+1 != n {
797+ return fmt.Errorf("%w: object role order", ErrInvalidData)
798+ }
799+ return nil
800+ default:
801+ return fmt.Errorf("%w: object role", ErrInvalidData)
802+ }
803+ }
804+ return fmt.Errorf("%w: missing visible object", ErrInvalidData)
805+}
806+
807+func validObjectRole(role ObjectRole) bool {
808+ return role == ObjectRoleHistory || role == ObjectRoleTail || role == ObjectRoleVisible
809+}
810+
811+func isZeroDigest(digest SnapshotDigest) bool { return isZeroDigestBytes(digest[:]) }
812+func isZeroDigestBytes(digest []byte) bool {
813+ for _, b := range digest {
814+ if b != 0 {
815+ return false
816+ }
817+ }
818+ return true
819+}
@@ -0,0 +1,819 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/binary"
6+ "fmt"
7+ "hash/crc32"
8+ "math"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+const (
15+ manifestMagic = "VEVM"
16+ headMagic = "VEVH"
17+ objectMagic = "VEVO"
18+ codecVersion = uint16(1)
19+)
20+
21+// SnapshotDigest is the shared content-address type used by the repository.
22+type SnapshotDigest = ports.SnapshotDigest
23+
24+// ManifestDigest returns the content address of a complete VEVM manifest.
25+func ManifestDigest(encoded []byte) SnapshotDigest { return sha256.Sum256(encoded) }
26+
27+// ObjectRole binds a referenced object to its position in a pane snapshot.
28+type ObjectRole uint8
29+
30+const (
31+ ObjectRoleHistory ObjectRole = iota + 1
32+ ObjectRoleTail
33+ ObjectRoleVisible
34+)
35+
36+// ObjectRef is a content-addressed object used by a reference-only manifest.
37+type ObjectRef struct {
38+ Role ObjectRole
39+ Digest SnapshotDigest
40+}
41+
42+// Manifest is the incremental durable representation of a named session. Its
43+// panes contain references only; terminal bytes belong exclusively in VEVO
44+// object envelopes.
45+type Manifest struct {
46+ Name string
47+ CreatedAt uint64
48+ Active uint16
49+ Tabs []Tab
50+}
51+
52+// Head is the atomically published name-to-manifest pointer.
53+type Head struct {
54+ Name string
55+ Generation uint64
56+ Manifest SnapshotDigest
57+}
58+
59+// Object is a complete typed object payload before VEVO framing.
60+type Object struct {
61+ Role ObjectRole
62+ Data []byte
63+}
64+
65+// MarshalManifest encodes a deterministic, strict VEVM v1 envelope.
66+func MarshalManifest(m Manifest) ([]byte, error) {
67+ if err := validateManifest(m); err != nil {
68+ return nil, err
69+ }
70+ var w payloadWriter
71+ if err := writeManifest(&w, m); err != nil {
72+ return nil, err
73+ }
74+ return marshalEnvelope(manifestMagic, w.b)
75+}
76+
77+// UnmarshalManifest validates a whole VEVM envelope before allocating its
78+// decoded graph. It rejects non-reference pane data, malformed layouts and
79+// noncanonical object roles.
80+func UnmarshalManifest(encoded []byte) (Manifest, error) {
81+ body, err := unmarshalEnvelope(encoded, manifestMagic)
82+ if err != nil {
83+ return Manifest{}, err
84+ }
85+ if err := preflightManifest(body); err != nil {
86+ return Manifest{}, err
87+ }
88+ r := payloadReader{b: body}
89+ m, err := readManifest(&r)
90+ if err != nil {
91+ return Manifest{}, err
92+ }
93+ if err := r.done(); err != nil {
94+ return Manifest{}, err
95+ }
96+ return m, nil
97+}
98+
99+// MarshalHead encodes a deterministic VEVH v1 publication pointer.
100+func MarshalHead(h Head) ([]byte, error) {
101+ if h.Name == "" || isZeroDigest(h.Manifest) {
102+ return nil, fmt.Errorf("%w: head name or manifest digest", ErrInvalidData)
103+ }
104+ var w payloadWriter
105+ if err := w.putString(h.Name); err != nil {
106+ return nil, err
107+ }
108+ w.putUint64(h.Generation)
109+ w.b = append(w.b, h.Manifest[:]...)
110+ return marshalEnvelope(headMagic, w.b)
111+}
112+
113+// UnmarshalHead decodes a complete VEVH envelope.
114+func UnmarshalHead(encoded []byte) (Head, error) {
115+ body, err := unmarshalEnvelope(encoded, headMagic)
116+ if err != nil {
117+ return Head{}, err
118+ }
119+ r := payloadReader{b: body}
120+ name, err := r.getString()
121+ if err != nil {
122+ return Head{}, err
123+ }
124+ generation, err := r.getUint64()
125+ if err != nil {
126+ return Head{}, err
127+ }
128+ if len(r.b) < sha256.Size {
129+ return Head{}, ErrShortPayload
130+ }
131+ var digest SnapshotDigest
132+ copy(digest[:], r.b[:sha256.Size])
133+ r.b = r.b[sha256.Size:]
134+ if err := r.done(); err != nil {
135+ return Head{}, err
136+ }
137+ if name == "" || isZeroDigest(digest) {
138+ return Head{}, fmt.Errorf("%w: head name or manifest digest", ErrInvalidData)
139+ }
140+ return Head{Name: name, Generation: generation, Manifest: digest}, nil
141+}
142+
143+// MarshalObject is the sole API that creates VEVO object envelopes.
144+func MarshalObject(object Object) ([]byte, error) {
145+ if !validObjectRole(object.Role) || len(object.Data) == 0 || len(object.Data) > maxSnapshotBytes {
146+ return nil, fmt.Errorf("%w: object", ErrInvalidData)
147+ }
148+ var w payloadWriter
149+ w.putUint8(uint8(object.Role))
150+ w.putUint32(uint32(len(object.Data)))
151+ w.b = append(w.b, object.Data...)
152+ return marshalEnvelope(objectMagic, w.b)
153+}
154+
155+// PreflightObject validates VEVO framing and all sizes without copying object
156+// bytes. Callers loading untrusted storage can use it before retaining data.
157+func PreflightObject(encoded []byte) error {
158+ body, err := unmarshalEnvelope(encoded, objectMagic)
159+ if err != nil {
160+ return err
161+ }
162+ if len(body) < 5 {
163+ return ErrShortPayload
164+ }
165+ if !validObjectRole(ObjectRole(body[0])) {
166+ return fmt.Errorf("%w: object role", ErrInvalidData)
167+ }
168+ n := binary.BigEndian.Uint32(body[1:5])
169+ if n == 0 || n > maxSnapshotBytes {
170+ return fmt.Errorf("%w: object length", ErrInvalidData)
171+ }
172+ if uint64(n) > uint64(len(body)-5) {
173+ return ErrShortPayload
174+ }
175+ if uint64(n) != uint64(len(body)-5) {
176+ return ErrTrailingBytes
177+ }
178+ return nil
179+}
180+
181+// UnmarshalObject validates and returns a caller-owned object payload.
182+func UnmarshalObject(encoded []byte) (Object, error) {
183+ if err := PreflightObject(encoded); err != nil {
184+ return Object{}, err
185+ }
186+ body := encoded[16:]
187+ return Object{Role: ObjectRole(body[0]), Data: append([]byte(nil), body[5:]...)}, nil
188+}
189+
190+func marshalEnvelope(magic string, body []byte) ([]byte, error) {
191+ if len(body) > maxDecodedBodySize || len(body) > math.MaxUint32 {
192+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
193+ }
194+ out := make([]byte, 16+len(body))
195+ copy(out[:4], magic)
196+ binary.BigEndian.PutUint16(out[4:6], codecVersion)
197+ // Flags [6:8] remain zero.
198+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
199+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
200+ copy(out[16:], body)
201+ return out, nil
202+}
203+
204+func unmarshalEnvelope(encoded []byte, expectedMagic string) ([]byte, error) {
205+ if len(encoded) < 16 {
206+ return nil, ErrShortPayload
207+ }
208+ if string(encoded[:4]) != expectedMagic {
209+ return nil, ErrBadMagic
210+ }
211+ if binary.BigEndian.Uint16(encoded[4:6]) != codecVersion {
212+ return nil, ErrBadVersion
213+ }
214+ if binary.BigEndian.Uint16(encoded[6:8]) != 0 {
215+ return nil, fmt.Errorf("%w: flags", ErrInvalidData)
216+ }
217+ bodyLen := binary.BigEndian.Uint32(encoded[8:12])
218+ if bodyLen > maxDecodedBodySize {
219+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
220+ }
221+ if uint64(len(encoded)) < 16+uint64(bodyLen) {
222+ return nil, ErrShortPayload
223+ }
224+ if uint64(len(encoded)) != 16+uint64(bodyLen) {
225+ return nil, ErrTrailingBytes
226+ }
227+ body := encoded[16:]
228+ if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(encoded[12:16]) {
229+ return nil, ErrBadCRC
230+ }
231+ return body, nil
232+}
233+
234+func validateManifest(m Manifest) error {
235+ if m.Name == "" {
236+ return fmt.Errorf("%w: manifest name", ErrInvalidData)
237+ }
238+ if err := validateActive(m.Active, len(m.Tabs)); err != nil {
239+ return err
240+ }
241+ for _, tab := range m.Tabs {
242+ if tab.Cols == 0 || tab.Rows == 0 {
243+ return fmt.Errorf("%w: dimensions", ErrInvalidData)
244+ }
245+ ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
246+ for _, pane := range tab.Panes {
247+ if pane.ID == "" {
248+ return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
249+ }
250+ if _, exists := ids[pane.ID]; exists {
251+ return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
252+ }
253+ if len(pane.SealedChunks) != 0 || len(pane.Tail) != 0 || len(pane.Visible) != 0 {
254+ return fmt.Errorf("%w: manifest pane contains inline object data", ErrInvalidData)
255+ }
256+ if err := validateObjectRefs(pane.Objects); err != nil {
257+ return err
258+ }
259+ ids[pane.ID] = struct{}{}
260+ }
261+ if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok {
262+ return ErrUnknownPane
263+ }
264+ if err := validateTree(tab.Tree, ids); err != nil {
265+ return err
266+ }
267+ }
268+ return nil
269+}
270+
271+func validateObjectRefs(refs []ObjectRef) error {
272+ if len(refs) < 2 {
273+ return fmt.Errorf("%w: missing tail or visible object", ErrInvalidData)
274+ }
275+ seenTail := false
276+ for i, ref := range refs {
277+ if isZeroDigest(ref.Digest) {
278+ return fmt.Errorf("%w: object digest", ErrInvalidData)
279+ }
280+ switch ref.Role {
281+ case ObjectRoleHistory:
282+ if seenTail {
283+ return fmt.Errorf("%w: object role order", ErrInvalidData)
284+ }
285+ case ObjectRoleTail:
286+ if seenTail || i+1 == len(refs) {
287+ return fmt.Errorf("%w: object role order", ErrInvalidData)
288+ }
289+ seenTail = true
290+ case ObjectRoleVisible:
291+ if !seenTail || i+1 != len(refs) {
292+ return fmt.Errorf("%w: object role order", ErrInvalidData)
293+ }
294+ return nil
295+ default:
296+ return fmt.Errorf("%w: object role", ErrInvalidData)
297+ }
298+ }
299+ return fmt.Errorf("%w: missing visible object", ErrInvalidData)
300+}
301+
302+func writeManifest(w *payloadWriter, m Manifest) error {
303+ if len(m.Tabs) > math.MaxUint16 {
304+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
305+ }
306+ if err := validateActive(m.Active, len(m.Tabs)); err != nil {
307+ return err
308+ }
309+ if err := w.putString(m.Name); err != nil {
310+ return err
311+ }
312+ w.putUint64(m.CreatedAt)
313+ w.putUint16(m.Active)
314+ w.putUint16(uint16(len(m.Tabs)))
315+ for _, tab := range m.Tabs {
316+ if err := writeManifestTab(w, tab); err != nil {
317+ return err
318+ }
319+ }
320+ return nil
321+}
322+
323+func writeManifestTab(w *payloadWriter, tab Tab) error {
324+ if err := w.putString(tab.StableID); err != nil {
325+ return err
326+ }
327+ w.putUint16(tab.Cols)
328+ w.putUint16(tab.Rows)
329+ w.putUint64(tab.NextPaneID)
330+ if err := w.putString(string(tab.Focus)); err != nil {
331+ return err
332+ }
333+ var root *layout.Node
334+ if tab.Tree != nil {
335+ root = tab.Tree.Root
336+ }
337+ if err := writeNode(w, root); err != nil {
338+ return err
339+ }
340+ if len(tab.Panes) > math.MaxUint16 {
341+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
342+ }
343+ w.putUint16(uint16(len(tab.Panes)))
344+ for _, pane := range tab.Panes {
345+ if err := writeManifestPane(w, pane); err != nil {
346+ return err
347+ }
348+ }
349+ return nil
350+}
351+
352+func writeManifestPane(w *payloadWriter, pane Pane) error {
353+ if err := w.putString(string(pane.ID)); err != nil {
354+ return err
355+ }
356+ if err := w.putString(pane.StableID); err != nil {
357+ return err
358+ }
359+ if err := w.putString(pane.Cwd); err != nil {
360+ return err
361+ }
362+ if len(pane.Objects) > math.MaxUint16 {
363+ return fmt.Errorf("%w: too many object references", ErrInvalidData)
364+ }
365+ w.putUint16(uint16(len(pane.Objects)))
366+ for _, ref := range pane.Objects {
367+ w.putUint8(uint8(ref.Role))
368+ w.b = append(w.b, ref.Digest[:]...)
369+ }
370+ return writeProcess(w, pane.Process)
371+}
372+
373+func readManifest(r *payloadReader) (Manifest, error) {
374+ name, err := r.getString()
375+ if err != nil {
376+ return Manifest{}, err
377+ }
378+ created, err := r.getUint64()
379+ if err != nil {
380+ return Manifest{}, err
381+ }
382+ active, err := r.getUint16()
383+ if err != nil {
384+ return Manifest{}, err
385+ }
386+ n, err := r.getUint16()
387+ if err != nil {
388+ return Manifest{}, err
389+ }
390+ m := Manifest{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, n)}
391+ for i := range m.Tabs {
392+ if m.Tabs[i], err = readManifestTab(r); err != nil {
393+ return Manifest{}, err
394+ }
395+ }
396+ return m, nil
397+}
398+
399+func readManifestTab(r *payloadReader) (Tab, error) {
400+ stableID, err := r.getString()
401+ if err != nil {
402+ return Tab{}, err
403+ }
404+ cols, err := r.getUint16()
405+ if err != nil {
406+ return Tab{}, err
407+ }
408+ rows, err := r.getUint16()
409+ if err != nil {
410+ return Tab{}, err
411+ }
412+ next, err := r.getUint64()
413+ if err != nil {
414+ return Tab{}, err
415+ }
416+ focus, err := r.getString()
417+ if err != nil {
418+ return Tab{}, err
419+ }
420+ root, err := readNode(r)
421+ if err != nil {
422+ return Tab{}, err
423+ }
424+ n, err := r.getUint16()
425+ if err != nil {
426+ return Tab{}, err
427+ }
428+ tab := Tab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, n)}
429+ for i := range tab.Panes {
430+ if tab.Panes[i], err = readManifestPane(r); err != nil {
431+ return Tab{}, err
432+ }
433+ }
434+ return tab, nil
435+}
436+
437+func readManifestPane(r *payloadReader) (Pane, error) {
438+ id, err := r.getString()
439+ if err != nil {
440+ return Pane{}, err
441+ }
442+ stableID, err := r.getString()
443+ if err != nil {
444+ return Pane{}, err
445+ }
446+ cwd, err := r.getString()
447+ if err != nil {
448+ return Pane{}, err
449+ }
450+ n, err := r.getUint16()
451+ if err != nil {
452+ return Pane{}, err
453+ }
454+ objects := make([]ObjectRef, n)
455+ for i := range objects {
456+ role, err := r.getUint8()
457+ if err != nil {
458+ return Pane{}, err
459+ }
460+ if len(r.b) < sha256.Size {
461+ return Pane{}, ErrShortPayload
462+ }
463+ objects[i].Role = ObjectRole(role)
464+ copy(objects[i].Digest[:], r.b[:sha256.Size])
465+ r.b = r.b[sha256.Size:]
466+ }
467+ process, err := readProcess(r)
468+ if err != nil {
469+ return Pane{}, err
470+ }
471+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Objects: objects, Process: process}, nil
472+}
473+
474+func preflightManifest(body []byte) error {
475+ if len(body) < 2 {
476+ return ErrShortPayload
477+ }
478+ if binary.BigEndian.Uint16(body[:2]) == 0 {
479+ return fmt.Errorf("%w: manifest name", ErrInvalidData)
480+ }
481+ r := payloadReader{b: body}
482+ budget := preflightBudget{}
483+ if err := skipManifestString(&r, &budget); err != nil {
484+ return err
485+ }
486+ if _, err := r.getUint64(); err != nil {
487+ return err
488+ }
489+ active, err := r.getUint16()
490+ if err != nil {
491+ return err
492+ }
493+ tabs, err := r.getUint16()
494+ if err != nil {
495+ return err
496+ }
497+ if err := validateActive(active, int(tabs)); err != nil {
498+ return err
499+ }
500+ if uint64(tabs) > uint64(len(r.b))/2 {
501+ return ErrShortPayload
502+ }
503+ for range tabs {
504+ if err := preflightManifestTab(&r, &budget); err != nil {
505+ return err
506+ }
507+ }
508+ if err := r.done(); err != nil {
509+ return err
510+ }
511+ return preflightManifestReferences(body)
512+}
513+
514+func preflightManifestTab(r *payloadReader, budget *preflightBudget) error {
515+ if !budget.addAllocation(256) {
516+ return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
517+ }
518+ if err := skipManifestString(r, budget); err != nil {
519+ return err
520+ }
521+ cols, err := r.getUint16()
522+ if err != nil {
523+ return err
524+ }
525+ rows, err := r.getUint16()
526+ if err != nil {
527+ return err
528+ }
529+ if cols == 0 || rows == 0 {
530+ return fmt.Errorf("%w: dimensions", ErrInvalidData)
531+ }
532+ if _, err := r.getUint64(); err != nil {
533+ return err
534+ }
535+ if err := skipManifestString(r, budget); err != nil {
536+ return err
537+ }
538+ if err := preflightManifestNode(r, 0, true, budget); err != nil {
539+ return err
540+ }
541+ panes, err := r.getUint16()
542+ if err != nil {
543+ return err
544+ }
545+ if uint64(panes) > uint64(len(r.b))/2 {
546+ return ErrShortPayload
547+ }
548+ for range panes {
549+ if err := preflightManifestPane(r, budget); err != nil {
550+ return err
551+ }
552+ }
553+ return nil
554+}
555+
556+func preflightManifestPane(r *payloadReader, budget *preflightBudget) error {
557+ if !budget.addAllocation(256) {
558+ return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
559+ }
560+ for range 3 {
561+ if err := skipManifestString(r, budget); err != nil {
562+ return err
563+ }
564+ }
565+ n, err := r.getUint16()
566+ if err != nil {
567+ return err
568+ }
569+ if uint64(n) > uint64(len(r.b))/33 || !budget.addProduct(uint64(n), 48) {
570+ return fmt.Errorf("%w: object references or decoded allocation budget", ErrInvalidData)
571+ }
572+ for range n {
573+ role, err := r.getUint8()
574+ if err != nil {
575+ return err
576+ }
577+ if !validObjectRole(ObjectRole(role)) {
578+ return fmt.Errorf("%w: object role", ErrInvalidData)
579+ }
580+ if len(r.b) < sha256.Size {
581+ return ErrShortPayload
582+ }
583+ if isZeroDigestBytes(r.b[:sha256.Size]) {
584+ return fmt.Errorf("%w: object digest", ErrInvalidData)
585+ }
586+ r.b = r.b[sha256.Size:]
587+ }
588+ return preflightManifestProcess(r, budget)
589+}
590+
591+func skipManifestString(r *payloadReader, budget *preflightBudget) error {
592+ n, err := r.getUint16()
593+ if err != nil {
594+ return err
595+ }
596+ if uint64(n) > uint64(len(r.b)) {
597+ return ErrShortPayload
598+ }
599+ if !budget.addAllocation(uint64(n) + 16) {
600+ return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
601+ }
602+ r.b = r.b[n:]
603+ return nil
604+}
605+
606+func preflightManifestNode(r *payloadReader, depth int, root bool, budget *preflightBudget) error {
607+ if depth > 64 || !budget.addNode() {
608+ return fmt.Errorf("%w: tree depth, node, or decoded allocation budget", ErrInvalidData)
609+ }
610+ kind, err := r.getUint8()
611+ if err != nil {
612+ return err
613+ }
614+ switch kind {
615+ case 0:
616+ return skipManifestString(r, budget)
617+ case 1:
618+ dir, err := r.getUint8()
619+ if err != nil {
620+ return err
621+ }
622+ if layout.SplitDir(dir) != layout.Horizontal && layout.SplitDir(dir) != layout.Vertical {
623+ return fmt.Errorf("%w: split dir", ErrInvalidData)
624+ }
625+ case 2:
626+ if err := skipManifestString(r, budget); err != nil {
627+ return err
628+ }
629+ case 3:
630+ if !root {
631+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
632+ }
633+ return nil
634+ default:
635+ return fmt.Errorf("%w: node kind", ErrInvalidData)
636+ }
637+ children, err := r.getUint16()
638+ if err != nil {
639+ return err
640+ }
641+ if uint64(children) > uint64(len(r.b)) {
642+ return ErrShortPayload
643+ }
644+ for range children {
645+ if err := preflightManifestNode(r, depth+1, false, budget); err != nil {
646+ return err
647+ }
648+ }
649+ return nil
650+}
651+
652+func preflightManifestProcess(r *payloadReader, budget *preflightBudget) error {
653+ present, err := r.getUint8()
654+ if err != nil {
655+ return err
656+ }
657+ if present == 0 {
658+ return nil
659+ }
660+ if present != 1 {
661+ return fmt.Errorf("%w: process presence", ErrInvalidData)
662+ }
663+ n, err := r.getUint16()
664+ if err != nil {
665+ return err
666+ }
667+ if n == 0 || !budget.addProduct(uint64(n), 16) {
668+ return fmt.Errorf("%w: process argv or decoded allocation budget", ErrInvalidData)
669+ }
670+ for range n {
671+ if err := skipManifestString(r, budget); err != nil {
672+ return err
673+ }
674+ }
675+ if err := skipManifestString(r, budget); err != nil {
676+ return err
677+ }
678+ return skipManifestString(r, budget)
679+}
680+
681+func preflightManifestReferences(body []byte) error {
682+ r := payloadReader{b: body}
683+ if err := skipString(&r); err != nil {
684+ return err
685+ }
686+ if _, err := r.getUint64(); err != nil {
687+ return err
688+ }
689+ if _, err := r.getUint16(); err != nil {
690+ return err
691+ }
692+ tabs, err := r.getUint16()
693+ if err != nil {
694+ return err
695+ }
696+ for range tabs {
697+ if err := preflightManifestTabReferences(&r); err != nil {
698+ return err
699+ }
700+ }
701+ return r.done()
702+}
703+
704+func preflightManifestTabReferences(r *payloadReader) error {
705+ if err := skipString(r); err != nil {
706+ return err
707+ }
708+ if _, err := r.getUint16(); err != nil {
709+ return err
710+ }
711+ if _, err := r.getUint16(); err != nil {
712+ return err
713+ }
714+ if _, err := r.getUint64(); err != nil {
715+ return err
716+ }
717+ focus, err := r.getString()
718+ if err != nil {
719+ return err
720+ }
721+ references := preflightTreeReferences{}
722+ if err := preflightNodeReferences(r, &references); err != nil {
723+ return err
724+ }
725+ n, err := r.getUint16()
726+ if err != nil {
727+ return err
728+ }
729+ ids := make(map[layout.PaneID]struct{}, n)
730+ for range n {
731+ id, err := r.getString()
732+ if err != nil {
733+ return err
734+ }
735+ if id == "" {
736+ return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
737+ }
738+ paneID := layout.PaneID(id)
739+ if _, exists := ids[paneID]; exists {
740+ return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
741+ }
742+ ids[paneID] = struct{}{}
743+ if err := skipString(r); err != nil {
744+ return err
745+ }
746+ if err := skipString(r); err != nil {
747+ return err
748+ }
749+ objects, err := r.getUint16()
750+ if err != nil {
751+ return err
752+ }
753+ if err := preflightObjectOrder(r, objects); err != nil {
754+ return err
755+ }
756+ if err := preflightProcess(r); err != nil {
757+ return err
758+ }
759+ }
760+ if focus != "" {
761+ references.add(layout.PaneID(focus))
762+ }
763+ for _, id := range references.ids {
764+ if _, ok := ids[id]; !ok {
765+ return ErrUnknownPane
766+ }
767+ }
768+ return nil
769+}
770+
771+func preflightObjectOrder(r *payloadReader, n uint16) error {
772+ if n < 2 {
773+ return fmt.Errorf("%w: missing tail or visible object", ErrInvalidData)
774+ }
775+ seenTail := false
776+ for i := uint16(0); i < n; i++ {
777+ role, err := r.getUint8()
778+ if err != nil {
779+ return err
780+ }
781+ if len(r.b) < sha256.Size {
782+ return ErrShortPayload
783+ }
784+ r.b = r.b[sha256.Size:]
785+ switch ObjectRole(role) {
786+ case ObjectRoleHistory:
787+ if seenTail {
788+ return fmt.Errorf("%w: object role order", ErrInvalidData)
789+ }
790+ case ObjectRoleTail:
791+ if seenTail || i+1 >= n {
792+ return fmt.Errorf("%w: object role order", ErrInvalidData)
793+ }
794+ seenTail = true
795+ case ObjectRoleVisible:
796+ if !seenTail || i+1 != n {
797+ return fmt.Errorf("%w: object role order", ErrInvalidData)
798+ }
799+ return nil
800+ default:
801+ return fmt.Errorf("%w: object role", ErrInvalidData)
802+ }
803+ }
804+ return fmt.Errorf("%w: missing visible object", ErrInvalidData)
805+}
806+
807+func validObjectRole(role ObjectRole) bool {
808+ return role == ObjectRoleHistory || role == ObjectRoleTail || role == ObjectRoleVisible
809+}
810+
811+func isZeroDigest(digest SnapshotDigest) bool { return isZeroDigestBytes(digest[:]) }
812+func isZeroDigestBytes(digest []byte) bool {
813+ for _, b := range digest {
814+ if b != 0 {
815+ return false
816+ }
817+ }
818+ return true
819+}
A internal/usecase/snapshot/manifest_test.go +118 -0
@@ -0,0 +1,118 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/usecase/layout"
9+)
10+
11+func TestManifestAndHeadCodecsAreDeterministicAndStrict(t *testing.T) {
12+ digest := SnapshotDigest{1}
13+ manifest := Manifest{
14+ Name: "named",
15+ CreatedAt: 9,
16+ Active: 0,
17+ Tabs: []Tab{{
18+ StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane",
19+ Tree: layout.NewTree("pane"),
20+ Panes: []Pane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp", Objects: []ObjectRef{
21+ {Role: ObjectRoleHistory, Digest: digest},
22+ {Role: ObjectRoleTail, Digest: SnapshotDigest{2}},
23+ {Role: ObjectRoleVisible, Digest: SnapshotDigest{3}},
24+ }}},
25+ }},
26+ }
27+ encoded, err := MarshalManifest(manifest)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ encodedAgain, err := MarshalManifest(manifest)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if !bytes.Equal(encoded, encodedAgain) {
36+ t.Fatal("manifest encoding is not deterministic")
37+ }
38+ got, err := UnmarshalManifest(encoded)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if got.Name != manifest.Name || len(got.Tabs) != 1 || len(got.Tabs[0].Panes[0].Objects) != 3 {
43+ t.Fatalf("manifest round trip = %#v", got)
44+ }
45+ for n := range len(encoded) {
46+ if _, err := UnmarshalManifest(encoded[:n]); err == nil {
47+ t.Fatalf("prefix %d accepted", n)
48+ }
49+ }
50+ if _, err := UnmarshalManifest(append(encoded, 0)); !errors.Is(err, ErrTrailingBytes) {
51+ t.Fatalf("trailing data error = %v", err)
52+ }
53+
54+ head := Head{Name: "named", Generation: 7, Manifest: ManifestDigest(encoded)}
55+ headBytes, err := MarshalHead(head)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if got, err := UnmarshalHead(headBytes); err != nil || got != head {
60+ t.Fatalf("head round trip = %#v, %v", got, err)
61+ }
62+ for n := range len(headBytes) {
63+ if _, err := UnmarshalHead(headBytes[:n]); err == nil {
64+ t.Fatalf("head prefix %d accepted", n)
65+ }
66+ }
67+ if _, err := UnmarshalHead(append(headBytes, 0)); !errors.Is(err, ErrTrailingBytes) {
68+ t.Fatalf("head trailing data error = %v", err)
69+ }
70+}
71+
72+func TestObjectCodecIsTypedAndStrict(t *testing.T) {
73+ object := Object{Role: ObjectRoleVisible, Data: []byte("canonical-vt")}
74+ encoded, err := MarshalObject(object)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ got, err := UnmarshalObject(encoded)
79+ if err != nil || got.Role != object.Role || !bytes.Equal(got.Data, object.Data) {
80+ t.Fatalf("object round trip = %#v, %v", got, err)
81+ }
82+ if err := PreflightObject(encoded); err != nil {
83+ t.Fatal(err)
84+ }
85+ for n := range len(encoded) {
86+ if _, err := UnmarshalObject(encoded[:n]); err == nil {
87+ t.Fatalf("object prefix %d accepted", n)
88+ }
89+ }
90+ if _, err := UnmarshalObject(append(encoded, 0)); !errors.Is(err, ErrTrailingBytes) {
91+ t.Fatalf("object trailing data error = %v", err)
92+ }
93+}
94+
95+func TestManifestPreflightRejectsReferencesBeforeDecode(t *testing.T) {
96+ valid := Manifest{Name: "valid", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Objects: []ObjectRef{{Role: ObjectRoleTail, Digest: SnapshotDigest{1}}, {Role: ObjectRoleVisible, Digest: SnapshotDigest{2}}}}}}}}
97+ for _, tc := range []struct {
98+ name string
99+ mutate func(*Manifest)
100+ }{
101+ {"zero digest", func(m *Manifest) { m.Tabs[0].Panes[0].Objects[0].Digest = SnapshotDigest{} }},
102+ {"unordered role", func(m *Manifest) { m.Tabs[0].Panes[0].Objects[0].Role = ObjectRoleVisible }},
103+ {"duplicate pane", func(m *Manifest) { m.Tabs[0].Panes = append(m.Tabs[0].Panes, m.Tabs[0].Panes[0]) }},
104+ {"dangling layout", func(m *Manifest) { m.Tabs[0].Tree = layout.NewTree("missing") }},
105+ {"zero dimensions", func(m *Manifest) { m.Tabs[0].Cols = 0 }},
106+ } {
107+ t.Run(tc.name, func(t *testing.T) {
108+ m := valid
109+ m.Tabs = append([]Tab(nil), valid.Tabs...)
110+ m.Tabs[0].Panes = append([]Pane(nil), valid.Tabs[0].Panes...)
111+ m.Tabs[0].Panes[0].Objects = append([]ObjectRef(nil), valid.Tabs[0].Panes[0].Objects...)
112+ tc.mutate(&m)
113+ if _, err := MarshalManifest(m); !errors.Is(err, ErrInvalidData) && !errors.Is(err, ErrUnknownPane) {
114+ t.Fatalf("MarshalManifest() error = %v", err)
115+ }
116+ })
117+ }
118+}
@@ -0,0 +1,118 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/usecase/layout"
9+)
10+
11+func TestManifestAndHeadCodecsAreDeterministicAndStrict(t *testing.T) {
12+ digest := SnapshotDigest{1}
13+ manifest := Manifest{
14+ Name: "named",
15+ CreatedAt: 9,
16+ Active: 0,
17+ Tabs: []Tab{{
18+ StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane",
19+ Tree: layout.NewTree("pane"),
20+ Panes: []Pane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp", Objects: []ObjectRef{
21+ {Role: ObjectRoleHistory, Digest: digest},
22+ {Role: ObjectRoleTail, Digest: SnapshotDigest{2}},
23+ {Role: ObjectRoleVisible, Digest: SnapshotDigest{3}},
24+ }}},
25+ }},
26+ }
27+ encoded, err := MarshalManifest(manifest)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ encodedAgain, err := MarshalManifest(manifest)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if !bytes.Equal(encoded, encodedAgain) {
36+ t.Fatal("manifest encoding is not deterministic")
37+ }
38+ got, err := UnmarshalManifest(encoded)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if got.Name != manifest.Name || len(got.Tabs) != 1 || len(got.Tabs[0].Panes[0].Objects) != 3 {
43+ t.Fatalf("manifest round trip = %#v", got)
44+ }
45+ for n := range len(encoded) {
46+ if _, err := UnmarshalManifest(encoded[:n]); err == nil {
47+ t.Fatalf("prefix %d accepted", n)
48+ }
49+ }
50+ if _, err := UnmarshalManifest(append(encoded, 0)); !errors.Is(err, ErrTrailingBytes) {
51+ t.Fatalf("trailing data error = %v", err)
52+ }
53+
54+ head := Head{Name: "named", Generation: 7, Manifest: ManifestDigest(encoded)}
55+ headBytes, err := MarshalHead(head)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if got, err := UnmarshalHead(headBytes); err != nil || got != head {
60+ t.Fatalf("head round trip = %#v, %v", got, err)
61+ }
62+ for n := range len(headBytes) {
63+ if _, err := UnmarshalHead(headBytes[:n]); err == nil {
64+ t.Fatalf("head prefix %d accepted", n)
65+ }
66+ }
67+ if _, err := UnmarshalHead(append(headBytes, 0)); !errors.Is(err, ErrTrailingBytes) {
68+ t.Fatalf("head trailing data error = %v", err)
69+ }
70+}
71+
72+func TestObjectCodecIsTypedAndStrict(t *testing.T) {
73+ object := Object{Role: ObjectRoleVisible, Data: []byte("canonical-vt")}
74+ encoded, err := MarshalObject(object)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ got, err := UnmarshalObject(encoded)
79+ if err != nil || got.Role != object.Role || !bytes.Equal(got.Data, object.Data) {
80+ t.Fatalf("object round trip = %#v, %v", got, err)
81+ }
82+ if err := PreflightObject(encoded); err != nil {
83+ t.Fatal(err)
84+ }
85+ for n := range len(encoded) {
86+ if _, err := UnmarshalObject(encoded[:n]); err == nil {
87+ t.Fatalf("object prefix %d accepted", n)
88+ }
89+ }
90+ if _, err := UnmarshalObject(append(encoded, 0)); !errors.Is(err, ErrTrailingBytes) {
91+ t.Fatalf("object trailing data error = %v", err)
92+ }
93+}
94+
95+func TestManifestPreflightRejectsReferencesBeforeDecode(t *testing.T) {
96+ valid := Manifest{Name: "valid", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Objects: []ObjectRef{{Role: ObjectRoleTail, Digest: SnapshotDigest{1}}, {Role: ObjectRoleVisible, Digest: SnapshotDigest{2}}}}}}}}
97+ for _, tc := range []struct {
98+ name string
99+ mutate func(*Manifest)
100+ }{
101+ {"zero digest", func(m *Manifest) { m.Tabs[0].Panes[0].Objects[0].Digest = SnapshotDigest{} }},
102+ {"unordered role", func(m *Manifest) { m.Tabs[0].Panes[0].Objects[0].Role = ObjectRoleVisible }},
103+ {"duplicate pane", func(m *Manifest) { m.Tabs[0].Panes = append(m.Tabs[0].Panes, m.Tabs[0].Panes[0]) }},
104+ {"dangling layout", func(m *Manifest) { m.Tabs[0].Tree = layout.NewTree("missing") }},
105+ {"zero dimensions", func(m *Manifest) { m.Tabs[0].Cols = 0 }},
106+ } {
107+ t.Run(tc.name, func(t *testing.T) {
108+ m := valid
109+ m.Tabs = append([]Tab(nil), valid.Tabs...)
110+ m.Tabs[0].Panes = append([]Pane(nil), valid.Tabs[0].Panes...)
111+ m.Tabs[0].Panes[0].Objects = append([]ObjectRef(nil), valid.Tabs[0].Panes[0].Objects...)
112+ tc.mutate(&m)
113+ if _, err := MarshalManifest(m); !errors.Is(err, ErrInvalidData) && !errors.Is(err, ErrUnknownPane) {
114+ t.Fatalf("MarshalManifest() error = %v", err)
115+ }
116+ })
117+ }
118+}
M internal/usecase/snapshot/snapshot.go +5 -0
@@ -31,6 +31,11 @@ type Pane struct {
3131 Tail []byte // mandatory nonempty canonical VT blob
3232 Visible []byte // mandatory nonempty canonical VT blob
3333 Process *Process
34+
35+ // Objects is used only by the v1 incremental manifest codec. It must be
36+ // ordered history objects, then exactly one tail and one visible object.
37+ // The v3 codec deliberately ignores it to preserve its wire format.
38+ Objects []ObjectRef
3439 }
3540
3641 // Process captures restorable foreground process metadata.
@@ -31,6 +31,11 @@ type Pane struct {
31 Tail []byte // mandatory nonempty canonical VT blob31 Tail []byte // mandatory nonempty canonical VT blob
32 Visible []byte // mandatory nonempty canonical VT blob32 Visible []byte // mandatory nonempty canonical VT blob
33 Process *Process33 Process *Process
34+
35+ // Objects is used only by the v1 incremental manifest codec. It must be
36+ // ordered history objects, then exactly one tail and one visible object.
37+ // The v3 codec deliberately ignores it to preserve its wire format.
38+ Objects []ObjectRef
34 }39 }
35 40
36 // Process captures restorable foreground process metadata.41 // Process captures restorable foreground process metadata.

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