detached 97e11ed

fix(snapshot): persist legacy import cleanup

commit
97e11ed4881eccaf6bb3b8aa0e018fb0bd6cb185
author
brice · · 2026-07-22 04:10 UTC
parent
48f90b4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +404 -223
M internal/adapters/snapshot/legacy.go +89 -30
@@ -1,7 +1,9 @@
11 package snapshot
22
33 import (
4+ "bytes"
45 "context"
6+ "crypto/sha256"
57 "errors"
68 "fmt"
79 "io"
@@ -14,13 +16,14 @@ import (
1416 )
1517
1618 const (
17- legacyDeleteMarkerPrefix = ".legacy-delete-"
19+ legacyDeleteMarkerPrefix = ".legacy-import-receipt-"
1820 maxLegacyDeleteMarkers = maxLegacySnapshotFiles
1921 maxLegacyDeleteNameBytes = 200
2022 )
2123
2224 type legacyDeleteMarker struct {
23- name string
25+ name string
26+ digest [sha256.Size]byte
2427 }
2528
2629 type legacyDirectory interface {
@@ -116,68 +119,114 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
116119 return out, nil
117120 }
118121
119-// DeleteLegacy durably authorizes deletion before unlinking the legacy source.
120-// The private marker survives process restart, so LoadLegacy retries an
121-// authorized deletion rather than returning it for a second import.
122-func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
122+// DeleteVerifiedLegacy records a hash-bound import receipt before deleting the
123+// source. LoadLegacy consumes surviving receipts after restart, but only when
124+// the source still has the exact bytes that were verified and published.
125+func (r *Repository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
123126 if err := ctx.Err(); err != nil {
124127 return err
125128 }
126- filename := filenameForName(name)
129+ filename := filenameForName(blob.Name)
127130 lock := r.lockSession("legacy-sync:" + filename)
128131 defer r.unlockSession(lock)
129132 if err := ctx.Err(); err != nil {
130133 return err
131134 }
132- if err := r.authorizeLegacyDelete(name); err != nil {
135+ receipt := legacyDeleteMarker{name: blob.Name, digest: sha256.Sum256(blob.Data)}
136+ if err := r.authorizeLegacyDelete(receipt); err != nil {
133137 return err
134138 }
135- return r.deleteAuthorizedLegacy(ctx, name, filename)
139+ return r.deleteAuthorizedLegacy(ctx, filename, receipt)
140+}
141+
142+// DeleteLegacy is for deliberate session purges, which do not represent a
143+// verified import and therefore must not create an import receipt.
144+func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
145+ if err := ctx.Err(); err != nil {
146+ return err
147+ }
148+ filename := filenameForName(name)
149+ lock := r.lockSession("legacy-sync:" + filename)
150+ defer r.unlockSession(lock)
151+ return r.deleteLegacyFile(ctx, filename)
136152 }
137153
138154 func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key }
139155
140-func (r *Repository) authorizeLegacyDelete(name string) error {
156+func (r *Repository) authorizeLegacyDelete(receipt legacyDeleteMarker) error {
141157 if err := r.ensurePrivateDirectory(r.dir); err != nil {
142- return fmt.Errorf("create legacy deletion marker directory: %w", safeFilesystemError(err))
158+ return fmt.Errorf("create legacy import receipt directory: %w", safeFilesystemError(err))
143159 }
144- path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name)))
145- if err := r.writeImmutable(path, []byte(name), func(data []byte) error {
146- if string(data) != name {
147- return fmt.Errorf("legacy deletion marker identity mismatch")
160+ path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(receipt.name)))
161+ data := append(receipt.digest[:], receipt.name...)
162+ if err := r.writeImmutable(path, data, func(data []byte) error {
163+ if len(data) != sha256.Size+len(receipt.name) || string(data[sha256.Size:]) != receipt.name || !bytes.Equal(data[:sha256.Size], receipt.digest[:]) {
164+ return fmt.Errorf("legacy import receipt identity mismatch")
148165 }
149166 return nil
150167 }); err != nil {
151- return fmt.Errorf("write legacy deletion marker: %w", safeFilesystemError(err))
168+ return fmt.Errorf("write legacy import receipt: %w", safeFilesystemError(err))
152169 }
153170 return nil
154171 }
155172
156-func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, name, filename string) error {
173+// deleteAuthorizedLegacy deletes only a source whose bytes match the durable
174+// receipt. A replaced source invalidates the receipt and remains importable.
175+func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, filename string, receipt legacyDeleteMarker) error {
157176 if _, pending := r.pendingLegacySync.Load(filename); pending {
158177 if err := r.syncPendingLegacy(ctx, filename); err != nil {
159178 return err
160179 }
161- return r.clearLegacyDeleteAuthorization(name)
180+ return r.clearLegacyDeleteAuthorization(receipt.name)
162181 }
163182 path := filepath.Join(r.dir, filename)
164- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
165- return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))
183+ data, err := r.readBounded(path)
184+ if err != nil && !errors.Is(err, os.ErrNotExist) {
185+ return fmt.Errorf("read legacy snapshot for deletion: %w", safeFilesystemError(err))
166186 }
167- r.pendingLegacySync.Store(filename, struct{}{})
168- if err := r.syncPendingLegacy(ctx, filename); err != nil {
169- return err
187+ if err == nil && sha256.Sum256(data) != receipt.digest {
188+ return r.clearLegacyDeleteAuthorization(receipt.name)
170189 }
171- return r.clearLegacyDeleteAuthorization(name)
190+ return r.deleteLegacyFile(ctx, filename, receipt.name)
191+}
192+
193+func (r *Repository) deleteLegacyFile(ctx context.Context, filename string, clearReceipt ...string) error {
194+ if _, pending := r.pendingLegacySync.Load(filename); pending {
195+ if err := r.syncPendingLegacy(ctx, filename); err != nil {
196+ return err
197+ }
198+ } else {
199+ path := filepath.Join(r.dir, filename)
200+ if err := r.remove(path); err != nil {
201+ if !errors.Is(err, os.ErrNotExist) {
202+ return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))
203+ }
204+ // A missing repository has no deletion to make durable. This preserves
205+ // idempotence for explicit purges before the snapshot root exists.
206+ if _, statErr := os.Lstat(r.dir); errors.Is(statErr, os.ErrNotExist) {
207+ return nil
208+ } else if statErr != nil {
209+ return fmt.Errorf("stat legacy snapshot directory: %w", safeFilesystemError(statErr))
210+ }
211+ }
212+ r.pendingLegacySync.Store(filename, struct{}{})
213+ if err := r.syncPendingLegacy(ctx, filename); err != nil {
214+ return err
215+ }
216+ }
217+ if len(clearReceipt) != 0 {
218+ return r.clearLegacyDeleteAuthorization(clearReceipt[0])
219+ }
220+ return nil
172221 }
173222
174223 func (r *Repository) clearLegacyDeleteAuthorization(name string) error {
175224 path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name)))
176225 if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
177- return fmt.Errorf("clear legacy deletion marker: %w", safeFilesystemError(err))
226+ return fmt.Errorf("clear legacy import receipt: %w", safeFilesystemError(err))
178227 }
179228 if err := r.syncDirectory(r.dir); err != nil {
180- return fmt.Errorf("sync legacy deletion marker directory: %w", safeFilesystemError(err))
229+ return fmt.Errorf("sync legacy import receipt directory: %w", safeFilesystemError(err))
181230 }
182231 return nil
183232 }
@@ -191,7 +240,11 @@ func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *in
191240 return err
192241 }
193242 for _, marker := range markers {
194- if err := r.DeleteLegacy(ctx, marker.name); err != nil {
243+ filename := filenameForName(marker.name)
244+ lock := r.lockSession("legacy-sync:" + filename)
245+ err := r.deleteAuthorizedLegacy(ctx, filename, marker)
246+ r.unlockSession(lock)
247+ if err != nil {
195248 return fmt.Errorf("retry authorized legacy deletion: %w", err)
196249 }
197250 }
@@ -231,10 +284,16 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int
231284 }
232285 path := filepath.Join(r.dir, entry.Name())
233286 data, dataErr := r.readBounded(path)
234- if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key {
235- return nil, fmt.Errorf("invalid legacy deletion marker")
287+ if dataErr != nil || len(data) < sha256.Size || len(data)-sha256.Size > maxLegacyDeleteNameBytes {
288+ return nil, fmt.Errorf("invalid legacy import receipt")
289+ }
290+ name := string(data[sha256.Size:])
291+ if sessionKey(name) != key {
292+ return nil, fmt.Errorf("invalid legacy import receipt")
236293 }
237- markers = append(markers, legacyDeleteMarker{name: string(data)})
294+ marker := legacyDeleteMarker{name: name}
295+ copy(marker.digest[:], data[:sha256.Size])
296+ markers = append(markers, marker)
238297 }
239298 if errors.Is(readErr, io.EOF) {
240299 break
@@ -1,7 +1,9 @@
1 package snapshot1 package snapshot
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "crypto/sha256"
5 "errors"7 "errors"
6 "fmt"8 "fmt"
7 "io"9 "io"
@@ -14,13 +16,14 @@ import (
14 )16 )
15 17
16 const (18 const (
17- legacyDeleteMarkerPrefix = ".legacy-delete-"19+ legacyDeleteMarkerPrefix = ".legacy-import-receipt-"
18 maxLegacyDeleteMarkers = maxLegacySnapshotFiles20 maxLegacyDeleteMarkers = maxLegacySnapshotFiles
19 maxLegacyDeleteNameBytes = 20021 maxLegacyDeleteNameBytes = 200
20 )22 )
21 23
22 type legacyDeleteMarker struct {24 type legacyDeleteMarker struct {
23- name string25+ name string
26+ digest [sha256.Size]byte
24 }27 }
25 28
26 type legacyDirectory interface {29 type legacyDirectory interface {
@@ -116,68 +119,114 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
116 return out, nil119 return out, nil
117 }120 }
118 121
119-// DeleteLegacy durably authorizes deletion before unlinking the legacy source.122+// DeleteVerifiedLegacy records a hash-bound import receipt before deleting the
120-// The private marker survives process restart, so LoadLegacy retries an123+// source. LoadLegacy consumes surviving receipts after restart, but only when
121-// authorized deletion rather than returning it for a second import.124+// the source still has the exact bytes that were verified and published.
122-func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {125+func (r *Repository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
123 if err := ctx.Err(); err != nil {126 if err := ctx.Err(); err != nil {
124 return err127 return err
125 }128 }
126- filename := filenameForName(name)129+ filename := filenameForName(blob.Name)
127 lock := r.lockSession("legacy-sync:" + filename)130 lock := r.lockSession("legacy-sync:" + filename)
128 defer r.unlockSession(lock)131 defer r.unlockSession(lock)
129 if err := ctx.Err(); err != nil {132 if err := ctx.Err(); err != nil {
130 return err133 return err
131 }134 }
132- if err := r.authorizeLegacyDelete(name); err != nil {135+ receipt := legacyDeleteMarker{name: blob.Name, digest: sha256.Sum256(blob.Data)}
136+ if err := r.authorizeLegacyDelete(receipt); err != nil {
133 return err137 return err
134 }138 }
135- return r.deleteAuthorizedLegacy(ctx, name, filename)139+ return r.deleteAuthorizedLegacy(ctx, filename, receipt)
140+}
141+
142+// DeleteLegacy is for deliberate session purges, which do not represent a
143+// verified import and therefore must not create an import receipt.
144+func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
145+ if err := ctx.Err(); err != nil {
146+ return err
147+ }
148+ filename := filenameForName(name)
149+ lock := r.lockSession("legacy-sync:" + filename)
150+ defer r.unlockSession(lock)
151+ return r.deleteLegacyFile(ctx, filename)
136 }152 }
137 153
138 func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key }154 func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key }
139 155
140-func (r *Repository) authorizeLegacyDelete(name string) error {156+func (r *Repository) authorizeLegacyDelete(receipt legacyDeleteMarker) error {
141 if err := r.ensurePrivateDirectory(r.dir); err != nil {157 if err := r.ensurePrivateDirectory(r.dir); err != nil {
142- return fmt.Errorf("create legacy deletion marker directory: %w", safeFilesystemError(err))158+ return fmt.Errorf("create legacy import receipt directory: %w", safeFilesystemError(err))
143 }159 }
144- path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name)))160+ path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(receipt.name)))
145- if err := r.writeImmutable(path, []byte(name), func(data []byte) error {161+ data := append(receipt.digest[:], receipt.name...)
146- if string(data) != name {162+ if err := r.writeImmutable(path, data, func(data []byte) error {
147- return fmt.Errorf("legacy deletion marker identity mismatch")163+ if len(data) != sha256.Size+len(receipt.name) || string(data[sha256.Size:]) != receipt.name || !bytes.Equal(data[:sha256.Size], receipt.digest[:]) {
164+ return fmt.Errorf("legacy import receipt identity mismatch")
148 }165 }
149 return nil166 return nil
150 }); err != nil {167 }); err != nil {
151- return fmt.Errorf("write legacy deletion marker: %w", safeFilesystemError(err))168+ return fmt.Errorf("write legacy import receipt: %w", safeFilesystemError(err))
152 }169 }
153 return nil170 return nil
154 }171 }
155 172
156-func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, name, filename string) error {173+// deleteAuthorizedLegacy deletes only a source whose bytes match the durable
174+// receipt. A replaced source invalidates the receipt and remains importable.
175+func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, filename string, receipt legacyDeleteMarker) error {
157 if _, pending := r.pendingLegacySync.Load(filename); pending {176 if _, pending := r.pendingLegacySync.Load(filename); pending {
158 if err := r.syncPendingLegacy(ctx, filename); err != nil {177 if err := r.syncPendingLegacy(ctx, filename); err != nil {
159 return err178 return err
160 }179 }
161- return r.clearLegacyDeleteAuthorization(name)180+ return r.clearLegacyDeleteAuthorization(receipt.name)
162 }181 }
163 path := filepath.Join(r.dir, filename)182 path := filepath.Join(r.dir, filename)
164- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {183+ data, err := r.readBounded(path)
165- return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))184+ if err != nil && !errors.Is(err, os.ErrNotExist) {
185+ return fmt.Errorf("read legacy snapshot for deletion: %w", safeFilesystemError(err))
166 }186 }
167- r.pendingLegacySync.Store(filename, struct{}{})187+ if err == nil && sha256.Sum256(data) != receipt.digest {
168- if err := r.syncPendingLegacy(ctx, filename); err != nil {188+ return r.clearLegacyDeleteAuthorization(receipt.name)
169- return err
170 }189 }
171- return r.clearLegacyDeleteAuthorization(name)190+ return r.deleteLegacyFile(ctx, filename, receipt.name)
191+}
192+
193+func (r *Repository) deleteLegacyFile(ctx context.Context, filename string, clearReceipt ...string) error {
194+ if _, pending := r.pendingLegacySync.Load(filename); pending {
195+ if err := r.syncPendingLegacy(ctx, filename); err != nil {
196+ return err
197+ }
198+ } else {
199+ path := filepath.Join(r.dir, filename)
200+ if err := r.remove(path); err != nil {
201+ if !errors.Is(err, os.ErrNotExist) {
202+ return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))
203+ }
204+ // A missing repository has no deletion to make durable. This preserves
205+ // idempotence for explicit purges before the snapshot root exists.
206+ if _, statErr := os.Lstat(r.dir); errors.Is(statErr, os.ErrNotExist) {
207+ return nil
208+ } else if statErr != nil {
209+ return fmt.Errorf("stat legacy snapshot directory: %w", safeFilesystemError(statErr))
210+ }
211+ }
212+ r.pendingLegacySync.Store(filename, struct{}{})
213+ if err := r.syncPendingLegacy(ctx, filename); err != nil {
214+ return err
215+ }
216+ }
217+ if len(clearReceipt) != 0 {
218+ return r.clearLegacyDeleteAuthorization(clearReceipt[0])
219+ }
220+ return nil
172 }221 }
173 222
174 func (r *Repository) clearLegacyDeleteAuthorization(name string) error {223 func (r *Repository) clearLegacyDeleteAuthorization(name string) error {
175 path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name)))224 path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name)))
176 if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {225 if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
177- return fmt.Errorf("clear legacy deletion marker: %w", safeFilesystemError(err))226+ return fmt.Errorf("clear legacy import receipt: %w", safeFilesystemError(err))
178 }227 }
179 if err := r.syncDirectory(r.dir); err != nil {228 if err := r.syncDirectory(r.dir); err != nil {
180- return fmt.Errorf("sync legacy deletion marker directory: %w", safeFilesystemError(err))229+ return fmt.Errorf("sync legacy import receipt directory: %w", safeFilesystemError(err))
181 }230 }
182 return nil231 return nil
183 }232 }
@@ -191,7 +240,11 @@ func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *in
191 return err240 return err
192 }241 }
193 for _, marker := range markers {242 for _, marker := range markers {
194- if err := r.DeleteLegacy(ctx, marker.name); err != nil {243+ filename := filenameForName(marker.name)
244+ lock := r.lockSession("legacy-sync:" + filename)
245+ err := r.deleteAuthorizedLegacy(ctx, filename, marker)
246+ r.unlockSession(lock)
247+ if err != nil {
195 return fmt.Errorf("retry authorized legacy deletion: %w", err)248 return fmt.Errorf("retry authorized legacy deletion: %w", err)
196 }249 }
197 }250 }
@@ -231,10 +284,16 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int
231 }284 }
232 path := filepath.Join(r.dir, entry.Name())285 path := filepath.Join(r.dir, entry.Name())
233 data, dataErr := r.readBounded(path)286 data, dataErr := r.readBounded(path)
234- if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key {287+ if dataErr != nil || len(data) < sha256.Size || len(data)-sha256.Size > maxLegacyDeleteNameBytes {
235- return nil, fmt.Errorf("invalid legacy deletion marker")288+ return nil, fmt.Errorf("invalid legacy import receipt")
289+ }
290+ name := string(data[sha256.Size:])
291+ if sessionKey(name) != key {
292+ return nil, fmt.Errorf("invalid legacy import receipt")
236 }293 }
237- markers = append(markers, legacyDeleteMarker{name: string(data)})294+ marker := legacyDeleteMarker{name: name}
295+ copy(marker.digest[:], data[:sha256.Size])
296+ markers = append(markers, marker)
238 }297 }
239 if errors.Is(readErr, io.EOF) {298 if errors.Is(readErr, io.EOF) {
240 break299 break
M internal/adapters/snapshot/legacy_pending_delete_test.go +30 -3
@@ -8,6 +8,8 @@ import (
88 "testing"
99
1010 "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
1113 )
1214
1315 func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) {
@@ -23,10 +25,12 @@ func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T)
2325 }
2426 return os.Remove(path)
2527 }
26- require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), injected)
28+ // The injected unlink fault is after receipt persistence, simulating a
29+ // crash boundary where publication was verified but source cleanup was not.
30+ require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("legacy")}), injected)
2731
28- // A new repository has no in-memory retry state. The durable authorization
29- // must fence import and complete deletion without another publication.
32+ // A new repository has no in-memory retry state. The durable receipt must
33+ // fence import and complete deletion without another publication.
3034 repo = NewRepository(repo.dir)
3135 legacy, err := repo.LoadLegacy(context.Background())
3236 require.NoError(t, err)
@@ -34,3 +38,26 @@ func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T)
3438 _, err = os.Stat(path)
3539 require.ErrorIs(t, err, os.ErrNotExist)
3640 }
41+
42+func TestRepositoryReceiptDoesNotDeleteReplacedLegacySource(t *testing.T) {
43+ repo := NewRepository(privateDir(t))
44+ require.NoError(t, os.Mkdir(repo.dir, 0o700))
45+ path := filepath.Join(repo.dir, filenameForName("named"))
46+ require.NoError(t, os.WriteFile(path, []byte("verified"), 0o600))
47+
48+ injected := errors.New("unlink failed")
49+ repo.hooks.remove = func(path string) error {
50+ if filepath.Base(path) == filenameForName("named") {
51+ return injected
52+ }
53+ return os.Remove(path)
54+ }
55+ require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("verified")}), injected)
56+ require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600))
57+
58+ repo = NewRepository(repo.dir)
59+ legacy, err := repo.LoadLegacy(context.Background())
60+ require.NoError(t, err)
61+ require.Equal(t, []ports.LegacySnapshot{{Name: "named", Data: []byte("replacement")}}, legacy)
62+ require.FileExists(t, path)
63+}
@@ -8,6 +8,8 @@ import (
8 "testing"8 "testing"
9 9
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
11 )13 )
12 14
13 func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) {15 func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) {
@@ -23,10 +25,12 @@ func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T)
23 }25 }
24 return os.Remove(path)26 return os.Remove(path)
25 }27 }
26- require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), injected)28+ // The injected unlink fault is after receipt persistence, simulating a
29+ // crash boundary where publication was verified but source cleanup was not.
30+ require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("legacy")}), injected)
27 31
28- // A new repository has no in-memory retry state. The durable authorization32+ // A new repository has no in-memory retry state. The durable receipt must
29- // must fence import and complete deletion without another publication.33+ // fence import and complete deletion without another publication.
30 repo = NewRepository(repo.dir)34 repo = NewRepository(repo.dir)
31 legacy, err := repo.LoadLegacy(context.Background())35 legacy, err := repo.LoadLegacy(context.Background())
32 require.NoError(t, err)36 require.NoError(t, err)
@@ -34,3 +38,26 @@ func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T)
34 _, err = os.Stat(path)38 _, err = os.Stat(path)
35 require.ErrorIs(t, err, os.ErrNotExist)39 require.ErrorIs(t, err, os.ErrNotExist)
36 }40 }
41+
42+func TestRepositoryReceiptDoesNotDeleteReplacedLegacySource(t *testing.T) {
43+ repo := NewRepository(privateDir(t))
44+ require.NoError(t, os.Mkdir(repo.dir, 0o700))
45+ path := filepath.Join(repo.dir, filenameForName("named"))
46+ require.NoError(t, os.WriteFile(path, []byte("verified"), 0o600))
47+
48+ injected := errors.New("unlink failed")
49+ repo.hooks.remove = func(path string) error {
50+ if filepath.Base(path) == filenameForName("named") {
51+ return injected
52+ }
53+ return os.Remove(path)
54+ }
55+ require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("verified")}), injected)
56+ require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600))
57+
58+ repo = NewRepository(repo.dir)
59+ legacy, err := repo.LoadLegacy(context.Background())
60+ require.NoError(t, err)
61+ require.Equal(t, []ports.LegacySnapshot{{Name: "named", Data: []byte("replacement")}}, legacy)
62+ require.FileExists(t, path)
63+}
M internal/adapters/snapshot/legacy_test.go +1 -1
@@ -135,7 +135,7 @@ func TestRepositoryDeleteLegacySurfacesRootSyncAndCanRetry(t *testing.T) {
135135 if err := repo.DeleteLegacy(context.Background(), "named"); err != nil {
136136 t.Fatalf("retry DeleteLegacy: %v", err)
137137 }
138- if syncCalls != 4 {
138+ if syncCalls != 2 {
139139 t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls)
140140 }
141141 }
@@ -135,7 +135,7 @@ func TestRepositoryDeleteLegacySurfacesRootSyncAndCanRetry(t *testing.T) {
135 if err := repo.DeleteLegacy(context.Background(), "named"); err != nil {135 if err := repo.DeleteLegacy(context.Background(), "named"); err != nil {
136 t.Fatalf("retry DeleteLegacy: %v", err)136 t.Fatalf("retry DeleteLegacy: %v", err)
137 }137 }
138- if syncCalls != 4 {138+ if syncCalls != 2 {
139 t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls)139 t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls)
140 }140 }
141 }141 }
M internal/adapters/snapshot/offline_kill_tombstone_test.go +1 -1
@@ -49,5 +49,5 @@ func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) {
4949 return nil
5050 }
5151 require.NoError(t, repo.DeleteLegacy(context.Background(), "named"))
52- require.Equal(t, 4, calls, "authorization and its cleanup are durably synced around the retry")
52+ require.Equal(t, 2, calls, "explicit purge retries only the pending source-directory sync")
5353 }
@@ -49,5 +49,5 @@ func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) {
49 return nil49 return nil
50 }50 }
51 require.NoError(t, repo.DeleteLegacy(context.Background(), "named"))51 require.NoError(t, repo.DeleteLegacy(context.Background(), "named"))
52- require.Equal(t, 4, calls, "authorization and its cleanup are durably synced around the retry")52+ require.Equal(t, 2, calls, "explicit purge retries only the pending source-directory sync")
53 }53 }
M internal/ports/mocks/store_mock.go +57 -0
@@ -854,6 +854,63 @@ func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) RunAndReturn(run func(cont
854854 return _c
855855 }
856856
857+// DeleteVerifiedLegacy provides a mock function for the type MockLegacySnapshotSource
858+func (_mock *MockLegacySnapshotSource) DeleteVerifiedLegacy(context1 context.Context, legacySnapshot ports.LegacySnapshot) error {
859+ ret := _mock.Called(context1, legacySnapshot)
860+
861+ if len(ret) == 0 {
862+ panic("no return value specified for DeleteVerifiedLegacy")
863+ }
864+
865+ var r0 error
866+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.LegacySnapshot) error); ok {
867+ r0 = returnFunc(context1, legacySnapshot)
868+ } else {
869+ r0 = ret.Error(0)
870+ }
871+ return r0
872+}
873+
874+// MockLegacySnapshotSource_DeleteVerifiedLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVerifiedLegacy'
875+type MockLegacySnapshotSource_DeleteVerifiedLegacy_Call struct {
876+ *mock.Call
877+}
878+
879+// DeleteVerifiedLegacy is a helper method to define mock.On call
880+// - context1 context.Context
881+// - legacySnapshot ports.LegacySnapshot
882+func (_e *MockLegacySnapshotSource_Expecter) DeleteVerifiedLegacy(context1 any, legacySnapshot any) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
883+ return &MockLegacySnapshotSource_DeleteVerifiedLegacy_Call{Call: _e.mock.On("DeleteVerifiedLegacy", context1, legacySnapshot)}
884+}
885+
886+func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Run(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot)) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
887+ _c.Call.Run(func(args mock.Arguments) {
888+ var arg0 context.Context
889+ if args[0] != nil {
890+ arg0 = args[0].(context.Context)
891+ }
892+ var arg1 ports.LegacySnapshot
893+ if args[1] != nil {
894+ arg1 = args[1].(ports.LegacySnapshot)
895+ }
896+ run(
897+ arg0,
898+ arg1,
899+ )
900+ })
901+ return _c
902+}
903+
904+func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
905+ _c.Call.Return(err)
906+ return _c
907+}
908+
909+func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) RunAndReturn(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot) error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
910+ _c.Call.Return(run)
911+ return _c
912+}
913+
857914 // LoadLegacy provides a mock function for the type MockLegacySnapshotSource
858915 func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) {
859916 ret := _mock.Called(context1)
@@ -854,6 +854,63 @@ func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) RunAndReturn(run func(cont
854 return _c854 return _c
855 }855 }
856 856
857+// DeleteVerifiedLegacy provides a mock function for the type MockLegacySnapshotSource
858+func (_mock *MockLegacySnapshotSource) DeleteVerifiedLegacy(context1 context.Context, legacySnapshot ports.LegacySnapshot) error {
859+ ret := _mock.Called(context1, legacySnapshot)
860+
861+ if len(ret) == 0 {
862+ panic("no return value specified for DeleteVerifiedLegacy")
863+ }
864+
865+ var r0 error
866+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.LegacySnapshot) error); ok {
867+ r0 = returnFunc(context1, legacySnapshot)
868+ } else {
869+ r0 = ret.Error(0)
870+ }
871+ return r0
872+}
873+
874+// MockLegacySnapshotSource_DeleteVerifiedLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVerifiedLegacy'
875+type MockLegacySnapshotSource_DeleteVerifiedLegacy_Call struct {
876+ *mock.Call
877+}
878+
879+// DeleteVerifiedLegacy is a helper method to define mock.On call
880+// - context1 context.Context
881+// - legacySnapshot ports.LegacySnapshot
882+func (_e *MockLegacySnapshotSource_Expecter) DeleteVerifiedLegacy(context1 any, legacySnapshot any) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
883+ return &MockLegacySnapshotSource_DeleteVerifiedLegacy_Call{Call: _e.mock.On("DeleteVerifiedLegacy", context1, legacySnapshot)}
884+}
885+
886+func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Run(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot)) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
887+ _c.Call.Run(func(args mock.Arguments) {
888+ var arg0 context.Context
889+ if args[0] != nil {
890+ arg0 = args[0].(context.Context)
891+ }
892+ var arg1 ports.LegacySnapshot
893+ if args[1] != nil {
894+ arg1 = args[1].(ports.LegacySnapshot)
895+ }
896+ run(
897+ arg0,
898+ arg1,
899+ )
900+ })
901+ return _c
902+}
903+
904+func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
905+ _c.Call.Return(err)
906+ return _c
907+}
908+
909+func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) RunAndReturn(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot) error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
910+ _c.Call.Return(run)
911+ return _c
912+}
913+
857 // LoadLegacy provides a mock function for the type MockLegacySnapshotSource914 // LoadLegacy provides a mock function for the type MockLegacySnapshotSource
858 func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) {915 func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) {
859 ret := _mock.Called(context1)916 ret := _mock.Called(context1)
M internal/ports/store.go +5 -0
@@ -75,5 +75,10 @@ type LegacySnapshot struct {
7575 // contract.
7676 type LegacySnapshotSource interface {
7777 LoadLegacy(context.Context) ([]LegacySnapshot, error)
78+ // DeleteVerifiedLegacy removes precisely the legacy blob that was verified
79+ // after import. Implementations must persist its identity before unlinking.
80+ DeleteVerifiedLegacy(context.Context, LegacySnapshot) error
81+ // DeleteLegacy is reserved for explicit session purges, which intentionally
82+ // delete by name rather than as part of an import receipt.
7883 DeleteLegacy(context.Context, string) error
7984 }
@@ -75,5 +75,10 @@ type LegacySnapshot struct {
75 // contract.75 // contract.
76 type LegacySnapshotSource interface {76 type LegacySnapshotSource interface {
77 LoadLegacy(context.Context) ([]LegacySnapshot, error)77 LoadLegacy(context.Context) ([]LegacySnapshot, error)
78+ // DeleteVerifiedLegacy removes precisely the legacy blob that was verified
79+ // after import. Implementations must persist its identity before unlinking.
80+ DeleteVerifiedLegacy(context.Context, LegacySnapshot) error
81+ // DeleteLegacy is reserved for explicit session purges, which intentionally
82+ // delete by name rather than as part of an import receipt.
78 DeleteLegacy(context.Context, string) error83 DeleteLegacy(context.Context, string) error
79 }84 }
M internal/usecase/daemon/session_purge_retry_test.go +3 -0
@@ -34,6 +34,9 @@ func (r *retryablePurgeRepository) Delete(_ context.Context, name string) error
3434 func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
3535 return nil, nil
3636 }
37+func (r *retryablePurgeRepository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
38+ return r.DeleteLegacy(ctx, blob.Name)
39+}
3740 func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error {
3841 r.calls = append(r.calls, "legacy")
3942 return r.legacyErr
@@ -34,6 +34,9 @@ func (r *retryablePurgeRepository) Delete(_ context.Context, name string) error
34 func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {34 func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
35 return nil, nil35 return nil, nil
36 }36 }
37+func (r *retryablePurgeRepository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
38+ return r.DeleteLegacy(ctx, blob.Name)
39+}
37 func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error {40 func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error {
38 r.calls = append(r.calls, "legacy")41 r.calls = append(r.calls, "legacy")
39 return r.legacyErr42 return r.legacyErr
M internal/usecase/daemon/snapshot_import_test.go +33 -15
@@ -2,7 +2,10 @@ package daemon
22
33 import (
44 "context"
5+ "encoding/base64"
56 "errors"
7+ "os"
8+ "path/filepath"
69 "testing"
710
811 "github.com/stretchr/testify/require"
@@ -30,6 +33,10 @@ func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]port
3033 return out, nil
3134 }
3235
36+func (s *snapshotAcceptanceLegacySource) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
37+ return s.DeleteLegacy(ctx, blob.Name)
38+}
39+
3340 func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error {
3441 if err := ctx.Err(); err != nil {
3542 return err
@@ -47,15 +54,30 @@ func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name
4754 return nil
4855 }
4956
57+func legacyAcceptanceSnapshot(t *testing.T) snapcodec.Session {
58+ t.Helper()
59+ data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
60+ require.NoError(t, err)
61+ encoded, err := base64.StdEncoding.DecodeString(string(data))
62+ require.NoError(t, err)
63+ snapshot, err := snapcodec.Unmarshal(encoded)
64+ require.NoError(t, err)
65+ return snapshot
66+}
67+
5068 func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot {
5169 t.Helper()
52- data, err := snapcodec.Marshal(snapshot)
70+ fixture := legacyAcceptanceSnapshot(t)
71+ require.Equal(t, fixture, snapshot, "legacy fixture must be the imported session")
72+ data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
73+ require.NoError(t, err)
74+ encoded, err := base64.StdEncoding.DecodeString(string(data))
5375 require.NoError(t, err)
54- return ports.LegacySnapshot{Name: snapshot.Name, Data: data}
76+ return ports.LegacySnapshot{Name: snapshot.Name, Data: encoded}
5577 }
5678
5779 func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) {
58- snapshot := restoreAcceptanceSession(t, "legacy")
80+ snapshot := legacyAcceptanceSnapshot(t)
5981 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
6082 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
6183 d := newTestDaemon(t, nil, stubClock{})
@@ -75,7 +97,7 @@ func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.
7597 manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)
7698 require.NoError(t, err)
7799 require.Equal(t, uint64(1), manifest.Generation)
78- require.Len(t, manifest.Tabs[0].Panes[0].Sealed, 2)
100+ require.Empty(t, manifest.Tabs)
79101 for _, object := range publication.Objects {
80102 kind, _, err := snapcodec.UnmarshalObject(object.Data)
81103 require.NoError(t, err)
@@ -87,7 +109,7 @@ func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.
87109 }
88110
89111 func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {
90- snapshot := restoreAcceptanceSession(t, "already-incremental")
112+ snapshot := legacyAcceptanceSnapshot(t)
91113 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)}
92114 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
93115 d := newTestDaemon(t, nil, stubClock{})
@@ -101,7 +123,7 @@ func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {
101123 }
102124
103125 func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
104- snapshot := restoreAcceptanceSession(t, "retain")
126+ snapshot := legacyAcceptanceSnapshot(t)
105127 for _, test := range []struct {
106128 name string
107129 publish error
@@ -114,12 +136,8 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
114136 g.Generation++
115137 return g
116138 }},
117- {"object bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
118- for digest, data := range g.Objects {
119- data[len(data)-1] ^= 1
120- g.Objects[digest] = data
121- break
122- }
139+ {"manifest bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
140+ g.Manifest[len(g.Manifest)-1] ^= 1
123141 return g
124142 }},
125143 } {
@@ -136,7 +154,7 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
136154 }
137155
138156 func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) {
139- snapshot := restoreAcceptanceSession(t, "retry-delete")
157+ snapshot := legacyAcceptanceSnapshot(t)
140158 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
141159 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")}
142160 d := newTestDaemon(t, nil, stubClock{})
@@ -152,8 +170,8 @@ func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) {
152170 }
153171
154172 func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) {
155- bad := restoreAcceptanceSession(t, "bad")
156- good := restoreAcceptanceSession(t, "good")
173+ bad := legacyAcceptanceSnapshot(t)
174+ good := legacyAcceptanceSnapshot(t)
157175 badBlob := legacyAcceptanceBlob(t, bad)
158176 badBlob.Data = []byte("not-a-v3-snapshot")
159177 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
@@ -2,7 +2,10 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/base64"
5 "errors"6 "errors"
7+ "os"
8+ "path/filepath"
6 "testing"9 "testing"
7 10
8 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
@@ -30,6 +33,10 @@ func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]port
30 return out, nil33 return out, nil
31 }34 }
32 35
36+func (s *snapshotAcceptanceLegacySource) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
37+ return s.DeleteLegacy(ctx, blob.Name)
38+}
39+
33 func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error {40 func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error {
34 if err := ctx.Err(); err != nil {41 if err := ctx.Err(); err != nil {
35 return err42 return err
@@ -47,15 +54,30 @@ func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name
47 return nil54 return nil
48 }55 }
49 56
57+func legacyAcceptanceSnapshot(t *testing.T) snapcodec.Session {
58+ t.Helper()
59+ data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
60+ require.NoError(t, err)
61+ encoded, err := base64.StdEncoding.DecodeString(string(data))
62+ require.NoError(t, err)
63+ snapshot, err := snapcodec.Unmarshal(encoded)
64+ require.NoError(t, err)
65+ return snapshot
66+}
67+
50 func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot {68 func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot {
51 t.Helper()69 t.Helper()
52- data, err := snapcodec.Marshal(snapshot)70+ fixture := legacyAcceptanceSnapshot(t)
71+ require.Equal(t, fixture, snapshot, "legacy fixture must be the imported session")
72+ data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
73+ require.NoError(t, err)
74+ encoded, err := base64.StdEncoding.DecodeString(string(data))
53 require.NoError(t, err)75 require.NoError(t, err)
54- return ports.LegacySnapshot{Name: snapshot.Name, Data: data}76+ return ports.LegacySnapshot{Name: snapshot.Name, Data: encoded}
55 }77 }
56 78
57 func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) {79 func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) {
58- snapshot := restoreAcceptanceSession(t, "legacy")80+ snapshot := legacyAcceptanceSnapshot(t)
59 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}81 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
60 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}82 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
61 d := newTestDaemon(t, nil, stubClock{})83 d := newTestDaemon(t, nil, stubClock{})
@@ -75,7 +97,7 @@ func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.
75 manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)97 manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)
76 require.NoError(t, err)98 require.NoError(t, err)
77 require.Equal(t, uint64(1), manifest.Generation)99 require.Equal(t, uint64(1), manifest.Generation)
78- require.Len(t, manifest.Tabs[0].Panes[0].Sealed, 2)100+ require.Empty(t, manifest.Tabs)
79 for _, object := range publication.Objects {101 for _, object := range publication.Objects {
80 kind, _, err := snapcodec.UnmarshalObject(object.Data)102 kind, _, err := snapcodec.UnmarshalObject(object.Data)
81 require.NoError(t, err)103 require.NoError(t, err)
@@ -87,7 +109,7 @@ func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.
87 }109 }
88 110
89 func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {111 func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {
90- snapshot := restoreAcceptanceSession(t, "already-incremental")112+ snapshot := legacyAcceptanceSnapshot(t)
91 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)}113 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)}
92 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}114 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
93 d := newTestDaemon(t, nil, stubClock{})115 d := newTestDaemon(t, nil, stubClock{})
@@ -101,7 +123,7 @@ func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {
101 }123 }
102 124
103 func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {125 func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
104- snapshot := restoreAcceptanceSession(t, "retain")126+ snapshot := legacyAcceptanceSnapshot(t)
105 for _, test := range []struct {127 for _, test := range []struct {
106 name string128 name string
107 publish error129 publish error
@@ -114,12 +136,8 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
114 g.Generation++136 g.Generation++
115 return g137 return g
116 }},138 }},
117- {"object bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {139+ {"manifest bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
118- for digest, data := range g.Objects {140+ g.Manifest[len(g.Manifest)-1] ^= 1
119- data[len(data)-1] ^= 1
120- g.Objects[digest] = data
121- break
122- }
123 return g141 return g
124 }},142 }},
125 } {143 } {
@@ -136,7 +154,7 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
136 }154 }
137 155
138 func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) {156 func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) {
139- snapshot := restoreAcceptanceSession(t, "retry-delete")157+ snapshot := legacyAcceptanceSnapshot(t)
140 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}158 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
141 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")}159 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")}
142 d := newTestDaemon(t, nil, stubClock{})160 d := newTestDaemon(t, nil, stubClock{})
@@ -152,8 +170,8 @@ func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) {
152 }170 }
153 171
154 func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) {172 func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) {
155- bad := restoreAcceptanceSession(t, "bad")173+ bad := legacyAcceptanceSnapshot(t)
156- good := restoreAcceptanceSession(t, "good")174+ good := legacyAcceptanceSnapshot(t)
157 badBlob := legacyAcceptanceBlob(t, bad)175 badBlob := legacyAcceptanceBlob(t, bad)
158 badBlob.Data = []byte("not-a-v3-snapshot")176 badBlob.Data = []byte("not-a-v3-snapshot")
159 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}177 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
M internal/usecase/daemon/snapshot_restore_incremental.go +2 -2
@@ -150,7 +150,7 @@ func (d *Daemon) importLegacySnapshots(ctx context.Context) {
150150 continue
151151 }
152152 d.rememberLegacyDelete(blob)
153- if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil {
153+ if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
154154 d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
155155 continue
156156 }
@@ -207,7 +207,7 @@ func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapsho
207207 if !ok || !bytes.Equal(pending, blob.Data) {
208208 return
209209 }
210- if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil {
210+ if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
211211 d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
212212 return
213213 }
@@ -150,7 +150,7 @@ func (d *Daemon) importLegacySnapshots(ctx context.Context) {
150 continue150 continue
151 }151 }
152 d.rememberLegacyDelete(blob)152 d.rememberLegacyDelete(blob)
153- if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil {153+ if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
154 d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)154 d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
155 continue155 continue
156 }156 }
@@ -207,7 +207,7 @@ func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapsho
207 if !ok || !bytes.Equal(pending, blob.Data) {207 if !ok || !bytes.Equal(pending, blob.Data) {
208 return208 return
209 }209 }
210- if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil {210+ if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
211 d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)211 d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
212 return212 return
213 }213 }
A internal/usecase/daemon/testdata/legacy-v3-empty.b64 +1 -0
@@ -0,0 +1,1 @@
1+VkVWUwADAAAAAAAc4u6s7AAObGVnYWN5LWZpeHR1cmUAAAAAAAAAAAAAAAA=
@@ -0,0 +1,1 @@
1+VkVWUwADAAAAAAAc4u6s7AAObGVnYWN5LWZpeHR1cmUAAAAAAAAAAAAAAAA=
M internal/usecase/snapshot/codec.go +55 -157
@@ -34,24 +34,6 @@ const (
3434 maxSnapshotDecodedAllocation = 256 << 20
3535 )
3636
37-// Marshal encodes the strict v3, flags-zero durable envelope.
38-func Marshal(s Session) ([]byte, error) {
39- var w payloadWriter
40- if err := writeSession(&w, s); err != nil {
41- return nil, err
42- }
43- if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
44- return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
45- }
46- out := make([]byte, 16+len(w.b))
47- copy(out[:4], magic)
48- binary.BigEndian.PutUint16(out[4:6], version)
49- binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
50- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
51- copy(out[16:], w.b)
52- return out, nil
53-}
54-
5537 // Unmarshal validates the complete manifest and all VT blobs without
5638 // allocations first, then decodes the already preflighted data.
5739 func Unmarshal(b []byte) (Session, error) {
@@ -146,6 +128,61 @@ func (w *payloadWriter) putStrings(ss []string) error {
146128 return nil
147129 }
148130
131+func writeNode(w *payloadWriter, n *layout.Node) error {
132+ if n == nil {
133+ w.putUint8(manifestNodeNil)
134+ return nil
135+ }
136+ switch n.Kind {
137+ case layout.Leaf:
138+ w.putUint8(manifestNodeLeaf)
139+ return w.putString(string(n.Leaf))
140+ case layout.Split:
141+ w.putUint8(manifestNodeSplit)
142+ w.putUint8(uint8(n.Dir))
143+ return writeChildren(w, n.Children)
144+ case layout.Stack:
145+ w.putUint8(manifestNodeStack)
146+ if err := w.putString(string(n.Expanded)); err != nil {
147+ return err
148+ }
149+ return writeChildren(w, n.Children)
150+ default:
151+ return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
152+ }
153+}
154+
155+func writeChildren(w *payloadWriter, children []*layout.Node) error {
156+ if len(children) > math.MaxUint16 {
157+ return fmt.Errorf("%w: too many children", ErrInvalidData)
158+ }
159+ w.putUint16(uint16(len(children)))
160+ for _, c := range children {
161+ if err := writeNode(w, c); err != nil {
162+ return err
163+ }
164+ }
165+ return nil
166+}
167+
168+func writeProcess(w *payloadWriter, p *Process) error {
169+ if p == nil {
170+ w.putUint8(processAbsent)
171+ return nil
172+ }
173+ if len(p.Argv) == 0 {
174+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
175+ }
176+ w.putUint8(processPresent)
177+ if err := w.putStrings(p.Argv); err != nil {
178+ return err
179+ }
180+ if err := w.putString(p.Strategy); err != nil {
181+ return err
182+ }
183+ return w.putString(p.Opts.AgentSessionID)
184+}
185+
149186 type payloadReader struct{ b []byte }
150187
151188 func (r *payloadReader) getUint8() (uint8, error) {
@@ -226,27 +263,6 @@ func (r *payloadReader) done() error {
226263 return nil
227264 }
228265
229-func writeSession(w *payloadWriter, s Session) error {
230- if len(s.Tabs) > math.MaxUint16 {
231- return fmt.Errorf("%w: too many tabs", ErrInvalidData)
232- }
233- if err := validateActive(s.Active, len(s.Tabs)); err != nil {
234- return err
235- }
236- if err := w.putString(s.Name); err != nil {
237- return err
238- }
239- w.putUint64(s.CreatedAt)
240- w.putUint16(s.Active)
241- w.putUint16(uint16(len(s.Tabs)))
242- for _, t := range s.Tabs {
243- if err := writeTab(w, t); err != nil {
244- return err
245- }
246- }
247- return nil
248-}
249-
250266 func readSession(r *payloadReader) (Session, error) {
251267 name, err := r.getString()
252268 if err != nil {
@@ -278,35 +294,6 @@ func readSession(r *payloadReader) (Session, error) {
278294 return s, nil
279295 }
280296
281-func writeTab(w *payloadWriter, t Tab) error {
282- if err := w.putString(t.StableID); err != nil {
283- return err
284- }
285- w.putUint16(t.Cols)
286- w.putUint16(t.Rows)
287- w.putUint64(t.NextPaneID)
288- if err := w.putString(string(t.Focus)); err != nil {
289- return err
290- }
291- var root *layout.Node
292- if t.Tree != nil {
293- root = t.Tree.Root
294- }
295- if err := writeNode(w, root); err != nil {
296- return err
297- }
298- if len(t.Panes) > math.MaxUint16 {
299- return fmt.Errorf("%w: too many panes", ErrInvalidData)
300- }
301- w.putUint16(uint16(len(t.Panes)))
302- for _, p := range t.Panes {
303- if err := writePane(w, p); err != nil {
304- return err
305- }
306- }
307- return nil
308-}
309-
310297 func readTab(r *payloadReader) (Tab, error) {
311298 stableID, err := r.getString()
312299 if err != nil {
@@ -347,43 +334,6 @@ func readTab(r *payloadReader) (Tab, error) {
347334 return t, nil
348335 }
349336
350-func writeNode(w *payloadWriter, n *layout.Node) error {
351- if n == nil {
352- w.putUint8(manifestNodeNil)
353- return nil
354- }
355- switch n.Kind {
356- case layout.Leaf:
357- w.putUint8(manifestNodeLeaf)
358- return w.putString(string(n.Leaf))
359- case layout.Split:
360- w.putUint8(manifestNodeSplit)
361- w.putUint8(uint8(n.Dir))
362- return writeChildren(w, n.Children)
363- case layout.Stack:
364- w.putUint8(manifestNodeStack)
365- if err := w.putString(string(n.Expanded)); err != nil {
366- return err
367- }
368- return writeChildren(w, n.Children)
369- default:
370- return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
371- }
372-}
373-
374-func writeChildren(w *payloadWriter, children []*layout.Node) error {
375- if len(children) > math.MaxUint16 {
376- return fmt.Errorf("%w: too many children", ErrInvalidData)
377- }
378- w.putUint16(uint16(len(children)))
379- for _, c := range children {
380- if err := writeNode(w, c); err != nil {
381- return err
382- }
383- }
384- return nil
385-}
386-
387337 func readNode(r *payloadReader) (*layout.Node, error) {
388338 kind, err := r.getUint8()
389339 if err != nil {
@@ -439,40 +389,6 @@ func readChildren(r *payloadReader) ([]*layout.Node, error) {
439389 return out, nil
440390 }
441391
442-func writePane(w *payloadWriter, p Pane) error {
443- if err := w.putString(string(p.ID)); err != nil {
444- return err
445- }
446- if err := w.putString(p.StableID); err != nil {
447- return err
448- }
449- if err := w.putString(p.Cwd); err != nil {
450- return err
451- }
452- if len(p.SealedChunks) > maxSnapshotObjects {
453- return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
454- }
455- w.putUint32(uint32(len(p.SealedChunks)))
456- for _, blob := range p.SealedChunks {
457- if len(blob) == 0 || len(blob) > math.MaxUint32 {
458- return fmt.Errorf("%w: sealed chunk length", ErrInvalidData)
459- }
460- w.putUint32(uint32(len(blob)))
461- w.b = append(w.b, blob...)
462- }
463- if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
464- return fmt.Errorf("%w: tail length", ErrInvalidData)
465- }
466- w.putUint32(uint32(len(p.Tail)))
467- w.b = append(w.b, p.Tail...)
468- if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
469- return fmt.Errorf("%w: visible length", ErrInvalidData)
470- }
471- w.putUint32(uint32(len(p.Visible)))
472- w.b = append(w.b, p.Visible...)
473- return writeProcess(w, p.Process)
474-}
475-
476392 func readPane(r *payloadReader) (Pane, error) {
477393 id, err := r.getString()
478394 if err != nil {
@@ -514,24 +430,6 @@ func readPane(r *payloadReader) (Pane, error) {
514430 return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil
515431 }
516432
517-func writeProcess(w *payloadWriter, p *Process) error {
518- if p == nil {
519- w.putUint8(processAbsent)
520- return nil
521- }
522- if len(p.Argv) == 0 {
523- return fmt.Errorf("%w: process argv empty", ErrInvalidData)
524- }
525- w.putUint8(processPresent)
526- if err := w.putStrings(p.Argv); err != nil {
527- return err
528- }
529- if err := w.putString(p.Strategy); err != nil {
530- return err
531- }
532- return w.putString(p.Opts.AgentSessionID)
533-}
534-
535433 func readProcess(r *payloadReader) (*Process, error) {
536434 present, err := r.getUint8()
537435 if err != nil {
@@ -34,24 +34,6 @@ const (
34 maxSnapshotDecodedAllocation = 256 << 2034 maxSnapshotDecodedAllocation = 256 << 20
35 )35 )
36 36
37-// Marshal encodes the strict v3, flags-zero durable envelope.
38-func Marshal(s Session) ([]byte, error) {
39- var w payloadWriter
40- if err := writeSession(&w, s); err != nil {
41- return nil, err
42- }
43- if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
44- return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
45- }
46- out := make([]byte, 16+len(w.b))
47- copy(out[:4], magic)
48- binary.BigEndian.PutUint16(out[4:6], version)
49- binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
50- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
51- copy(out[16:], w.b)
52- return out, nil
53-}
54-
55 // Unmarshal validates the complete manifest and all VT blobs without37 // Unmarshal validates the complete manifest and all VT blobs without
56 // allocations first, then decodes the already preflighted data.38 // allocations first, then decodes the already preflighted data.
57 func Unmarshal(b []byte) (Session, error) {39 func Unmarshal(b []byte) (Session, error) {
@@ -146,6 +128,61 @@ func (w *payloadWriter) putStrings(ss []string) error {
146 return nil128 return nil
147 }129 }
148 130
131+func writeNode(w *payloadWriter, n *layout.Node) error {
132+ if n == nil {
133+ w.putUint8(manifestNodeNil)
134+ return nil
135+ }
136+ switch n.Kind {
137+ case layout.Leaf:
138+ w.putUint8(manifestNodeLeaf)
139+ return w.putString(string(n.Leaf))
140+ case layout.Split:
141+ w.putUint8(manifestNodeSplit)
142+ w.putUint8(uint8(n.Dir))
143+ return writeChildren(w, n.Children)
144+ case layout.Stack:
145+ w.putUint8(manifestNodeStack)
146+ if err := w.putString(string(n.Expanded)); err != nil {
147+ return err
148+ }
149+ return writeChildren(w, n.Children)
150+ default:
151+ return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
152+ }
153+}
154+
155+func writeChildren(w *payloadWriter, children []*layout.Node) error {
156+ if len(children) > math.MaxUint16 {
157+ return fmt.Errorf("%w: too many children", ErrInvalidData)
158+ }
159+ w.putUint16(uint16(len(children)))
160+ for _, c := range children {
161+ if err := writeNode(w, c); err != nil {
162+ return err
163+ }
164+ }
165+ return nil
166+}
167+
168+func writeProcess(w *payloadWriter, p *Process) error {
169+ if p == nil {
170+ w.putUint8(processAbsent)
171+ return nil
172+ }
173+ if len(p.Argv) == 0 {
174+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
175+ }
176+ w.putUint8(processPresent)
177+ if err := w.putStrings(p.Argv); err != nil {
178+ return err
179+ }
180+ if err := w.putString(p.Strategy); err != nil {
181+ return err
182+ }
183+ return w.putString(p.Opts.AgentSessionID)
184+}
185+
149 type payloadReader struct{ b []byte }186 type payloadReader struct{ b []byte }
150 187
151 func (r *payloadReader) getUint8() (uint8, error) {188 func (r *payloadReader) getUint8() (uint8, error) {
@@ -226,27 +263,6 @@ func (r *payloadReader) done() error {
226 return nil263 return nil
227 }264 }
228 265
229-func writeSession(w *payloadWriter, s Session) error {
230- if len(s.Tabs) > math.MaxUint16 {
231- return fmt.Errorf("%w: too many tabs", ErrInvalidData)
232- }
233- if err := validateActive(s.Active, len(s.Tabs)); err != nil {
234- return err
235- }
236- if err := w.putString(s.Name); err != nil {
237- return err
238- }
239- w.putUint64(s.CreatedAt)
240- w.putUint16(s.Active)
241- w.putUint16(uint16(len(s.Tabs)))
242- for _, t := range s.Tabs {
243- if err := writeTab(w, t); err != nil {
244- return err
245- }
246- }
247- return nil
248-}
249-
250 func readSession(r *payloadReader) (Session, error) {266 func readSession(r *payloadReader) (Session, error) {
251 name, err := r.getString()267 name, err := r.getString()
252 if err != nil {268 if err != nil {
@@ -278,35 +294,6 @@ func readSession(r *payloadReader) (Session, error) {
278 return s, nil294 return s, nil
279 }295 }
280 296
281-func writeTab(w *payloadWriter, t Tab) error {
282- if err := w.putString(t.StableID); err != nil {
283- return err
284- }
285- w.putUint16(t.Cols)
286- w.putUint16(t.Rows)
287- w.putUint64(t.NextPaneID)
288- if err := w.putString(string(t.Focus)); err != nil {
289- return err
290- }
291- var root *layout.Node
292- if t.Tree != nil {
293- root = t.Tree.Root
294- }
295- if err := writeNode(w, root); err != nil {
296- return err
297- }
298- if len(t.Panes) > math.MaxUint16 {
299- return fmt.Errorf("%w: too many panes", ErrInvalidData)
300- }
301- w.putUint16(uint16(len(t.Panes)))
302- for _, p := range t.Panes {
303- if err := writePane(w, p); err != nil {
304- return err
305- }
306- }
307- return nil
308-}
309-
310 func readTab(r *payloadReader) (Tab, error) {297 func readTab(r *payloadReader) (Tab, error) {
311 stableID, err := r.getString()298 stableID, err := r.getString()
312 if err != nil {299 if err != nil {
@@ -347,43 +334,6 @@ func readTab(r *payloadReader) (Tab, error) {
347 return t, nil334 return t, nil
348 }335 }
349 336
350-func writeNode(w *payloadWriter, n *layout.Node) error {
351- if n == nil {
352- w.putUint8(manifestNodeNil)
353- return nil
354- }
355- switch n.Kind {
356- case layout.Leaf:
357- w.putUint8(manifestNodeLeaf)
358- return w.putString(string(n.Leaf))
359- case layout.Split:
360- w.putUint8(manifestNodeSplit)
361- w.putUint8(uint8(n.Dir))
362- return writeChildren(w, n.Children)
363- case layout.Stack:
364- w.putUint8(manifestNodeStack)
365- if err := w.putString(string(n.Expanded)); err != nil {
366- return err
367- }
368- return writeChildren(w, n.Children)
369- default:
370- return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
371- }
372-}
373-
374-func writeChildren(w *payloadWriter, children []*layout.Node) error {
375- if len(children) > math.MaxUint16 {
376- return fmt.Errorf("%w: too many children", ErrInvalidData)
377- }
378- w.putUint16(uint16(len(children)))
379- for _, c := range children {
380- if err := writeNode(w, c); err != nil {
381- return err
382- }
383- }
384- return nil
385-}
386-
387 func readNode(r *payloadReader) (*layout.Node, error) {337 func readNode(r *payloadReader) (*layout.Node, error) {
388 kind, err := r.getUint8()338 kind, err := r.getUint8()
389 if err != nil {339 if err != nil {
@@ -439,40 +389,6 @@ func readChildren(r *payloadReader) ([]*layout.Node, error) {
439 return out, nil389 return out, nil
440 }390 }
441 391
442-func writePane(w *payloadWriter, p Pane) error {
443- if err := w.putString(string(p.ID)); err != nil {
444- return err
445- }
446- if err := w.putString(p.StableID); err != nil {
447- return err
448- }
449- if err := w.putString(p.Cwd); err != nil {
450- return err
451- }
452- if len(p.SealedChunks) > maxSnapshotObjects {
453- return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
454- }
455- w.putUint32(uint32(len(p.SealedChunks)))
456- for _, blob := range p.SealedChunks {
457- if len(blob) == 0 || len(blob) > math.MaxUint32 {
458- return fmt.Errorf("%w: sealed chunk length", ErrInvalidData)
459- }
460- w.putUint32(uint32(len(blob)))
461- w.b = append(w.b, blob...)
462- }
463- if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
464- return fmt.Errorf("%w: tail length", ErrInvalidData)
465- }
466- w.putUint32(uint32(len(p.Tail)))
467- w.b = append(w.b, p.Tail...)
468- if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
469- return fmt.Errorf("%w: visible length", ErrInvalidData)
470- }
471- w.putUint32(uint32(len(p.Visible)))
472- w.b = append(w.b, p.Visible...)
473- return writeProcess(w, p.Process)
474-}
475-
476 func readPane(r *payloadReader) (Pane, error) {392 func readPane(r *payloadReader) (Pane, error) {
477 id, err := r.getString()393 id, err := r.getString()
478 if err != nil {394 if err != nil {
@@ -514,24 +430,6 @@ func readPane(r *payloadReader) (Pane, error) {
514 return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil430 return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil
515 }431 }
516 432
517-func writeProcess(w *payloadWriter, p *Process) error {
518- if p == nil {
519- w.putUint8(processAbsent)
520- return nil
521- }
522- if len(p.Argv) == 0 {
523- return fmt.Errorf("%w: process argv empty", ErrInvalidData)
524- }
525- w.putUint8(processPresent)
526- if err := w.putStrings(p.Argv); err != nil {
527- return err
528- }
529- if err := w.putString(p.Strategy); err != nil {
530- return err
531- }
532- return w.putString(p.Opts.AgentSessionID)
533-}
534-
535 func readProcess(r *payloadReader) (*Process, error) {433 func readProcess(r *payloadReader) (*Process, error) {
536 present, err := r.getUint8()434 present, err := r.getUint8()
537 if err != nil {435 if err != nil {
A internal/usecase/snapshot/codec_encoder_test.go +113 -0
@@ -0,0 +1,113 @@
1+package snapshot
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "hash/crc32"
7+ "math"
8+
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+// marshalTest is a test-only v3 encoder. Production retains only the decoder
13+// required by the one-time legacy import.
14+func marshalTest(s Session) ([]byte, error) {
15+ var w payloadWriter
16+ if err := writeSession(&w, s); err != nil {
17+ return nil, err
18+ }
19+ if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
20+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
21+ }
22+ out := make([]byte, 16+len(w.b))
23+ copy(out[:4], magic)
24+ binary.BigEndian.PutUint16(out[4:6], version)
25+ binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
26+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
27+ copy(out[16:], w.b)
28+ return out, nil
29+}
30+
31+func writeSession(w *payloadWriter, s Session) error {
32+ if len(s.Tabs) > math.MaxUint16 {
33+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
34+ }
35+ if err := validateActive(s.Active, len(s.Tabs)); err != nil {
36+ return err
37+ }
38+ if err := w.putString(s.Name); err != nil {
39+ return err
40+ }
41+ w.putUint64(s.CreatedAt)
42+ w.putUint16(s.Active)
43+ w.putUint16(uint16(len(s.Tabs)))
44+ for _, t := range s.Tabs {
45+ if err := writeTab(w, t); err != nil {
46+ return err
47+ }
48+ }
49+ return nil
50+}
51+
52+func writeTab(w *payloadWriter, t Tab) error {
53+ if err := w.putString(t.StableID); err != nil {
54+ return err
55+ }
56+ w.putUint16(t.Cols)
57+ w.putUint16(t.Rows)
58+ w.putUint64(t.NextPaneID)
59+ if err := w.putString(string(t.Focus)); err != nil {
60+ return err
61+ }
62+ var root *layout.Node
63+ if t.Tree != nil {
64+ root = t.Tree.Root
65+ }
66+ if err := writeNode(w, root); err != nil {
67+ return err
68+ }
69+ if len(t.Panes) > math.MaxUint16 {
70+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
71+ }
72+ w.putUint16(uint16(len(t.Panes)))
73+ for _, p := range t.Panes {
74+ if err := writePane(w, p); err != nil {
75+ return err
76+ }
77+ }
78+ return nil
79+}
80+
81+func writePane(w *payloadWriter, p Pane) error {
82+ if err := w.putString(string(p.ID)); err != nil {
83+ return err
84+ }
85+ if err := w.putString(p.StableID); err != nil {
86+ return err
87+ }
88+ if err := w.putString(p.Cwd); err != nil {
89+ return err
90+ }
91+ if len(p.SealedChunks) > maxSnapshotObjects {
92+ return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
93+ }
94+ w.putUint32(uint32(len(p.SealedChunks)))
95+ for _, blob := range p.SealedChunks {
96+ if len(blob) == 0 || len(blob) > math.MaxUint32 {
97+ return fmt.Errorf("%w: sealed chunk length", ErrInvalidData)
98+ }
99+ w.putUint32(uint32(len(blob)))
100+ w.b = append(w.b, blob...)
101+ }
102+ if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
103+ return fmt.Errorf("%w: tail length", ErrInvalidData)
104+ }
105+ w.putUint32(uint32(len(p.Tail)))
106+ w.b = append(w.b, p.Tail...)
107+ if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
108+ return fmt.Errorf("%w: visible length", ErrInvalidData)
109+ }
110+ w.putUint32(uint32(len(p.Visible)))
111+ w.b = append(w.b, p.Visible...)
112+ return writeProcess(w, p.Process)
113+}
@@ -0,0 +1,113 @@
1+package snapshot
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "hash/crc32"
7+ "math"
8+
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+// marshalTest is a test-only v3 encoder. Production retains only the decoder
13+// required by the one-time legacy import.
14+func marshalTest(s Session) ([]byte, error) {
15+ var w payloadWriter
16+ if err := writeSession(&w, s); err != nil {
17+ return nil, err
18+ }
19+ if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
20+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
21+ }
22+ out := make([]byte, 16+len(w.b))
23+ copy(out[:4], magic)
24+ binary.BigEndian.PutUint16(out[4:6], version)
25+ binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
26+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
27+ copy(out[16:], w.b)
28+ return out, nil
29+}
30+
31+func writeSession(w *payloadWriter, s Session) error {
32+ if len(s.Tabs) > math.MaxUint16 {
33+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
34+ }
35+ if err := validateActive(s.Active, len(s.Tabs)); err != nil {
36+ return err
37+ }
38+ if err := w.putString(s.Name); err != nil {
39+ return err
40+ }
41+ w.putUint64(s.CreatedAt)
42+ w.putUint16(s.Active)
43+ w.putUint16(uint16(len(s.Tabs)))
44+ for _, t := range s.Tabs {
45+ if err := writeTab(w, t); err != nil {
46+ return err
47+ }
48+ }
49+ return nil
50+}
51+
52+func writeTab(w *payloadWriter, t Tab) error {
53+ if err := w.putString(t.StableID); err != nil {
54+ return err
55+ }
56+ w.putUint16(t.Cols)
57+ w.putUint16(t.Rows)
58+ w.putUint64(t.NextPaneID)
59+ if err := w.putString(string(t.Focus)); err != nil {
60+ return err
61+ }
62+ var root *layout.Node
63+ if t.Tree != nil {
64+ root = t.Tree.Root
65+ }
66+ if err := writeNode(w, root); err != nil {
67+ return err
68+ }
69+ if len(t.Panes) > math.MaxUint16 {
70+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
71+ }
72+ w.putUint16(uint16(len(t.Panes)))
73+ for _, p := range t.Panes {
74+ if err := writePane(w, p); err != nil {
75+ return err
76+ }
77+ }
78+ return nil
79+}
80+
81+func writePane(w *payloadWriter, p Pane) error {
82+ if err := w.putString(string(p.ID)); err != nil {
83+ return err
84+ }
85+ if err := w.putString(p.StableID); err != nil {
86+ return err
87+ }
88+ if err := w.putString(p.Cwd); err != nil {
89+ return err
90+ }
91+ if len(p.SealedChunks) > maxSnapshotObjects {
92+ return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
93+ }
94+ w.putUint32(uint32(len(p.SealedChunks)))
95+ for _, blob := range p.SealedChunks {
96+ if len(blob) == 0 || len(blob) > math.MaxUint32 {
97+ return fmt.Errorf("%w: sealed chunk length", ErrInvalidData)
98+ }
99+ w.putUint32(uint32(len(blob)))
100+ w.b = append(w.b, blob...)
101+ }
102+ if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
103+ return fmt.Errorf("%w: tail length", ErrInvalidData)
104+ }
105+ w.putUint32(uint32(len(p.Tail)))
106+ w.b = append(w.b, p.Tail...)
107+ if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
108+ return fmt.Errorf("%w: visible length", ErrInvalidData)
109+ }
110+ w.putUint32(uint32(len(p.Visible)))
111+ w.b = append(w.b, p.Visible...)
112+ return writeProcess(w, p.Process)
113+}
M internal/usecase/snapshot/codec_test.go +13 -13
@@ -32,7 +32,7 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
3232 {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},
3333 {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},
3434 }}}}
35- encoded, err := Marshal(want)
35+ encoded, err := marshalTest(want)
3636 if err != nil {
3737 t.Fatal(err)
3838 }
@@ -86,13 +86,13 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) {
8686 {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},
8787 } {
8888 t.Run("marshal "+tc.name, func(t *testing.T) {
89- _, err := Marshal(tc.s)
89+ _, err := marshalTest(tc.s)
9090 if tc.valid {
9191 requireNoError(t, err)
9292 return
9393 }
9494 if !errors.Is(err, ErrInvalidData) {
95- t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
95+ t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData)
9696 }
9797 })
9898 }
@@ -153,7 +153,7 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin
153153 func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
154154 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})
155155 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
156- encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
156+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
157157 if err != nil {
158158 t.Fatal(err)
159159 }
@@ -174,7 +174,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
174174 {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}},
175175 } {
176176 t.Run(tc.name, func(t *testing.T) {
177- data, err := Marshal(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})
177+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})
178178 requireNoError(t, err)
179179 reject(t, data, ErrInvalidData)
180180 })
@@ -184,7 +184,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
184184 func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
185185 sealed, tail := historyBlobs(t, nil)
186186 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
187- encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
187+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
188188 if err != nil {
189189 t.Fatal(err)
190190 }
@@ -216,7 +216,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
216216 requireNoError(t, err)
217217 fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView())
218218 requireNoError(t, err)
219- valid, err := Marshal(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}})
219+ valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}})
220220 requireNoError(t, err)
221221 roundTrip, err := Unmarshal(valid)
222222 requireNoError(t, err)
@@ -234,7 +234,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
234234 {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}},
235235 } {
236236 t.Run(tc.name, func(t *testing.T) {
237- data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
237+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}})
238238 requireNoError(t, err)
239239 reject(t, data, ErrInvalidData)
240240 })
@@ -259,7 +259,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
259259 {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
260260 } {
261261 t.Run(tc.name, func(t *testing.T) {
262- data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
262+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}})
263263 requireNoError(t, err)
264264 got, err := Unmarshal(data)
265265 requireNoError(t, err)
@@ -286,7 +286,7 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
286286 },
287287 {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}},
288288 }}
289- data, err := Marshal(want)
289+ data, err := marshalTest(want)
290290 requireNoError(t, err)
291291 got, err := Unmarshal(data)
292292 requireNoError(t, err)
@@ -300,11 +300,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
300300 }
301301
302302 func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
303- _, err := Marshal(Session{Name: "s", Tabs: []Tab{
303+ _, err := marshalTest(Session{Name: "s", Tabs: []Tab{
304304 {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}},
305305 }})
306306 if !errors.Is(err, ErrInvalidData) {
307- t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
307+ t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData)
308308 }
309309 }
310310
@@ -354,7 +354,7 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
354354 for i := range tabs {
355355 tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}
356356 }
357- data, err := Marshal(Session{Name: "over-budget", Tabs: tabs})
357+ data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs})
358358 requireNoError(t, err)
359359
360360 allocs := testing.AllocsPerRun(3, func() {
@@ -32,7 +32,7 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
32 {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},32 {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},
33 {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},33 {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},
34 }}}}34 }}}}
35- encoded, err := Marshal(want)35+ encoded, err := marshalTest(want)
36 if err != nil {36 if err != nil {
37 t.Fatal(err)37 t.Fatal(err)
38 }38 }
@@ -86,13 +86,13 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) {
86 {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},86 {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},
87 } {87 } {
88 t.Run("marshal "+tc.name, func(t *testing.T) {88 t.Run("marshal "+tc.name, func(t *testing.T) {
89- _, err := Marshal(tc.s)89+ _, err := marshalTest(tc.s)
90 if tc.valid {90 if tc.valid {
91 requireNoError(t, err)91 requireNoError(t, err)
92 return92 return
93 }93 }
94 if !errors.Is(err, ErrInvalidData) {94 if !errors.Is(err, ErrInvalidData) {
95- t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)95+ t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData)
96 }96 }
97 })97 })
98 }98 }
@@ -153,7 +153,7 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin
153 func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {153 func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
154 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})154 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})
155 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})155 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
156- encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})156+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
157 if err != nil {157 if err != nil {
158 t.Fatal(err)158 t.Fatal(err)
159 }159 }
@@ -174,7 +174,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
174 {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}},174 {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}},
175 } {175 } {
176 t.Run(tc.name, func(t *testing.T) {176 t.Run(tc.name, func(t *testing.T) {
177- data, err := Marshal(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})177+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})
178 requireNoError(t, err)178 requireNoError(t, err)
179 reject(t, data, ErrInvalidData)179 reject(t, data, ErrInvalidData)
180 })180 })
@@ -184,7 +184,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
184 func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {184 func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
185 sealed, tail := historyBlobs(t, nil)185 sealed, tail := historyBlobs(t, nil)
186 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})186 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
187- encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})187+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
188 if err != nil {188 if err != nil {
189 t.Fatal(err)189 t.Fatal(err)
190 }190 }
@@ -216,7 +216,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
216 requireNoError(t, err)216 requireNoError(t, err)
217 fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView())217 fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView())
218 requireNoError(t, err)218 requireNoError(t, err)
219- valid, err := Marshal(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}})219+ valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}})
220 requireNoError(t, err)220 requireNoError(t, err)
221 roundTrip, err := Unmarshal(valid)221 roundTrip, err := Unmarshal(valid)
222 requireNoError(t, err)222 requireNoError(t, err)
@@ -234,7 +234,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
234 {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}},234 {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}},
235 } {235 } {
236 t.Run(tc.name, func(t *testing.T) {236 t.Run(tc.name, func(t *testing.T) {
237- data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})237+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}})
238 requireNoError(t, err)238 requireNoError(t, err)
239 reject(t, data, ErrInvalidData)239 reject(t, data, ErrInvalidData)
240 })240 })
@@ -259,7 +259,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
259 {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},259 {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
260 } {260 } {
261 t.Run(tc.name, func(t *testing.T) {261 t.Run(tc.name, func(t *testing.T) {
262- data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})262+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}})
263 requireNoError(t, err)263 requireNoError(t, err)
264 got, err := Unmarshal(data)264 got, err := Unmarshal(data)
265 requireNoError(t, err)265 requireNoError(t, err)
@@ -286,7 +286,7 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
286 },286 },
287 {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}},287 {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}},
288 }}288 }}
289- data, err := Marshal(want)289+ data, err := marshalTest(want)
290 requireNoError(t, err)290 requireNoError(t, err)
291 got, err := Unmarshal(data)291 got, err := Unmarshal(data)
292 requireNoError(t, err)292 requireNoError(t, err)
@@ -300,11 +300,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
300 }300 }
301 301
302 func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {302 func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
303- _, err := Marshal(Session{Name: "s", Tabs: []Tab{303+ _, err := marshalTest(Session{Name: "s", Tabs: []Tab{
304 {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}},304 {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}},
305 }})305 }})
306 if !errors.Is(err, ErrInvalidData) {306 if !errors.Is(err, ErrInvalidData) {
307- t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)307+ t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData)
308 }308 }
309 }309 }
310 310
@@ -354,7 +354,7 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
354 for i := range tabs {354 for i := range tabs {
355 tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}355 tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}
356 }356 }
357- data, err := Marshal(Session{Name: "over-budget", Tabs: tabs})357+ data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs})
358 requireNoError(t, err)358 requireNoError(t, err)
359 359
360 allocs := testing.AllocsPerRun(3, func() {360 allocs := testing.AllocsPerRun(3, func() {
M internal/usecase/snapshot/preflight_acceptance_test.go +1 -1
@@ -33,7 +33,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T
3333 },
3434 } {
3535 t.Run(tt.name, func(t *testing.T) {
36- encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}})
36+ encoded, err := marshalTest(Session{Name: "hostile", Tabs: []Tab{tt.tab}})
3737 if err != nil {
3838 t.Fatalf("Marshal: %v", err)
3939 }
@@ -33,7 +33,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T
33 },33 },
34 } {34 } {
35 t.Run(tt.name, func(t *testing.T) {35 t.Run(tt.name, func(t *testing.T) {
36- encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}})36+ encoded, err := marshalTest(Session{Name: "hostile", Tabs: []Tab{tt.tab}})
37 if err != nil {37 if err != nil {
38 t.Fatalf("Marshal: %v", err)38 t.Fatalf("Marshal: %v", err)
39 }39 }

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