detached 4102d7e

fix(persist): reject all non-current durable state

commit
4102d7e7bfc8a63d41d71cf3cd4611ac1339dc68
author
brice · · 2026-07-26 11:03 UTC
parent
8849f70
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +130 -455
M internal/adapters/snapshot/load_bounds_test.go +0 -63
@@ -2,74 +2,11 @@ package snapshot
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "os"
8- "path/filepath"
97 "testing"
108 )
119
12-func TestRepositoryListStopsAtTraversalBudget(t *testing.T) {
13- repo := NewRepository(privateDir(t))
14- sessions := filepath.Join(repo.dir, repositorySessionsDir)
15- if err := os.MkdirAll(sessions, 0o700); err != nil {
16- t.Fatal(err)
17- }
18- for i := range maxDirectoryTraversalEntries + 1 {
19- if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("hostile-%05d", i)), 0o700); err != nil {
20- t.Fatal(err)
21- }
22- }
23-
24- _, err := repo.List(context.Background())
25- if !errors.Is(err, ErrDirectoryTraversalBudget) {
26- t.Fatalf("List error = %v, want directory traversal budget", err)
27- }
28-}
29-
30-func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) {
31- repo := NewRepository(privateDir(t))
32- key := legacyIncarnationID("named").String()
33- generations := filepath.Join(repo.legacySessionPath(key), repositoryGenerations)
34- if err := os.MkdirAll(generations, 0o700); err != nil {
35- t.Fatal(err)
36- }
37- for i := range maxDirectoryTraversalEntries + 1 {
38- generation := uint64(i + 1)
39- if err := os.WriteFile(filepath.Join(generations, generationFilename(generation)), nil, 0o600); err != nil {
40- t.Fatal(err)
41- }
42- }
43-
44- _, err := repo.Load(context.Background(), "named")
45- if !errors.Is(err, ErrDirectoryTraversalBudget) {
46- t.Fatalf("Load error = %v, want directory traversal budget", err)
47- }
48-}
49-
50-func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *testing.T) {
51- repo := NewRepository(privateDir(t))
52- for i := range 3 {
53- generation := uint64(i + 1)
54- if err := repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, fmt.Appendf(nil, "state-%d", generation))); err != nil {
55- t.Fatal(err)
56- }
57- }
58- key := legacyIncarnationID("named").String()
59- publication := repositoryPublication(t, "named", 3, []byte("state-3"))
60- if err := os.Remove(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
61- t.Fatal(err)
62- }
63-
64- got, err := repo.Load(context.Background(), "named")
65- if err != nil {
66- t.Fatal(err)
67- }
68- if got.Generation != 2 {
69- t.Fatalf("Load generation = %d, want 2", got.Generation)
70- }
71-}
72-
7310 func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing.T) {
7411 repo := NewRepository(privateDir(t))
7512 for i := range maxMaintenanceMarkedGenerations + 1 {
@@ -2,74 +2,11 @@ package snapshot
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "fmt"5 "fmt"
7 "os"6 "os"
8- "path/filepath"
9 "testing"7 "testing"
10 )8 )
11 9
12-func TestRepositoryListStopsAtTraversalBudget(t *testing.T) {
13- repo := NewRepository(privateDir(t))
14- sessions := filepath.Join(repo.dir, repositorySessionsDir)
15- if err := os.MkdirAll(sessions, 0o700); err != nil {
16- t.Fatal(err)
17- }
18- for i := range maxDirectoryTraversalEntries + 1 {
19- if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("hostile-%05d", i)), 0o700); err != nil {
20- t.Fatal(err)
21- }
22- }
23-
24- _, err := repo.List(context.Background())
25- if !errors.Is(err, ErrDirectoryTraversalBudget) {
26- t.Fatalf("List error = %v, want directory traversal budget", err)
27- }
28-}
29-
30-func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) {
31- repo := NewRepository(privateDir(t))
32- key := legacyIncarnationID("named").String()
33- generations := filepath.Join(repo.legacySessionPath(key), repositoryGenerations)
34- if err := os.MkdirAll(generations, 0o700); err != nil {
35- t.Fatal(err)
36- }
37- for i := range maxDirectoryTraversalEntries + 1 {
38- generation := uint64(i + 1)
39- if err := os.WriteFile(filepath.Join(generations, generationFilename(generation)), nil, 0o600); err != nil {
40- t.Fatal(err)
41- }
42- }
43-
44- _, err := repo.Load(context.Background(), "named")
45- if !errors.Is(err, ErrDirectoryTraversalBudget) {
46- t.Fatalf("Load error = %v, want directory traversal budget", err)
47- }
48-}
49-
50-func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *testing.T) {
51- repo := NewRepository(privateDir(t))
52- for i := range 3 {
53- generation := uint64(i + 1)
54- if err := repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, fmt.Appendf(nil, "state-%d", generation))); err != nil {
55- t.Fatal(err)
56- }
57- }
58- key := legacyIncarnationID("named").String()
59- publication := repositoryPublication(t, "named", 3, []byte("state-3"))
60- if err := os.Remove(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
61- t.Fatal(err)
62- }
63-
64- got, err := repo.Load(context.Background(), "named")
65- if err != nil {
66- t.Fatal(err)
67- }
68- if got.Generation != 2 {
69- t.Fatalf("Load generation = %d, want 2", got.Generation)
70- }
71-}
72-
73 func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing.T) {10 func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing.T) {
74 repo := NewRepository(privateDir(t))11 repo := NewRepository(privateDir(t))
75 for i := range maxMaintenanceMarkedGenerations + 1 {12 for i := range maxMaintenanceMarkedGenerations + 1 {
D internal/adapters/snapshot/load_major_test.go +0 -102
@@ -1,102 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "os"
6- "path/filepath"
7- "syscall"
8- "testing"
9-
10- "github.com/stretchr/testify/require"
11-)
12-
13-func TestRepositoryLoadPropagatesOperationalHeadErrors(t *testing.T) {
14- repo := NewRepository(privateDir(t))
15- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
16-
17- repo.hooks.beforeHeadRead = func(string) error { return os.ErrPermission }
18-
19- _, err := repo.Load(context.Background(), "named")
20- require.ErrorIs(t, err, os.ErrPermission)
21-}
22-
23-func TestRepositoryCurrentGenerationPropagatesOperationalHeadErrors(t *testing.T) {
24- repo := NewRepository(privateDir(t))
25- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
26-
27- repo.hooks.beforeHeadRead = func(string) error { return syscall.EIO }
28-
29- _, _, err := repo.currentGeneration(context.Background(), "named", legacyIncarnationID("named").String())
30- require.ErrorIs(t, err, syscall.EIO)
31-}
32-
33-func TestRepositoryLoadUsesEstablishedLegacySessionKey(t *testing.T) {
34- repo := NewRepository(privateDir(t))
35- publication := repositoryPublication(t, "named", 1, []byte("legacy-state"))
36- require.NoError(t, repo.Publish(context.Background(), publication))
37- require.NoError(t, os.Rename(repo.sessionPath(publication.IncarnationID), filepath.Join(repo.dir, repositorySessionsDir, sessionKey(publication.Name))))
38-
39- got, err := repo.Load(context.Background(), publication.Name)
40- require.NoError(t, err)
41- require.Equal(t, publication.Objects[0].Data, got.Objects[publication.Objects[0].Digest])
42-}
43-
44-func TestRepositoryLoadFallsBackFromInvalidHead(t *testing.T) {
45- repo := NewRepository(privateDir(t))
46- publication := repositoryPublication(t, "named", 1, []byte("state"))
47- require.NoError(t, repo.Publish(context.Background(), publication))
48- require.NoError(t, os.WriteFile(repo.legacyHeadPath(legacyIncarnationID("named").String()), []byte("invalid"), 0o600))
49-
50- got, err := repo.Load(context.Background(), "named")
51- require.NoError(t, err)
52- require.Equal(t, uint64(1), got.Generation)
53- require.Equal(t, publication.Objects[0].Data, got.Objects[publication.Objects[0].Digest])
54-}
55-
56-func TestRepositoryLoadFallsBackAcrossMultipleCorruptCandidates(t *testing.T) {
57- repo := NewRepository(privateDir(t))
58- for generation := uint64(1); generation <= 4; generation++ {
59- require.NoError(t, repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, []byte{byte(generation)})))
60- }
61- key := legacyIncarnationID("named").String()
62- for _, generation := range []uint64{4, 3} {
63- publication := repositoryPublication(t, "named", generation, []byte{byte(generation)})
64- require.NoError(t, os.Remove(repo.legacyObjectPath(key, publication.Objects[0].Digest)))
65- }
66-
67- got, err := repo.Load(context.Background(), "named")
68- require.NoError(t, err)
69- require.Equal(t, uint64(2), got.Generation)
70- fallback := repositoryPublication(t, "named", 2, []byte{2})
71- require.Equal(t, fallback.Objects[0].Data, got.Objects[fallback.Objects[0].Digest])
72-}
73-
74-func TestRepositoryLoadHoldsSessionLockAcrossTombstoneCheck(t *testing.T) {
75- repo := NewRepository(privateDir(t))
76- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
77-
78- tombstoneCheckStarted := make(chan struct{})
79- releaseTombstoneCheck := make(chan struct{})
80- repo.hooks.beforeTombstoneCheck = func(string) {
81- close(tombstoneCheckStarted)
82- <-releaseTombstoneCheck
83- }
84- loadDone := make(chan error, 1)
85- go func() {
86- _, err := repo.Load(context.Background(), "named")
87- loadDone <- err
88- }()
89- <-tombstoneCheckStarted
90-
91- tombstoneDone := make(chan error, 1)
92- go func() { tombstoneDone <- repo.Tombstone(context.Background(), "named") }()
93- select {
94- case err := <-tombstoneDone:
95- t.Fatalf("Tombstone completed before Load released the session lock: %v", err)
96- default:
97- }
98-
99- close(releaseTombstoneCheck)
100- require.NoError(t, <-loadDone)
101- require.NoError(t, <-tombstoneDone)
102-}
@@ -1,102 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "os"
6- "path/filepath"
7- "syscall"
8- "testing"
9-
10- "github.com/stretchr/testify/require"
11-)
12-
13-func TestRepositoryLoadPropagatesOperationalHeadErrors(t *testing.T) {
14- repo := NewRepository(privateDir(t))
15- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
16-
17- repo.hooks.beforeHeadRead = func(string) error { return os.ErrPermission }
18-
19- _, err := repo.Load(context.Background(), "named")
20- require.ErrorIs(t, err, os.ErrPermission)
21-}
22-
23-func TestRepositoryCurrentGenerationPropagatesOperationalHeadErrors(t *testing.T) {
24- repo := NewRepository(privateDir(t))
25- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
26-
27- repo.hooks.beforeHeadRead = func(string) error { return syscall.EIO }
28-
29- _, _, err := repo.currentGeneration(context.Background(), "named", legacyIncarnationID("named").String())
30- require.ErrorIs(t, err, syscall.EIO)
31-}
32-
33-func TestRepositoryLoadUsesEstablishedLegacySessionKey(t *testing.T) {
34- repo := NewRepository(privateDir(t))
35- publication := repositoryPublication(t, "named", 1, []byte("legacy-state"))
36- require.NoError(t, repo.Publish(context.Background(), publication))
37- require.NoError(t, os.Rename(repo.sessionPath(publication.IncarnationID), filepath.Join(repo.dir, repositorySessionsDir, sessionKey(publication.Name))))
38-
39- got, err := repo.Load(context.Background(), publication.Name)
40- require.NoError(t, err)
41- require.Equal(t, publication.Objects[0].Data, got.Objects[publication.Objects[0].Digest])
42-}
43-
44-func TestRepositoryLoadFallsBackFromInvalidHead(t *testing.T) {
45- repo := NewRepository(privateDir(t))
46- publication := repositoryPublication(t, "named", 1, []byte("state"))
47- require.NoError(t, repo.Publish(context.Background(), publication))
48- require.NoError(t, os.WriteFile(repo.legacyHeadPath(legacyIncarnationID("named").String()), []byte("invalid"), 0o600))
49-
50- got, err := repo.Load(context.Background(), "named")
51- require.NoError(t, err)
52- require.Equal(t, uint64(1), got.Generation)
53- require.Equal(t, publication.Objects[0].Data, got.Objects[publication.Objects[0].Digest])
54-}
55-
56-func TestRepositoryLoadFallsBackAcrossMultipleCorruptCandidates(t *testing.T) {
57- repo := NewRepository(privateDir(t))
58- for generation := uint64(1); generation <= 4; generation++ {
59- require.NoError(t, repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, []byte{byte(generation)})))
60- }
61- key := legacyIncarnationID("named").String()
62- for _, generation := range []uint64{4, 3} {
63- publication := repositoryPublication(t, "named", generation, []byte{byte(generation)})
64- require.NoError(t, os.Remove(repo.legacyObjectPath(key, publication.Objects[0].Digest)))
65- }
66-
67- got, err := repo.Load(context.Background(), "named")
68- require.NoError(t, err)
69- require.Equal(t, uint64(2), got.Generation)
70- fallback := repositoryPublication(t, "named", 2, []byte{2})
71- require.Equal(t, fallback.Objects[0].Data, got.Objects[fallback.Objects[0].Digest])
72-}
73-
74-func TestRepositoryLoadHoldsSessionLockAcrossTombstoneCheck(t *testing.T) {
75- repo := NewRepository(privateDir(t))
76- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
77-
78- tombstoneCheckStarted := make(chan struct{})
79- releaseTombstoneCheck := make(chan struct{})
80- repo.hooks.beforeTombstoneCheck = func(string) {
81- close(tombstoneCheckStarted)
82- <-releaseTombstoneCheck
83- }
84- loadDone := make(chan error, 1)
85- go func() {
86- _, err := repo.Load(context.Background(), "named")
87- loadDone <- err
88- }()
89- <-tombstoneCheckStarted
90-
91- tombstoneDone := make(chan error, 1)
92- go func() { tombstoneDone <- repo.Tombstone(context.Background(), "named") }()
93- select {
94- case err := <-tombstoneDone:
95- t.Fatalf("Tombstone completed before Load released the session lock: %v", err)
96- default:
97- }
98-
99- close(releaseTombstoneCheck)
100- require.NoError(t, <-loadDone)
101- require.NoError(t, <-tombstoneDone)
102-}
M internal/adapters/snapshot/maintenance_test.go +14 -11
@@ -408,8 +408,8 @@ func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) {
408408 t.Fatalf("old manifest after Maintain = %v, want not exist", err)
409409 }
410410 for _, generation := range []uint64{2, 3} {
411- if _, err := repo.Load(context.Background(), "named"); err != nil {
412- t.Fatalf("Load after collecting generation %d: %v", generation, err)
411+ if _, err := loadGenerationCheckpoint(context.Background(), repo, legacyIncarnationID("named"), "named", generation); err != nil {
412+ t.Fatalf("LoadCheckpoint after collecting generation %d: %v", generation, err)
413413 }
414414 }
415415 }
@@ -461,15 +461,16 @@ func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.
461461 })
462462 t.Run("load and delete", func(t *testing.T) {
463463 repo := NewRepository(privateDir(t))
464- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
464+ publication := repositoryPublication(t, "named", 1, []byte("state"))
465+ if err := repo.Publish(context.Background(), publication); err != nil {
465466 t.Fatal(err)
466467 }
467- key := legacyIncarnationID("named").String()
468+ key := publication.IncarnationID.String()
468469 for _, operation := range []struct {
469470 name string
470471 run func(context.Context) error
471472 }{
472- {"load", func(ctx context.Context) error { _, err := repo.Load(ctx, "named"); return err }},
473+ {"load", func(ctx context.Context) error { _, err := loadPublication(ctx, repo, publication); return err }},
473474 {"delete", func(ctx context.Context) error { return repo.Delete(ctx, "named") }},
474475 } {
475476 t.Run(operation.name, func(t *testing.T) {
@@ -488,7 +489,7 @@ func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.
488489 t.Fatalf("%s error = %v, want canceled", operation.name, err)
489490 }
490491 repo.hooks.beforeSessionLock = nil
491- if _, err := repo.Load(context.Background(), "named"); err != nil {
492+ if _, err := loadPublication(context.Background(), repo, publication); err != nil {
492493 t.Fatalf("%s mutated session: %v", operation.name, err)
493494 }
494495 })
@@ -496,10 +497,11 @@ func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.
496497 })
497498 t.Run("maintain", func(t *testing.T) {
498499 repo := NewRepository(privateDir(t))
499- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
500+ publication := repositoryPublication(t, "named", 1, []byte("state"))
501+ if err := repo.Publish(context.Background(), publication); err != nil {
500502 t.Fatal(err)
501503 }
502- lock := repo.lockSession(legacyIncarnationID("named").String())
504+ lock := repo.lockSession(publication.IncarnationID.String())
503505 ctx, cancel := context.WithCancel(context.Background())
504506 reached := make(chan struct{})
505507 release := make(chan struct{})
@@ -514,7 +516,7 @@ func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.
514516 t.Fatalf("Maintain error = %v, want canceled", err)
515517 }
516518 repo.hooks.beforeSessionLock = nil
517- if _, err := repo.Load(context.Background(), "named"); err != nil {
519+ if _, err := loadPublication(context.Background(), repo, publication); err != nil {
518520 t.Fatalf("Maintain mutated session: %v", err)
519521 }
520522 })
@@ -538,13 +540,14 @@ func TestRepositoryDeleteRetriesPendingQuarantineSyncWithoutDeletingRecreatedSes
538540 t.Fatalf("Delete error = %v, want sync failure", err)
539541 }
540542 repo.hooks.syncDirectory = nil
541- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("new"))); err != nil {
543+ recreated := repositoryPublication(t, "named", 1, []byte("new"))
544+ if err := repo.Publish(context.Background(), recreated); err != nil {
542545 t.Fatal(err)
543546 }
544547 if err := repo.Delete(context.Background(), "named"); err != nil {
545548 t.Fatalf("retry Delete: %v", err)
546549 }
547- if _, err := repo.Load(context.Background(), "named"); err != nil {
550+ if _, err := loadPublication(context.Background(), repo, recreated); err != nil {
548551 t.Fatalf("recreated session was deleted: %v", err)
549552 }
550553 }
@@ -408,8 +408,8 @@ func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) {
408 t.Fatalf("old manifest after Maintain = %v, want not exist", err)408 t.Fatalf("old manifest after Maintain = %v, want not exist", err)
409 }409 }
410 for _, generation := range []uint64{2, 3} {410 for _, generation := range []uint64{2, 3} {
411- if _, err := repo.Load(context.Background(), "named"); err != nil {411+ if _, err := loadGenerationCheckpoint(context.Background(), repo, legacyIncarnationID("named"), "named", generation); err != nil {
412- t.Fatalf("Load after collecting generation %d: %v", generation, err)412+ t.Fatalf("LoadCheckpoint after collecting generation %d: %v", generation, err)
413 }413 }
414 }414 }
415 }415 }
@@ -461,15 +461,16 @@ func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.
461 })461 })
462 t.Run("load and delete", func(t *testing.T) {462 t.Run("load and delete", func(t *testing.T) {
463 repo := NewRepository(privateDir(t))463 repo := NewRepository(privateDir(t))
464- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {464+ publication := repositoryPublication(t, "named", 1, []byte("state"))
465+ if err := repo.Publish(context.Background(), publication); err != nil {
465 t.Fatal(err)466 t.Fatal(err)
466 }467 }
467- key := legacyIncarnationID("named").String()468+ key := publication.IncarnationID.String()
468 for _, operation := range []struct {469 for _, operation := range []struct {
469 name string470 name string
470 run func(context.Context) error471 run func(context.Context) error
471 }{472 }{
472- {"load", func(ctx context.Context) error { _, err := repo.Load(ctx, "named"); return err }},473+ {"load", func(ctx context.Context) error { _, err := loadPublication(ctx, repo, publication); return err }},
473 {"delete", func(ctx context.Context) error { return repo.Delete(ctx, "named") }},474 {"delete", func(ctx context.Context) error { return repo.Delete(ctx, "named") }},
474 } {475 } {
475 t.Run(operation.name, func(t *testing.T) {476 t.Run(operation.name, func(t *testing.T) {
@@ -488,7 +489,7 @@ func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.
488 t.Fatalf("%s error = %v, want canceled", operation.name, err)489 t.Fatalf("%s error = %v, want canceled", operation.name, err)
489 }490 }
490 repo.hooks.beforeSessionLock = nil491 repo.hooks.beforeSessionLock = nil
491- if _, err := repo.Load(context.Background(), "named"); err != nil {492+ if _, err := loadPublication(context.Background(), repo, publication); err != nil {
492 t.Fatalf("%s mutated session: %v", operation.name, err)493 t.Fatalf("%s mutated session: %v", operation.name, err)
493 }494 }
494 })495 })
@@ -496,10 +497,11 @@ func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.
496 })497 })
497 t.Run("maintain", func(t *testing.T) {498 t.Run("maintain", func(t *testing.T) {
498 repo := NewRepository(privateDir(t))499 repo := NewRepository(privateDir(t))
499- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {500+ publication := repositoryPublication(t, "named", 1, []byte("state"))
501+ if err := repo.Publish(context.Background(), publication); err != nil {
500 t.Fatal(err)502 t.Fatal(err)
501 }503 }
502- lock := repo.lockSession(legacyIncarnationID("named").String())504+ lock := repo.lockSession(publication.IncarnationID.String())
503 ctx, cancel := context.WithCancel(context.Background())505 ctx, cancel := context.WithCancel(context.Background())
504 reached := make(chan struct{})506 reached := make(chan struct{})
505 release := make(chan struct{})507 release := make(chan struct{})
@@ -514,7 +516,7 @@ func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.
514 t.Fatalf("Maintain error = %v, want canceled", err)516 t.Fatalf("Maintain error = %v, want canceled", err)
515 }517 }
516 repo.hooks.beforeSessionLock = nil518 repo.hooks.beforeSessionLock = nil
517- if _, err := repo.Load(context.Background(), "named"); err != nil {519+ if _, err := loadPublication(context.Background(), repo, publication); err != nil {
518 t.Fatalf("Maintain mutated session: %v", err)520 t.Fatalf("Maintain mutated session: %v", err)
519 }521 }
520 })522 })
@@ -538,13 +540,14 @@ func TestRepositoryDeleteRetriesPendingQuarantineSyncWithoutDeletingRecreatedSes
538 t.Fatalf("Delete error = %v, want sync failure", err)540 t.Fatalf("Delete error = %v, want sync failure", err)
539 }541 }
540 repo.hooks.syncDirectory = nil542 repo.hooks.syncDirectory = nil
541- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("new"))); err != nil {543+ recreated := repositoryPublication(t, "named", 1, []byte("new"))
544+ if err := repo.Publish(context.Background(), recreated); err != nil {
542 t.Fatal(err)545 t.Fatal(err)
543 }546 }
544 if err := repo.Delete(context.Background(), "named"); err != nil {547 if err := repo.Delete(context.Background(), "named"); err != nil {
545 t.Fatalf("retry Delete: %v", err)548 t.Fatalf("retry Delete: %v", err)
546 }549 }
547- if _, err := repo.Load(context.Background(), "named"); err != nil {550+ if _, err := loadPublication(context.Background(), repo, recreated); err != nil {
548 t.Fatalf("recreated session was deleted: %v", err)551 t.Fatalf("recreated session was deleted: %v", err)
549 }552 }
550 }553 }
M internal/adapters/snapshot/path_security_test.go +5 -5
@@ -38,13 +38,13 @@ func TestRepositoryRejectsSymlinkedRoot(t *testing.T) {
3838 t.Fatal(err)
3939 }
4040
41- if _, err := repo.Load(context.Background(), publication.Name); err == nil {
42- t.Fatal("Load succeeded through replaced configured-root symlink")
41+ if _, err := loadPublication(context.Background(), repo, publication); err == nil {
42+ t.Fatal("LoadCheckpoint succeeded through replaced configured-root symlink")
4343 }
4444 if err := repo.Maintain(context.Background()); err == nil {
4545 t.Fatal("Maintain succeeded through replaced configured-root symlink")
4646 }
47- loaded, err := external.Load(context.Background(), publication.Name)
47+ loaded, err := loadPublication(context.Background(), external, externalPublication)
4848 if err != nil {
4949 t.Fatal(err)
5050 }
@@ -165,8 +165,8 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
165165 if err := os.Symlink(outside, inside); err != nil {
166166 t.Fatal(err)
167167 }
168- if _, err := repo.Load(context.Background(), publication.Name); err == nil {
169- t.Fatal("Load succeeded through symlinked repository component")
168+ if _, err := loadPublication(context.Background(), repo, publication); err == nil {
169+ t.Fatal("LoadCheckpoint succeeded through symlinked repository component")
170170 }
171171 if err := repo.Maintain(context.Background()); err == nil {
172172 t.Fatal("Maintain succeeded through symlinked repository component")
@@ -38,13 +38,13 @@ func TestRepositoryRejectsSymlinkedRoot(t *testing.T) {
38 t.Fatal(err)38 t.Fatal(err)
39 }39 }
40 40
41- if _, err := repo.Load(context.Background(), publication.Name); err == nil {41+ if _, err := loadPublication(context.Background(), repo, publication); err == nil {
42- t.Fatal("Load succeeded through replaced configured-root symlink")42+ t.Fatal("LoadCheckpoint succeeded through replaced configured-root symlink")
43 }43 }
44 if err := repo.Maintain(context.Background()); err == nil {44 if err := repo.Maintain(context.Background()); err == nil {
45 t.Fatal("Maintain succeeded through replaced configured-root symlink")45 t.Fatal("Maintain succeeded through replaced configured-root symlink")
46 }46 }
47- loaded, err := external.Load(context.Background(), publication.Name)47+ loaded, err := loadPublication(context.Background(), external, externalPublication)
48 if err != nil {48 if err != nil {
49 t.Fatal(err)49 t.Fatal(err)
50 }50 }
@@ -165,8 +165,8 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
165 if err := os.Symlink(outside, inside); err != nil {165 if err := os.Symlink(outside, inside); err != nil {
166 t.Fatal(err)166 t.Fatal(err)
167 }167 }
168- if _, err := repo.Load(context.Background(), publication.Name); err == nil {168+ if _, err := loadPublication(context.Background(), repo, publication); err == nil {
169- t.Fatal("Load succeeded through symlinked repository component")169+ t.Fatal("LoadCheckpoint succeeded through symlinked repository component")
170 }170 }
171 if err := repo.Maintain(context.Background()); err == nil {171 if err := repo.Maintain(context.Background()); err == nil {
172 t.Fatal("Maintain succeeded through symlinked repository component")172 t.Fatal("Maintain succeeded through symlinked repository component")
M internal/adapters/snapshot/publish_efficiency_test.go +1 -1
@@ -49,7 +49,7 @@ func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) {
4949 t.Fatalf("unchanged 10k history Publish allocations = %.0f, want <= 450; retained objects were likely copied", allocations)
5050 }
5151
52- got, err := repo.Load(context.Background(), second.Name)
52+ got, err := loadPublication(context.Background(), repo, second)
5353 if err != nil {
5454 t.Fatal(err)
5555 }
@@ -49,7 +49,7 @@ func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) {
49 t.Fatalf("unchanged 10k history Publish allocations = %.0f, want <= 450; retained objects were likely copied", allocations)49 t.Fatalf("unchanged 10k history Publish allocations = %.0f, want <= 450; retained objects were likely copied", allocations)
50 }50 }
51 51
52- got, err := repo.Load(context.Background(), second.Name)52+ got, err := loadPublication(context.Background(), repo, second)
53 if err != nil {53 if err != nil {
54 t.Fatal(err)54 t.Fatal(err)
55 }55 }
M internal/adapters/snapshot/repository_durability_test.go +8 -5
@@ -88,12 +88,15 @@ func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
8888 t.Fatal("Publish succeeded with injected persistence failure")
8989 }
9090 repo.hooks = repositoryHooks{}
91- got, err := repo.Load(context.Background(), "named")
92- if err != nil {
93- t.Fatal(err)
91+ var loaded bool
92+ for _, publication := range []ports.SnapshotPublication{second, first} {
93+ if _, err := loadPublication(context.Background(), repo, publication); err == nil {
94+ loaded = true
95+ break
96+ }
9497 }
95- if got.Generation != 1 && got.Generation != 2 {
96- t.Fatalf("authoritative generation = %d, want old or new", got.Generation)
98+ if !loaded {
99+ t.Fatal("neither the old nor new checkpoint is complete")
97100 }
98101 })
99102 }
@@ -88,12 +88,15 @@ func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
88 t.Fatal("Publish succeeded with injected persistence failure")88 t.Fatal("Publish succeeded with injected persistence failure")
89 }89 }
90 repo.hooks = repositoryHooks{}90 repo.hooks = repositoryHooks{}
91- got, err := repo.Load(context.Background(), "named")91+ var loaded bool
92- if err != nil {92+ for _, publication := range []ports.SnapshotPublication{second, first} {
93- t.Fatal(err)93+ if _, err := loadPublication(context.Background(), repo, publication); err == nil {
94+ loaded = true
95+ break
96+ }
94 }97 }
95- if got.Generation != 1 && got.Generation != 2 {98+ if !loaded {
96- t.Fatalf("authoritative generation = %d, want old or new", got.Generation)99+ t.Fatal("neither the old nor new checkpoint is complete")
97 }100 }
98 })101 })
99 }102 }
M internal/adapters/snapshot/repository_fault_test.go +7 -39
@@ -3,12 +3,9 @@ package snapshot
33 import (
44 "context"
55 "errors"
6- "os"
76 "path/filepath"
87 "strings"
98 "testing"
10-
11- codec "github.com/bnema/vev/internal/usecase/snapshot"
129 )
1310
1411 func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
@@ -24,7 +21,7 @@ func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
2421 t.Fatal("Publish succeeded with injected HEAD failure")
2522 }
2623 repo.hooks.beforeHeadWrite = nil
27- got, err := repo.Load(context.Background(), "named")
24+ got, err := loadPublication(context.Background(), repo, first)
2825 if err != nil {
2926 t.Fatal(err)
3027 }
@@ -155,8 +152,8 @@ func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
155152 repo.hooks = repositoryHooks{}
156153 // A first-generation failure cannot have an older complete generation;
157154 // it must not expose an incomplete one as if it were loadable.
158- if _, loadErr := repo.Load(context.Background(), "named"); loadErr == nil || !strings.Contains(loadErr.Error(), "no complete snapshot generation") {
159- t.Fatalf("Load error after incomplete first generation = %v, want no complete generation", loadErr)
155+ if _, loadErr := loadPublication(context.Background(), repo, publication); loadErr == nil {
156+ t.Fatal("LoadCheckpoint exposed an incomplete first generation")
160157 }
161158 if err := repo.Publish(context.Background(), publication); err != nil {
162159 t.Fatal(err)
@@ -168,41 +165,12 @@ func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
168165
169166 func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) {
170167 t.Helper()
171- got, err := repo.Load(context.Background(), name)
172- if err != nil {
173- t.Fatal(err)
174- }
168+ id := legacyIncarnationID(name)
175169 for _, generation := range generations {
176- if got.Generation == generation && len(got.Manifest) != 0 && len(got.Objects) != 0 {
170+ got, err := loadGenerationCheckpoint(context.Background(), repo, id, name, generation)
171+ if err == nil && len(got.Manifest) != 0 && len(got.Objects) != 0 {
177172 return
178173 }
179174 }
180- t.Fatalf("Load = incomplete or mixed generation %+v, want one of %v", got, generations)
181-}
182-
183-func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) {
184- dir := privateDir(t)
185- repo := NewRepository(dir)
186- first := repositoryPublication(t, "named", 1, []byte("one"))
187- if err := repo.Publish(context.Background(), first); err != nil {
188- t.Fatal(err)
189- }
190- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("two"))
191- if err := repo.Publish(context.Background(), second); err != nil {
192- t.Fatal(err)
193- }
194- manifest, err := codec.UnmarshalManifest(second.Manifest)
195- if err != nil {
196- t.Fatal(err)
197- }
198- if err := os.Remove(repo.legacyObjectPath(legacyIncarnationID("named").String(), manifest.Tabs[0].Panes[0].Tail.Digest)); err != nil {
199- t.Fatal(err)
200- }
201- got, err := repo.Load(context.Background(), "named")
202- if err != nil {
203- t.Fatal(err)
204- }
205- if got.Generation != 1 {
206- t.Fatalf("Load generation = %d, want 1", got.Generation)
207- }
175+ t.Fatalf("no complete checkpoint found for generations %v", generations)
208176 }
@@ -3,12 +3,9 @@ package snapshot
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "os"
7 "path/filepath"6 "path/filepath"
8 "strings"7 "strings"
9 "testing"8 "testing"
10-
11- codec "github.com/bnema/vev/internal/usecase/snapshot"
12 )9 )
13 10
14 func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {11 func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
@@ -24,7 +21,7 @@ func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
24 t.Fatal("Publish succeeded with injected HEAD failure")21 t.Fatal("Publish succeeded with injected HEAD failure")
25 }22 }
26 repo.hooks.beforeHeadWrite = nil23 repo.hooks.beforeHeadWrite = nil
27- got, err := repo.Load(context.Background(), "named")24+ got, err := loadPublication(context.Background(), repo, first)
28 if err != nil {25 if err != nil {
29 t.Fatal(err)26 t.Fatal(err)
30 }27 }
@@ -155,8 +152,8 @@ func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
155 repo.hooks = repositoryHooks{}152 repo.hooks = repositoryHooks{}
156 // A first-generation failure cannot have an older complete generation;153 // A first-generation failure cannot have an older complete generation;
157 // it must not expose an incomplete one as if it were loadable.154 // it must not expose an incomplete one as if it were loadable.
158- if _, loadErr := repo.Load(context.Background(), "named"); loadErr == nil || !strings.Contains(loadErr.Error(), "no complete snapshot generation") {155+ if _, loadErr := loadPublication(context.Background(), repo, publication); loadErr == nil {
159- t.Fatalf("Load error after incomplete first generation = %v, want no complete generation", loadErr)156+ t.Fatal("LoadCheckpoint exposed an incomplete first generation")
160 }157 }
161 if err := repo.Publish(context.Background(), publication); err != nil {158 if err := repo.Publish(context.Background(), publication); err != nil {
162 t.Fatal(err)159 t.Fatal(err)
@@ -168,41 +165,12 @@ func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
168 165
169 func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) {166 func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) {
170 t.Helper()167 t.Helper()
171- got, err := repo.Load(context.Background(), name)168+ id := legacyIncarnationID(name)
172- if err != nil {
173- t.Fatal(err)
174- }
175 for _, generation := range generations {169 for _, generation := range generations {
176- if got.Generation == generation && len(got.Manifest) != 0 && len(got.Objects) != 0 {170+ got, err := loadGenerationCheckpoint(context.Background(), repo, id, name, generation)
171+ if err == nil && len(got.Manifest) != 0 && len(got.Objects) != 0 {
177 return172 return
178 }173 }
179 }174 }
180- t.Fatalf("Load = incomplete or mixed generation %+v, want one of %v", got, generations)175+ t.Fatalf("no complete checkpoint found for generations %v", generations)
181-}
182-
183-func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) {
184- dir := privateDir(t)
185- repo := NewRepository(dir)
186- first := repositoryPublication(t, "named", 1, []byte("one"))
187- if err := repo.Publish(context.Background(), first); err != nil {
188- t.Fatal(err)
189- }
190- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("two"))
191- if err := repo.Publish(context.Background(), second); err != nil {
192- t.Fatal(err)
193- }
194- manifest, err := codec.UnmarshalManifest(second.Manifest)
195- if err != nil {
196- t.Fatal(err)
197- }
198- if err := os.Remove(repo.legacyObjectPath(legacyIncarnationID("named").String(), manifest.Tabs[0].Panes[0].Tail.Digest)); err != nil {
199- t.Fatal(err)
200- }
201- got, err := repo.Load(context.Background(), "named")
202- if err != nil {
203- t.Fatal(err)
204- }
205- if got.Generation != 1 {
206- t.Fatalf("Load generation = %d, want 1", got.Generation)
207- }
208 }176 }
M internal/adapters/snapshot/repository_test.go +19 -1
@@ -18,7 +18,7 @@ func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {
1818 if err := repo.Publish(context.Background(), pub); err != nil {
1919 t.Fatalf("Publish: %v", err)
2020 }
21- got, err := repo.Load(context.Background(), "named")
21+ got, err := loadPublication(context.Background(), repo, pub)
2222 if err != nil {
2323 t.Fatalf("Load: %v", err)
2424 }
@@ -58,6 +58,24 @@ func TestRepositoryDoesNotRewriteVerifiedImmutableBlob(t *testing.T) {
5858 }
5959 }
6060
61+func loadPublication(ctx context.Context, repo *Repository, publication ports.SnapshotPublication) (ports.SnapshotGeneration, error) {
62+ return repo.LoadCheckpoint(ctx, publication.IncarnationID, publication.Name, ports.CheckpointRef{
63+ Generation: publication.Generation,
64+ ManifestDigest: codec.ManifestDigest(publication.Manifest),
65+ })
66+}
67+
68+func loadGenerationCheckpoint(ctx context.Context, repo *Repository, id domain.IncarnationID, name string, generation uint64) (ports.SnapshotGeneration, error) {
69+ manifest, err := repo.readBounded(repo.manifestPath(id, generation))
70+ if err != nil {
71+ return ports.SnapshotGeneration{}, err
72+ }
73+ return repo.LoadCheckpoint(ctx, id, name, ports.CheckpointRef{
74+ Generation: generation,
75+ ManifestDigest: codec.ManifestDigest(manifest),
76+ })
77+}
78+
6179 func repositoryPublicationAfter(t *testing.T, repo *Repository, name string, generation uint64, payload []byte) ports.SnapshotPublication {
6280 t.Helper()
6381 return publicationWithCurrentParent(t, repo, repositoryPublication(t, name, generation, payload))
@@ -18,7 +18,7 @@ func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {
18 if err := repo.Publish(context.Background(), pub); err != nil {18 if err := repo.Publish(context.Background(), pub); err != nil {
19 t.Fatalf("Publish: %v", err)19 t.Fatalf("Publish: %v", err)
20 }20 }
21- got, err := repo.Load(context.Background(), "named")21+ got, err := loadPublication(context.Background(), repo, pub)
22 if err != nil {22 if err != nil {
23 t.Fatalf("Load: %v", err)23 t.Fatalf("Load: %v", err)
24 }24 }
@@ -58,6 +58,24 @@ func TestRepositoryDoesNotRewriteVerifiedImmutableBlob(t *testing.T) {
58 }58 }
59 }59 }
60 60
61+func loadPublication(ctx context.Context, repo *Repository, publication ports.SnapshotPublication) (ports.SnapshotGeneration, error) {
62+ return repo.LoadCheckpoint(ctx, publication.IncarnationID, publication.Name, ports.CheckpointRef{
63+ Generation: publication.Generation,
64+ ManifestDigest: codec.ManifestDigest(publication.Manifest),
65+ })
66+}
67+
68+func loadGenerationCheckpoint(ctx context.Context, repo *Repository, id domain.IncarnationID, name string, generation uint64) (ports.SnapshotGeneration, error) {
69+ manifest, err := repo.readBounded(repo.manifestPath(id, generation))
70+ if err != nil {
71+ return ports.SnapshotGeneration{}, err
72+ }
73+ return repo.LoadCheckpoint(ctx, id, name, ports.CheckpointRef{
74+ Generation: generation,
75+ ManifestDigest: codec.ManifestDigest(manifest),
76+ })
77+}
78+
61 func repositoryPublicationAfter(t *testing.T, repo *Repository, name string, generation uint64, payload []byte) ports.SnapshotPublication {79 func repositoryPublicationAfter(t *testing.T, repo *Repository, name string, generation uint64, payload []byte) ports.SnapshotPublication {
62 t.Helper()80 t.Helper()
63 return publicationWithCurrentParent(t, repo, repositoryPublication(t, name, generation, payload))81 return publicationWithCurrentParent(t, repo, repositoryPublication(t, name, generation, payload))
D internal/adapters/snapshot/retired_name_keyed_test.go +0 -222
@@ -1,222 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "errors"
7- "fmt"
8- "os"
9- "path/filepath"
10- "sort"
11-
12- "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"
14-)
15-
16-// Retired name-keyed repository behavior is compiled only for pre-incarnation
17-// tests. Production startup and daemon paths cannot call these methods.
18-func (r *Repository) List(ctx context.Context) ([]string, error) {
19- if err := ctx.Err(); err != nil {
20- return nil, err
21- }
22- budget := maxDirectoryTraversalEntries
23- out := make([]string, 0)
24- sessions := filepath.Join(r.dir, repositorySessionsDir)
25- err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
26- if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
27- return nil
28- }
29- generation, ok, err := r.loadNewestGeneration(ctx, entry.Name(), &budget)
30- if err != nil || !ok {
31- return err
32- }
33- out = append(out, generation.Name)
34- return nil
35- })
36- if errors.Is(err, os.ErrNotExist) {
37- return nil, nil
38- }
39- if err != nil {
40- return nil, fmt.Errorf("read snapshot sessions: %w", err)
41- }
42- sort.Strings(out)
43- return out, nil
44-}
45-
46-func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
47- if err := ctx.Err(); err != nil {
48- return ports.SnapshotGeneration{}, err
49- }
50- key, err := r.legacyRepositoryKey(name)
51- if err != nil {
52- return ports.SnapshotGeneration{}, err
53- }
54- lock := r.lockSession(key)
55- defer r.unlockSession(lock)
56- if err := ctx.Err(); err != nil {
57- return ports.SnapshotGeneration{}, err
58- }
59- killed, err := r.tombstoned(name)
60- if err != nil {
61- return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
62- }
63- if killed {
64- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
65- }
66- return r.load(ctx, name, key)
67-}
68-
69-func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) {
70- preferred, preferredDigest, err := r.readLegacyHead(key)
71- if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
72- return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err)
73- }
74- if preferred != 0 {
75- got, err := r.loadGeneration(ctx, name, key, preferred)
76- if err == nil && sha256.Sum256(got.Manifest) == preferredDigest {
77- return got, nil
78- }
79- }
80-
81- budget := maxDirectoryTraversalEntries
82- candidates, err := r.generationCandidates(ctx, key, preferred, &budget)
83- if err != nil {
84- return ports.SnapshotGeneration{}, err
85- }
86- var lastLoadErr error
87- for _, generation := range candidates {
88- got, err := r.loadGeneration(ctx, name, key, generation)
89- if err != nil {
90- lastLoadErr = err
91- continue
92- }
93- return got, nil
94- }
95- if lastLoadErr != nil {
96- return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q: %w", name, lastLoadErr)
97- }
98- return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
99-}
100-
101-func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
102- generation, digest, err := r.readLegacyHead(key)
103- if err == nil {
104- got, err := r.loadGeneration(ctx, name, key, generation)
105- if err == nil && sha256.Sum256(got.Manifest) == digest {
106- return generation, got.Manifest, nil
107- }
108- } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
109- return 0, nil, fmt.Errorf("read snapshot HEAD: %w", err)
110- }
111- budget := maxDirectoryTraversalEntries
112- candidates, err := r.generationCandidates(ctx, key, 0, &budget)
113- if err != nil {
114- return 0, nil, err
115- }
116- for _, generation := range candidates {
117- got, err := r.loadGeneration(ctx, name, key, generation)
118- if err == nil {
119- return generation, got.Manifest, nil
120- }
121- }
122- return 0, nil, nil
123-}
124-
125-func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
126- candidates, err := r.generationCandidates(ctx, key, 0, budget)
127- if err != nil {
128- return ports.SnapshotGeneration{}, false, err
129- }
130- for _, generation := range candidates {
131- data, err := r.readBounded(r.legacyManifestPath(key, generation))
132- if err != nil {
133- continue
134- }
135- manifest, err := codec.UnmarshalManifest(data)
136- if err != nil || sessionKey(manifest.Name) != key {
137- continue
138- }
139- killed, err := r.tombstoned(manifest.Name)
140- if err != nil {
141- return ports.SnapshotGeneration{}, false, fmt.Errorf("read killed session marker: %w", err)
142- }
143- if killed {
144- return ports.SnapshotGeneration{}, false, nil
145- }
146- got, err := r.loadGeneration(ctx, manifest.Name, key, generation)
147- if err == nil {
148- return got, true, nil
149- }
150- }
151- return ports.SnapshotGeneration{}, false, nil
152-}
153-
154-func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
155- candidates := make([]uint64, 0)
156- err := r.walkDirectory(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
157- if entry.IsDir() {
158- return nil
159- }
160- generation, ok := parseGenerationFilename(entry.Name())
161- if ok && generation != exclude {
162- candidates = append(candidates, generation)
163- }
164- return nil
165- })
166- if errors.Is(err, os.ErrNotExist) {
167- return candidates, nil
168- }
169- if err != nil {
170- return nil, err
171- }
172- sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
173- return candidates, nil
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-}
@@ -1,222 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "errors"
7- "fmt"
8- "os"
9- "path/filepath"
10- "sort"
11-
12- "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"
14-)
15-
16-// Retired name-keyed repository behavior is compiled only for pre-incarnation
17-// tests. Production startup and daemon paths cannot call these methods.
18-func (r *Repository) List(ctx context.Context) ([]string, error) {
19- if err := ctx.Err(); err != nil {
20- return nil, err
21- }
22- budget := maxDirectoryTraversalEntries
23- out := make([]string, 0)
24- sessions := filepath.Join(r.dir, repositorySessionsDir)
25- err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
26- if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
27- return nil
28- }
29- generation, ok, err := r.loadNewestGeneration(ctx, entry.Name(), &budget)
30- if err != nil || !ok {
31- return err
32- }
33- out = append(out, generation.Name)
34- return nil
35- })
36- if errors.Is(err, os.ErrNotExist) {
37- return nil, nil
38- }
39- if err != nil {
40- return nil, fmt.Errorf("read snapshot sessions: %w", err)
41- }
42- sort.Strings(out)
43- return out, nil
44-}
45-
46-func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
47- if err := ctx.Err(); err != nil {
48- return ports.SnapshotGeneration{}, err
49- }
50- key, err := r.legacyRepositoryKey(name)
51- if err != nil {
52- return ports.SnapshotGeneration{}, err
53- }
54- lock := r.lockSession(key)
55- defer r.unlockSession(lock)
56- if err := ctx.Err(); err != nil {
57- return ports.SnapshotGeneration{}, err
58- }
59- killed, err := r.tombstoned(name)
60- if err != nil {
61- return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
62- }
63- if killed {
64- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
65- }
66- return r.load(ctx, name, key)
67-}
68-
69-func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) {
70- preferred, preferredDigest, err := r.readLegacyHead(key)
71- if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
72- return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err)
73- }
74- if preferred != 0 {
75- got, err := r.loadGeneration(ctx, name, key, preferred)
76- if err == nil && sha256.Sum256(got.Manifest) == preferredDigest {
77- return got, nil
78- }
79- }
80-
81- budget := maxDirectoryTraversalEntries
82- candidates, err := r.generationCandidates(ctx, key, preferred, &budget)
83- if err != nil {
84- return ports.SnapshotGeneration{}, err
85- }
86- var lastLoadErr error
87- for _, generation := range candidates {
88- got, err := r.loadGeneration(ctx, name, key, generation)
89- if err != nil {
90- lastLoadErr = err
91- continue
92- }
93- return got, nil
94- }
95- if lastLoadErr != nil {
96- return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q: %w", name, lastLoadErr)
97- }
98- return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
99-}
100-
101-func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
102- generation, digest, err := r.readLegacyHead(key)
103- if err == nil {
104- got, err := r.loadGeneration(ctx, name, key, generation)
105- if err == nil && sha256.Sum256(got.Manifest) == digest {
106- return generation, got.Manifest, nil
107- }
108- } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
109- return 0, nil, fmt.Errorf("read snapshot HEAD: %w", err)
110- }
111- budget := maxDirectoryTraversalEntries
112- candidates, err := r.generationCandidates(ctx, key, 0, &budget)
113- if err != nil {
114- return 0, nil, err
115- }
116- for _, generation := range candidates {
117- got, err := r.loadGeneration(ctx, name, key, generation)
118- if err == nil {
119- return generation, got.Manifest, nil
120- }
121- }
122- return 0, nil, nil
123-}
124-
125-func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
126- candidates, err := r.generationCandidates(ctx, key, 0, budget)
127- if err != nil {
128- return ports.SnapshotGeneration{}, false, err
129- }
130- for _, generation := range candidates {
131- data, err := r.readBounded(r.legacyManifestPath(key, generation))
132- if err != nil {
133- continue
134- }
135- manifest, err := codec.UnmarshalManifest(data)
136- if err != nil || sessionKey(manifest.Name) != key {
137- continue
138- }
139- killed, err := r.tombstoned(manifest.Name)
140- if err != nil {
141- return ports.SnapshotGeneration{}, false, fmt.Errorf("read killed session marker: %w", err)
142- }
143- if killed {
144- return ports.SnapshotGeneration{}, false, nil
145- }
146- got, err := r.loadGeneration(ctx, manifest.Name, key, generation)
147- if err == nil {
148- return got, true, nil
149- }
150- }
151- return ports.SnapshotGeneration{}, false, nil
152-}
153-
154-func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
155- candidates := make([]uint64, 0)
156- err := r.walkDirectory(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
157- if entry.IsDir() {
158- return nil
159- }
160- generation, ok := parseGenerationFilename(entry.Name())
161- if ok && generation != exclude {
162- candidates = append(candidates, generation)
163- }
164- return nil
165- })
166- if errors.Is(err, os.ErrNotExist) {
167- return candidates, nil
168- }
169- if err != nil {
170- return nil, err
171- }
172- sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
173- return candidates, nil
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/persist/persist.go +20 -6
@@ -1,6 +1,7 @@
11 package persist
22
33 import (
4+ "encoding/binary"
45 "errors"
56 "fmt"
67 "os"
@@ -13,7 +14,10 @@ import (
1314 "github.com/bnema/vev/pkg/kv"
1415 )
1516
16-const filename = "sessions.kv"
17+const (
18+ filename = "sessions.kv"
19+ currentCatalogueVersion = 2
20+)
1721
1822 var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")
1923
@@ -76,17 +80,27 @@ func Open(dir string) (*Persister, error) {
7680 // durable state exists. Any state that exists but does not decode is returned
7781 // as ErrCatalogueUnreadable and left untouched on disk.
7882 func OpenOrCreate(dir string) (OpenResult, error) {
79- existed := true
80- if _, err := os.Stat(StorePath(dir)); errors.Is(err, os.ErrNotExist) {
81- existed = false
82- }
8383 path := StorePath(dir)
84+ _, statErr := os.Stat(path)
85+ existed := statErr == nil
86+ if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
87+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, statErr)
88+ }
89+ if !existed {
90+ for _, companion := range []string{path + ".next", path + ".prev"} {
91+ if _, err := os.Stat(companion); err == nil {
92+ return OpenResult{}, fmt.Errorf("%w: %s exists without %s", ErrCatalogueUnreadable, companion, path)
93+ } else if !errors.Is(err, os.ErrNotExist) {
94+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, companion, err)
95+ }
96+ }
97+ }
8498 if existed {
8599 raw, err := os.ReadFile(path)
86100 if err != nil {
87101 return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
88102 }
89- if len(raw) < 4 || string(raw[:4]) != "VEVK" {
103+ if len(raw) < 6 || string(raw[:4]) != "VEVK" || binary.BigEndian.Uint16(raw[4:6]) != currentCatalogueVersion {
90104 return OpenResult{}, fmt.Errorf("%w: %s: unknown catalogue format", ErrCatalogueUnreadable, path)
91105 }
92106 data, err := kv.Replay(path)
@@ -1,6 +1,7 @@
1 package persist1 package persist
2 2
3 import (3 import (
4+ "encoding/binary"
4 "errors"5 "errors"
5 "fmt"6 "fmt"
6 "os"7 "os"
@@ -13,7 +14,10 @@ import (
13 "github.com/bnema/vev/pkg/kv"14 "github.com/bnema/vev/pkg/kv"
14 )15 )
15 16
16-const filename = "sessions.kv"17+const (
18+ filename = "sessions.kv"
19+ currentCatalogueVersion = 2
20+)
17 21
18 var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")22 var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")
19 23
@@ -76,17 +80,27 @@ func Open(dir string) (*Persister, error) {
76 // durable state exists. Any state that exists but does not decode is returned80 // durable state exists. Any state that exists but does not decode is returned
77 // as ErrCatalogueUnreadable and left untouched on disk.81 // as ErrCatalogueUnreadable and left untouched on disk.
78 func OpenOrCreate(dir string) (OpenResult, error) {82 func OpenOrCreate(dir string) (OpenResult, error) {
79- existed := true
80- if _, err := os.Stat(StorePath(dir)); errors.Is(err, os.ErrNotExist) {
81- existed = false
82- }
83 path := StorePath(dir)83 path := StorePath(dir)
84+ _, statErr := os.Stat(path)
85+ existed := statErr == nil
86+ if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
87+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, statErr)
88+ }
89+ if !existed {
90+ for _, companion := range []string{path + ".next", path + ".prev"} {
91+ if _, err := os.Stat(companion); err == nil {
92+ return OpenResult{}, fmt.Errorf("%w: %s exists without %s", ErrCatalogueUnreadable, companion, path)
93+ } else if !errors.Is(err, os.ErrNotExist) {
94+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, companion, err)
95+ }
96+ }
97+ }
84 if existed {98 if existed {
85 raw, err := os.ReadFile(path)99 raw, err := os.ReadFile(path)
86 if err != nil {100 if err != nil {
87 return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)101 return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
88 }102 }
89- if len(raw) < 4 || string(raw[:4]) != "VEVK" {103+ if len(raw) < 6 || string(raw[:4]) != "VEVK" || binary.BigEndian.Uint16(raw[4:6]) != currentCatalogueVersion {
90 return OpenResult{}, fmt.Errorf("%w: %s: unknown catalogue format", ErrCatalogueUnreadable, path)104 return OpenResult{}, fmt.Errorf("%w: %s: unknown catalogue format", ErrCatalogueUnreadable, path)
91 }105 }
92 data, err := kv.Replay(path)106 data, err := kv.Replay(path)
M internal/persist/persist_test.go +56 -0
@@ -55,6 +55,62 @@ func TestOpenOrCreate(t *testing.T) {
5555 }
5656 }
5757
58+func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T) {
59+ t.Parallel()
60+ tests := []struct {
61+ name string
62+ files map[string][]byte
63+ }{
64+ {
65+ name: "old VEVK version",
66+ files: map[string][]byte{
67+ filename: {'V', 'E', 'V', 'K', 0, 1},
68+ },
69+ },
70+ {
71+ name: "next companion without catalogue",
72+ files: map[string][]byte{
73+ filename + ".next": []byte("uncommitted catalogue candidate"),
74+ },
75+ },
76+ {
77+ name: "previous companion without catalogue",
78+ files: map[string][]byte{
79+ filename + ".prev": []byte("previous catalogue candidate"),
80+ },
81+ },
82+ }
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ t.Parallel()
86+ dir := privateDir(t)
87+ require.NoError(t, os.MkdirAll(dir, 0o700))
88+ for name, data := range tt.files {
89+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600))
90+ }
91+
92+ before := directoryBytes(t, dir)
93+ _, err := OpenOrCreate(dir)
94+ require.ErrorIs(t, err, ErrCatalogueUnreadable)
95+ require.Equal(t, before, directoryBytes(t, dir), "failure must preserve every durable path and byte")
96+ })
97+ }
98+}
99+
100+func directoryBytes(t *testing.T, dir string) map[string][]byte {
101+ t.Helper()
102+ entries, err := os.ReadDir(dir)
103+ require.NoError(t, err)
104+ files := make(map[string][]byte, len(entries))
105+ for _, entry := range entries {
106+ require.False(t, entry.IsDir(), "unexpected directory %q", entry.Name())
107+ data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
108+ require.NoError(t, err)
109+ files[entry.Name()] = data
110+ }
111+ return files
112+}
113+
58114 func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {
59115 good, err := encodeRecordValue(validRecord("good", 1))
60116 require.NoError(t, err)
@@ -55,6 +55,62 @@ func TestOpenOrCreate(t *testing.T) {
55 }55 }
56 }56 }
57 57
58+func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T) {
59+ t.Parallel()
60+ tests := []struct {
61+ name string
62+ files map[string][]byte
63+ }{
64+ {
65+ name: "old VEVK version",
66+ files: map[string][]byte{
67+ filename: {'V', 'E', 'V', 'K', 0, 1},
68+ },
69+ },
70+ {
71+ name: "next companion without catalogue",
72+ files: map[string][]byte{
73+ filename + ".next": []byte("uncommitted catalogue candidate"),
74+ },
75+ },
76+ {
77+ name: "previous companion without catalogue",
78+ files: map[string][]byte{
79+ filename + ".prev": []byte("previous catalogue candidate"),
80+ },
81+ },
82+ }
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ t.Parallel()
86+ dir := privateDir(t)
87+ require.NoError(t, os.MkdirAll(dir, 0o700))
88+ for name, data := range tt.files {
89+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600))
90+ }
91+
92+ before := directoryBytes(t, dir)
93+ _, err := OpenOrCreate(dir)
94+ require.ErrorIs(t, err, ErrCatalogueUnreadable)
95+ require.Equal(t, before, directoryBytes(t, dir), "failure must preserve every durable path and byte")
96+ })
97+ }
98+}
99+
100+func directoryBytes(t *testing.T, dir string) map[string][]byte {
101+ t.Helper()
102+ entries, err := os.ReadDir(dir)
103+ require.NoError(t, err)
104+ files := make(map[string][]byte, len(entries))
105+ for _, entry := range entries {
106+ require.False(t, entry.IsDir(), "unexpected directory %q", entry.Name())
107+ data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
108+ require.NoError(t, err)
109+ files[entry.Name()] = data
110+ }
111+ return files
112+}
113+
58 func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {114 func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {
59 good, err := encodeRecordValue(validRecord("good", 1))115 good, err := encodeRecordValue(validRecord("good", 1))
60 require.NoError(t, err)116 require.NoError(t, err)

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