detached 434d046

refactor(snapshot): retire name-keyed paths and production fault hooks

commit
434d046460269028caedeb4d8422828671dce341
author
brice · · 2026-07-26 12:48 UTC
parent
70770e7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +43 -845
M internal/adapters/snapshot/atomic.go +1 -48
@@ -49,11 +49,6 @@ func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) (err error)
4949 return err
5050 }
5151 if created {
52- if hook := r.hooks.syncDirectory; hook != nil {
53- if err := hook(filepath.Dir(dir)); err != nil {
54- return fmt.Errorf("%s parent directory sync: %w", phase, err)
55- }
56- }
5752 if err := syncDirectory(filepath.Dir(dir)); err != nil {
5853 return fmt.Errorf("%s parent directory sync: %w", phase, err)
5954 }
@@ -193,36 +188,17 @@ func (r *Repository) withAtomicTemp(dir string, data []byte, publish func(string
193188 }
194189
195190 func (r *Repository) createTemp(dir string) (*os.File, error) {
196- if r.hooks.createTemp != nil {
197- if err := r.hooks.createTemp(dir); err != nil {
198- return nil, err
199- }
200- }
201191 return r.createTempAt(dir)
202192 }
203193 func (r *Repository) writeFile(f *os.File, data []byte) error {
204- if r.hooks.writeTemp != nil {
205- if err := r.hooks.writeTemp(f.Name()); err != nil {
206- return err
207- }
208- }
209194 _, err := f.Write(data)
210195 return err
211196 }
212197 func (r *Repository) syncFile(f *os.File) error {
213- if r.hooks.syncFile != nil {
214- if err := r.hooks.syncFile(f.Name()); err != nil {
215- return err
216- }
217- }
218198 return f.Sync()
219199 }
220200 func (r *Repository) closeFile(f *os.File) error {
221- var injected error
222- if r.hooks.closeFile != nil {
223- injected = r.hooks.closeFile(f.Name())
224- }
225- return errors.Join(injected, f.Close())
201+ return f.Close()
226202 }
227203
228204 // joinCloseError retains a primary operation failure and appends contextual
@@ -234,11 +210,6 @@ func joinCloseError(primary *error, operation string, closeErr error) {
234210 }
235211
236212 func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
237- if r.hooks.installImmutable != nil {
238- if err := r.hooks.installImmutable(newPath); err != nil {
239- return err
240- }
241- }
242213 oldRel, ok := r.repositoryRelative(oldPath)
243214 if !ok {
244215 return fmt.Errorf("snapshot path outside repository")
@@ -255,11 +226,6 @@ func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
255226 return root.Link(oldRel, newRel)
256227 }
257228 func (r *Repository) rename(oldPath, newPath string) (err error) {
258- if r.hooks.rename != nil {
259- if err := r.hooks.rename(newPath); err != nil {
260- return err
261- }
262- }
263229 oldRel, ok := r.repositoryRelative(oldPath)
264230 if !ok {
265231 return fmt.Errorf("snapshot path outside repository")
@@ -276,11 +242,6 @@ func (r *Repository) rename(oldPath, newPath string) (err error) {
276242 return root.Rename(oldRel, newRel)
277243 }
278244 func (r *Repository) remove(path string) (err error) {
279- if r.hooks.remove != nil {
280- if err := r.hooks.remove(path); err != nil {
281- return err
282- }
283- }
284245 rel, ok := r.repositoryRelative(path)
285246 if !ok {
286247 return fmt.Errorf("snapshot path outside repository")
@@ -293,11 +254,6 @@ func (r *Repository) remove(path string) (err error) {
293254 return root.Remove(rel)
294255 }
295256 func (r *Repository) syncDirectory(dir string) error {
296- if r.hooks.syncDirectory != nil {
297- if err := r.hooks.syncDirectory(dir); err != nil {
298- return err
299- }
300- }
301257 return syncDirectory(dir)
302258 }
303259 func syncDirectory(dir string) error {
@@ -345,9 +301,6 @@ func (r *Repository) readBoundedRoot(root *os.Root, path string) ([]byte, error)
345301 if err != nil {
346302 return nil, err
347303 }
348- if hook := r.hooks.beforePayloadRead; hook != nil {
349- hook(path)
350- }
351304 return readBoundedFile(f)
352305 }
353306
@@ -49,11 +49,6 @@ func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) (err error)
49 return err49 return err
50 }50 }
51 if created {51 if created {
52- if hook := r.hooks.syncDirectory; hook != nil {
53- if err := hook(filepath.Dir(dir)); err != nil {
54- return fmt.Errorf("%s parent directory sync: %w", phase, err)
55- }
56- }
57 if err := syncDirectory(filepath.Dir(dir)); err != nil {52 if err := syncDirectory(filepath.Dir(dir)); err != nil {
58 return fmt.Errorf("%s parent directory sync: %w", phase, err)53 return fmt.Errorf("%s parent directory sync: %w", phase, err)
59 }54 }
@@ -193,36 +188,17 @@ func (r *Repository) withAtomicTemp(dir string, data []byte, publish func(string
193 }188 }
194 189
195 func (r *Repository) createTemp(dir string) (*os.File, error) {190 func (r *Repository) createTemp(dir string) (*os.File, error) {
196- if r.hooks.createTemp != nil {
197- if err := r.hooks.createTemp(dir); err != nil {
198- return nil, err
199- }
200- }
201 return r.createTempAt(dir)191 return r.createTempAt(dir)
202 }192 }
203 func (r *Repository) writeFile(f *os.File, data []byte) error {193 func (r *Repository) writeFile(f *os.File, data []byte) error {
204- if r.hooks.writeTemp != nil {
205- if err := r.hooks.writeTemp(f.Name()); err != nil {
206- return err
207- }
208- }
209 _, err := f.Write(data)194 _, err := f.Write(data)
210 return err195 return err
211 }196 }
212 func (r *Repository) syncFile(f *os.File) error {197 func (r *Repository) syncFile(f *os.File) error {
213- if r.hooks.syncFile != nil {
214- if err := r.hooks.syncFile(f.Name()); err != nil {
215- return err
216- }
217- }
218 return f.Sync()198 return f.Sync()
219 }199 }
220 func (r *Repository) closeFile(f *os.File) error {200 func (r *Repository) closeFile(f *os.File) error {
221- var injected error201+ return f.Close()
222- if r.hooks.closeFile != nil {
223- injected = r.hooks.closeFile(f.Name())
224- }
225- return errors.Join(injected, f.Close())
226 }202 }
227 203
228 // joinCloseError retains a primary operation failure and appends contextual204 // joinCloseError retains a primary operation failure and appends contextual
@@ -234,11 +210,6 @@ func joinCloseError(primary *error, operation string, closeErr error) {
234 }210 }
235 211
236 func (r *Repository) installImmutable(oldPath, newPath string) (err error) {212 func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
237- if r.hooks.installImmutable != nil {
238- if err := r.hooks.installImmutable(newPath); err != nil {
239- return err
240- }
241- }
242 oldRel, ok := r.repositoryRelative(oldPath)213 oldRel, ok := r.repositoryRelative(oldPath)
243 if !ok {214 if !ok {
244 return fmt.Errorf("snapshot path outside repository")215 return fmt.Errorf("snapshot path outside repository")
@@ -255,11 +226,6 @@ func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
255 return root.Link(oldRel, newRel)226 return root.Link(oldRel, newRel)
256 }227 }
257 func (r *Repository) rename(oldPath, newPath string) (err error) {228 func (r *Repository) rename(oldPath, newPath string) (err error) {
258- if r.hooks.rename != nil {
259- if err := r.hooks.rename(newPath); err != nil {
260- return err
261- }
262- }
263 oldRel, ok := r.repositoryRelative(oldPath)229 oldRel, ok := r.repositoryRelative(oldPath)
264 if !ok {230 if !ok {
265 return fmt.Errorf("snapshot path outside repository")231 return fmt.Errorf("snapshot path outside repository")
@@ -276,11 +242,6 @@ func (r *Repository) rename(oldPath, newPath string) (err error) {
276 return root.Rename(oldRel, newRel)242 return root.Rename(oldRel, newRel)
277 }243 }
278 func (r *Repository) remove(path string) (err error) {244 func (r *Repository) remove(path string) (err error) {
279- if r.hooks.remove != nil {
280- if err := r.hooks.remove(path); err != nil {
281- return err
282- }
283- }
284 rel, ok := r.repositoryRelative(path)245 rel, ok := r.repositoryRelative(path)
285 if !ok {246 if !ok {
286 return fmt.Errorf("snapshot path outside repository")247 return fmt.Errorf("snapshot path outside repository")
@@ -293,11 +254,6 @@ func (r *Repository) remove(path string) (err error) {
293 return root.Remove(rel)254 return root.Remove(rel)
294 }255 }
295 func (r *Repository) syncDirectory(dir string) error {256 func (r *Repository) syncDirectory(dir string) error {
296- if r.hooks.syncDirectory != nil {
297- if err := r.hooks.syncDirectory(dir); err != nil {
298- return err
299- }
300- }
301 return syncDirectory(dir)257 return syncDirectory(dir)
302 }258 }
303 func syncDirectory(dir string) error {259 func syncDirectory(dir string) error {
@@ -345,9 +301,6 @@ func (r *Repository) readBoundedRoot(root *os.Root, path string) ([]byte, error)
345 if err != nil {301 if err != nil {
346 return nil, err302 return nil, err
347 }303 }
348- if hook := r.hooks.beforePayloadRead; hook != nil {
349- hook(path)
350- }
351 return readBoundedFile(f)304 return readBoundedFile(f)
352 }305 }
353 306
M internal/adapters/snapshot/atomic_test.go +24 -32
@@ -8,51 +8,46 @@ import (
88 "testing"
99 )
1010
11-func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {
11+func TestWriteImmutablePublishesAndReusesIdenticalData(t *testing.T) {
1212 repo := NewRepository(privateDir(t))
1313 dir := filepath.Join(repo.dir, "immutable")
1414 if err := repo.ensurePrivateDirectory(dir); err != nil {
1515 t.Fatal(err)
1616 }
1717
18- syncs := 0
19- repo.hooks.syncDirectory = func(got string) error {
20- if got == dir {
21- syncs++
22- }
23- return nil
24- }
18+ path := filepath.Join(dir, "object")
2519 data := []byte("immutable")
26- if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error {
20+ verify := func(existing []byte) error {
2721 if !bytes.Equal(existing, data) {
2822 return errors.New("unexpected immutable data")
2923 }
3024 return nil
31- }); err != nil {
25+ }
26+ if err := repo.writeImmutable(path, data, verify); err != nil {
3227 t.Fatal(err)
3328 }
34- if syncs != 1 {
35- t.Fatalf("successful immutable directory syncs = %d, want 1", syncs)
29+ if err := repo.writeImmutable(path, data, verify); err != nil {
30+ t.Fatal(err)
31+ }
32+ got, err := os.ReadFile(path)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if !bytes.Equal(got, data) {
37+ t.Fatalf("immutable data = %q, want %q", got, data)
3638 }
3739 }
3840
39-func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) {
41+func TestWithAtomicTempRemovesPublishedTemporaryFile(t *testing.T) {
4042 repo := NewRepository(privateDir(t))
4143 dir := filepath.Join(repo.dir, "temporary")
4244 if err := repo.ensurePrivateDirectory(dir); err != nil {
4345 t.Fatal(err)
4446 }
4547
46- var events []string
47- repo.hooks.createTemp = func(string) error { events = append(events, "create"); return nil }
48- repo.hooks.writeTemp = func(string) error { events = append(events, "write"); return nil }
49- repo.hooks.syncFile = func(string) error { events = append(events, "sync file"); return nil }
50- repo.hooks.closeFile = func(string) error { events = append(events, "close"); return nil }
51- repo.hooks.remove = func(string) error { events = append(events, "remove"); return nil }
52- repo.hooks.syncDirectory = func(string) error { events = append(events, "sync directory"); return nil }
53-
48+ var temporary string
5449 if err := repo.withAtomicTemp(dir, []byte("data"), func(path string) (bool, error) {
55- events = append(events, "publish")
50+ temporary = path
5651 info, err := os.Stat(path)
5752 if err != nil {
5853 return true, err
@@ -64,21 +59,14 @@ func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) {
6459 }); err != nil {
6560 t.Fatal(err)
6661 }
67- want := []string{"create", "write", "sync file", "close", "publish", "remove", "sync directory"}
68- if len(events) != len(want) {
69- t.Fatalf("lifecycle = %v, want %v", events, want)
70- }
71- for i := range want {
72- if events[i] != want[i] {
73- t.Fatalf("lifecycle = %v, want %v", events, want)
74- }
62+ if _, err := os.Stat(temporary); !errors.Is(err, os.ErrNotExist) {
63+ t.Fatalf("temporary file remains after publication: %v", err)
7564 }
7665 }
7766
78-func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) {
67+func TestWriteMutableConsumesTemporaryFile(t *testing.T) {
7968 repo := NewRepository(privateDir(t))
8069 path := filepath.Join(repo.dir, "HEAD")
81- repo.hooks.remove = func(string) error { return errors.New("remove must not run") }
8270
8371 if err := repo.writeMutable(path, []byte("head")); err != nil {
8472 t.Fatalf("writeMutable error = %v", err)
@@ -90,4 +78,8 @@ func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) {
9078 if !bytes.Equal(got, []byte("head")) {
9179 t.Fatalf("HEAD = %q, want %q", got, "head")
9280 }
81+ matches, err := filepath.Glob(filepath.Join(repo.dir, ".tmp-*"))
82+ if err != nil || len(matches) != 0 {
83+ t.Fatalf("temporary files after mutable write = %v, glob error = %v", matches, err)
84+ }
9385 }
@@ -8,51 +8,46 @@ import (
8 "testing"8 "testing"
9 )9 )
10 10
11-func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {11+func TestWriteImmutablePublishesAndReusesIdenticalData(t *testing.T) {
12 repo := NewRepository(privateDir(t))12 repo := NewRepository(privateDir(t))
13 dir := filepath.Join(repo.dir, "immutable")13 dir := filepath.Join(repo.dir, "immutable")
14 if err := repo.ensurePrivateDirectory(dir); err != nil {14 if err := repo.ensurePrivateDirectory(dir); err != nil {
15 t.Fatal(err)15 t.Fatal(err)
16 }16 }
17 17
18- syncs := 018+ path := filepath.Join(dir, "object")
19- repo.hooks.syncDirectory = func(got string) error {
20- if got == dir {
21- syncs++
22- }
23- return nil
24- }
25 data := []byte("immutable")19 data := []byte("immutable")
26- if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error {20+ verify := func(existing []byte) error {
27 if !bytes.Equal(existing, data) {21 if !bytes.Equal(existing, data) {
28 return errors.New("unexpected immutable data")22 return errors.New("unexpected immutable data")
29 }23 }
30 return nil24 return nil
31- }); err != nil {25+ }
26+ if err := repo.writeImmutable(path, data, verify); err != nil {
32 t.Fatal(err)27 t.Fatal(err)
33 }28 }
34- if syncs != 1 {29+ if err := repo.writeImmutable(path, data, verify); err != nil {
35- t.Fatalf("successful immutable directory syncs = %d, want 1", syncs)30+ t.Fatal(err)
31+ }
32+ got, err := os.ReadFile(path)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if !bytes.Equal(got, data) {
37+ t.Fatalf("immutable data = %q, want %q", got, data)
36 }38 }
37 }39 }
38 40
39-func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) {41+func TestWithAtomicTempRemovesPublishedTemporaryFile(t *testing.T) {
40 repo := NewRepository(privateDir(t))42 repo := NewRepository(privateDir(t))
41 dir := filepath.Join(repo.dir, "temporary")43 dir := filepath.Join(repo.dir, "temporary")
42 if err := repo.ensurePrivateDirectory(dir); err != nil {44 if err := repo.ensurePrivateDirectory(dir); err != nil {
43 t.Fatal(err)45 t.Fatal(err)
44 }46 }
45 47
46- var events []string48+ var temporary string
47- repo.hooks.createTemp = func(string) error { events = append(events, "create"); return nil }
48- repo.hooks.writeTemp = func(string) error { events = append(events, "write"); return nil }
49- repo.hooks.syncFile = func(string) error { events = append(events, "sync file"); return nil }
50- repo.hooks.closeFile = func(string) error { events = append(events, "close"); return nil }
51- repo.hooks.remove = func(string) error { events = append(events, "remove"); return nil }
52- repo.hooks.syncDirectory = func(string) error { events = append(events, "sync directory"); return nil }
53-
54 if err := repo.withAtomicTemp(dir, []byte("data"), func(path string) (bool, error) {49 if err := repo.withAtomicTemp(dir, []byte("data"), func(path string) (bool, error) {
55- events = append(events, "publish")50+ temporary = path
56 info, err := os.Stat(path)51 info, err := os.Stat(path)
57 if err != nil {52 if err != nil {
58 return true, err53 return true, err
@@ -64,21 +59,14 @@ func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) {
64 }); err != nil {59 }); err != nil {
65 t.Fatal(err)60 t.Fatal(err)
66 }61 }
67- want := []string{"create", "write", "sync file", "close", "publish", "remove", "sync directory"}62+ if _, err := os.Stat(temporary); !errors.Is(err, os.ErrNotExist) {
68- if len(events) != len(want) {63+ t.Fatalf("temporary file remains after publication: %v", err)
69- t.Fatalf("lifecycle = %v, want %v", events, want)
70- }
71- for i := range want {
72- if events[i] != want[i] {
73- t.Fatalf("lifecycle = %v, want %v", events, want)
74- }
75 }64 }
76 }65 }
77 66
78-func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) {67+func TestWriteMutableConsumesTemporaryFile(t *testing.T) {
79 repo := NewRepository(privateDir(t))68 repo := NewRepository(privateDir(t))
80 path := filepath.Join(repo.dir, "HEAD")69 path := filepath.Join(repo.dir, "HEAD")
81- repo.hooks.remove = func(string) error { return errors.New("remove must not run") }
82 70
83 if err := repo.writeMutable(path, []byte("head")); err != nil {71 if err := repo.writeMutable(path, []byte("head")); err != nil {
84 t.Fatalf("writeMutable error = %v", err)72 t.Fatalf("writeMutable error = %v", err)
@@ -90,4 +78,8 @@ func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) {
90 if !bytes.Equal(got, []byte("head")) {78 if !bytes.Equal(got, []byte("head")) {
91 t.Fatalf("HEAD = %q, want %q", got, "head")79 t.Fatalf("HEAD = %q, want %q", got, "head")
92 }80 }
81+ matches, err := filepath.Glob(filepath.Join(repo.dir, ".tmp-*"))
82+ if err != nil || len(matches) != 0 {
83+ t.Fatalf("temporary files after mutable write = %v, glob error = %v", matches, err)
84+ }
93 }85 }
M internal/adapters/snapshot/direct_load_test.go +0 -19
@@ -5,7 +5,6 @@ import (
55 "crypto/sha256"
66 "os"
77 "path/filepath"
8- "sync/atomic"
98 "testing"
109
1110 "github.com/bnema/vev/internal/domain"
@@ -49,12 +48,6 @@ func TestDirectCheckpointLoad(t *testing.T) {
4948 t.Fatal(err)
5049 }
5150 }
52- var generationDirectoryReadCount atomic.Int64
53- repo.hooks.beforeDirectoryRead = func(path string) {
54- if path == generations {
55- generationDirectoryReadCount.Add(1)
56- }
57- }
5851 ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)}
5952 got, err := repo.LoadCheckpoint(context.Background(), id, "work", ref)
6053 if err != nil {
@@ -63,9 +56,6 @@ func TestDirectCheckpointLoad(t *testing.T) {
6356 if got.Generation != 1 || got.IncarnationID != id {
6457 t.Fatalf("generation = %#v", got)
6558 }
66- if generationDirectoryReadCount.Load() != 0 {
67- t.Fatal("direct load enumerated generation directory")
68- }
6959 }
7060
7161 func TestRepairHEAD(t *testing.T) {
@@ -76,18 +66,9 @@ func TestRepairHEAD(t *testing.T) {
7666 t.Fatal(err)
7767 }
7868 valid := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)}
79- manifestReads := 0
80- repo.hooks.beforePayloadRead = func(path string) {
81- if path == repo.manifestPath(id, valid.Generation) {
82- manifestReads++
83- }
84- }
8569 if err := repo.RepairHEAD(context.Background(), id, valid); err != nil {
8670 t.Fatal(err)
8771 }
88- if manifestReads != 1 {
89- t.Fatalf("RepairHEAD manifest reads = %d, want 1", manifestReads)
90- }
9172 before, err := os.ReadFile(repo.headPath(id))
9273 if err != nil {
9374 t.Fatal(err)
@@ -5,7 +5,6 @@ import (
5 "crypto/sha256"5 "crypto/sha256"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8- "sync/atomic"
9 "testing"8 "testing"
10 9
11 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
@@ -49,12 +48,6 @@ func TestDirectCheckpointLoad(t *testing.T) {
49 t.Fatal(err)48 t.Fatal(err)
50 }49 }
51 }50 }
52- var generationDirectoryReadCount atomic.Int64
53- repo.hooks.beforeDirectoryRead = func(path string) {
54- if path == generations {
55- generationDirectoryReadCount.Add(1)
56- }
57- }
58 ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)}51 ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)}
59 got, err := repo.LoadCheckpoint(context.Background(), id, "work", ref)52 got, err := repo.LoadCheckpoint(context.Background(), id, "work", ref)
60 if err != nil {53 if err != nil {
@@ -63,9 +56,6 @@ func TestDirectCheckpointLoad(t *testing.T) {
63 if got.Generation != 1 || got.IncarnationID != id {56 if got.Generation != 1 || got.IncarnationID != id {
64 t.Fatalf("generation = %#v", got)57 t.Fatalf("generation = %#v", got)
65 }58 }
66- if generationDirectoryReadCount.Load() != 0 {
67- t.Fatal("direct load enumerated generation directory")
68- }
69 }59 }
70 60
71 func TestRepairHEAD(t *testing.T) {61 func TestRepairHEAD(t *testing.T) {
@@ -76,18 +66,9 @@ func TestRepairHEAD(t *testing.T) {
76 t.Fatal(err)66 t.Fatal(err)
77 }67 }
78 valid := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)}68 valid := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)}
79- manifestReads := 0
80- repo.hooks.beforePayloadRead = func(path string) {
81- if path == repo.manifestPath(id, valid.Generation) {
82- manifestReads++
83- }
84- }
85 if err := repo.RepairHEAD(context.Background(), id, valid); err != nil {69 if err := repo.RepairHEAD(context.Background(), id, valid); err != nil {
86 t.Fatal(err)70 t.Fatal(err)
87 }71 }
88- if manifestReads != 1 {
89- t.Fatalf("RepairHEAD manifest reads = %d, want 1", manifestReads)
90- }
91 before, err := os.ReadFile(repo.headPath(id))72 before, err := os.ReadFile(repo.headPath(id))
92 if err != nil {73 if err != nil {
93 t.Fatal(err)74 t.Fatal(err)
M internal/adapters/snapshot/load.go +0 -42
@@ -31,9 +31,6 @@ var (
3131 // File.ReadDir maintains a cursor on one descriptor and returns at most one
3232 // small batch, so no full directory enumeration is retained in memory.
3333 func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) {
34- if hook := r.hooks.beforeDirectoryRead; hook != nil {
35- hook(dir)
36- }
3734 file, err := r.openDirectory(dir)
3835 if err != nil {
3936 return err
@@ -136,36 +133,6 @@ func (r *Repository) RepairHEAD(ctx context.Context, id domain.IncarnationID, re
136133 return r.writeMutable(r.headPath(id), marshalHead(ref.Generation, ref.ManifestDigest))
137134 }
138135
139-func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
140- data, err := r.readBounded(r.legacyManifestPath(key, generation))
141- if err != nil {
142- return ports.SnapshotGeneration{}, err
143- }
144- manifest, err := codec.UnmarshalManifest(data)
145- if err != nil || manifest.Name != name || manifest.Generation != generation {
146- return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest")
147- }
148- refs := manifestRefs(manifest)
149- if refs == nil || !withinGenerationBudget(len(data), refs) {
150- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large")
151- }
152- objects := make(map[ports.SnapshotDigest][]byte, len(refs))
153- for digest, ref := range refs {
154- if err := ctx.Err(); err != nil {
155- return ports.SnapshotGeneration{}, err
156- }
157- object, err := r.readBounded(r.legacyObjectPath(key, digest))
158- if err != nil {
159- return ports.SnapshotGeneration{}, err
160- }
161- if sha256.Sum256(object) != digest || !validObject(object, ref) {
162- return ports.SnapshotGeneration{}, fmt.Errorf("invalid object")
163- }
164- objects[digest] = object
165- }
166- return ports.SnapshotGeneration{Name: manifest.Name, Generation: generation, Manifest: data, Objects: objects}, nil
167-}
168-
169136 func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
170137 out := make([]byte, 4+8+sha256.Size)
171138 copy(out, "VEVH")
@@ -182,11 +149,6 @@ func (r *Repository) readHeadWithRoot(root *os.Root, id domain.IncarnationID) (u
182149 }
183150
184151 func (r *Repository) readHeadAt(root *os.Root, path string) (uint64, ports.SnapshotDigest, error) {
185- if hook := r.hooks.beforeHeadRead; hook != nil {
186- if err := hook(path); err != nil {
187- return 0, ports.SnapshotDigest{}, err
188- }
189- }
190152 var data []byte
191153 var err error
192154 if root == nil {
@@ -208,7 +170,3 @@ func (r *Repository) readHeadAt(root *os.Root, path string) (uint64, ports.Snaps
208170 }
209171 return generation, digest, nil
210172 }
211-
212-func (r *Repository) readLegacyHead(key string) (uint64, ports.SnapshotDigest, error) {
213- return r.readHeadAt(nil, r.legacyHeadPath(key))
214-}
@@ -31,9 +31,6 @@ var (
31 // File.ReadDir maintains a cursor on one descriptor and returns at most one31 // File.ReadDir maintains a cursor on one descriptor and returns at most one
32 // small batch, so no full directory enumeration is retained in memory.32 // small batch, so no full directory enumeration is retained in memory.
33 func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) {33 func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) {
34- if hook := r.hooks.beforeDirectoryRead; hook != nil {
35- hook(dir)
36- }
37 file, err := r.openDirectory(dir)34 file, err := r.openDirectory(dir)
38 if err != nil {35 if err != nil {
39 return err36 return err
@@ -136,36 +133,6 @@ func (r *Repository) RepairHEAD(ctx context.Context, id domain.IncarnationID, re
136 return r.writeMutable(r.headPath(id), marshalHead(ref.Generation, ref.ManifestDigest))133 return r.writeMutable(r.headPath(id), marshalHead(ref.Generation, ref.ManifestDigest))
137 }134 }
138 135
139-func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
140- data, err := r.readBounded(r.legacyManifestPath(key, generation))
141- if err != nil {
142- return ports.SnapshotGeneration{}, err
143- }
144- manifest, err := codec.UnmarshalManifest(data)
145- if err != nil || manifest.Name != name || manifest.Generation != generation {
146- return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest")
147- }
148- refs := manifestRefs(manifest)
149- if refs == nil || !withinGenerationBudget(len(data), refs) {
150- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large")
151- }
152- objects := make(map[ports.SnapshotDigest][]byte, len(refs))
153- for digest, ref := range refs {
154- if err := ctx.Err(); err != nil {
155- return ports.SnapshotGeneration{}, err
156- }
157- object, err := r.readBounded(r.legacyObjectPath(key, digest))
158- if err != nil {
159- return ports.SnapshotGeneration{}, err
160- }
161- if sha256.Sum256(object) != digest || !validObject(object, ref) {
162- return ports.SnapshotGeneration{}, fmt.Errorf("invalid object")
163- }
164- objects[digest] = object
165- }
166- return ports.SnapshotGeneration{Name: manifest.Name, Generation: generation, Manifest: data, Objects: objects}, nil
167-}
168-
169 func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {136 func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
170 out := make([]byte, 4+8+sha256.Size)137 out := make([]byte, 4+8+sha256.Size)
171 copy(out, "VEVH")138 copy(out, "VEVH")
@@ -182,11 +149,6 @@ func (r *Repository) readHeadWithRoot(root *os.Root, id domain.IncarnationID) (u
182 }149 }
183 150
184 func (r *Repository) readHeadAt(root *os.Root, path string) (uint64, ports.SnapshotDigest, error) {151 func (r *Repository) readHeadAt(root *os.Root, path string) (uint64, ports.SnapshotDigest, error) {
185- if hook := r.hooks.beforeHeadRead; hook != nil {
186- if err := hook(path); err != nil {
187- return 0, ports.SnapshotDigest{}, err
188- }
189- }
190 var data []byte152 var data []byte
191 var err error153 var err error
192 if root == nil {154 if root == nil {
@@ -208,7 +170,3 @@ func (r *Repository) readHeadAt(root *os.Root, path string) (uint64, ports.Snaps
208 }170 }
209 return generation, digest, nil171 return generation, digest, nil
210 }172 }
211-
212-func (r *Repository) readLegacyHead(key string) (uint64, ports.SnapshotDigest, error) {
213- return r.readHeadAt(nil, r.legacyHeadPath(key))
214-}
M internal/adapters/snapshot/path.go +0 -70
@@ -1,13 +1,9 @@
11 package snapshot
22
33 import (
4- "crypto/sha256"
54 "encoding/hex"
6- "errors"
75 "fmt"
8- "os"
96 "path/filepath"
10- "regexp"
117 "strconv"
128 "strings"
139
@@ -15,30 +11,8 @@ import (
1511 "github.com/bnema/vev/internal/ports"
1612 )
1713
18-var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
19-
2014 const maxSnapshotFileSize = 16 + (256 << 20)
2115
22-// filenameForName maps a session name to its deterministic legacy filename.
23-// It remains only for the one-way v3 import path.
24-func filenameForName(name string) string {
25- if safeNameRE.MatchString(name) {
26- return name + ".snap"
27- }
28- sum := sha256.Sum256([]byte(name))
29- return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
30-}
31-
32-// legacyIncarnationID identifies snapshots produced by the short-lived
33-// incarnation bridge. Established name-keyed repositories use sessionKey.
34-// New callers must supply the durable catalogue incarnation ID.
35-func legacyIncarnationID(name string) domain.IncarnationID {
36- id := domain.IncarnationID{}
37- digest := sha256.Sum256([]byte(name))
38- copy(id[:], digest[:])
39- return id
40-}
41-
4216 func incarnationKey(id domain.IncarnationID) (string, error) {
4317 if id == (domain.IncarnationID{}) {
4418 return "", fmt.Errorf("snapshot: zero incarnation ID")
@@ -60,50 +34,6 @@ func (r *Repository) headPath(id domain.IncarnationID) string {
6034 return filepath.Join(r.sessionPath(id), repositoryHead)
6135 }
6236
63-// Legacy path helpers are isolated from normal incarnation-keyed operations.
64-func (r *Repository) legacySessionPath(key string) string {
65- return filepath.Join(r.dir, repositorySessionsDir, key)
66-}
67-
68-// legacyRepositoryKey resolves the established name-keyed namespace first.
69-// The digest fallback keeps the retired bridge readable without changing any
70-// durable catalogue incarnation identity.
71-func (r *Repository) legacyRepositoryKey(name string) (string, error) {
72- key := sessionKey(name)
73- if _, err := r.stat(r.legacySessionPath(key)); err == nil {
74- return key, nil
75- } else if !errors.Is(err, os.ErrNotExist) {
76- return "", err
77- }
78- return legacyIncarnationID(name).String(), nil
79-}
80-func (r *Repository) legacyObjectPath(key string, digest ports.SnapshotDigest) string {
81- hexDigest := hex.EncodeToString(digest[:])
82- return filepath.Join(r.legacySessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
83-}
84-func (r *Repository) legacyManifestPath(key string, generation uint64) string {
85- return filepath.Join(r.legacySessionPath(key), repositoryGenerations, generationFilename(generation))
86-}
87-func (r *Repository) legacyHeadPath(key string) string {
88- return filepath.Join(r.legacySessionPath(key), repositoryHead)
89-}
90-func sessionKey(name string) string {
91- if safeNameRE.MatchString(name) && name != "." && name != ".." {
92- return name
93- }
94- sum := sha256.Sum256([]byte(name))
95- return "@" + hex.EncodeToString(sum[:])
96-}
97-func canonicalSessionKey(key string) bool {
98- if safeNameRE.MatchString(key) && key != "." && key != ".." {
99- return true
100- }
101- if len(key) != 65 || key[0] != '@' {
102- return false
103- }
104- _, err := hex.DecodeString(key[1:])
105- return err == nil && strings.ToLower(key[1:]) == key[1:]
106-}
10737 func generationFilename(generation uint64) string {
10838 return fmt.Sprintf("%0*d.manifest", generationWidth, generation)
10939 }
@@ -1,13 +1,9 @@
1 package snapshot1 package snapshot
2 2
3 import (3 import (
4- "crypto/sha256"
5 "encoding/hex"4 "encoding/hex"
6- "errors"
7 "fmt"5 "fmt"
8- "os"
9 "path/filepath"6 "path/filepath"
10- "regexp"
11 "strconv"7 "strconv"
12 "strings"8 "strings"
13 9
@@ -15,30 +11,8 @@ import (
15 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
16 )12 )
17 13
18-var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
19-
20 const maxSnapshotFileSize = 16 + (256 << 20)14 const maxSnapshotFileSize = 16 + (256 << 20)
21 15
22-// filenameForName maps a session name to its deterministic legacy filename.
23-// It remains only for the one-way v3 import path.
24-func filenameForName(name string) string {
25- if safeNameRE.MatchString(name) {
26- return name + ".snap"
27- }
28- sum := sha256.Sum256([]byte(name))
29- return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
30-}
31-
32-// legacyIncarnationID identifies snapshots produced by the short-lived
33-// incarnation bridge. Established name-keyed repositories use sessionKey.
34-// New callers must supply the durable catalogue incarnation ID.
35-func legacyIncarnationID(name string) domain.IncarnationID {
36- id := domain.IncarnationID{}
37- digest := sha256.Sum256([]byte(name))
38- copy(id[:], digest[:])
39- return id
40-}
41-
42 func incarnationKey(id domain.IncarnationID) (string, error) {16 func incarnationKey(id domain.IncarnationID) (string, error) {
43 if id == (domain.IncarnationID{}) {17 if id == (domain.IncarnationID{}) {
44 return "", fmt.Errorf("snapshot: zero incarnation ID")18 return "", fmt.Errorf("snapshot: zero incarnation ID")
@@ -60,50 +34,6 @@ func (r *Repository) headPath(id domain.IncarnationID) string {
60 return filepath.Join(r.sessionPath(id), repositoryHead)34 return filepath.Join(r.sessionPath(id), repositoryHead)
61 }35 }
62 36
63-// Legacy path helpers are isolated from normal incarnation-keyed operations.
64-func (r *Repository) legacySessionPath(key string) string {
65- return filepath.Join(r.dir, repositorySessionsDir, key)
66-}
67-
68-// legacyRepositoryKey resolves the established name-keyed namespace first.
69-// The digest fallback keeps the retired bridge readable without changing any
70-// durable catalogue incarnation identity.
71-func (r *Repository) legacyRepositoryKey(name string) (string, error) {
72- key := sessionKey(name)
73- if _, err := r.stat(r.legacySessionPath(key)); err == nil {
74- return key, nil
75- } else if !errors.Is(err, os.ErrNotExist) {
76- return "", err
77- }
78- return legacyIncarnationID(name).String(), nil
79-}
80-func (r *Repository) legacyObjectPath(key string, digest ports.SnapshotDigest) string {
81- hexDigest := hex.EncodeToString(digest[:])
82- return filepath.Join(r.legacySessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
83-}
84-func (r *Repository) legacyManifestPath(key string, generation uint64) string {
85- return filepath.Join(r.legacySessionPath(key), repositoryGenerations, generationFilename(generation))
86-}
87-func (r *Repository) legacyHeadPath(key string) string {
88- return filepath.Join(r.legacySessionPath(key), repositoryHead)
89-}
90-func sessionKey(name string) string {
91- if safeNameRE.MatchString(name) && name != "." && name != ".." {
92- return name
93- }
94- sum := sha256.Sum256([]byte(name))
95- return "@" + hex.EncodeToString(sum[:])
96-}
97-func canonicalSessionKey(key string) bool {
98- if safeNameRE.MatchString(key) && key != "." && key != ".." {
99- return true
100- }
101- if len(key) != 65 || key[0] != '@' {
102- return false
103- }
104- _, err := hex.DecodeString(key[1:])
105- return err == nil && strings.ToLower(key[1:]) == key[1:]
106-}
107 func generationFilename(generation uint64) string {37 func generationFilename(generation uint64) string {
108 return fmt.Sprintf("%0*d.manifest", generationWidth, generation)38 return fmt.Sprintf("%0*d.manifest", generationWidth, generation)
109 }39 }
M internal/adapters/snapshot/path_security_test.go +1 -80
@@ -70,84 +70,6 @@ func TestOpenRootRejectsUnsafePinnedRoot(t *testing.T) {
7070 }
7171 }
7272
73-func TestOpenRootDetectsReplacementAfterOpen(t *testing.T) {
74- dir := privateDir(t)
75- if err := os.Mkdir(dir, 0o700); err != nil {
76- t.Fatal(err)
77- }
78- old := dir + "-old"
79- replacementMarker := filepath.Join(dir, "replacement-marker")
80- repo := NewRepository(dir)
81- repo.hooks.afterOpenRoot = func() {
82- repo.hooks.afterOpenRoot = nil
83- if err := os.Rename(dir, old); err != nil {
84- t.Fatal(err)
85- }
86- if err := os.Mkdir(dir, 0o700); err != nil {
87- t.Fatal(err)
88- }
89- if err := os.WriteFile(replacementMarker, []byte("unchanged"), 0o600); err != nil {
90- t.Fatal(err)
91- }
92- }
93- if _, err := repo.openRoot(); !errors.Is(err, syscall.ESTALE) {
94- t.Fatalf("openRoot error = %v, want ESTALE", err)
95- }
96- if got, err := os.ReadFile(replacementMarker); err != nil || string(got) != "unchanged" {
97- t.Fatalf("replacement marker = %q, %v", got, err)
98- }
99- if _, err := os.Stat(filepath.Join(old, "replacement-marker")); !errors.Is(err, os.ErrNotExist) {
100- t.Fatalf("pinned tree was operated on: %v", err)
101- }
102-}
103-
104-func TestOpenDirectoryClosesChildWhenRootCloseFails(t *testing.T) {
105- repo := NewRepository(privateDir(t))
106- if err := os.Mkdir(repo.dir, 0o700); err != nil {
107- t.Fatal(err)
108- }
109- child := filepath.Join(repo.dir, "child")
110- if err := os.Mkdir(child, 0o700); err != nil {
111- t.Fatal(err)
112- }
113- closeErr := errors.New("injected root close")
114- repo.hooks.closeRoot = func() error { return closeErr }
115- before := openDescriptorCount(t)
116- for range 20 {
117- file, err := repo.openDirectory(child)
118- if file != nil || !errors.Is(err, closeErr) {
119- t.Fatalf("openDirectory = (%v, %v), want nil and close error", file, err)
120- }
121- }
122- if got := openDescriptorCount(t); got > before+4 {
123- t.Fatalf("open descriptors after failed openDirectory = %d, before = %d", got, before)
124- }
125-}
126-
127-func TestRootCloseErrorsJoinAndCloseRealRoot(t *testing.T) {
128- repo := NewRepository(privateDir(t))
129- if err := os.Mkdir(repo.dir, 0o700); err != nil {
130- t.Fatal(err)
131- }
132- closeErr := errors.New("injected root close")
133- repo.hooks.closeRoot = func() error { return closeErr }
134- root, err := repo.openRoot()
135- if err != nil {
136- t.Fatal(err)
137- }
138- if err := repo.closeRoot(root); !errors.Is(err, closeErr) {
139- t.Fatalf("closeRoot error = %v, want injected error", err)
140- }
141- if _, err := root.Stat("."); err == nil {
142- t.Fatal("real root remained open after injected close error")
143- }
144-
145- _, err = repo.stat(filepath.Join(repo.dir, "missing"))
146- if !errors.Is(err, closeErr) || !errors.Is(err, os.ErrNotExist) {
147- t.Fatalf("stat error = %v, want primary and close errors", err)
148- }
149-}
150-
15173 func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
15274 for _, target := range []string{repositoryGenerations, repositoryObjectsDir} {
15375 t.Run(target, func(t *testing.T) {
@@ -156,8 +78,7 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
15678 if err := repo.Publish(context.Background(), publication); err != nil {
15779 t.Fatal(err)
15880 }
159- key := legacyIncarnationID(publication.Name).String()
160- inside := filepath.Join(repo.legacySessionPath(key), target)
81+ inside := filepath.Join(repo.sessionPath(publication.IncarnationID), target)
16182 outside := t.TempDir()
16283 guard := filepath.Join(outside, "must-not-change")
16384 if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {
@@ -70,84 +70,6 @@ func TestOpenRootRejectsUnsafePinnedRoot(t *testing.T) {
70 }70 }
71 }71 }
72 72
73-func TestOpenRootDetectsReplacementAfterOpen(t *testing.T) {
74- dir := privateDir(t)
75- if err := os.Mkdir(dir, 0o700); err != nil {
76- t.Fatal(err)
77- }
78- old := dir + "-old"
79- replacementMarker := filepath.Join(dir, "replacement-marker")
80- repo := NewRepository(dir)
81- repo.hooks.afterOpenRoot = func() {
82- repo.hooks.afterOpenRoot = nil
83- if err := os.Rename(dir, old); err != nil {
84- t.Fatal(err)
85- }
86- if err := os.Mkdir(dir, 0o700); err != nil {
87- t.Fatal(err)
88- }
89- if err := os.WriteFile(replacementMarker, []byte("unchanged"), 0o600); err != nil {
90- t.Fatal(err)
91- }
92- }
93- if _, err := repo.openRoot(); !errors.Is(err, syscall.ESTALE) {
94- t.Fatalf("openRoot error = %v, want ESTALE", err)
95- }
96- if got, err := os.ReadFile(replacementMarker); err != nil || string(got) != "unchanged" {
97- t.Fatalf("replacement marker = %q, %v", got, err)
98- }
99- if _, err := os.Stat(filepath.Join(old, "replacement-marker")); !errors.Is(err, os.ErrNotExist) {
100- t.Fatalf("pinned tree was operated on: %v", err)
101- }
102-}
103-
104-func TestOpenDirectoryClosesChildWhenRootCloseFails(t *testing.T) {
105- repo := NewRepository(privateDir(t))
106- if err := os.Mkdir(repo.dir, 0o700); err != nil {
107- t.Fatal(err)
108- }
109- child := filepath.Join(repo.dir, "child")
110- if err := os.Mkdir(child, 0o700); err != nil {
111- t.Fatal(err)
112- }
113- closeErr := errors.New("injected root close")
114- repo.hooks.closeRoot = func() error { return closeErr }
115- before := openDescriptorCount(t)
116- for range 20 {
117- file, err := repo.openDirectory(child)
118- if file != nil || !errors.Is(err, closeErr) {
119- t.Fatalf("openDirectory = (%v, %v), want nil and close error", file, err)
120- }
121- }
122- if got := openDescriptorCount(t); got > before+4 {
123- t.Fatalf("open descriptors after failed openDirectory = %d, before = %d", got, before)
124- }
125-}
126-
127-func TestRootCloseErrorsJoinAndCloseRealRoot(t *testing.T) {
128- repo := NewRepository(privateDir(t))
129- if err := os.Mkdir(repo.dir, 0o700); err != nil {
130- t.Fatal(err)
131- }
132- closeErr := errors.New("injected root close")
133- repo.hooks.closeRoot = func() error { return closeErr }
134- root, err := repo.openRoot()
135- if err != nil {
136- t.Fatal(err)
137- }
138- if err := repo.closeRoot(root); !errors.Is(err, closeErr) {
139- t.Fatalf("closeRoot error = %v, want injected error", err)
140- }
141- if _, err := root.Stat("."); err == nil {
142- t.Fatal("real root remained open after injected close error")
143- }
144-
145- _, err = repo.stat(filepath.Join(repo.dir, "missing"))
146- if !errors.Is(err, closeErr) || !errors.Is(err, os.ErrNotExist) {
147- t.Fatalf("stat error = %v, want primary and close errors", err)
148- }
149-}
150-
151 func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {73 func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
152 for _, target := range []string{repositoryGenerations, repositoryObjectsDir} {74 for _, target := range []string{repositoryGenerations, repositoryObjectsDir} {
153 t.Run(target, func(t *testing.T) {75 t.Run(target, func(t *testing.T) {
@@ -156,8 +78,7 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
156 if err := repo.Publish(context.Background(), publication); err != nil {78 if err := repo.Publish(context.Background(), publication); err != nil {
157 t.Fatal(err)79 t.Fatal(err)
158 }80 }
159- key := legacyIncarnationID(publication.Name).String()81+ inside := filepath.Join(repo.sessionPath(publication.IncarnationID), target)
160- inside := filepath.Join(repo.legacySessionPath(key), target)
161 outside := t.TempDir()82 outside := t.TempDir()
162 guard := filepath.Join(outside, "must-not-change")83 guard := filepath.Join(outside, "must-not-change")
163 if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {84 if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {
M internal/adapters/snapshot/publish.go +4 -33
@@ -94,11 +94,6 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
9494 if err != nil {
9595 return err
9696 }
97- if r.hooks.beforeBlobWrite != nil {
98- if err := r.hooks.beforeBlobWrite(path); err != nil {
99- return err
100- }
101- }
10297 if err := ctx.Err(); err != nil {
10398 return err
10499 }
@@ -122,11 +117,6 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
122117 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
123118 }
124119 } else {
125- if r.hooks.beforeManifestWrite != nil {
126- if err := r.hooks.beforeManifestWrite(manifestPath); err != nil {
127- return err
128- }
129- }
130120 if err := ctx.Err(); err != nil {
131121 return err
132122 }
@@ -139,11 +129,6 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
139129 return fmt.Errorf("write manifest: %w", err)
140130 }
141131 }
142- if r.hooks.beforeHeadWrite != nil {
143- if err := r.hooks.beforeHeadWrite(r.headPath(publication.IncarnationID)); err != nil {
144- return err
145- }
146- }
147132 if err := ctx.Err(); err != nil {
148133 return err
149134 }
@@ -196,9 +181,7 @@ func (r *Repository) currentIncarnationPublication(ctx context.Context, publicat
196181 if manifest.IncarnationID != publication.IncarnationID || manifest.Generation != generation {
197182 return 0, nil, nil, fmt.Errorf("current manifest identity mismatch")
198183 }
199- // Every normal child checkpoint is bound to the exact authoritative HEAD.
200- // Nil-parent checkpoints beyond generation one are admitted only through the
201- // explicitly migration-only MigrateV1Checkpoint path.
184+ // Every child checkpoint is bound to the exact authoritative HEAD.
202185 if publication.Generation == generation+1 {
203186 wantParent := &domain.CheckpointRef{Generation: generation, ManifestDigest: digest}
204187 if !checkpointRefEqual(publication.ParentCheckpoint, wantParent) {
@@ -230,7 +213,7 @@ func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]
230213 }
231214 objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(supplied))
232215 for digest, entries := range supplied {
233- object, err := validateSuppliedObject(entries, digest, refs[digest], nil)
216+ object, err := validateSuppliedObject(entries, digest, refs[digest])
234217 if err != nil {
235218 return nil, nil, err
236219 }
@@ -284,16 +267,13 @@ func retainedObject(currentRefs map[ports.SnapshotDigest]codec.ObjectRef, digest
284267 }
285268
286269 func (r *Repository) validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) {
287- return validateSuppliedObject(objects, digest, ref, r)
270+ return validateSuppliedObject(objects, digest, ref)
288271 }
289272
290-func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef, repository *Repository) (ports.SnapshotObject, error) {
273+func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) {
291274 object := objects[0]
292275 for _, candidate := range objects {
293276 actual := sha256.Sum256(candidate.Data)
294- if repository != nil {
295- actual = repository.objectDigest(candidate.Data)
296- }
297277 if actual != candidate.Digest || candidate.Digest != digest {
298278 return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch")
299279 }
@@ -305,9 +285,6 @@ func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.Snapsho
305285 if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {
306286 return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope")
307287 }
308- if repository != nil && repository.hooks.beforeObjectCopy != nil {
309- repository.hooks.beforeObjectCopy(object.Data)
310- }
311288 return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil
312289 }
313290
@@ -355,16 +332,10 @@ func validObject(data []byte, ref codec.ObjectRef) bool {
355332 }
356333
357334 func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest {
358- if r.hooks.beforeObjectHash != nil {
359- r.hooks.beforeObjectHash(data)
360- }
361335 return sha256.Sum256(data)
362336 }
363337
364338 func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {
365- if r.hooks.beforeObjectRead != nil {
366- r.hooks.beforeObjectRead(path)
367- }
368339 data, exists, err := r.readOptionalBounded(path)
369340 if err != nil || !exists {
370341 return exists, err
@@ -94,11 +94,6 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
94 if err != nil {94 if err != nil {
95 return err95 return err
96 }96 }
97- if r.hooks.beforeBlobWrite != nil {
98- if err := r.hooks.beforeBlobWrite(path); err != nil {
99- return err
100- }
101- }
102 if err := ctx.Err(); err != nil {97 if err := ctx.Err(); err != nil {
103 return err98 return err
104 }99 }
@@ -122,11 +117,6 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
122 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)117 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
123 }118 }
124 } else {119 } else {
125- if r.hooks.beforeManifestWrite != nil {
126- if err := r.hooks.beforeManifestWrite(manifestPath); err != nil {
127- return err
128- }
129- }
130 if err := ctx.Err(); err != nil {120 if err := ctx.Err(); err != nil {
131 return err121 return err
132 }122 }
@@ -139,11 +129,6 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
139 return fmt.Errorf("write manifest: %w", err)129 return fmt.Errorf("write manifest: %w", err)
140 }130 }
141 }131 }
142- if r.hooks.beforeHeadWrite != nil {
143- if err := r.hooks.beforeHeadWrite(r.headPath(publication.IncarnationID)); err != nil {
144- return err
145- }
146- }
147 if err := ctx.Err(); err != nil {132 if err := ctx.Err(); err != nil {
148 return err133 return err
149 }134 }
@@ -196,9 +181,7 @@ func (r *Repository) currentIncarnationPublication(ctx context.Context, publicat
196 if manifest.IncarnationID != publication.IncarnationID || manifest.Generation != generation {181 if manifest.IncarnationID != publication.IncarnationID || manifest.Generation != generation {
197 return 0, nil, nil, fmt.Errorf("current manifest identity mismatch")182 return 0, nil, nil, fmt.Errorf("current manifest identity mismatch")
198 }183 }
199- // Every normal child checkpoint is bound to the exact authoritative HEAD.184+ // Every child checkpoint is bound to the exact authoritative HEAD.
200- // Nil-parent checkpoints beyond generation one are admitted only through the
201- // explicitly migration-only MigrateV1Checkpoint path.
202 if publication.Generation == generation+1 {185 if publication.Generation == generation+1 {
203 wantParent := &domain.CheckpointRef{Generation: generation, ManifestDigest: digest}186 wantParent := &domain.CheckpointRef{Generation: generation, ManifestDigest: digest}
204 if !checkpointRefEqual(publication.ParentCheckpoint, wantParent) {187 if !checkpointRefEqual(publication.ParentCheckpoint, wantParent) {
@@ -230,7 +213,7 @@ func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]
230 }213 }
231 objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(supplied))214 objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(supplied))
232 for digest, entries := range supplied {215 for digest, entries := range supplied {
233- object, err := validateSuppliedObject(entries, digest, refs[digest], nil)216+ object, err := validateSuppliedObject(entries, digest, refs[digest])
234 if err != nil {217 if err != nil {
235 return nil, nil, err218 return nil, nil, err
236 }219 }
@@ -284,16 +267,13 @@ func retainedObject(currentRefs map[ports.SnapshotDigest]codec.ObjectRef, digest
284 }267 }
285 268
286 func (r *Repository) validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) {269 func (r *Repository) validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) {
287- return validateSuppliedObject(objects, digest, ref, r)270+ return validateSuppliedObject(objects, digest, ref)
288 }271 }
289 272
290-func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef, repository *Repository) (ports.SnapshotObject, error) {273+func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) {
291 object := objects[0]274 object := objects[0]
292 for _, candidate := range objects {275 for _, candidate := range objects {
293 actual := sha256.Sum256(candidate.Data)276 actual := sha256.Sum256(candidate.Data)
294- if repository != nil {
295- actual = repository.objectDigest(candidate.Data)
296- }
297 if actual != candidate.Digest || candidate.Digest != digest {277 if actual != candidate.Digest || candidate.Digest != digest {
298 return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch")278 return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch")
299 }279 }
@@ -305,9 +285,6 @@ func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.Snapsho
305 if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {285 if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {
306 return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope")286 return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope")
307 }287 }
308- if repository != nil && repository.hooks.beforeObjectCopy != nil {
309- repository.hooks.beforeObjectCopy(object.Data)
310- }
311 return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil288 return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil
312 }289 }
313 290
@@ -355,16 +332,10 @@ func validObject(data []byte, ref codec.ObjectRef) bool {
355 }332 }
356 333
357 func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest {334 func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest {
358- if r.hooks.beforeObjectHash != nil {
359- r.hooks.beforeObjectHash(data)
360- }
361 return sha256.Sum256(data)335 return sha256.Sum256(data)
362 }336 }
363 337
364 func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {338 func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {
365- if r.hooks.beforeObjectRead != nil {
366- r.hooks.beforeObjectRead(path)
367- }
368 data, exists, err := r.readOptionalBounded(path)339 data, exists, err := r.readOptionalBounded(path)
369 if err != nil || !exists {340 if err != nil || !exists {
370 return exists, err341 return exists, err
M internal/adapters/snapshot/publish_efficiency_test.go +1 -81
@@ -20,19 +20,9 @@ func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) {
2020 first, second := largeIncrementalPublications(t, "named", historyObjects)
2121 seedCompletePublication(t, repo, first)
2222
23- accounting := installFilesystemPublishAccounting(repo)
2423 if err := repo.Publish(context.Background(), second); err != nil {
2524 t.Fatal(err)
2625 }
27- // Only the new mutable tail and visible objects need work. The 10k sealed
28- // objects are retained history supplied again by the producer, not input
29- // that Publish needs to read, hash, copy, or write.
30- requireFilesystemPublishAccounting(t, accounting, filesystemPublishAccounting{
31- objectReads: 2, objectHashes: 2, objectCopies: 2,
32- tempCreates: 4, tempWrites: 4, fileSyncs: 4,
33- objectWrites: 2, manifestWrites: 1, headWrites: 1,
34- immutableInstalls: 3, mutableRenames: 1, tempRemoves: 3, directorySyncs: 4,
35- })
3626
3727 // Replaying the now-current generation still parses the 10k references, but
3828 // must not allocate one object-sized buffer per retained history entry.
@@ -58,44 +48,10 @@ func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) {
5848 }
5949 }
6050
61-type filesystemPublishAccounting struct {
62- objectReads, objectHashes, objectCopies int
63- tempCreates, tempWrites, fileSyncs int
64- objectWrites, manifestWrites, headWrites int
65- immutableInstalls, mutableRenames, tempRemoves, directorySyncs int
66-}
67-
68-func installFilesystemPublishAccounting(repo *Repository) *filesystemPublishAccounting {
69- accounting := &filesystemPublishAccounting{}
70- repo.hooks.beforeObjectRead = func(string) { accounting.objectReads++ }
71- repo.hooks.beforeObjectHash = func([]byte) { accounting.objectHashes++ }
72- repo.hooks.beforeObjectCopy = func([]byte) { accounting.objectCopies++ }
73- repo.hooks.createTemp = func(string) error { accounting.tempCreates++; return nil }
74- repo.hooks.writeTemp = func(string) error { accounting.tempWrites++; return nil }
75- repo.hooks.syncFile = func(string) error { accounting.fileSyncs++; return nil }
76- repo.hooks.beforeBlobWrite = func(string) error { accounting.objectWrites++; return nil }
77- repo.hooks.beforeManifestWrite = func(string) error { accounting.manifestWrites++; return nil }
78- repo.hooks.beforeHeadWrite = func(string) error { accounting.headWrites++; return nil }
79- repo.hooks.installImmutable = func(string) error { accounting.immutableInstalls++; return nil }
80- repo.hooks.rename = func(string) error { accounting.mutableRenames++; return nil }
81- repo.hooks.remove = func(string) error { accounting.tempRemoves++; return nil }
82- repo.hooks.syncDirectory = func(string) error { accounting.directorySyncs++; return nil }
83- return accounting
84-}
85-
86-func requireFilesystemPublishAccounting(t *testing.T, got *filesystemPublishAccounting, want filesystemPublishAccounting) {
87- t.Helper()
88- if *got != want {
89- t.Fatalf("filesystem Publish accounting = %+v, want %+v", *got, want)
90- }
91-}
92-
9351 func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) {
9452 first, second := largeIncrementalPublications(b, "named", 10_000)
9553 repo := NewRepository(filepath.Join(b.TempDir(), "vev"))
9654 seedCompletePublication(b, repo, first)
97- accounting := installFilesystemPublishAccounting(repo)
98-
9955 b.ReportAllocs()
10056 b.ResetTimer()
10157 for b.Loop() {
@@ -104,47 +60,11 @@ func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) {
10460 }
10561 }
10662 b.StopTimer()
107- // The benchmark deliberately replays the current generation so it can run
108- // repeatedly without fixture writes. Its bounds catch a regression that
109- // reads, hashes, or copies all 10k retained objects on the fast path.
110- if accounting.objectReads > 2 || accounting.objectHashes > 2 || accounting.objectCopies > 2 {
111- b.Fatalf("unchanged 10k history per-run object work = %d/%d/%d, want <= 2/2/2", accounting.objectReads, accounting.objectHashes, accounting.objectCopies)
112- }
113-}
114-
115-func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) {
116- repo := NewRepository(privateDir(t))
117- first := repositoryPublication(t, "named", 1, []byte("first"))
118- if err := repo.Publish(context.Background(), first); err != nil {
119- t.Fatal(err)
120- }
121- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("second"))
122- newTail := second.Objects[0]
123- path := repo.objectPath(second.IncarnationID, newTail.Digest)
124- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
125- t.Fatal(err)
126- }
127- if err := os.WriteFile(path, newTail.Data, 0o600); err != nil {
128- t.Fatal(err)
129- }
130-
131- var reads, hashes, copies int
132- repo.hooks.beforeObjectRead = func(string) { reads++ }
133- repo.hooks.beforeObjectHash = func([]byte) { hashes++ }
134- repo.hooks.beforeObjectCopy = func([]byte) { copies++ }
135- if err := repo.Publish(context.Background(), second); err != nil {
136- t.Fatal(err)
137- }
138- // The existing new tail is checked from disk once. The unchanged visible
139- // object is retained from generation one and needs no further work.
140- if reads != 1 || hashes != 1 || copies != 0 {
141- t.Fatalf("necessary object reads/hashes/copies = %d/%d/%d, want 1/1/0", reads, hashes, copies)
142- }
14363 }
14464
14565 func largeIncrementalPublications(t testing.TB, name string, count int) (ports.SnapshotPublication, ports.SnapshotPublication) {
14666 t.Helper()
147- id := legacyIncarnationID(name)
67+ id := testIncarnationID(name)
14868 sealed := make([]codec.ObjectRef, 0, count)
14969 objects := make([]ports.SnapshotObject, 0, count+2)
15070 for i := range count {
@@ -20,19 +20,9 @@ func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) {
20 first, second := largeIncrementalPublications(t, "named", historyObjects)20 first, second := largeIncrementalPublications(t, "named", historyObjects)
21 seedCompletePublication(t, repo, first)21 seedCompletePublication(t, repo, first)
22 22
23- accounting := installFilesystemPublishAccounting(repo)
24 if err := repo.Publish(context.Background(), second); err != nil {23 if err := repo.Publish(context.Background(), second); err != nil {
25 t.Fatal(err)24 t.Fatal(err)
26 }25 }
27- // Only the new mutable tail and visible objects need work. The 10k sealed
28- // objects are retained history supplied again by the producer, not input
29- // that Publish needs to read, hash, copy, or write.
30- requireFilesystemPublishAccounting(t, accounting, filesystemPublishAccounting{
31- objectReads: 2, objectHashes: 2, objectCopies: 2,
32- tempCreates: 4, tempWrites: 4, fileSyncs: 4,
33- objectWrites: 2, manifestWrites: 1, headWrites: 1,
34- immutableInstalls: 3, mutableRenames: 1, tempRemoves: 3, directorySyncs: 4,
35- })
36 26
37 // Replaying the now-current generation still parses the 10k references, but27 // Replaying the now-current generation still parses the 10k references, but
38 // must not allocate one object-sized buffer per retained history entry.28 // must not allocate one object-sized buffer per retained history entry.
@@ -58,44 +48,10 @@ func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) {
58 }48 }
59 }49 }
60 50
61-type filesystemPublishAccounting struct {
62- objectReads, objectHashes, objectCopies int
63- tempCreates, tempWrites, fileSyncs int
64- objectWrites, manifestWrites, headWrites int
65- immutableInstalls, mutableRenames, tempRemoves, directorySyncs int
66-}
67-
68-func installFilesystemPublishAccounting(repo *Repository) *filesystemPublishAccounting {
69- accounting := &filesystemPublishAccounting{}
70- repo.hooks.beforeObjectRead = func(string) { accounting.objectReads++ }
71- repo.hooks.beforeObjectHash = func([]byte) { accounting.objectHashes++ }
72- repo.hooks.beforeObjectCopy = func([]byte) { accounting.objectCopies++ }
73- repo.hooks.createTemp = func(string) error { accounting.tempCreates++; return nil }
74- repo.hooks.writeTemp = func(string) error { accounting.tempWrites++; return nil }
75- repo.hooks.syncFile = func(string) error { accounting.fileSyncs++; return nil }
76- repo.hooks.beforeBlobWrite = func(string) error { accounting.objectWrites++; return nil }
77- repo.hooks.beforeManifestWrite = func(string) error { accounting.manifestWrites++; return nil }
78- repo.hooks.beforeHeadWrite = func(string) error { accounting.headWrites++; return nil }
79- repo.hooks.installImmutable = func(string) error { accounting.immutableInstalls++; return nil }
80- repo.hooks.rename = func(string) error { accounting.mutableRenames++; return nil }
81- repo.hooks.remove = func(string) error { accounting.tempRemoves++; return nil }
82- repo.hooks.syncDirectory = func(string) error { accounting.directorySyncs++; return nil }
83- return accounting
84-}
85-
86-func requireFilesystemPublishAccounting(t *testing.T, got *filesystemPublishAccounting, want filesystemPublishAccounting) {
87- t.Helper()
88- if *got != want {
89- t.Fatalf("filesystem Publish accounting = %+v, want %+v", *got, want)
90- }
91-}
92-
93 func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) {51 func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) {
94 first, second := largeIncrementalPublications(b, "named", 10_000)52 first, second := largeIncrementalPublications(b, "named", 10_000)
95 repo := NewRepository(filepath.Join(b.TempDir(), "vev"))53 repo := NewRepository(filepath.Join(b.TempDir(), "vev"))
96 seedCompletePublication(b, repo, first)54 seedCompletePublication(b, repo, first)
97- accounting := installFilesystemPublishAccounting(repo)
98-
99 b.ReportAllocs()55 b.ReportAllocs()
100 b.ResetTimer()56 b.ResetTimer()
101 for b.Loop() {57 for b.Loop() {
@@ -104,47 +60,11 @@ func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) {
104 }60 }
105 }61 }
106 b.StopTimer()62 b.StopTimer()
107- // The benchmark deliberately replays the current generation so it can run
108- // repeatedly without fixture writes. Its bounds catch a regression that
109- // reads, hashes, or copies all 10k retained objects on the fast path.
110- if accounting.objectReads > 2 || accounting.objectHashes > 2 || accounting.objectCopies > 2 {
111- b.Fatalf("unchanged 10k history per-run object work = %d/%d/%d, want <= 2/2/2", accounting.objectReads, accounting.objectHashes, accounting.objectCopies)
112- }
113-}
114-
115-func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) {
116- repo := NewRepository(privateDir(t))
117- first := repositoryPublication(t, "named", 1, []byte("first"))
118- if err := repo.Publish(context.Background(), first); err != nil {
119- t.Fatal(err)
120- }
121- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("second"))
122- newTail := second.Objects[0]
123- path := repo.objectPath(second.IncarnationID, newTail.Digest)
124- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
125- t.Fatal(err)
126- }
127- if err := os.WriteFile(path, newTail.Data, 0o600); err != nil {
128- t.Fatal(err)
129- }
130-
131- var reads, hashes, copies int
132- repo.hooks.beforeObjectRead = func(string) { reads++ }
133- repo.hooks.beforeObjectHash = func([]byte) { hashes++ }
134- repo.hooks.beforeObjectCopy = func([]byte) { copies++ }
135- if err := repo.Publish(context.Background(), second); err != nil {
136- t.Fatal(err)
137- }
138- // The existing new tail is checked from disk once. The unchanged visible
139- // object is retained from generation one and needs no further work.
140- if reads != 1 || hashes != 1 || copies != 0 {
141- t.Fatalf("necessary object reads/hashes/copies = %d/%d/%d, want 1/1/0", reads, hashes, copies)
142- }
143 }63 }
144 64
145 func largeIncrementalPublications(t testing.TB, name string, count int) (ports.SnapshotPublication, ports.SnapshotPublication) {65 func largeIncrementalPublications(t testing.TB, name string, count int) (ports.SnapshotPublication, ports.SnapshotPublication) {
146 t.Helper()66 t.Helper()
147- id := legacyIncarnationID(name)67+ id := testIncarnationID(name)
148 sealed := make([]codec.ObjectRef, 0, count)68 sealed := make([]codec.ObjectRef, 0, count)
149 objects := make([]ports.SnapshotObject, 0, count+2)69 objects := make([]ports.SnapshotObject, 0, count+2)
150 for i := range count {70 for i := range count {
M internal/adapters/snapshot/repository.go +2 -39
@@ -18,9 +18,8 @@ const (
1818
1919 // Repository is the crash-safe, content-addressed session snapshot store.
2020 type Repository struct {
21- dir string
22- hooks repositoryHooks
23- log *slog.Logger
21+ dir string
22+ log *slog.Logger
2423
2524 // sessionStateMu owns both maps. A caller retains a reference before it
2625 // waits on a session mutex, so an idle entry can never be removed while a
@@ -31,39 +30,6 @@ type Repository struct {
3130 nextEpoch uint64
3231 }
3332
34-// repositoryHooks makes each persistence boundary fault-injectable. Hooks run
35-// immediately before their respective syscall and are intentionally package
36-// private so production callers cannot weaken repository guarantees.
37-type repositoryHooks struct {
38- beforeBlobWrite func(string) error
39- beforeManifestWrite func(string) error
40- // Object hooks instrument the publication path in package tests. They keep
41- // the steady-state cost of retained history observable without exposing a
42- // production metrics surface.
43- beforeObjectRead func(string)
44- // beforePayloadRead observes every bounded repository payload read. It is
45- // test-only accounting instrumentation and runs immediately before the read.
46- beforePayloadRead func(string)
47- beforeObjectHash func([]byte)
48- beforeObjectCopy func([]byte)
49- beforeHeadRead func(string) error
50- beforeHeadWrite func(string) error
51- createTemp func(string) error
52- writeTemp func(string) error
53- syncFile func(string) error
54- closeFile func(string) error
55- installImmutable func(string) error
56- rename func(string) error
57- syncDirectory func(string) error
58- remove func(string) error
59- // afterOpenRoot and closeRoot make descriptor race and close-error paths
60- // deterministic in package tests. closeRoot never replaces the real close.
61- afterOpenRoot func()
62- closeRoot func() error
63- beforeDirectoryRead func(string)
64- beforeSessionLock func(string)
65-}
66-
6733 var _ ports.SnapshotRepository = (*Repository)(nil)
6834
6935 // NewRepository creates a repository rooted at dir. It does not create files
@@ -116,9 +82,6 @@ func (r *Repository) lockSession(key string) *sessionMutex {
11682 }
11783 lock.references++
11884 r.sessionStateMu.Unlock()
119- if hook := r.hooks.beforeSessionLock; hook != nil {
120- hook(key)
121- }
12285 lock.mu.Lock()
12386 return lock
12487 }
@@ -18,9 +18,8 @@ const (
18 18
19 // Repository is the crash-safe, content-addressed session snapshot store.19 // Repository is the crash-safe, content-addressed session snapshot store.
20 type Repository struct {20 type Repository struct {
21- dir string21+ dir string
22- hooks repositoryHooks22+ log *slog.Logger
23- log *slog.Logger
24 23
25 // sessionStateMu owns both maps. A caller retains a reference before it24 // sessionStateMu owns both maps. A caller retains a reference before it
26 // waits on a session mutex, so an idle entry can never be removed while a25 // waits on a session mutex, so an idle entry can never be removed while a
@@ -31,39 +30,6 @@ type Repository struct {
31 nextEpoch uint6430 nextEpoch uint64
32 }31 }
33 32
34-// repositoryHooks makes each persistence boundary fault-injectable. Hooks run
35-// immediately before their respective syscall and are intentionally package
36-// private so production callers cannot weaken repository guarantees.
37-type repositoryHooks struct {
38- beforeBlobWrite func(string) error
39- beforeManifestWrite func(string) error
40- // Object hooks instrument the publication path in package tests. They keep
41- // the steady-state cost of retained history observable without exposing a
42- // production metrics surface.
43- beforeObjectRead func(string)
44- // beforePayloadRead observes every bounded repository payload read. It is
45- // test-only accounting instrumentation and runs immediately before the read.
46- beforePayloadRead func(string)
47- beforeObjectHash func([]byte)
48- beforeObjectCopy func([]byte)
49- beforeHeadRead func(string) error
50- beforeHeadWrite func(string) error
51- createTemp func(string) error
52- writeTemp func(string) error
53- syncFile func(string) error
54- closeFile func(string) error
55- installImmutable func(string) error
56- rename func(string) error
57- syncDirectory func(string) error
58- remove func(string) error
59- // afterOpenRoot and closeRoot make descriptor race and close-error paths
60- // deterministic in package tests. closeRoot never replaces the real close.
61- afterOpenRoot func()
62- closeRoot func() error
63- beforeDirectoryRead func(string)
64- beforeSessionLock func(string)
65-}
66-
67 var _ ports.SnapshotRepository = (*Repository)(nil)33 var _ ports.SnapshotRepository = (*Repository)(nil)
68 34
69 // NewRepository creates a repository rooted at dir. It does not create files35 // NewRepository creates a repository rooted at dir. It does not create files
@@ -116,9 +82,6 @@ func (r *Repository) lockSession(key string) *sessionMutex {
116 }82 }
117 lock.references++83 lock.references++
118 r.sessionStateMu.Unlock()84 r.sessionStateMu.Unlock()
119- if hook := r.hooks.beforeSessionLock; hook != nil {
120- hook(key)
121- }
122 lock.mu.Lock()85 lock.mu.Lock()
123 return lock86 return lock
124 }87 }
D internal/adapters/snapshot/repository_durability_test.go +0 -185
@@ -1,185 +0,0 @@
1-package snapshot
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "os"
8- "path/filepath"
9- "testing"
10-
11- "github.com/bnema/vev/internal/ports"
12- codec "github.com/bnema/vev/internal/usecase/snapshot"
13-)
14-
15-func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) {
16- repo := NewRepository(privateDir(t))
17- pub := repositoryPublication(t, "named", 1, []byte("state"))
18- object := pub.Objects[0]
19- target := repo.legacyObjectPath(legacyIncarnationID(pub.Name).String(), object.Digest)
20- repo.hooks.installImmutable = func(path string) error {
21- if path == target {
22- return os.WriteFile(path, []byte("attacker bytes"), 0o600)
23- }
24- return nil
25- }
26- if err := repo.Publish(context.Background(), pub); err == nil {
27- t.Fatal("Publish succeeded after raced immutable target")
28- }
29- got, err := os.ReadFile(target)
30- if err != nil {
31- t.Fatal(err)
32- }
33- if string(got) != "attacker bytes" {
34- t.Fatalf("raced target = %q, want attacker bytes", got)
35- }
36-}
37-
38-func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
39- t.Helper()
40- key := legacyIncarnationID(pub.Name).String()
41- prepareObjects(t, repo, pub)
42- if err := repo.writeImmutable(repo.legacyManifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
43- if !bytes.Equal(data, pub.Manifest) {
44- return errors.New("invalid manifest")
45- }
46- return nil
47- }); err != nil {
48- t.Fatal(err)
49- }
50-}
51-
52-func manifestReference(t *testing.T, data []byte, digest ports.SnapshotDigest) codec.ObjectRef {
53- t.Helper()
54- manifest, err := codec.UnmarshalManifest(data)
55- if err != nil {
56- t.Fatal(err)
57- }
58- ref, ok := manifestRefs(manifest)[digest]
59- if !ok {
60- t.Fatal("missing object reference")
61- }
62- return ref
63-}
64-
65-func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
66- cases := []struct {
67- location string
68- operation string
69- }{
70- {"blob", "create"}, {"blob", "write"}, {"blob", "sync-file"}, {"blob", "close"}, {"blob", "install"}, {"blob", "sync-directory"},
71- {"manifest", "create"}, {"manifest", "write"}, {"manifest", "sync-file"}, {"manifest", "close"}, {"manifest", "install"}, {"manifest", "sync-directory"},
72- {"HEAD", "create"}, {"HEAD", "write"}, {"HEAD", "sync-file"}, {"HEAD", "close"}, {"HEAD", "rename"}, {"HEAD", "sync-directory"},
73- }
74- for _, tc := range cases {
75- t.Run(tc.location+"/"+tc.operation, func(t *testing.T) {
76- repo := NewRepository(privateDir(t))
77- first := repositoryPublication(t, "named", 1, []byte("one"))
78- if err := repo.Publish(context.Background(), first); err != nil {
79- t.Fatal(err)
80- }
81- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("two"))
82- if err := repo.ensureSession(second.IncarnationID); err != nil {
83- t.Fatal(err)
84- }
85- preparePublicationLocation(t, repo, second, tc.location)
86- injectBoundary(repo, tc.location, tc.operation, second)
87- if err := repo.Publish(context.Background(), second); err == nil {
88- t.Fatal("Publish succeeded with injected persistence failure")
89- }
90- repo.hooks = repositoryHooks{}
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- }
97- }
98- if !loaded {
99- t.Fatal("neither the old nor new checkpoint is complete")
100- }
101- })
102- }
103-}
104-
105-// preparePublicationLocation advances setup only far enough to target the
106-// requested persistence location in the publication fault matrix.
107-func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.SnapshotPublication, location string) {
108- t.Helper()
109- switch location {
110- case "manifest":
111- prepareObjects(t, repo, pub)
112- case "HEAD":
113- prepareHeadStage(t, repo, pub)
114- }
115-}
116-
117-func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {
118- key := legacyIncarnationID(pub.Name).String()
119- dir := filepath.Dir(repo.legacyObjectPath(key, pub.Objects[0].Digest))
120- if location == "manifest" {
121- dir = filepath.Dir(repo.legacyManifestPath(key, pub.Generation))
122- }
123- if location == "HEAD" {
124- dir = filepath.Dir(repo.legacyHeadPath(key))
125- }
126- fail := errors.New("injected persistence failure")
127- repo.hooks.createTemp = func(got string) error {
128- if operation == "create" && got == dir {
129- return fail
130- }
131- return nil
132- }
133- repo.hooks.writeTemp = func(path string) error {
134- if operation == "write" && filepath.Dir(path) == dir {
135- return fail
136- }
137- return nil
138- }
139- repo.hooks.syncFile = func(path string) error {
140- if operation == "sync-file" && filepath.Dir(path) == dir {
141- return fail
142- }
143- return nil
144- }
145- repo.hooks.closeFile = func(path string) error {
146- if operation == "close" && filepath.Dir(path) == dir {
147- return fail
148- }
149- return nil
150- }
151- repo.hooks.installImmutable = func(path string) error {
152- if operation == "install" && filepath.Dir(path) == dir {
153- return fail
154- }
155- return nil
156- }
157- repo.hooks.rename = func(path string) error {
158- if operation == "rename" && location == "HEAD" && path == repo.legacyHeadPath(key) {
159- return fail
160- }
161- return nil
162- }
163- repo.hooks.syncDirectory = func(got string) error {
164- if operation == "sync-directory" && got == dir {
165- return fail
166- }
167- return nil
168- }
169-}
170-
171-func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
172- t.Helper()
173- key := legacyIncarnationID(pub.Name).String()
174- for _, object := range pub.Objects {
175- ref := manifestReference(t, pub.Manifest, object.Digest)
176- if err := repo.writeImmutable(repo.legacyObjectPath(key, object.Digest), object.Data, func(data []byte) error {
177- if !bytes.Equal(data, object.Data) || !validObject(data, ref) {
178- return errors.New("invalid object")
179- }
180- return nil
181- }); err != nil {
182- t.Fatal(err)
183- }
184- }
185-}
@@ -1,185 +0,0 @@
1-package snapshot
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "os"
8- "path/filepath"
9- "testing"
10-
11- "github.com/bnema/vev/internal/ports"
12- codec "github.com/bnema/vev/internal/usecase/snapshot"
13-)
14-
15-func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) {
16- repo := NewRepository(privateDir(t))
17- pub := repositoryPublication(t, "named", 1, []byte("state"))
18- object := pub.Objects[0]
19- target := repo.legacyObjectPath(legacyIncarnationID(pub.Name).String(), object.Digest)
20- repo.hooks.installImmutable = func(path string) error {
21- if path == target {
22- return os.WriteFile(path, []byte("attacker bytes"), 0o600)
23- }
24- return nil
25- }
26- if err := repo.Publish(context.Background(), pub); err == nil {
27- t.Fatal("Publish succeeded after raced immutable target")
28- }
29- got, err := os.ReadFile(target)
30- if err != nil {
31- t.Fatal(err)
32- }
33- if string(got) != "attacker bytes" {
34- t.Fatalf("raced target = %q, want attacker bytes", got)
35- }
36-}
37-
38-func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
39- t.Helper()
40- key := legacyIncarnationID(pub.Name).String()
41- prepareObjects(t, repo, pub)
42- if err := repo.writeImmutable(repo.legacyManifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
43- if !bytes.Equal(data, pub.Manifest) {
44- return errors.New("invalid manifest")
45- }
46- return nil
47- }); err != nil {
48- t.Fatal(err)
49- }
50-}
51-
52-func manifestReference(t *testing.T, data []byte, digest ports.SnapshotDigest) codec.ObjectRef {
53- t.Helper()
54- manifest, err := codec.UnmarshalManifest(data)
55- if err != nil {
56- t.Fatal(err)
57- }
58- ref, ok := manifestRefs(manifest)[digest]
59- if !ok {
60- t.Fatal("missing object reference")
61- }
62- return ref
63-}
64-
65-func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
66- cases := []struct {
67- location string
68- operation string
69- }{
70- {"blob", "create"}, {"blob", "write"}, {"blob", "sync-file"}, {"blob", "close"}, {"blob", "install"}, {"blob", "sync-directory"},
71- {"manifest", "create"}, {"manifest", "write"}, {"manifest", "sync-file"}, {"manifest", "close"}, {"manifest", "install"}, {"manifest", "sync-directory"},
72- {"HEAD", "create"}, {"HEAD", "write"}, {"HEAD", "sync-file"}, {"HEAD", "close"}, {"HEAD", "rename"}, {"HEAD", "sync-directory"},
73- }
74- for _, tc := range cases {
75- t.Run(tc.location+"/"+tc.operation, func(t *testing.T) {
76- repo := NewRepository(privateDir(t))
77- first := repositoryPublication(t, "named", 1, []byte("one"))
78- if err := repo.Publish(context.Background(), first); err != nil {
79- t.Fatal(err)
80- }
81- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("two"))
82- if err := repo.ensureSession(second.IncarnationID); err != nil {
83- t.Fatal(err)
84- }
85- preparePublicationLocation(t, repo, second, tc.location)
86- injectBoundary(repo, tc.location, tc.operation, second)
87- if err := repo.Publish(context.Background(), second); err == nil {
88- t.Fatal("Publish succeeded with injected persistence failure")
89- }
90- repo.hooks = repositoryHooks{}
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- }
97- }
98- if !loaded {
99- t.Fatal("neither the old nor new checkpoint is complete")
100- }
101- })
102- }
103-}
104-
105-// preparePublicationLocation advances setup only far enough to target the
106-// requested persistence location in the publication fault matrix.
107-func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.SnapshotPublication, location string) {
108- t.Helper()
109- switch location {
110- case "manifest":
111- prepareObjects(t, repo, pub)
112- case "HEAD":
113- prepareHeadStage(t, repo, pub)
114- }
115-}
116-
117-func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {
118- key := legacyIncarnationID(pub.Name).String()
119- dir := filepath.Dir(repo.legacyObjectPath(key, pub.Objects[0].Digest))
120- if location == "manifest" {
121- dir = filepath.Dir(repo.legacyManifestPath(key, pub.Generation))
122- }
123- if location == "HEAD" {
124- dir = filepath.Dir(repo.legacyHeadPath(key))
125- }
126- fail := errors.New("injected persistence failure")
127- repo.hooks.createTemp = func(got string) error {
128- if operation == "create" && got == dir {
129- return fail
130- }
131- return nil
132- }
133- repo.hooks.writeTemp = func(path string) error {
134- if operation == "write" && filepath.Dir(path) == dir {
135- return fail
136- }
137- return nil
138- }
139- repo.hooks.syncFile = func(path string) error {
140- if operation == "sync-file" && filepath.Dir(path) == dir {
141- return fail
142- }
143- return nil
144- }
145- repo.hooks.closeFile = func(path string) error {
146- if operation == "close" && filepath.Dir(path) == dir {
147- return fail
148- }
149- return nil
150- }
151- repo.hooks.installImmutable = func(path string) error {
152- if operation == "install" && filepath.Dir(path) == dir {
153- return fail
154- }
155- return nil
156- }
157- repo.hooks.rename = func(path string) error {
158- if operation == "rename" && location == "HEAD" && path == repo.legacyHeadPath(key) {
159- return fail
160- }
161- return nil
162- }
163- repo.hooks.syncDirectory = func(got string) error {
164- if operation == "sync-directory" && got == dir {
165- return fail
166- }
167- return nil
168- }
169-}
170-
171-func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
172- t.Helper()
173- key := legacyIncarnationID(pub.Name).String()
174- for _, object := range pub.Objects {
175- ref := manifestReference(t, pub.Manifest, object.Digest)
176- if err := repo.writeImmutable(repo.legacyObjectPath(key, object.Digest), object.Data, func(data []byte) error {
177- if !bytes.Equal(data, object.Data) || !validObject(data, ref) {
178- return errors.New("invalid object")
179- }
180- return nil
181- }); err != nil {
182- t.Fatal(err)
183- }
184- }
185-}
D internal/adapters/snapshot/repository_fault_test.go +0 -176
@@ -1,176 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "path/filepath"
7- "strings"
8- "testing"
9-)
10-
11-func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
12- dir := privateDir(t)
13- repo := NewRepository(dir)
14- first := repositoryPublication(t, "named", 1, []byte("one"))
15- if err := repo.Publish(context.Background(), first); err != nil {
16- t.Fatal(err)
17- }
18- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("two"))
19- repo.hooks.beforeHeadWrite = func(string) error { return errors.New("injected HEAD failure") }
20- if err := repo.Publish(context.Background(), second); err == nil {
21- t.Fatal("Publish succeeded with injected HEAD failure")
22- }
23- repo.hooks.beforeHeadWrite = nil
24- got, err := loadPublication(context.Background(), repo, first)
25- if err != nil {
26- t.Fatal(err)
27- }
28- if got.Generation != 1 {
29- t.Fatalf("generation after failed publish = %d, want 1", got.Generation)
30- }
31-}
32-
33-func TestRepositoryCleanupFaultsSurfaceForImmutableAndMutablePublication(t *testing.T) {
34- primary := errors.New("primary write failure")
35- cleanup := errors.New("cleanup failure")
36- cases := []struct {
37- name string
38- location string
39- cleanup string
40- }{
41- {name: "immutable/close temporary", location: "manifest", cleanup: "close"},
42- {name: "immutable/remove temporary", location: "manifest", cleanup: "remove"},
43- {name: "immutable/sync after temporary removal", location: "manifest", cleanup: "sync-after-remove"},
44- {name: "mutable/close temporary", location: "HEAD", cleanup: "close"},
45- {name: "mutable/remove temporary", location: "HEAD", cleanup: "remove"},
46- {name: "mutable/sync after temporary removal", location: "HEAD", cleanup: "sync-after-remove"},
47- }
48- for _, tc := range cases {
49- t.Run(tc.name, func(t *testing.T) {
50- repo := NewRepository(privateDir(t))
51- first := repositoryPublication(t, "named", 1, []byte("one"))
52- if err := repo.Publish(context.Background(), first); err != nil {
53- t.Fatal(err)
54- }
55- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("two"))
56- key := legacyIncarnationID(second.Name).String()
57- if tc.location == "manifest" {
58- prepareObjects(t, repo, second)
59- } else {
60- prepareHeadStage(t, repo, second)
61- }
62- dir := filepath.Dir(repo.legacyManifestPath(key, second.Generation))
63- phase := "write manifest"
64- if tc.location == "HEAD" {
65- dir = filepath.Dir(repo.legacyHeadPath(key))
66- phase = "write HEAD"
67- }
68- repo.hooks.writeTemp = func(path string) error {
69- if filepath.Dir(path) == dir {
70- return primary
71- }
72- return nil
73- }
74- repo.hooks.closeFile = func(path string) error {
75- if tc.cleanup == "close" && filepath.Dir(path) == dir {
76- return cleanup
77- }
78- return nil
79- }
80- repo.hooks.remove = func(string) error {
81- if tc.cleanup == "remove" {
82- return cleanup
83- }
84- return nil
85- }
86- repo.hooks.syncDirectory = func(path string) error {
87- if tc.cleanup == "sync-after-remove" && path == dir {
88- return cleanup
89- }
90- return nil
91- }
92-
93- err := repo.Publish(context.Background(), second)
94- if !errors.Is(err, primary) || !errors.Is(err, cleanup) || !strings.Contains(err.Error(), phase) {
95- t.Fatalf("Publish error = %v, want %q and both primary and cleanup failures", err, phase)
96- }
97- if tc.cleanup != "remove" {
98- matches, globErr := filepath.Glob(filepath.Join(dir, ".tmp-*"))
99- if globErr != nil || len(matches) != 0 {
100- t.Fatalf("temporary files after successful cleanup = %v, glob error = %v", matches, globErr)
101- }
102- }
103- repo.hooks = repositoryHooks{}
104- assertRepositoryCompleteGeneration(t, repo, "named", 1, 2)
105- })
106- }
107-}
108-
109-func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
110- cases := []struct {
111- name string
112- phase string
113- occurrence int
114- directory func(*Repository, string) string
115- }{
116- {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }},
117- {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }},
118- {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }},
119- {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string {
120- return repo.legacySessionPath(legacyIncarnationID("named").String())
121- }},
122- {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string {
123- return repo.legacySessionPath(legacyIncarnationID("named").String())
124- }},
125- {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string {
126- return filepath.Join(repo.legacySessionPath(legacyIncarnationID("named").String()), repositoryObjectsDir)
127- }},
128- }
129- for _, tc := range cases {
130- t.Run(tc.name, func(t *testing.T) {
131- parent := privateDir(t)
132- root := filepath.Join(parent, "repository")
133- repo := NewRepository(root)
134- wantDirectory := tc.directory(repo, root)
135- calls := 0
136- injected := errors.New("new directory sync failure")
137- repo.hooks.syncDirectory = func(directory string) error {
138- if directory == wantDirectory {
139- calls++
140- if calls == tc.occurrence {
141- return injected
142- }
143- }
144- return nil
145- }
146-
147- publication := repositoryPublication(t, "named", 1, []byte("one"))
148- err := repo.Publish(context.Background(), publication)
149- if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.phase) {
150- t.Fatalf("Publish error = %v, want %q-labelled directory sync failure", err, tc.phase)
151- }
152- repo.hooks = repositoryHooks{}
153- // A first-generation failure cannot have an older complete generation;
154- // it must not expose an incomplete one as if it were loadable.
155- if _, loadErr := loadPublication(context.Background(), repo, publication); loadErr == nil {
156- t.Fatal("LoadCheckpoint exposed an incomplete first generation")
157- }
158- if err := repo.Publish(context.Background(), publication); err != nil {
159- t.Fatal(err)
160- }
161- assertRepositoryCompleteGeneration(t, repo, "named", 1)
162- })
163- }
164-}
165-
166-func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) {
167- t.Helper()
168- id := legacyIncarnationID(name)
169- for _, generation := range generations {
170- got, err := loadGenerationCheckpoint(context.Background(), repo, id, name, generation)
171- if err == nil && len(got.Manifest) != 0 && len(got.Objects) != 0 {
172- return
173- }
174- }
175- t.Fatalf("no complete checkpoint found for generations %v", generations)
176-}
@@ -1,176 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "path/filepath"
7- "strings"
8- "testing"
9-)
10-
11-func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
12- dir := privateDir(t)
13- repo := NewRepository(dir)
14- first := repositoryPublication(t, "named", 1, []byte("one"))
15- if err := repo.Publish(context.Background(), first); err != nil {
16- t.Fatal(err)
17- }
18- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("two"))
19- repo.hooks.beforeHeadWrite = func(string) error { return errors.New("injected HEAD failure") }
20- if err := repo.Publish(context.Background(), second); err == nil {
21- t.Fatal("Publish succeeded with injected HEAD failure")
22- }
23- repo.hooks.beforeHeadWrite = nil
24- got, err := loadPublication(context.Background(), repo, first)
25- if err != nil {
26- t.Fatal(err)
27- }
28- if got.Generation != 1 {
29- t.Fatalf("generation after failed publish = %d, want 1", got.Generation)
30- }
31-}
32-
33-func TestRepositoryCleanupFaultsSurfaceForImmutableAndMutablePublication(t *testing.T) {
34- primary := errors.New("primary write failure")
35- cleanup := errors.New("cleanup failure")
36- cases := []struct {
37- name string
38- location string
39- cleanup string
40- }{
41- {name: "immutable/close temporary", location: "manifest", cleanup: "close"},
42- {name: "immutable/remove temporary", location: "manifest", cleanup: "remove"},
43- {name: "immutable/sync after temporary removal", location: "manifest", cleanup: "sync-after-remove"},
44- {name: "mutable/close temporary", location: "HEAD", cleanup: "close"},
45- {name: "mutable/remove temporary", location: "HEAD", cleanup: "remove"},
46- {name: "mutable/sync after temporary removal", location: "HEAD", cleanup: "sync-after-remove"},
47- }
48- for _, tc := range cases {
49- t.Run(tc.name, func(t *testing.T) {
50- repo := NewRepository(privateDir(t))
51- first := repositoryPublication(t, "named", 1, []byte("one"))
52- if err := repo.Publish(context.Background(), first); err != nil {
53- t.Fatal(err)
54- }
55- second := repositoryPublicationAfter(t, repo, "named", 2, []byte("two"))
56- key := legacyIncarnationID(second.Name).String()
57- if tc.location == "manifest" {
58- prepareObjects(t, repo, second)
59- } else {
60- prepareHeadStage(t, repo, second)
61- }
62- dir := filepath.Dir(repo.legacyManifestPath(key, second.Generation))
63- phase := "write manifest"
64- if tc.location == "HEAD" {
65- dir = filepath.Dir(repo.legacyHeadPath(key))
66- phase = "write HEAD"
67- }
68- repo.hooks.writeTemp = func(path string) error {
69- if filepath.Dir(path) == dir {
70- return primary
71- }
72- return nil
73- }
74- repo.hooks.closeFile = func(path string) error {
75- if tc.cleanup == "close" && filepath.Dir(path) == dir {
76- return cleanup
77- }
78- return nil
79- }
80- repo.hooks.remove = func(string) error {
81- if tc.cleanup == "remove" {
82- return cleanup
83- }
84- return nil
85- }
86- repo.hooks.syncDirectory = func(path string) error {
87- if tc.cleanup == "sync-after-remove" && path == dir {
88- return cleanup
89- }
90- return nil
91- }
92-
93- err := repo.Publish(context.Background(), second)
94- if !errors.Is(err, primary) || !errors.Is(err, cleanup) || !strings.Contains(err.Error(), phase) {
95- t.Fatalf("Publish error = %v, want %q and both primary and cleanup failures", err, phase)
96- }
97- if tc.cleanup != "remove" {
98- matches, globErr := filepath.Glob(filepath.Join(dir, ".tmp-*"))
99- if globErr != nil || len(matches) != 0 {
100- t.Fatalf("temporary files after successful cleanup = %v, glob error = %v", matches, globErr)
101- }
102- }
103- repo.hooks = repositoryHooks{}
104- assertRepositoryCompleteGeneration(t, repo, "named", 1, 2)
105- })
106- }
107-}
108-
109-func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
110- cases := []struct {
111- name string
112- phase string
113- occurrence int
114- directory func(*Repository, string) string
115- }{
116- {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }},
117- {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }},
118- {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }},
119- {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string {
120- return repo.legacySessionPath(legacyIncarnationID("named").String())
121- }},
122- {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string {
123- return repo.legacySessionPath(legacyIncarnationID("named").String())
124- }},
125- {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string {
126- return filepath.Join(repo.legacySessionPath(legacyIncarnationID("named").String()), repositoryObjectsDir)
127- }},
128- }
129- for _, tc := range cases {
130- t.Run(tc.name, func(t *testing.T) {
131- parent := privateDir(t)
132- root := filepath.Join(parent, "repository")
133- repo := NewRepository(root)
134- wantDirectory := tc.directory(repo, root)
135- calls := 0
136- injected := errors.New("new directory sync failure")
137- repo.hooks.syncDirectory = func(directory string) error {
138- if directory == wantDirectory {
139- calls++
140- if calls == tc.occurrence {
141- return injected
142- }
143- }
144- return nil
145- }
146-
147- publication := repositoryPublication(t, "named", 1, []byte("one"))
148- err := repo.Publish(context.Background(), publication)
149- if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.phase) {
150- t.Fatalf("Publish error = %v, want %q-labelled directory sync failure", err, tc.phase)
151- }
152- repo.hooks = repositoryHooks{}
153- // A first-generation failure cannot have an older complete generation;
154- // it must not expose an incomplete one as if it were loadable.
155- if _, loadErr := loadPublication(context.Background(), repo, publication); loadErr == nil {
156- t.Fatal("LoadCheckpoint exposed an incomplete first generation")
157- }
158- if err := repo.Publish(context.Background(), publication); err != nil {
159- t.Fatal(err)
160- }
161- assertRepositoryCompleteGeneration(t, repo, "named", 1)
162- })
163- }
164-}
165-
166-func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) {
167- t.Helper()
168- id := legacyIncarnationID(name)
169- for _, generation := range generations {
170- got, err := loadGenerationCheckpoint(context.Background(), repo, id, name, generation)
171- if err == nil && len(got.Manifest) != 0 && len(got.Objects) != 0 {
172- return
173- }
174- }
175- t.Fatalf("no complete checkpoint found for generations %v", generations)
176-}
M internal/adapters/snapshot/repository_test.go +9 -32
@@ -2,6 +2,7 @@ package snapshot
22
33 import (
44 "context"
5+ "crypto/sha256"
56 "testing"
67
78 "github.com/bnema/vev/internal/domain"
@@ -27,37 +28,6 @@ func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {
2728 }
2829 }
2930
30-func TestRepositoryDoesNotRewriteVerifiedImmutableBlob(t *testing.T) {
31- t.Parallel()
32- dir := privateDir(t)
33- repo := NewRepository(dir)
34- pub := repositoryPublication(t, "named", 1, []byte("state"))
35- if err := repo.Publish(context.Background(), pub); err != nil {
36- t.Fatal(err)
37- }
38- writes := 0
39- repo.hooks.beforeBlobWrite = func(string) error { writes++; return nil }
40- pub.Generation = 2
41- manifest, err := codec.UnmarshalManifest(pub.Manifest)
42- if err != nil {
43- t.Fatal(err)
44- }
45- manifest.Generation = 2
46- parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: codec.ManifestDigest(pub.Manifest)}
47- manifest.ParentCheckpoint = parent
48- pub.ParentCheckpoint = parent
49- pub.Manifest, err = codec.MarshalManifest(manifest)
50- if err != nil {
51- t.Fatal(err)
52- }
53- if err := repo.Publish(context.Background(), pub); err != nil {
54- t.Fatal(err)
55- }
56- if writes != 0 {
57- t.Fatalf("blob writes = %d, want 0", writes)
58- }
59-}
60-
6131 func loadPublication(ctx context.Context, repo *Repository, publication ports.SnapshotPublication) (ports.SnapshotGeneration, error) {
6232 return repo.LoadCheckpoint(ctx, publication.IncarnationID, publication.Name, ports.CheckpointRef{
6333 Generation: publication.Generation,
@@ -109,7 +79,7 @@ func publicationWithCurrentParent(t *testing.T, repo *Repository, publication po
10979
11080 func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication {
11181 t.Helper()
112- id := legacyIncarnationID(name)
82+ id := testIncarnationID(name)
11383 tail, err := codec.MarshalObject(codec.HistoryTail, payload)
11484 if err != nil {
11585 t.Fatal(err)
@@ -124,3 +94,10 @@ func repositoryPublication(t *testing.T, name string, generation uint64, payload
12494 }
12595 return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}
12696 }
97+
98+func testIncarnationID(name string) domain.IncarnationID {
99+ digest := sha256.Sum256([]byte("test incarnation: " + name))
100+ var id domain.IncarnationID
101+ copy(id[:], digest[:])
102+ return id
103+}
@@ -2,6 +2,7 @@ package snapshot
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/sha256"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -27,37 +28,6 @@ func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {
27 }28 }
28 }29 }
29 30
30-func TestRepositoryDoesNotRewriteVerifiedImmutableBlob(t *testing.T) {
31- t.Parallel()
32- dir := privateDir(t)
33- repo := NewRepository(dir)
34- pub := repositoryPublication(t, "named", 1, []byte("state"))
35- if err := repo.Publish(context.Background(), pub); err != nil {
36- t.Fatal(err)
37- }
38- writes := 0
39- repo.hooks.beforeBlobWrite = func(string) error { writes++; return nil }
40- pub.Generation = 2
41- manifest, err := codec.UnmarshalManifest(pub.Manifest)
42- if err != nil {
43- t.Fatal(err)
44- }
45- manifest.Generation = 2
46- parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: codec.ManifestDigest(pub.Manifest)}
47- manifest.ParentCheckpoint = parent
48- pub.ParentCheckpoint = parent
49- pub.Manifest, err = codec.MarshalManifest(manifest)
50- if err != nil {
51- t.Fatal(err)
52- }
53- if err := repo.Publish(context.Background(), pub); err != nil {
54- t.Fatal(err)
55- }
56- if writes != 0 {
57- t.Fatalf("blob writes = %d, want 0", writes)
58- }
59-}
60-
61 func loadPublication(ctx context.Context, repo *Repository, publication ports.SnapshotPublication) (ports.SnapshotGeneration, error) {31 func loadPublication(ctx context.Context, repo *Repository, publication ports.SnapshotPublication) (ports.SnapshotGeneration, error) {
62 return repo.LoadCheckpoint(ctx, publication.IncarnationID, publication.Name, ports.CheckpointRef{32 return repo.LoadCheckpoint(ctx, publication.IncarnationID, publication.Name, ports.CheckpointRef{
63 Generation: publication.Generation,33 Generation: publication.Generation,
@@ -109,7 +79,7 @@ func publicationWithCurrentParent(t *testing.T, repo *Repository, publication po
109 79
110 func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication {80 func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication {
111 t.Helper()81 t.Helper()
112- id := legacyIncarnationID(name)82+ id := testIncarnationID(name)
113 tail, err := codec.MarshalObject(codec.HistoryTail, payload)83 tail, err := codec.MarshalObject(codec.HistoryTail, payload)
114 if err != nil {84 if err != nil {
115 t.Fatal(err)85 t.Fatal(err)
@@ -124,3 +94,10 @@ func repositoryPublication(t *testing.T, name string, generation uint64, payload
124 }94 }
125 return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}95 return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}
126 }96 }
97+
98+func testIncarnationID(name string) domain.IncarnationID {
99+ digest := sha256.Sum256([]byte("test incarnation: " + name))
100+ var id domain.IncarnationID
101+ copy(id[:], digest[:])
102+ return id
103+}
M internal/adapters/snapshot/safe_path.go +1 -8
@@ -18,9 +18,6 @@ func (r *Repository) openRoot() (*os.Root, error) {
1818 if err != nil {
1919 return nil, err
2020 }
21- if hook := r.hooks.afterOpenRoot; hook != nil {
22- hook()
23- }
2421 current, err := os.Lstat(r.dir)
2522 if err != nil {
2623 return nil, r.closeRootOnError(root, err)
@@ -47,11 +44,7 @@ func privateDirectory(fi os.FileInfo) bool {
4744 }
4845
4946 func (r *Repository) closeRoot(root *os.Root) error {
50- var injected error
51- if hook := r.hooks.closeRoot; hook != nil {
52- injected = hook()
53- }
54- return errors.Join(injected, root.Close())
47+ return root.Close()
5548 }
5649
5750 func (r *Repository) closeRootOnError(root *os.Root, err error) error {
@@ -18,9 +18,6 @@ func (r *Repository) openRoot() (*os.Root, error) {
18 if err != nil {18 if err != nil {
19 return nil, err19 return nil, err
20 }20 }
21- if hook := r.hooks.afterOpenRoot; hook != nil {
22- hook()
23- }
24 current, err := os.Lstat(r.dir)21 current, err := os.Lstat(r.dir)
25 if err != nil {22 if err != nil {
26 return nil, r.closeRootOnError(root, err)23 return nil, r.closeRootOnError(root, err)
@@ -47,11 +44,7 @@ func privateDirectory(fi os.FileInfo) bool {
47 }44 }
48 45
49 func (r *Repository) closeRoot(root *os.Root) error {46 func (r *Repository) closeRoot(root *os.Root) error {
50- var injected error47+ return root.Close()
51- if hook := r.hooks.closeRoot; hook != nil {
52- injected = hook()
53- }
54- return errors.Join(injected, root.Close())
55 }48 }
56 49
57 func (r *Repository) closeRootOnError(root *os.Root, err error) error {50 func (r *Repository) closeRootOnError(root *os.Root, err error) error {

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