detached 5297bf6

refactor(snapshot): delete the dead legacy blob bridge

commit
5297bf60e8589748b209051e7b3cf5af9432d410
author
brice · · 2026-07-26 10:35 UTC
parent
23c31e3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +49 -926
D internal/adapters/snapshot/legacy.go +0 -399
@@ -1,399 +0,0 @@
1-package snapshot
2-
3-import (
4- "bytes"
5- "context"
6- "crypto/sha256"
7- "errors"
8- "fmt"
9- "io"
10- "os"
11- "path/filepath"
12- "sort"
13- "strings"
14-
15- "github.com/bnema/vev/internal/ports"
16-)
17-
18-const (
19- legacyDeleteMarkerPrefix = ".legacy-import-receipt-"
20- maxLegacyDeleteMarkers = maxLegacySnapshotFiles
21- maxLegacyDeleteNameBytes = 200
22-)
23-
24-type legacyDeleteMarker struct {
25- name string
26- digest [sha256.Size]byte
27-}
28-
29-type legacyDirectory interface {
30- ReadDir(int) ([]os.DirEntry, error)
31- Close() error
32-}
33-
34-func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) {
35- if r.hooks.openLegacyDirectory != nil {
36- return r.hooks.openLegacyDirectory(path)
37- }
38- return r.openDirectory(path)
39-}
40-
41-// readLegacyImportCandidate reports whether an untrusted root entry can be
42-// safely imported. Legacy scanning skips entries that disappear or fail secure
43-// descriptor validation so one hostile .snap file does not block other imports.
44-func (r *Repository) readLegacyImportCandidate(path string) ([]byte, bool) {
45- data, err := r.readBounded(path)
46- return data, err == nil
47-}
48-
49-// LoadLegacy reads only pre-incremental root .snap files. It is deliberately
50-// isolated from repository reads so incremental directories are never traversed.
51-func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot, err error) {
52- if err := ctx.Err(); err != nil {
53- return nil, err
54- }
55- // One budget covers marker recovery and import. Every directory entry costs
56- // work, including unrelated attacker-created names, so neither phase can be
57- // made unbounded by a huge legacy root.
58- budget := maxDirectoryTraversalEntries
59- // A marker is an already-authorized deletion, not an import candidate. Finish
60- // it before exposing any legacy blobs so process restart cannot republish one.
61- if err := r.cleanupPendingLegacyDeletes(ctx, &budget); err != nil {
62- return nil, err
63- }
64- f, err := r.openLegacyDirectory(r.dir)
65- if errors.Is(err, os.ErrNotExist) {
66- return nil, nil
67- }
68- if err != nil {
69- return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(err))
70- }
71- defer func() {
72- if closeErr := f.Close(); closeErr != nil {
73- err = errors.Join(err, fmt.Errorf("close legacy snapshot directory: %w", safeFilesystemError(closeErr)))
74- }
75- }()
76-
77- out = make([]ports.LegacySnapshot, 0, maxLegacySnapshotFiles)
78- files := 0
79- total := 0
80- for {
81- entries, readErr := f.ReadDir(maintenanceBatch)
82- for _, entry := range entries {
83- if err := ctx.Err(); err != nil {
84- return nil, err
85- }
86- if budget == 0 {
87- return nil, ErrDirectoryTraversalBudget
88- }
89- budget--
90- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") {
91- continue
92- }
93- files++
94- if files > maxLegacySnapshotFiles {
95- return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)
96- }
97- path := filepath.Join(r.dir, entry.Name())
98- data, importable := r.readLegacyImportCandidate(path)
99- if !importable {
100- continue
101- }
102- if len(data) > maxLegacySnapshotBytes-total {
103- return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes)
104- }
105- total += len(data)
106- name := strings.TrimSuffix(entry.Name(), ".snap")
107- if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
108- name = ""
109- }
110- killed, tombstoneErr := r.tombstoned(name)
111- if tombstoneErr != nil {
112- return nil, fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr))
113- }
114- if killed {
115- continue
116- }
117- out = append(out, ports.LegacySnapshot{Name: name, Data: data})
118- }
119- if errors.Is(readErr, io.EOF) {
120- break
121- }
122- if readErr != nil {
123- return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(readErr))
124- }
125- }
126- sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
127- return out, nil
128-}
129-
130-// DeleteVerifiedLegacy records a hash-bound import receipt before deleting the
131-// source. LoadLegacy consumes surviving receipts after restart, but only when
132-// the source still has the exact bytes that were verified and published.
133-func (r *Repository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
134- if err := ctx.Err(); err != nil {
135- return err
136- }
137- filename := filenameForName(blob.Name)
138- lock := r.lockSession("legacy-sync:" + filename)
139- defer r.unlockSession(lock)
140- if err := ctx.Err(); err != nil {
141- return err
142- }
143- receipt := legacyDeleteMarker{name: blob.Name, digest: sha256.Sum256(blob.Data)}
144- if err := r.authorizeLegacyDelete(receipt); err != nil {
145- return err
146- }
147- return r.deleteAuthorizedLegacy(ctx, filename, receipt)
148-}
149-
150-// DeleteLegacy is for deliberate session purges, which do not represent a
151-// verified import and therefore must not create an import receipt.
152-func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
153- if err := ctx.Err(); err != nil {
154- return err
155- }
156- filename := filenameForName(name)
157- lock := r.lockSession("legacy-sync:" + filename)
158- defer r.unlockSession(lock)
159- return r.deleteLegacyFile(ctx, filename)
160-}
161-
162-func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key }
163-
164-func (r *Repository) authorizeLegacyDelete(receipt legacyDeleteMarker) error {
165- if err := r.ensurePrivateDirectory(r.dir); err != nil {
166- return fmt.Errorf("create legacy import receipt directory: %w", safeFilesystemError(err))
167- }
168- path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(receipt.name)))
169- data := append(receipt.digest[:], receipt.name...)
170- if err := r.writeImmutable(path, data, func(data []byte) error {
171- if len(data) != sha256.Size+len(receipt.name) || string(data[sha256.Size:]) != receipt.name || !bytes.Equal(data[:sha256.Size], receipt.digest[:]) {
172- return fmt.Errorf("legacy import receipt identity mismatch")
173- }
174- return nil
175- }); err != nil {
176- return fmt.Errorf("write legacy import receipt: %w", safeFilesystemError(err))
177- }
178- return nil
179-}
180-
181-// deleteAuthorizedLegacy deletes only a source whose bytes match the durable
182-// receipt. A replaced source invalidates the receipt and remains importable.
183-func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, filename string, receipt legacyDeleteMarker) error {
184- if _, pending := r.pendingLegacySync.Load(filename); pending {
185- if err := r.syncPendingLegacy(ctx, filename); err != nil {
186- return err
187- }
188- return r.clearLegacyDeleteAuthorization(receipt.name)
189- }
190- path := filepath.Join(r.dir, filename)
191- data, err := r.readBounded(path)
192- if err != nil && !errors.Is(err, os.ErrNotExist) {
193- return fmt.Errorf("read legacy snapshot for deletion: %w", safeFilesystemError(err))
194- }
195- if err == nil && sha256.Sum256(data) != receipt.digest {
196- return r.clearLegacyDeleteAuthorization(receipt.name)
197- }
198- return r.deleteLegacyFile(ctx, filename, receipt.name)
199-}
200-
201-func (r *Repository) deleteLegacyFile(ctx context.Context, filename string, clearReceipt ...string) error {
202- if _, pending := r.pendingLegacySync.Load(filename); pending {
203- if err := r.syncPendingLegacy(ctx, filename); err != nil {
204- return err
205- }
206- } else {
207- path := filepath.Join(r.dir, filename)
208- if err := r.remove(path); err != nil {
209- if !errors.Is(err, os.ErrNotExist) {
210- return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))
211- }
212- // A missing repository has no deletion to make durable. This preserves
213- // idempotence for explicit purges before the snapshot root exists.
214- if _, statErr := os.Lstat(r.dir); errors.Is(statErr, os.ErrNotExist) {
215- return nil
216- } else if statErr != nil {
217- return fmt.Errorf("stat legacy snapshot directory: %w", safeFilesystemError(statErr))
218- }
219- }
220- r.pendingLegacySync.Store(filename, struct{}{})
221- if err := r.syncPendingLegacy(ctx, filename); err != nil {
222- return err
223- }
224- }
225- if len(clearReceipt) != 0 {
226- return r.clearLegacyDeleteAuthorization(clearReceipt[0])
227- }
228- return nil
229-}
230-
231-func (r *Repository) clearLegacyDeleteAuthorization(name string) error {
232- path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name)))
233- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
234- return fmt.Errorf("clear legacy import receipt: %w", safeFilesystemError(err))
235- }
236- if err := r.syncDirectory(r.dir); err != nil {
237- return fmt.Errorf("sync legacy import receipt directory: %w", safeFilesystemError(err))
238- }
239- return nil
240-}
241-
242-func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *int) error {
243- markers, err := r.pendingLegacyDeleteMarkers(ctx, budget)
244- if errors.Is(err, os.ErrNotExist) {
245- return nil
246- }
247- if err != nil {
248- return err
249- }
250- for _, marker := range markers {
251- filename := filenameForName(marker.name)
252- lock := r.lockSession("legacy-sync:" + filename)
253- err := r.deleteAuthorizedLegacy(ctx, filename, marker)
254- r.unlockSession(lock)
255- if err != nil {
256- return fmt.Errorf("retry authorized legacy deletion: %w", err)
257- }
258- }
259- return nil
260-}
261-
262-func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int) (markers []legacyDeleteMarker, err error) {
263- f, err := r.openLegacyDirectory(r.dir)
264- if err != nil {
265- return nil, err
266- }
267- defer func() {
268- if closeErr := f.Close(); closeErr != nil {
269- err = errors.Join(err, fmt.Errorf("close legacy deletion marker directory: %w", safeFilesystemError(closeErr)))
270- }
271- }()
272- markers = make([]legacyDeleteMarker, 0, maxLegacyDeleteMarkers)
273- for {
274- entries, readErr := f.ReadDir(maintenanceBatch)
275- for _, entry := range entries {
276- if err := ctx.Err(); err != nil {
277- return nil, err
278- }
279- if *budget == 0 {
280- return nil, ErrDirectoryTraversalBudget
281- }
282- *budget--
283- if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) {
284- continue
285- }
286- if len(markers) == maxLegacyDeleteMarkers {
287- return nil, fmt.Errorf("legacy deletion retry has too many markers (maximum %d)", maxLegacyDeleteMarkers)
288- }
289- key := strings.TrimPrefix(entry.Name(), legacyDeleteMarkerPrefix)
290- if !canonicalSessionKey(key) {
291- return nil, fmt.Errorf("invalid legacy deletion marker")
292- }
293- path := filepath.Join(r.dir, entry.Name())
294- data, dataErr := r.readBounded(path)
295- if dataErr != nil || len(data) < sha256.Size || len(data)-sha256.Size > maxLegacyDeleteNameBytes {
296- return nil, fmt.Errorf("invalid legacy import receipt")
297- }
298- name := string(data[sha256.Size:])
299- if sessionKey(name) != key {
300- return nil, fmt.Errorf("invalid legacy import receipt")
301- }
302- marker := legacyDeleteMarker{name: name}
303- copy(marker.digest[:], data[:sha256.Size])
304- markers = append(markers, marker)
305- }
306- if errors.Is(readErr, io.EOF) {
307- break
308- }
309- if readErr != nil {
310- return nil, fmt.Errorf("read legacy deletion markers: %w", safeFilesystemError(readErr))
311- }
312- }
313- return markers, nil
314-}
315-
316-// Tombstone durably marks name as logically killed before either snapshot
317-// source is deleted. Startup consults this marker before importing legacy or
318-// restoring incremental state, so a partial offline kill cannot resurrect it.
319-func (r *Repository) Tombstone(ctx context.Context, name string) error {
320- if err := ctx.Err(); err != nil {
321- return err
322- }
323- key := sessionKey(name)
324- lock := r.lockSession(key)
325- defer r.unlockSession(lock)
326- if err := ctx.Err(); err != nil {
327- return err
328- }
329- if err := r.ensurePrivateDirectory(r.dir); err != nil {
330- return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err))
331- }
332- path := filepath.Join(r.dir, tombstoneFilename(key))
333- if err := r.writeImmutable(path, []byte(name), func(data []byte) error {
334- if string(data) != name {
335- return fmt.Errorf("killed session marker identity mismatch")
336- }
337- return nil
338- }); err != nil {
339- return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err))
340- }
341- return nil
342-}
343-
344-// DeleteTombstone removes a marker only after both sources have been durably
345-// deleted. A missing marker is idempotent for offline-kill retries.
346-func (r *Repository) DeleteTombstone(ctx context.Context, name string) error {
347- if err := ctx.Err(); err != nil {
348- return err
349- }
350- key := sessionKey(name)
351- lock := r.lockSession(key)
352- defer r.unlockSession(lock)
353- if err := ctx.Err(); err != nil {
354- return err
355- }
356- if err := r.remove(filepath.Join(r.dir, tombstoneFilename(key))); errors.Is(err, os.ErrNotExist) {
357- return nil
358- } else if err != nil {
359- return fmt.Errorf("delete killed session marker: %w", safeFilesystemError(err))
360- }
361- if err := r.syncDirectory(r.dir); err != nil {
362- return fmt.Errorf("sync killed session marker directory: %w", safeFilesystemError(err))
363- }
364- return nil
365-}
366-
367-func tombstoneFilename(key string) string { return ".killed-" + key }
368-
369-func (r *Repository) tombstoned(name string) (bool, error) {
370- if name == "" {
371- return false, nil
372- }
373- path := filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))
374- if hook := r.hooks.beforeTombstoneCheck; hook != nil {
375- hook(path)
376- }
377- data, err := r.readBounded(path)
378- if errors.Is(err, os.ErrNotExist) {
379- return false, nil
380- }
381- if err != nil {
382- return false, err
383- }
384- if string(data) != name {
385- return false, fmt.Errorf("killed session marker identity mismatch")
386- }
387- return true, nil
388-}
389-
390-func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error {
391- if err := ctx.Err(); err != nil {
392- return err
393- }
394- if err := r.syncDirectory(r.dir); err != nil {
395- return fmt.Errorf("sync legacy snapshot directory: %w", safeFilesystemError(err))
396- }
397- r.pendingLegacySync.Delete(filename)
398- return nil
399-}
@@ -1,399 +0,0 @@
1-package snapshot
2-
3-import (
4- "bytes"
5- "context"
6- "crypto/sha256"
7- "errors"
8- "fmt"
9- "io"
10- "os"
11- "path/filepath"
12- "sort"
13- "strings"
14-
15- "github.com/bnema/vev/internal/ports"
16-)
17-
18-const (
19- legacyDeleteMarkerPrefix = ".legacy-import-receipt-"
20- maxLegacyDeleteMarkers = maxLegacySnapshotFiles
21- maxLegacyDeleteNameBytes = 200
22-)
23-
24-type legacyDeleteMarker struct {
25- name string
26- digest [sha256.Size]byte
27-}
28-
29-type legacyDirectory interface {
30- ReadDir(int) ([]os.DirEntry, error)
31- Close() error
32-}
33-
34-func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) {
35- if r.hooks.openLegacyDirectory != nil {
36- return r.hooks.openLegacyDirectory(path)
37- }
38- return r.openDirectory(path)
39-}
40-
41-// readLegacyImportCandidate reports whether an untrusted root entry can be
42-// safely imported. Legacy scanning skips entries that disappear or fail secure
43-// descriptor validation so one hostile .snap file does not block other imports.
44-func (r *Repository) readLegacyImportCandidate(path string) ([]byte, bool) {
45- data, err := r.readBounded(path)
46- return data, err == nil
47-}
48-
49-// LoadLegacy reads only pre-incremental root .snap files. It is deliberately
50-// isolated from repository reads so incremental directories are never traversed.
51-func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot, err error) {
52- if err := ctx.Err(); err != nil {
53- return nil, err
54- }
55- // One budget covers marker recovery and import. Every directory entry costs
56- // work, including unrelated attacker-created names, so neither phase can be
57- // made unbounded by a huge legacy root.
58- budget := maxDirectoryTraversalEntries
59- // A marker is an already-authorized deletion, not an import candidate. Finish
60- // it before exposing any legacy blobs so process restart cannot republish one.
61- if err := r.cleanupPendingLegacyDeletes(ctx, &budget); err != nil {
62- return nil, err
63- }
64- f, err := r.openLegacyDirectory(r.dir)
65- if errors.Is(err, os.ErrNotExist) {
66- return nil, nil
67- }
68- if err != nil {
69- return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(err))
70- }
71- defer func() {
72- if closeErr := f.Close(); closeErr != nil {
73- err = errors.Join(err, fmt.Errorf("close legacy snapshot directory: %w", safeFilesystemError(closeErr)))
74- }
75- }()
76-
77- out = make([]ports.LegacySnapshot, 0, maxLegacySnapshotFiles)
78- files := 0
79- total := 0
80- for {
81- entries, readErr := f.ReadDir(maintenanceBatch)
82- for _, entry := range entries {
83- if err := ctx.Err(); err != nil {
84- return nil, err
85- }
86- if budget == 0 {
87- return nil, ErrDirectoryTraversalBudget
88- }
89- budget--
90- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") {
91- continue
92- }
93- files++
94- if files > maxLegacySnapshotFiles {
95- return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)
96- }
97- path := filepath.Join(r.dir, entry.Name())
98- data, importable := r.readLegacyImportCandidate(path)
99- if !importable {
100- continue
101- }
102- if len(data) > maxLegacySnapshotBytes-total {
103- return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes)
104- }
105- total += len(data)
106- name := strings.TrimSuffix(entry.Name(), ".snap")
107- if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
108- name = ""
109- }
110- killed, tombstoneErr := r.tombstoned(name)
111- if tombstoneErr != nil {
112- return nil, fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr))
113- }
114- if killed {
115- continue
116- }
117- out = append(out, ports.LegacySnapshot{Name: name, Data: data})
118- }
119- if errors.Is(readErr, io.EOF) {
120- break
121- }
122- if readErr != nil {
123- return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(readErr))
124- }
125- }
126- sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
127- return out, nil
128-}
129-
130-// DeleteVerifiedLegacy records a hash-bound import receipt before deleting the
131-// source. LoadLegacy consumes surviving receipts after restart, but only when
132-// the source still has the exact bytes that were verified and published.
133-func (r *Repository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
134- if err := ctx.Err(); err != nil {
135- return err
136- }
137- filename := filenameForName(blob.Name)
138- lock := r.lockSession("legacy-sync:" + filename)
139- defer r.unlockSession(lock)
140- if err := ctx.Err(); err != nil {
141- return err
142- }
143- receipt := legacyDeleteMarker{name: blob.Name, digest: sha256.Sum256(blob.Data)}
144- if err := r.authorizeLegacyDelete(receipt); err != nil {
145- return err
146- }
147- return r.deleteAuthorizedLegacy(ctx, filename, receipt)
148-}
149-
150-// DeleteLegacy is for deliberate session purges, which do not represent a
151-// verified import and therefore must not create an import receipt.
152-func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
153- if err := ctx.Err(); err != nil {
154- return err
155- }
156- filename := filenameForName(name)
157- lock := r.lockSession("legacy-sync:" + filename)
158- defer r.unlockSession(lock)
159- return r.deleteLegacyFile(ctx, filename)
160-}
161-
162-func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key }
163-
164-func (r *Repository) authorizeLegacyDelete(receipt legacyDeleteMarker) error {
165- if err := r.ensurePrivateDirectory(r.dir); err != nil {
166- return fmt.Errorf("create legacy import receipt directory: %w", safeFilesystemError(err))
167- }
168- path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(receipt.name)))
169- data := append(receipt.digest[:], receipt.name...)
170- if err := r.writeImmutable(path, data, func(data []byte) error {
171- if len(data) != sha256.Size+len(receipt.name) || string(data[sha256.Size:]) != receipt.name || !bytes.Equal(data[:sha256.Size], receipt.digest[:]) {
172- return fmt.Errorf("legacy import receipt identity mismatch")
173- }
174- return nil
175- }); err != nil {
176- return fmt.Errorf("write legacy import receipt: %w", safeFilesystemError(err))
177- }
178- return nil
179-}
180-
181-// deleteAuthorizedLegacy deletes only a source whose bytes match the durable
182-// receipt. A replaced source invalidates the receipt and remains importable.
183-func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, filename string, receipt legacyDeleteMarker) error {
184- if _, pending := r.pendingLegacySync.Load(filename); pending {
185- if err := r.syncPendingLegacy(ctx, filename); err != nil {
186- return err
187- }
188- return r.clearLegacyDeleteAuthorization(receipt.name)
189- }
190- path := filepath.Join(r.dir, filename)
191- data, err := r.readBounded(path)
192- if err != nil && !errors.Is(err, os.ErrNotExist) {
193- return fmt.Errorf("read legacy snapshot for deletion: %w", safeFilesystemError(err))
194- }
195- if err == nil && sha256.Sum256(data) != receipt.digest {
196- return r.clearLegacyDeleteAuthorization(receipt.name)
197- }
198- return r.deleteLegacyFile(ctx, filename, receipt.name)
199-}
200-
201-func (r *Repository) deleteLegacyFile(ctx context.Context, filename string, clearReceipt ...string) error {
202- if _, pending := r.pendingLegacySync.Load(filename); pending {
203- if err := r.syncPendingLegacy(ctx, filename); err != nil {
204- return err
205- }
206- } else {
207- path := filepath.Join(r.dir, filename)
208- if err := r.remove(path); err != nil {
209- if !errors.Is(err, os.ErrNotExist) {
210- return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))
211- }
212- // A missing repository has no deletion to make durable. This preserves
213- // idempotence for explicit purges before the snapshot root exists.
214- if _, statErr := os.Lstat(r.dir); errors.Is(statErr, os.ErrNotExist) {
215- return nil
216- } else if statErr != nil {
217- return fmt.Errorf("stat legacy snapshot directory: %w", safeFilesystemError(statErr))
218- }
219- }
220- r.pendingLegacySync.Store(filename, struct{}{})
221- if err := r.syncPendingLegacy(ctx, filename); err != nil {
222- return err
223- }
224- }
225- if len(clearReceipt) != 0 {
226- return r.clearLegacyDeleteAuthorization(clearReceipt[0])
227- }
228- return nil
229-}
230-
231-func (r *Repository) clearLegacyDeleteAuthorization(name string) error {
232- path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name)))
233- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
234- return fmt.Errorf("clear legacy import receipt: %w", safeFilesystemError(err))
235- }
236- if err := r.syncDirectory(r.dir); err != nil {
237- return fmt.Errorf("sync legacy import receipt directory: %w", safeFilesystemError(err))
238- }
239- return nil
240-}
241-
242-func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *int) error {
243- markers, err := r.pendingLegacyDeleteMarkers(ctx, budget)
244- if errors.Is(err, os.ErrNotExist) {
245- return nil
246- }
247- if err != nil {
248- return err
249- }
250- for _, marker := range markers {
251- filename := filenameForName(marker.name)
252- lock := r.lockSession("legacy-sync:" + filename)
253- err := r.deleteAuthorizedLegacy(ctx, filename, marker)
254- r.unlockSession(lock)
255- if err != nil {
256- return fmt.Errorf("retry authorized legacy deletion: %w", err)
257- }
258- }
259- return nil
260-}
261-
262-func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int) (markers []legacyDeleteMarker, err error) {
263- f, err := r.openLegacyDirectory(r.dir)
264- if err != nil {
265- return nil, err
266- }
267- defer func() {
268- if closeErr := f.Close(); closeErr != nil {
269- err = errors.Join(err, fmt.Errorf("close legacy deletion marker directory: %w", safeFilesystemError(closeErr)))
270- }
271- }()
272- markers = make([]legacyDeleteMarker, 0, maxLegacyDeleteMarkers)
273- for {
274- entries, readErr := f.ReadDir(maintenanceBatch)
275- for _, entry := range entries {
276- if err := ctx.Err(); err != nil {
277- return nil, err
278- }
279- if *budget == 0 {
280- return nil, ErrDirectoryTraversalBudget
281- }
282- *budget--
283- if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) {
284- continue
285- }
286- if len(markers) == maxLegacyDeleteMarkers {
287- return nil, fmt.Errorf("legacy deletion retry has too many markers (maximum %d)", maxLegacyDeleteMarkers)
288- }
289- key := strings.TrimPrefix(entry.Name(), legacyDeleteMarkerPrefix)
290- if !canonicalSessionKey(key) {
291- return nil, fmt.Errorf("invalid legacy deletion marker")
292- }
293- path := filepath.Join(r.dir, entry.Name())
294- data, dataErr := r.readBounded(path)
295- if dataErr != nil || len(data) < sha256.Size || len(data)-sha256.Size > maxLegacyDeleteNameBytes {
296- return nil, fmt.Errorf("invalid legacy import receipt")
297- }
298- name := string(data[sha256.Size:])
299- if sessionKey(name) != key {
300- return nil, fmt.Errorf("invalid legacy import receipt")
301- }
302- marker := legacyDeleteMarker{name: name}
303- copy(marker.digest[:], data[:sha256.Size])
304- markers = append(markers, marker)
305- }
306- if errors.Is(readErr, io.EOF) {
307- break
308- }
309- if readErr != nil {
310- return nil, fmt.Errorf("read legacy deletion markers: %w", safeFilesystemError(readErr))
311- }
312- }
313- return markers, nil
314-}
315-
316-// Tombstone durably marks name as logically killed before either snapshot
317-// source is deleted. Startup consults this marker before importing legacy or
318-// restoring incremental state, so a partial offline kill cannot resurrect it.
319-func (r *Repository) Tombstone(ctx context.Context, name string) error {
320- if err := ctx.Err(); err != nil {
321- return err
322- }
323- key := sessionKey(name)
324- lock := r.lockSession(key)
325- defer r.unlockSession(lock)
326- if err := ctx.Err(); err != nil {
327- return err
328- }
329- if err := r.ensurePrivateDirectory(r.dir); err != nil {
330- return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err))
331- }
332- path := filepath.Join(r.dir, tombstoneFilename(key))
333- if err := r.writeImmutable(path, []byte(name), func(data []byte) error {
334- if string(data) != name {
335- return fmt.Errorf("killed session marker identity mismatch")
336- }
337- return nil
338- }); err != nil {
339- return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err))
340- }
341- return nil
342-}
343-
344-// DeleteTombstone removes a marker only after both sources have been durably
345-// deleted. A missing marker is idempotent for offline-kill retries.
346-func (r *Repository) DeleteTombstone(ctx context.Context, name string) error {
347- if err := ctx.Err(); err != nil {
348- return err
349- }
350- key := sessionKey(name)
351- lock := r.lockSession(key)
352- defer r.unlockSession(lock)
353- if err := ctx.Err(); err != nil {
354- return err
355- }
356- if err := r.remove(filepath.Join(r.dir, tombstoneFilename(key))); errors.Is(err, os.ErrNotExist) {
357- return nil
358- } else if err != nil {
359- return fmt.Errorf("delete killed session marker: %w", safeFilesystemError(err))
360- }
361- if err := r.syncDirectory(r.dir); err != nil {
362- return fmt.Errorf("sync killed session marker directory: %w", safeFilesystemError(err))
363- }
364- return nil
365-}
366-
367-func tombstoneFilename(key string) string { return ".killed-" + key }
368-
369-func (r *Repository) tombstoned(name string) (bool, error) {
370- if name == "" {
371- return false, nil
372- }
373- path := filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))
374- if hook := r.hooks.beforeTombstoneCheck; hook != nil {
375- hook(path)
376- }
377- data, err := r.readBounded(path)
378- if errors.Is(err, os.ErrNotExist) {
379- return false, nil
380- }
381- if err != nil {
382- return false, err
383- }
384- if string(data) != name {
385- return false, fmt.Errorf("killed session marker identity mismatch")
386- }
387- return true, nil
388-}
389-
390-func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error {
391- if err := ctx.Err(); err != nil {
392- return err
393- }
394- if err := r.syncDirectory(r.dir); err != nil {
395- return fmt.Errorf("sync legacy snapshot directory: %w", safeFilesystemError(err))
396- }
397- r.pendingLegacySync.Delete(filename)
398- return nil
399-}
D internal/adapters/snapshot/legacy_pending_delete_test.go +0 -63
@@ -1,63 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "path/filepath"
8- "testing"
9-
10- "github.com/stretchr/testify/require"
11-
12- "github.com/bnema/vev/internal/ports"
13-)
14-
15-func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) {
16- repo := NewRepository(privateDir(t))
17- require.NoError(t, os.Mkdir(repo.dir, 0o700))
18- path := filepath.Join(repo.dir, filenameForName("named"))
19- require.NoError(t, os.WriteFile(path, []byte("legacy"), 0o600))
20-
21- injected := errors.New("unlink failed")
22- repo.hooks.remove = func(path string) error {
23- if filepath.Base(path) == filenameForName("named") {
24- return injected
25- }
26- return os.Remove(path)
27- }
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)
31-
32- // A new repository has no in-memory retry state. The durable receipt must
33- // fence import and complete deletion without another publication.
34- repo = NewRepository(repo.dir)
35- legacy, err := repo.LoadLegacy(context.Background())
36- require.NoError(t, err)
37- require.Empty(t, legacy)
38- _, err = os.Stat(path)
39- require.ErrorIs(t, err, os.ErrNotExist)
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-}
@@ -1,63 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "path/filepath"
8- "testing"
9-
10- "github.com/stretchr/testify/require"
11-
12- "github.com/bnema/vev/internal/ports"
13-)
14-
15-func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) {
16- repo := NewRepository(privateDir(t))
17- require.NoError(t, os.Mkdir(repo.dir, 0o700))
18- path := filepath.Join(repo.dir, filenameForName("named"))
19- require.NoError(t, os.WriteFile(path, []byte("legacy"), 0o600))
20-
21- injected := errors.New("unlink failed")
22- repo.hooks.remove = func(path string) error {
23- if filepath.Base(path) == filenameForName("named") {
24- return injected
25- }
26- return os.Remove(path)
27- }
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)
31-
32- // A new repository has no in-memory retry state. The durable receipt must
33- // fence import and complete deletion without another publication.
34- repo = NewRepository(repo.dir)
35- legacy, err := repo.LoadLegacy(context.Background())
36- require.NoError(t, err)
37- require.Empty(t, legacy)
38- _, err = os.Stat(path)
39- require.ErrorIs(t, err, os.ErrNotExist)
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-}
D internal/adapters/snapshot/legacy_test.go +0 -141
@@ -1,141 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "os"
9- "path/filepath"
10- "strings"
11- "testing"
12-)
13-
14-func TestRepositoryLegacyReadsOnlyBoundedRootSnapshots(t *testing.T) {
15- repo := NewRepository(privateDir(t))
16- if err := os.MkdirAll(filepath.Join(repo.dir, repositorySessionsDir), 0o700); err != nil {
17- t.Fatal(err)
18- }
19- if err := os.WriteFile(filepath.Join(repo.dir, "named.snap"), []byte("legacy"), 0o600); err != nil {
20- t.Fatal(err)
21- }
22- if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("unsafe/name")), []byte("hashed"), 0o600); err != nil {
23- t.Fatal(err)
24- }
25- if err := os.WriteFile(filepath.Join(repo.dir, repositorySessionsDir, "ignored.snap"), []byte("incremental"), 0o600); err != nil {
26- t.Fatal(err)
27- }
28-
29- got, err := repo.LoadLegacy(context.Background())
30- if err != nil {
31- t.Fatal(err)
32- }
33- if len(got) != 2 || got[0].Name != "" || got[1].Name != "named" {
34- t.Fatalf("LoadLegacy = %#v, want root safe and hashed snapshots", got)
35- }
36-}
37-
38-func TestRepositoryLoadLegacyReportsDirectoryCloseError(t *testing.T) {
39- for _, tt := range []struct {
40- name string
41- failOpen int
42- operation string
43- }{
44- {name: "pending deletion markers", failOpen: 1, operation: "close legacy deletion marker directory"},
45- {name: "legacy snapshots", failOpen: 2, operation: "close legacy snapshot directory"},
46- } {
47- t.Run(tt.name, func(t *testing.T) {
48- closeErr := errors.New("injected directory close failure")
49- repo := NewRepository(privateDir(t))
50- openCalls := 0
51- repo.hooks.openLegacyDirectory = func(string) (legacyDirectory, error) {
52- openCalls++
53- if openCalls == tt.failOpen {
54- return fakeLegacyDirectory{closeErr: closeErr}, nil
55- }
56- return fakeLegacyDirectory{}, nil
57- }
58-
59- _, err := repo.LoadLegacy(context.Background())
60- if !errors.Is(err, closeErr) {
61- t.Fatalf("LoadLegacy error = %v, want close error", err)
62- }
63- if !strings.Contains(err.Error(), tt.operation) {
64- t.Fatalf("LoadLegacy error = %v, want %q", err, tt.operation)
65- }
66- })
67- }
68-}
69-
70-type fakeLegacyDirectory struct {
71- closeErr error
72-}
73-
74-func (d fakeLegacyDirectory) ReadDir(int) ([]os.DirEntry, error) { return nil, io.EOF }
75-func (d fakeLegacyDirectory) Close() error { return d.closeErr }
76-
77-func TestRepositoryLegacyRejectsTooManyAndTooLargeAggregateSnapshots(t *testing.T) {
78- t.Run("count", func(t *testing.T) {
79- repo := NewRepository(privateDir(t))
80- if err := os.Mkdir(repo.dir, 0o700); err != nil {
81- t.Fatal(err)
82- }
83- for i := 0; i < maxLegacySnapshotFiles+1; i++ {
84- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%03d.snap", i)), []byte("legacy"), 0o600); err != nil {
85- t.Fatal(err)
86- }
87- }
88- if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "too many") {
89- t.Fatalf("LoadLegacy error = %v, want actionable count limit", err)
90- }
91- })
92- t.Run("aggregate bytes", func(t *testing.T) {
93- repo := NewRepository(privateDir(t))
94- if err := os.Mkdir(repo.dir, 0o700); err != nil {
95- t.Fatal(err)
96- }
97- for i, size := range []int{maxLegacySnapshotBytes / 2, maxLegacySnapshotBytes/2 + 1} {
98- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%d.snap", i)), make([]byte, size), 0o600); err != nil {
99- t.Fatal(err)
100- }
101- }
102- if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "aggregate") {
103- t.Fatalf("LoadLegacy error = %v, want actionable aggregate limit", err)
104- }
105- })
106-}
107-
108-func TestRepositoryDeleteLegacyMissingFileDoesNotRequireRoot(t *testing.T) {
109- repo := NewRepository(privateDir(t))
110- if err := repo.DeleteLegacy(context.Background(), "missing"); err != nil {
111- t.Fatalf("DeleteLegacy missing file: %v", err)
112- }
113-}
114-
115-func TestRepositoryDeleteLegacySurfacesRootSyncAndCanRetry(t *testing.T) {
116- repo := NewRepository(privateDir(t))
117- if err := os.Mkdir(repo.dir, 0o700); err != nil {
118- t.Fatal(err)
119- }
120- if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600); err != nil {
121- t.Fatal(err)
122- }
123- injected := errors.New("root sync")
124- syncCalls := 0
125- repo.hooks.syncDirectory = func(string) error {
126- syncCalls++
127- if syncCalls == 1 {
128- return injected
129- }
130- return nil
131- }
132- if err := repo.DeleteLegacy(context.Background(), "named"); !errors.Is(err, injected) {
133- t.Fatalf("DeleteLegacy error = %v, want root sync error", err)
134- }
135- if err := repo.DeleteLegacy(context.Background(), "named"); err != nil {
136- t.Fatalf("retry DeleteLegacy: %v", err)
137- }
138- if syncCalls != 2 {
139- t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls)
140- }
141-}
@@ -1,141 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "os"
9- "path/filepath"
10- "strings"
11- "testing"
12-)
13-
14-func TestRepositoryLegacyReadsOnlyBoundedRootSnapshots(t *testing.T) {
15- repo := NewRepository(privateDir(t))
16- if err := os.MkdirAll(filepath.Join(repo.dir, repositorySessionsDir), 0o700); err != nil {
17- t.Fatal(err)
18- }
19- if err := os.WriteFile(filepath.Join(repo.dir, "named.snap"), []byte("legacy"), 0o600); err != nil {
20- t.Fatal(err)
21- }
22- if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("unsafe/name")), []byte("hashed"), 0o600); err != nil {
23- t.Fatal(err)
24- }
25- if err := os.WriteFile(filepath.Join(repo.dir, repositorySessionsDir, "ignored.snap"), []byte("incremental"), 0o600); err != nil {
26- t.Fatal(err)
27- }
28-
29- got, err := repo.LoadLegacy(context.Background())
30- if err != nil {
31- t.Fatal(err)
32- }
33- if len(got) != 2 || got[0].Name != "" || got[1].Name != "named" {
34- t.Fatalf("LoadLegacy = %#v, want root safe and hashed snapshots", got)
35- }
36-}
37-
38-func TestRepositoryLoadLegacyReportsDirectoryCloseError(t *testing.T) {
39- for _, tt := range []struct {
40- name string
41- failOpen int
42- operation string
43- }{
44- {name: "pending deletion markers", failOpen: 1, operation: "close legacy deletion marker directory"},
45- {name: "legacy snapshots", failOpen: 2, operation: "close legacy snapshot directory"},
46- } {
47- t.Run(tt.name, func(t *testing.T) {
48- closeErr := errors.New("injected directory close failure")
49- repo := NewRepository(privateDir(t))
50- openCalls := 0
51- repo.hooks.openLegacyDirectory = func(string) (legacyDirectory, error) {
52- openCalls++
53- if openCalls == tt.failOpen {
54- return fakeLegacyDirectory{closeErr: closeErr}, nil
55- }
56- return fakeLegacyDirectory{}, nil
57- }
58-
59- _, err := repo.LoadLegacy(context.Background())
60- if !errors.Is(err, closeErr) {
61- t.Fatalf("LoadLegacy error = %v, want close error", err)
62- }
63- if !strings.Contains(err.Error(), tt.operation) {
64- t.Fatalf("LoadLegacy error = %v, want %q", err, tt.operation)
65- }
66- })
67- }
68-}
69-
70-type fakeLegacyDirectory struct {
71- closeErr error
72-}
73-
74-func (d fakeLegacyDirectory) ReadDir(int) ([]os.DirEntry, error) { return nil, io.EOF }
75-func (d fakeLegacyDirectory) Close() error { return d.closeErr }
76-
77-func TestRepositoryLegacyRejectsTooManyAndTooLargeAggregateSnapshots(t *testing.T) {
78- t.Run("count", func(t *testing.T) {
79- repo := NewRepository(privateDir(t))
80- if err := os.Mkdir(repo.dir, 0o700); err != nil {
81- t.Fatal(err)
82- }
83- for i := 0; i < maxLegacySnapshotFiles+1; i++ {
84- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%03d.snap", i)), []byte("legacy"), 0o600); err != nil {
85- t.Fatal(err)
86- }
87- }
88- if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "too many") {
89- t.Fatalf("LoadLegacy error = %v, want actionable count limit", err)
90- }
91- })
92- t.Run("aggregate bytes", func(t *testing.T) {
93- repo := NewRepository(privateDir(t))
94- if err := os.Mkdir(repo.dir, 0o700); err != nil {
95- t.Fatal(err)
96- }
97- for i, size := range []int{maxLegacySnapshotBytes / 2, maxLegacySnapshotBytes/2 + 1} {
98- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%d.snap", i)), make([]byte, size), 0o600); err != nil {
99- t.Fatal(err)
100- }
101- }
102- if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "aggregate") {
103- t.Fatalf("LoadLegacy error = %v, want actionable aggregate limit", err)
104- }
105- })
106-}
107-
108-func TestRepositoryDeleteLegacyMissingFileDoesNotRequireRoot(t *testing.T) {
109- repo := NewRepository(privateDir(t))
110- if err := repo.DeleteLegacy(context.Background(), "missing"); err != nil {
111- t.Fatalf("DeleteLegacy missing file: %v", err)
112- }
113-}
114-
115-func TestRepositoryDeleteLegacySurfacesRootSyncAndCanRetry(t *testing.T) {
116- repo := NewRepository(privateDir(t))
117- if err := os.Mkdir(repo.dir, 0o700); err != nil {
118- t.Fatal(err)
119- }
120- if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600); err != nil {
121- t.Fatal(err)
122- }
123- injected := errors.New("root sync")
124- syncCalls := 0
125- repo.hooks.syncDirectory = func(string) error {
126- syncCalls++
127- if syncCalls == 1 {
128- return injected
129- }
130- return nil
131- }
132- if err := repo.DeleteLegacy(context.Background(), "named"); !errors.Is(err, injected) {
133- t.Fatalf("DeleteLegacy error = %v, want root sync error", err)
134- }
135- if err := repo.DeleteLegacy(context.Background(), "named"); err != nil {
136- t.Fatalf("retry DeleteLegacy: %v", err)
137- }
138- if syncCalls != 2 {
139- t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls)
140- }
141-}
D internal/adapters/snapshot/offline_kill_tombstone_test.go +0 -53
@@ -1,53 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "os"
6- "path/filepath"
7- "testing"
8-
9- "github.com/stretchr/testify/require"
10-)
11-
12-func TestRepositoryTombstoneBlocksIncrementalRestoreAndLegacyImport(t *testing.T) {
13- repo := NewRepository(privateDir(t))
14- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
15- require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
16-
17- require.NoError(t, repo.Tombstone(context.Background(), "named"))
18-
19- names, err := repo.List(context.Background())
20- require.NoError(t, err)
21- require.NotContains(t, names, "named")
22- legacy, err := repo.LoadLegacy(context.Background())
23- require.NoError(t, err)
24- for _, snapshot := range legacy {
25- require.NotEqual(t, "named", snapshot.Name)
26- }
27-}
28-
29-func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) {
30- repo := NewRepository(privateDir(t))
31- require.NoError(t, os.Mkdir(repo.dir, 0o700))
32- require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
33- require.NoError(t, repo.Tombstone(context.Background(), "named"))
34-
35- calls := 0
36- repo.hooks.syncDirectory = func(string) error {
37- calls++
38- if calls == 1 {
39- return os.ErrPermission
40- }
41- return nil
42- }
43- require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), os.ErrPermission)
44-
45- // A retry after process restart has no in-memory pending-sync state.
46- repo = NewRepository(repo.dir)
47- repo.hooks.syncDirectory = func(string) error {
48- calls++
49- return nil
50- }
51- require.NoError(t, repo.DeleteLegacy(context.Background(), "named"))
52- require.Equal(t, 2, calls, "explicit purge retries only the pending source-directory sync")
53-}
@@ -1,53 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "os"
6- "path/filepath"
7- "testing"
8-
9- "github.com/stretchr/testify/require"
10-)
11-
12-func TestRepositoryTombstoneBlocksIncrementalRestoreAndLegacyImport(t *testing.T) {
13- repo := NewRepository(privateDir(t))
14- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
15- require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
16-
17- require.NoError(t, repo.Tombstone(context.Background(), "named"))
18-
19- names, err := repo.List(context.Background())
20- require.NoError(t, err)
21- require.NotContains(t, names, "named")
22- legacy, err := repo.LoadLegacy(context.Background())
23- require.NoError(t, err)
24- for _, snapshot := range legacy {
25- require.NotEqual(t, "named", snapshot.Name)
26- }
27-}
28-
29-func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) {
30- repo := NewRepository(privateDir(t))
31- require.NoError(t, os.Mkdir(repo.dir, 0o700))
32- require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
33- require.NoError(t, repo.Tombstone(context.Background(), "named"))
34-
35- calls := 0
36- repo.hooks.syncDirectory = func(string) error {
37- calls++
38- if calls == 1 {
39- return os.ErrPermission
40- }
41- return nil
42- }
43- require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), os.ErrPermission)
44-
45- // A retry after process restart has no in-memory pending-sync state.
46- repo = NewRepository(repo.dir)
47- repo.hooks.syncDirectory = func(string) error {
48- calls++
49- return nil
50- }
51- require.NoError(t, repo.DeleteLegacy(context.Background(), "named"))
52- require.Equal(t, 2, calls, "explicit purge retries only the pending source-directory sync")
53-}
M internal/adapters/snapshot/path_security_test.go +0 -16
@@ -3,7 +3,6 @@ package snapshot
33 import (
44 "context"
55 "errors"
6- "fmt"
76 "os"
87 "path/filepath"
98 "strings"
@@ -200,18 +199,3 @@ func TestFinalSymlinksRejectedByRootOperations(t *testing.T) {
200199 t.Fatalf("readBounded error = %v, want sanitized ELOOP", err)
201200 }
202201 }
203-
204-func TestRepositoryLoadLegacyChargesUnrelatedRootEntries(t *testing.T) {
205- repo := NewRepository(privateDir(t))
206- if err := os.MkdirAll(repo.dir, 0o700); err != nil {
207- t.Fatal(err)
208- }
209- for i := 0; i <= maxDirectoryTraversalEntries; i++ {
210- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("unrelated-%05d", i)), nil, 0o600); err != nil {
211- t.Fatal(err)
212- }
213- }
214- if _, err := repo.LoadLegacy(context.Background()); !errors.Is(err, ErrDirectoryTraversalBudget) {
215- t.Fatalf("LoadLegacy error = %v, want traversal budget error", err)
216- }
217-}
@@ -3,7 +3,6 @@ package snapshot
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "fmt"
7 "os"6 "os"
8 "path/filepath"7 "path/filepath"
9 "strings"8 "strings"
@@ -200,18 +199,3 @@ func TestFinalSymlinksRejectedByRootOperations(t *testing.T) {
200 t.Fatalf("readBounded error = %v, want sanitized ELOOP", err)199 t.Fatalf("readBounded error = %v, want sanitized ELOOP", err)
201 }200 }
202 }201 }
203-
204-func TestRepositoryLoadLegacyChargesUnrelatedRootEntries(t *testing.T) {
205- repo := NewRepository(privateDir(t))
206- if err := os.MkdirAll(repo.dir, 0o700); err != nil {
207- t.Fatal(err)
208- }
209- for i := 0; i <= maxDirectoryTraversalEntries; i++ {
210- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("unrelated-%05d", i)), nil, 0o600); err != nil {
211- t.Fatal(err)
212- }
213- }
214- if _, err := repo.LoadLegacy(context.Background()); !errors.Is(err, ErrDirectoryTraversalBudget) {
215- t.Fatalf("LoadLegacy error = %v, want traversal budget error", err)
216- }
217-}
M internal/adapters/snapshot/repository.go +0 -12
@@ -14,14 +14,9 @@ const (
1414 repositoryHead = "HEAD"
1515 generationWidth = 20
1616 maxRepositoryRead = maxSnapshotFileSize
17- // Legacy import is deliberately smaller than the per-file parser ceiling:
18- // importing old snapshots must not accumulate an unbounded startup payload.
19- maxLegacySnapshotFiles = 64
20- maxLegacySnapshotBytes = 8 << 20
2117 )
2218
2319 // Repository is the crash-safe, content-addressed session snapshot store.
24-// Store remains available separately for the one-way legacy bridge.
2520 type Repository struct {
2621 dir string
2722 hooks repositoryHooks
@@ -43,11 +38,6 @@ type Repository struct {
4338 maintenanceSessions map[string]*sessionMaintenance
4439 retentionSessions map[string]*retentionMaintenance
4540 maintenanceQuarantine *quarantineMaintenance
46-
47- // pendingLegacySync records an unlink whose root-directory sync failed.
48- // It is keyed by the deterministic legacy filename and shares a per-file
49- // lock with DeleteLegacy so a retry cannot acknowledge a recreated file.
50- pendingLegacySync sync.Map // map[string]struct{}
5141 }
5242
5343 // repositoryHooks makes each persistence boundary fault-injectable. Hooks run
@@ -95,12 +85,10 @@ type repositoryHooks struct {
9585 // beforeMaintenanceWork observes each budgeted quarantine filesystem step.
9686 // It is test-only instrumentation for hostile traversal bounds.
9787 beforeMaintenanceWork func(string)
98- openLegacyDirectory func(string) (legacyDirectory, error)
9988 beforeSessionLock func(string)
10089 }
10190
10291 var _ ports.SnapshotRepository = (*Repository)(nil)
103-var _ ports.LegacySnapshotSource = (*Repository)(nil)
10492
10593 // NewRepository creates a repository rooted at dir. It does not create files
10694 // until the first publication, so merely constructing it is side-effect free.
@@ -14,14 +14,9 @@ const (
14 repositoryHead = "HEAD"14 repositoryHead = "HEAD"
15 generationWidth = 2015 generationWidth = 20
16 maxRepositoryRead = maxSnapshotFileSize16 maxRepositoryRead = maxSnapshotFileSize
17- // Legacy import is deliberately smaller than the per-file parser ceiling:
18- // importing old snapshots must not accumulate an unbounded startup payload.
19- maxLegacySnapshotFiles = 64
20- maxLegacySnapshotBytes = 8 << 20
21 )17 )
22 18
23 // Repository is the crash-safe, content-addressed session snapshot store.19 // Repository is the crash-safe, content-addressed session snapshot store.
24-// Store remains available separately for the one-way legacy bridge.
25 type Repository struct {20 type Repository struct {
26 dir string21 dir string
27 hooks repositoryHooks22 hooks repositoryHooks
@@ -43,11 +38,6 @@ type Repository struct {
43 maintenanceSessions map[string]*sessionMaintenance38 maintenanceSessions map[string]*sessionMaintenance
44 retentionSessions map[string]*retentionMaintenance39 retentionSessions map[string]*retentionMaintenance
45 maintenanceQuarantine *quarantineMaintenance40 maintenanceQuarantine *quarantineMaintenance
46-
47- // pendingLegacySync records an unlink whose root-directory sync failed.
48- // It is keyed by the deterministic legacy filename and shares a per-file
49- // lock with DeleteLegacy so a retry cannot acknowledge a recreated file.
50- pendingLegacySync sync.Map // map[string]struct{}
51 }41 }
52 42
53 // repositoryHooks makes each persistence boundary fault-injectable. Hooks run43 // repositoryHooks makes each persistence boundary fault-injectable. Hooks run
@@ -95,12 +85,10 @@ type repositoryHooks struct {
95 // beforeMaintenanceWork observes each budgeted quarantine filesystem step.85 // beforeMaintenanceWork observes each budgeted quarantine filesystem step.
96 // It is test-only instrumentation for hostile traversal bounds.86 // It is test-only instrumentation for hostile traversal bounds.
97 beforeMaintenanceWork func(string)87 beforeMaintenanceWork func(string)
98- openLegacyDirectory func(string) (legacyDirectory, error)
99 beforeSessionLock func(string)88 beforeSessionLock func(string)
100 }89 }
101 90
102 var _ ports.SnapshotRepository = (*Repository)(nil)91 var _ ports.SnapshotRepository = (*Repository)(nil)
103-var _ ports.LegacySnapshotSource = (*Repository)(nil)
104 92
105 // NewRepository creates a repository rooted at dir. It does not create files93 // NewRepository creates a repository rooted at dir. It does not create files
106 // until the first publication, so merely constructing it is side-effect free.94 // until the first publication, so merely constructing it is side-effect free.
R internal/adapters/snapshot/legacy_load_test.go → internal/adapters/snapshot/retired_name_keyed_test.go +48 -6 renamed, 80% similar
@@ -122,9 +122,6 @@ func (r *Repository) currentGeneration(ctx context.Context, name, key string) (u
122122 return 0, nil, nil
123123 }
124124
125-// loadNewestGeneration validates a single bounded candidate batch in strict
126-// descending generation order. It is used by List, whose name is discovered
127-// from each manifest rather than supplied by a caller.
128125 func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
129126 candidates, err := r.generationCandidates(ctx, key, 0, budget)
130127 if err != nil {
@@ -154,9 +151,6 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge
154151 return ports.SnapshotGeneration{}, false, nil
155152 }
156153
157-// generationCandidates reads the generations directory once per load attempt.
158-// The shared traversal budget bounds both retained names and work; sorting the
159-// bounded batch preserves strict newest-to-oldest fallback without rescanning.
160154 func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
161155 candidates := make([]uint64, 0)
162156 err := r.walkDirectory(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
@@ -178,3 +172,51 @@ func (r *Repository) generationCandidates(ctx context.Context, key string, exclu
178172 sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
179173 return candidates, nil
180174 }
175+
176+func (r *Repository) Tombstone(ctx context.Context, name string) error {
177+ if err := ctx.Err(); err != nil {
178+ return err
179+ }
180+ key := sessionKey(name)
181+ lock := r.lockSession(key)
182+ defer r.unlockSession(lock)
183+ if err := ctx.Err(); err != nil {
184+ return err
185+ }
186+ if err := r.ensurePrivateDirectory(r.dir); err != nil {
187+ return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err))
188+ }
189+ path := filepath.Join(r.dir, tombstoneFilename(key))
190+ if err := r.writeImmutable(path, []byte(name), func(data []byte) error {
191+ if string(data) != name {
192+ return fmt.Errorf("killed session marker identity mismatch")
193+ }
194+ return nil
195+ }); err != nil {
196+ return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err))
197+ }
198+ return nil
199+}
200+
201+func tombstoneFilename(key string) string { return ".killed-" + key }
202+
203+func (r *Repository) tombstoned(name string) (bool, error) {
204+ if name == "" {
205+ return false, nil
206+ }
207+ path := filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))
208+ if hook := r.hooks.beforeTombstoneCheck; hook != nil {
209+ hook(path)
210+ }
211+ data, err := r.readBounded(path)
212+ if errors.Is(err, os.ErrNotExist) {
213+ return false, nil
214+ }
215+ if err != nil {
216+ return false, err
217+ }
218+ if string(data) != name {
219+ return false, fmt.Errorf("killed session marker identity mismatch")
220+ }
221+ return true, nil
222+}
@@ -122,9 +122,6 @@ func (r *Repository) currentGeneration(ctx context.Context, name, key string) (u
122 return 0, nil, nil122 return 0, nil, nil
123 }123 }
124 124
125-// loadNewestGeneration validates a single bounded candidate batch in strict
126-// descending generation order. It is used by List, whose name is discovered
127-// from each manifest rather than supplied by a caller.
128 func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {125 func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
129 candidates, err := r.generationCandidates(ctx, key, 0, budget)126 candidates, err := r.generationCandidates(ctx, key, 0, budget)
130 if err != nil {127 if err != nil {
@@ -154,9 +151,6 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge
154 return ports.SnapshotGeneration{}, false, nil151 return ports.SnapshotGeneration{}, false, nil
155 }152 }
156 153
157-// generationCandidates reads the generations directory once per load attempt.
158-// The shared traversal budget bounds both retained names and work; sorting the
159-// bounded batch preserves strict newest-to-oldest fallback without rescanning.
160 func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {154 func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
161 candidates := make([]uint64, 0)155 candidates := make([]uint64, 0)
162 err := r.walkDirectory(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {156 err := r.walkDirectory(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
@@ -178,3 +172,51 @@ func (r *Repository) generationCandidates(ctx context.Context, key string, exclu
178 sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })172 sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
179 return candidates, nil173 return candidates, nil
180 }174 }
175+
176+func (r *Repository) Tombstone(ctx context.Context, name string) error {
177+ if err := ctx.Err(); err != nil {
178+ return err
179+ }
180+ key := sessionKey(name)
181+ lock := r.lockSession(key)
182+ defer r.unlockSession(lock)
183+ if err := ctx.Err(); err != nil {
184+ return err
185+ }
186+ if err := r.ensurePrivateDirectory(r.dir); err != nil {
187+ return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err))
188+ }
189+ path := filepath.Join(r.dir, tombstoneFilename(key))
190+ if err := r.writeImmutable(path, []byte(name), func(data []byte) error {
191+ if string(data) != name {
192+ return fmt.Errorf("killed session marker identity mismatch")
193+ }
194+ return nil
195+ }); err != nil {
196+ return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err))
197+ }
198+ return nil
199+}
200+
201+func tombstoneFilename(key string) string { return ".killed-" + key }
202+
203+func (r *Repository) tombstoned(name string) (bool, error) {
204+ if name == "" {
205+ return false, nil
206+ }
207+ path := filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))
208+ if hook := r.hooks.beforeTombstoneCheck; hook != nil {
209+ hook(path)
210+ }
211+ data, err := r.readBounded(path)
212+ if errors.Is(err, os.ErrNotExist) {
213+ return false, nil
214+ }
215+ if err != nil {
216+ return false, err
217+ }
218+ if string(data) != name {
219+ return false, fmt.Errorf("killed session marker identity mismatch")
220+ }
221+ return true, nil
222+}
M internal/ports/mocks/store_mock.go +0 -205
@@ -5,8 +5,6 @@
55 package portsmocks
66
77 import (
8- "context"
9-
108 "github.com/bnema/vev/internal/ports"
119 mock "github.com/stretchr/testify/mock"
1210 )
@@ -386,206 +384,3 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
386384 _c.Call.Return(run)
387385 return _c
388386 }
389-
390-// NewMockLegacySnapshotSource creates a new instance of MockLegacySnapshotSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
391-// The first argument is typically a *testing.T value.
392-func NewMockLegacySnapshotSource(t interface {
393- mock.TestingT
394- Cleanup(func())
395-}) *MockLegacySnapshotSource {
396- mock := &MockLegacySnapshotSource{}
397- mock.Mock.Test(t)
398-
399- t.Cleanup(func() { mock.AssertExpectations(t) })
400-
401- return mock
402-}
403-
404-// MockLegacySnapshotSource is an autogenerated mock type for the LegacySnapshotSource type
405-type MockLegacySnapshotSource struct {
406- mock.Mock
407-}
408-
409-type MockLegacySnapshotSource_Expecter struct {
410- mock *mock.Mock
411-}
412-
413-func (_m *MockLegacySnapshotSource) EXPECT() *MockLegacySnapshotSource_Expecter {
414- return &MockLegacySnapshotSource_Expecter{mock: &_m.Mock}
415-}
416-
417-// DeleteLegacy provides a mock function for the type MockLegacySnapshotSource
418-func (_mock *MockLegacySnapshotSource) DeleteLegacy(context1 context.Context, s string) error {
419- ret := _mock.Called(context1, s)
420-
421- if len(ret) == 0 {
422- panic("no return value specified for DeleteLegacy")
423- }
424-
425- var r0 error
426- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
427- r0 = returnFunc(context1, s)
428- } else {
429- r0 = ret.Error(0)
430- }
431- return r0
432-}
433-
434-// MockLegacySnapshotSource_DeleteLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteLegacy'
435-type MockLegacySnapshotSource_DeleteLegacy_Call struct {
436- *mock.Call
437-}
438-
439-// DeleteLegacy is a helper method to define mock.On call
440-// - context1 context.Context
441-// - s string
442-func (_e *MockLegacySnapshotSource_Expecter) DeleteLegacy(context1 any, s any) *MockLegacySnapshotSource_DeleteLegacy_Call {
443- return &MockLegacySnapshotSource_DeleteLegacy_Call{Call: _e.mock.On("DeleteLegacy", context1, s)}
444-}
445-
446-func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Run(run func(context1 context.Context, s string)) *MockLegacySnapshotSource_DeleteLegacy_Call {
447- _c.Call.Run(func(args mock.Arguments) {
448- var arg0 context.Context
449- if args[0] != nil {
450- arg0 = args[0].(context.Context)
451- }
452- var arg1 string
453- if args[1] != nil {
454- arg1 = args[1].(string)
455- }
456- run(
457- arg0,
458- arg1,
459- )
460- })
461- return _c
462-}
463-
464-func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteLegacy_Call {
465- _c.Call.Return(err)
466- return _c
467-}
468-
469-func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockLegacySnapshotSource_DeleteLegacy_Call {
470- _c.Call.Return(run)
471- return _c
472-}
473-
474-// DeleteVerifiedLegacy provides a mock function for the type MockLegacySnapshotSource
475-func (_mock *MockLegacySnapshotSource) DeleteVerifiedLegacy(context1 context.Context, legacySnapshot ports.LegacySnapshot) error {
476- ret := _mock.Called(context1, legacySnapshot)
477-
478- if len(ret) == 0 {
479- panic("no return value specified for DeleteVerifiedLegacy")
480- }
481-
482- var r0 error
483- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.LegacySnapshot) error); ok {
484- r0 = returnFunc(context1, legacySnapshot)
485- } else {
486- r0 = ret.Error(0)
487- }
488- return r0
489-}
490-
491-// MockLegacySnapshotSource_DeleteVerifiedLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVerifiedLegacy'
492-type MockLegacySnapshotSource_DeleteVerifiedLegacy_Call struct {
493- *mock.Call
494-}
495-
496-// DeleteVerifiedLegacy is a helper method to define mock.On call
497-// - context1 context.Context
498-// - legacySnapshot ports.LegacySnapshot
499-func (_e *MockLegacySnapshotSource_Expecter) DeleteVerifiedLegacy(context1 any, legacySnapshot any) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
500- return &MockLegacySnapshotSource_DeleteVerifiedLegacy_Call{Call: _e.mock.On("DeleteVerifiedLegacy", context1, legacySnapshot)}
501-}
502-
503-func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Run(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot)) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
504- _c.Call.Run(func(args mock.Arguments) {
505- var arg0 context.Context
506- if args[0] != nil {
507- arg0 = args[0].(context.Context)
508- }
509- var arg1 ports.LegacySnapshot
510- if args[1] != nil {
511- arg1 = args[1].(ports.LegacySnapshot)
512- }
513- run(
514- arg0,
515- arg1,
516- )
517- })
518- return _c
519-}
520-
521-func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
522- _c.Call.Return(err)
523- return _c
524-}
525-
526-func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) RunAndReturn(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot) error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
527- _c.Call.Return(run)
528- return _c
529-}
530-
531-// LoadLegacy provides a mock function for the type MockLegacySnapshotSource
532-func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) {
533- ret := _mock.Called(context1)
534-
535- if len(ret) == 0 {
536- panic("no return value specified for LoadLegacy")
537- }
538-
539- var r0 []ports.LegacySnapshot
540- var r1 error
541- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]ports.LegacySnapshot, error)); ok {
542- return returnFunc(context1)
543- }
544- if returnFunc, ok := ret.Get(0).(func(context.Context) []ports.LegacySnapshot); ok {
545- r0 = returnFunc(context1)
546- } else {
547- if ret.Get(0) != nil {
548- r0 = ret.Get(0).([]ports.LegacySnapshot)
549- }
550- }
551- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
552- r1 = returnFunc(context1)
553- } else {
554- r1 = ret.Error(1)
555- }
556- return r0, r1
557-}
558-
559-// MockLegacySnapshotSource_LoadLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadLegacy'
560-type MockLegacySnapshotSource_LoadLegacy_Call struct {
561- *mock.Call
562-}
563-
564-// LoadLegacy is a helper method to define mock.On call
565-// - context1 context.Context
566-func (_e *MockLegacySnapshotSource_Expecter) LoadLegacy(context1 any) *MockLegacySnapshotSource_LoadLegacy_Call {
567- return &MockLegacySnapshotSource_LoadLegacy_Call{Call: _e.mock.On("LoadLegacy", context1)}
568-}
569-
570-func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Run(run func(context1 context.Context)) *MockLegacySnapshotSource_LoadLegacy_Call {
571- _c.Call.Run(func(args mock.Arguments) {
572- var arg0 context.Context
573- if args[0] != nil {
574- arg0 = args[0].(context.Context)
575- }
576- run(
577- arg0,
578- )
579- })
580- return _c
581-}
582-
583-func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Return(legacySnapshots []ports.LegacySnapshot, err error) *MockLegacySnapshotSource_LoadLegacy_Call {
584- _c.Call.Return(legacySnapshots, err)
585- return _c
586-}
587-
588-func (_c *MockLegacySnapshotSource_LoadLegacy_Call) RunAndReturn(run func(context1 context.Context) ([]ports.LegacySnapshot, error)) *MockLegacySnapshotSource_LoadLegacy_Call {
589- _c.Call.Return(run)
590- return _c
591-}
@@ -5,8 +5,6 @@
5 package portsmocks5 package portsmocks
6 6
7 import (7 import (
8- "context"
9-
10 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
11 mock "github.com/stretchr/testify/mock"9 mock "github.com/stretchr/testify/mock"
12 )10 )
@@ -386,206 +384,3 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
386 _c.Call.Return(run)384 _c.Call.Return(run)
387 return _c385 return _c
388 }386 }
389-
390-// NewMockLegacySnapshotSource creates a new instance of MockLegacySnapshotSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
391-// The first argument is typically a *testing.T value.
392-func NewMockLegacySnapshotSource(t interface {
393- mock.TestingT
394- Cleanup(func())
395-}) *MockLegacySnapshotSource {
396- mock := &MockLegacySnapshotSource{}
397- mock.Mock.Test(t)
398-
399- t.Cleanup(func() { mock.AssertExpectations(t) })
400-
401- return mock
402-}
403-
404-// MockLegacySnapshotSource is an autogenerated mock type for the LegacySnapshotSource type
405-type MockLegacySnapshotSource struct {
406- mock.Mock
407-}
408-
409-type MockLegacySnapshotSource_Expecter struct {
410- mock *mock.Mock
411-}
412-
413-func (_m *MockLegacySnapshotSource) EXPECT() *MockLegacySnapshotSource_Expecter {
414- return &MockLegacySnapshotSource_Expecter{mock: &_m.Mock}
415-}
416-
417-// DeleteLegacy provides a mock function for the type MockLegacySnapshotSource
418-func (_mock *MockLegacySnapshotSource) DeleteLegacy(context1 context.Context, s string) error {
419- ret := _mock.Called(context1, s)
420-
421- if len(ret) == 0 {
422- panic("no return value specified for DeleteLegacy")
423- }
424-
425- var r0 error
426- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
427- r0 = returnFunc(context1, s)
428- } else {
429- r0 = ret.Error(0)
430- }
431- return r0
432-}
433-
434-// MockLegacySnapshotSource_DeleteLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteLegacy'
435-type MockLegacySnapshotSource_DeleteLegacy_Call struct {
436- *mock.Call
437-}
438-
439-// DeleteLegacy is a helper method to define mock.On call
440-// - context1 context.Context
441-// - s string
442-func (_e *MockLegacySnapshotSource_Expecter) DeleteLegacy(context1 any, s any) *MockLegacySnapshotSource_DeleteLegacy_Call {
443- return &MockLegacySnapshotSource_DeleteLegacy_Call{Call: _e.mock.On("DeleteLegacy", context1, s)}
444-}
445-
446-func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Run(run func(context1 context.Context, s string)) *MockLegacySnapshotSource_DeleteLegacy_Call {
447- _c.Call.Run(func(args mock.Arguments) {
448- var arg0 context.Context
449- if args[0] != nil {
450- arg0 = args[0].(context.Context)
451- }
452- var arg1 string
453- if args[1] != nil {
454- arg1 = args[1].(string)
455- }
456- run(
457- arg0,
458- arg1,
459- )
460- })
461- return _c
462-}
463-
464-func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteLegacy_Call {
465- _c.Call.Return(err)
466- return _c
467-}
468-
469-func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockLegacySnapshotSource_DeleteLegacy_Call {
470- _c.Call.Return(run)
471- return _c
472-}
473-
474-// DeleteVerifiedLegacy provides a mock function for the type MockLegacySnapshotSource
475-func (_mock *MockLegacySnapshotSource) DeleteVerifiedLegacy(context1 context.Context, legacySnapshot ports.LegacySnapshot) error {
476- ret := _mock.Called(context1, legacySnapshot)
477-
478- if len(ret) == 0 {
479- panic("no return value specified for DeleteVerifiedLegacy")
480- }
481-
482- var r0 error
483- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.LegacySnapshot) error); ok {
484- r0 = returnFunc(context1, legacySnapshot)
485- } else {
486- r0 = ret.Error(0)
487- }
488- return r0
489-}
490-
491-// MockLegacySnapshotSource_DeleteVerifiedLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVerifiedLegacy'
492-type MockLegacySnapshotSource_DeleteVerifiedLegacy_Call struct {
493- *mock.Call
494-}
495-
496-// DeleteVerifiedLegacy is a helper method to define mock.On call
497-// - context1 context.Context
498-// - legacySnapshot ports.LegacySnapshot
499-func (_e *MockLegacySnapshotSource_Expecter) DeleteVerifiedLegacy(context1 any, legacySnapshot any) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
500- return &MockLegacySnapshotSource_DeleteVerifiedLegacy_Call{Call: _e.mock.On("DeleteVerifiedLegacy", context1, legacySnapshot)}
501-}
502-
503-func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Run(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot)) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
504- _c.Call.Run(func(args mock.Arguments) {
505- var arg0 context.Context
506- if args[0] != nil {
507- arg0 = args[0].(context.Context)
508- }
509- var arg1 ports.LegacySnapshot
510- if args[1] != nil {
511- arg1 = args[1].(ports.LegacySnapshot)
512- }
513- run(
514- arg0,
515- arg1,
516- )
517- })
518- return _c
519-}
520-
521-func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
522- _c.Call.Return(err)
523- return _c
524-}
525-
526-func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) RunAndReturn(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot) error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
527- _c.Call.Return(run)
528- return _c
529-}
530-
531-// LoadLegacy provides a mock function for the type MockLegacySnapshotSource
532-func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) {
533- ret := _mock.Called(context1)
534-
535- if len(ret) == 0 {
536- panic("no return value specified for LoadLegacy")
537- }
538-
539- var r0 []ports.LegacySnapshot
540- var r1 error
541- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]ports.LegacySnapshot, error)); ok {
542- return returnFunc(context1)
543- }
544- if returnFunc, ok := ret.Get(0).(func(context.Context) []ports.LegacySnapshot); ok {
545- r0 = returnFunc(context1)
546- } else {
547- if ret.Get(0) != nil {
548- r0 = ret.Get(0).([]ports.LegacySnapshot)
549- }
550- }
551- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
552- r1 = returnFunc(context1)
553- } else {
554- r1 = ret.Error(1)
555- }
556- return r0, r1
557-}
558-
559-// MockLegacySnapshotSource_LoadLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadLegacy'
560-type MockLegacySnapshotSource_LoadLegacy_Call struct {
561- *mock.Call
562-}
563-
564-// LoadLegacy is a helper method to define mock.On call
565-// - context1 context.Context
566-func (_e *MockLegacySnapshotSource_Expecter) LoadLegacy(context1 any) *MockLegacySnapshotSource_LoadLegacy_Call {
567- return &MockLegacySnapshotSource_LoadLegacy_Call{Call: _e.mock.On("LoadLegacy", context1)}
568-}
569-
570-func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Run(run func(context1 context.Context)) *MockLegacySnapshotSource_LoadLegacy_Call {
571- _c.Call.Run(func(args mock.Arguments) {
572- var arg0 context.Context
573- if args[0] != nil {
574- arg0 = args[0].(context.Context)
575- }
576- run(
577- arg0,
578- )
579- })
580- return _c
581-}
582-
583-func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Return(legacySnapshots []ports.LegacySnapshot, err error) *MockLegacySnapshotSource_LoadLegacy_Call {
584- _c.Call.Return(legacySnapshots, err)
585- return _c
586-}
587-
588-func (_c *MockLegacySnapshotSource_LoadLegacy_Call) RunAndReturn(run func(context1 context.Context) ([]ports.LegacySnapshot, error)) *MockLegacySnapshotSource_LoadLegacy_Call {
589- _c.Call.Return(run)
590- return _c
591-}
M internal/ports/store.go +1 -23
@@ -1,9 +1,6 @@
11 package ports
22
3-import (
4- "context"
5- "crypto/sha256"
6-)
3+import "crypto/sha256"
74
85 // StoreChange is one key mutation in an atomic store batch.
96 type StoreChange struct {
@@ -42,22 +39,3 @@ type SnapshotObject struct {
4239 Digest SnapshotDigest
4340 Data []byte
4441 }
45-
46-// LegacySnapshot is the pre-incremental named-session blob retained only for
47-// one-way migration. Data is caller-owned.
48-type LegacySnapshot struct {
49- Name string
50- Data []byte
51-}
52-
53-// LegacySnapshotSource exposes the v3 bridge separately from the new write
54-// contract.
55-type LegacySnapshotSource interface {
56- LoadLegacy(context.Context) ([]LegacySnapshot, error)
57- // DeleteVerifiedLegacy removes precisely the legacy blob that was verified
58- // after import. Implementations must persist its identity before unlinking.
59- DeleteVerifiedLegacy(context.Context, LegacySnapshot) error
60- // DeleteLegacy is reserved for explicit session purges, which intentionally
61- // delete by name rather than as part of an import receipt.
62- DeleteLegacy(context.Context, string) error
63-}
@@ -1,9 +1,6 @@
1 package ports1 package ports
2 2
3-import (3+import "crypto/sha256"
4- "context"
5- "crypto/sha256"
6-)
7 4
8 // StoreChange is one key mutation in an atomic store batch.5 // StoreChange is one key mutation in an atomic store batch.
9 type StoreChange struct {6 type StoreChange struct {
@@ -42,22 +39,3 @@ type SnapshotObject struct {
42 Digest SnapshotDigest39 Digest SnapshotDigest
43 Data []byte40 Data []byte
44 }41 }
45-
46-// LegacySnapshot is the pre-incremental named-session blob retained only for
47-// one-way migration. Data is caller-owned.
48-type LegacySnapshot struct {
49- Name string
50- Data []byte
51-}
52-
53-// LegacySnapshotSource exposes the v3 bridge separately from the new write
54-// contract.
55-type LegacySnapshotSource interface {
56- LoadLegacy(context.Context) ([]LegacySnapshot, error)
57- // DeleteVerifiedLegacy removes precisely the legacy blob that was verified
58- // after import. Implementations must persist its identity before unlinking.
59- DeleteVerifiedLegacy(context.Context, LegacySnapshot) error
60- // DeleteLegacy is reserved for explicit session purges, which intentionally
61- // delete by name rather than as part of an import receipt.
62- DeleteLegacy(context.Context, string) error
63-}
M internal/usecase/daemon/session_purge_retry_test.go +0 -8
@@ -53,14 +53,6 @@ func (r *retryablePurgeRepository) DeleteDeletionTombstone(ctx context.Context,
5353 }
5454 return nil
5555 }
56-func (*retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
57- return nil, nil
58-}
59-func (*retryablePurgeRepository) DeleteVerifiedLegacy(context.Context, ports.LegacySnapshot) error {
60- return nil
61-}
62-func (*retryablePurgeRepository) DeleteLegacy(context.Context, string) error { return nil }
63-
6456 func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {
6557 for _, tt := range []struct {
6658 name string
@@ -53,14 +53,6 @@ func (r *retryablePurgeRepository) DeleteDeletionTombstone(ctx context.Context,
53 }53 }
54 return nil54 return nil
55 }55 }
56-func (*retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
57- return nil, nil
58-}
59-func (*retryablePurgeRepository) DeleteVerifiedLegacy(context.Context, ports.LegacySnapshot) error {
60- return nil
61-}
62-func (*retryablePurgeRepository) DeleteLegacy(context.Context, string) error { return nil }
63-
64 func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {56 func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {
65 for _, tt := range []struct {57 for _, tt := range []struct {
66 name string58 name string

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