detached 13c7e30

refactor(snapshot): split repository responsibilities

commit
13c7e30c2666ca9d0337a4bf858eb0ba2d2674e2
author
brice · · 2026-07-21 20:25 UTC
parent
a8652f5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +935 -905
A internal/adapters/snapshot/atomic.go +328 -0
@@ -0,0 +1,328 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "syscall"
10+
11+ "github.com/bnema/vev/pkg/safedir"
12+)
13+
14+func (r *Repository) ensureSession(key string) error {
15+ for _, directory := range []struct {
16+ path string
17+ phase string
18+ }{
19+ {r.dir, "repository"},
20+ {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
21+ {r.sessionPath(key), "session"},
22+ {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},
23+ {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},
24+ } {
25+ if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
26+ return fmt.Errorf("create snapshot repository directory: %w", err)
27+ }
28+ }
29+ return nil
30+}
31+
32+func (r *Repository) ensurePrivateDirectory(dir string) error {
33+ return r.ensurePrivateDirectoryPhase(dir, "snapshot directory")
34+}
35+
36+func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
37+ _, err := os.Lstat(dir)
38+ created := errors.Is(err, os.ErrNotExist)
39+ if err != nil && !created {
40+ return err
41+ }
42+ if created {
43+ parent := filepath.Dir(dir)
44+ if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {
45+ if err := r.ensurePrivateDirectoryPhase(parent, "snapshot directory"); err != nil {
46+ return err
47+ }
48+ } else if err != nil {
49+ return err
50+ }
51+ if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
52+ return err
53+ }
54+ }
55+ if err := safedir.EnsurePrivate(dir); err != nil {
56+ return err
57+ }
58+ if created {
59+ if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
60+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
61+ }
62+ }
63+ return nil
64+}
65+
66+// writeImmutable publishes data with link(2), whose EEXIST behavior prevents a
67+// raced target from being overwritten. A competing target is accepted only
68+// after verifier reads it through the same secure descriptor path as Load.
69+func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error {
70+ dir := filepath.Dir(path)
71+ phase := "object shard"
72+ if filepath.Base(dir) == repositoryGenerations {
73+ phase = "generation"
74+ }
75+ if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
76+ return err
77+ }
78+ temp, err := r.createTemp(dir)
79+ if err != nil {
80+ return err
81+ }
82+ tempPath := temp.Name()
83+ closed := false
84+ cleanup := func(cause error) error {
85+ if !closed {
86+ if err := r.closeFile(temp); err != nil {
87+ cause = errors.Join(cause, err)
88+ }
89+ closed = true
90+ }
91+ if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
92+ cause = errors.Join(cause, err)
93+ } else if err == nil {
94+ cause = errors.Join(cause, r.syncDirectory(dir))
95+ }
96+ return cause
97+ }
98+ if err := temp.Chmod(0o600); err != nil {
99+ return cleanup(err)
100+ }
101+ if err := r.writeFile(temp, data); err != nil {
102+ return cleanup(err)
103+ }
104+ if err := r.syncFile(temp); err != nil {
105+ return cleanup(err)
106+ }
107+ if err := r.closeFile(temp); err != nil {
108+ closed = true
109+ return cleanup(err)
110+ }
111+ closed = true
112+ if err := r.installImmutable(tempPath, path); err != nil {
113+ if errors.Is(err, os.ErrExist) {
114+ existing, readErr := readBounded(path)
115+ if readErr != nil {
116+ return cleanup(readErr)
117+ }
118+ if verifyErr := verifier(existing); verifyErr != nil {
119+ return cleanup(verifyErr)
120+ }
121+ return cleanup(nil)
122+ }
123+ return cleanup(err)
124+ }
125+ // Removing the linked temporary entry before the directory sync lets one
126+ // sync persist both the immutable install and its consumed temporary file.
127+ return cleanup(nil)
128+}
129+
130+// writeMutable is used only for HEAD, the sole authoritative pointer allowed
131+// to advance. The old or fully synced new HEAD is therefore always recoverable.
132+func (r *Repository) writeMutable(path string, data []byte) error {
133+ dir := filepath.Dir(path)
134+ if err := r.ensurePrivateDirectory(dir); err != nil {
135+ return err
136+ }
137+ temp, err := r.createTemp(dir)
138+ if err != nil {
139+ return err
140+ }
141+ tempPath := temp.Name()
142+ closed := false
143+ cleanup := func(cause error) error {
144+ if !closed {
145+ if err := r.closeFile(temp); err != nil {
146+ cause = errors.Join(cause, err)
147+ }
148+ closed = true
149+ }
150+ if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
151+ cause = errors.Join(cause, err)
152+ } else if err == nil {
153+ cause = errors.Join(cause, r.syncDirectory(dir))
154+ }
155+ return cause
156+ }
157+ if err := temp.Chmod(0o600); err != nil {
158+ return cleanup(err)
159+ }
160+ if err := r.writeFile(temp, data); err != nil {
161+ return cleanup(err)
162+ }
163+ if err := r.syncFile(temp); err != nil {
164+ return cleanup(err)
165+ }
166+ if err := r.closeFile(temp); err != nil {
167+ closed = true
168+ return cleanup(err)
169+ }
170+ closed = true
171+ if err := r.rename(tempPath, path); err != nil {
172+ return cleanup(err)
173+ }
174+ if err := r.syncDirectory(dir); err != nil {
175+ return cleanup(err)
176+ }
177+ // rename consumed tempPath. Its directory sync above persists both rename
178+ // and the absence of the temporary entry.
179+ return nil
180+}
181+
182+func (r *Repository) createTemp(dir string) (*os.File, error) {
183+ if r.hooks.createTemp != nil {
184+ if err := r.hooks.createTemp(dir); err != nil {
185+ return nil, err
186+ }
187+ }
188+ return os.CreateTemp(dir, ".tmp-")
189+}
190+func (r *Repository) writeFile(f *os.File, data []byte) error {
191+ if r.hooks.writeTemp != nil {
192+ if err := r.hooks.writeTemp(f.Name()); err != nil {
193+ return err
194+ }
195+ }
196+ _, err := f.Write(data)
197+ return err
198+}
199+func (r *Repository) syncFile(f *os.File) error {
200+ if r.hooks.syncFile != nil {
201+ if err := r.hooks.syncFile(f.Name()); err != nil {
202+ return err
203+ }
204+ }
205+ return f.Sync()
206+}
207+func (r *Repository) closeFile(f *os.File) error {
208+ var injected error
209+ if r.hooks.closeFile != nil {
210+ injected = r.hooks.closeFile(f.Name())
211+ }
212+ closeErr := f.Close()
213+ if injected != nil {
214+ return injected
215+ }
216+ return closeErr
217+}
218+func (r *Repository) installImmutable(oldPath, newPath string) error {
219+ if r.hooks.installImmutable != nil {
220+ if err := r.hooks.installImmutable(newPath); err != nil {
221+ return err
222+ }
223+ }
224+ return os.Link(oldPath, newPath)
225+}
226+func (r *Repository) rename(oldPath, newPath string) error {
227+ if r.hooks.rename != nil {
228+ if err := r.hooks.rename(newPath); err != nil {
229+ return err
230+ }
231+ }
232+ return os.Rename(oldPath, newPath)
233+}
234+func (r *Repository) remove(path string) error {
235+ if r.hooks.remove != nil {
236+ if err := r.hooks.remove(path); err != nil {
237+ return err
238+ }
239+ }
240+ return os.Remove(path)
241+}
242+func (r *Repository) syncDirectory(dir string) error {
243+ if r.hooks.syncDirectory != nil {
244+ if err := r.hooks.syncDirectory(dir); err != nil {
245+ return err
246+ }
247+ }
248+ return syncDirectory(dir)
249+}
250+func syncDirectory(dir string) error {
251+ f, err := os.Open(dir)
252+ if err != nil {
253+ return err
254+ }
255+ if err := f.Sync(); err != nil {
256+ _ = f.Close()
257+ return err
258+ }
259+ return f.Close()
260+}
261+
262+func readOptionalBounded(path string) ([]byte, bool, error) {
263+ data, err := readBounded(path)
264+ if errors.Is(err, os.ErrNotExist) {
265+ return nil, false, nil
266+ }
267+ return data, err == nil, err
268+}
269+
270+// readBounded opens the final component with O_NOFOLLOW, validates the opened
271+// descriptor, then reads a hard bounded amount. It deliberately never trusts
272+// path metadata obtained before opening the descriptor.
273+func readBounded(path string) ([]byte, error) {
274+ fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
275+ if err != nil {
276+ return nil, err
277+ }
278+ f := os.NewFile(uintptr(fd), path)
279+ if f == nil {
280+ _ = syscall.Close(fd)
281+ return nil, fmt.Errorf("open snapshot file")
282+ }
283+ var stat syscall.Stat_t
284+ if err := syscall.Fstat(fd, &stat); err != nil {
285+ _ = f.Close()
286+ return nil, err
287+ }
288+ if stat.Mode&syscall.S_IFMT != syscall.S_IFREG {
289+ _ = f.Close()
290+ return nil, fmt.Errorf("not a regular file")
291+ }
292+ if int(stat.Uid) != os.Geteuid() {
293+ _ = f.Close()
294+ return nil, fmt.Errorf("snapshot file is not owned by effective uid")
295+ }
296+ if stat.Mode&0o077 != 0 {
297+ _ = f.Close()
298+ return nil, fmt.Errorf("snapshot file has unsafe permissions")
299+ }
300+ if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) {
301+ _ = f.Close()
302+ return nil, fmt.Errorf("snapshot file too large")
303+ }
304+ data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1))
305+ closeErr := f.Close()
306+ if readErr != nil {
307+ return nil, readErr
308+ }
309+ if closeErr != nil {
310+ return nil, closeErr
311+ }
312+ if len(data) > maxRepositoryRead {
313+ return nil, fmt.Errorf("snapshot file too large")
314+ }
315+ return data, nil
316+}
317+
318+func equalBytes(a, b []byte) bool {
319+ if len(a) != len(b) {
320+ return false
321+ }
322+ for i := range a {
323+ if a[i] != b[i] {
324+ return false
325+ }
326+ }
327+ return true
328+}
@@ -0,0 +1,328 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "syscall"
10+
11+ "github.com/bnema/vev/pkg/safedir"
12+)
13+
14+func (r *Repository) ensureSession(key string) error {
15+ for _, directory := range []struct {
16+ path string
17+ phase string
18+ }{
19+ {r.dir, "repository"},
20+ {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
21+ {r.sessionPath(key), "session"},
22+ {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},
23+ {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},
24+ } {
25+ if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
26+ return fmt.Errorf("create snapshot repository directory: %w", err)
27+ }
28+ }
29+ return nil
30+}
31+
32+func (r *Repository) ensurePrivateDirectory(dir string) error {
33+ return r.ensurePrivateDirectoryPhase(dir, "snapshot directory")
34+}
35+
36+func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
37+ _, err := os.Lstat(dir)
38+ created := errors.Is(err, os.ErrNotExist)
39+ if err != nil && !created {
40+ return err
41+ }
42+ if created {
43+ parent := filepath.Dir(dir)
44+ if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {
45+ if err := r.ensurePrivateDirectoryPhase(parent, "snapshot directory"); err != nil {
46+ return err
47+ }
48+ } else if err != nil {
49+ return err
50+ }
51+ if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
52+ return err
53+ }
54+ }
55+ if err := safedir.EnsurePrivate(dir); err != nil {
56+ return err
57+ }
58+ if created {
59+ if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
60+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
61+ }
62+ }
63+ return nil
64+}
65+
66+// writeImmutable publishes data with link(2), whose EEXIST behavior prevents a
67+// raced target from being overwritten. A competing target is accepted only
68+// after verifier reads it through the same secure descriptor path as Load.
69+func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error {
70+ dir := filepath.Dir(path)
71+ phase := "object shard"
72+ if filepath.Base(dir) == repositoryGenerations {
73+ phase = "generation"
74+ }
75+ if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
76+ return err
77+ }
78+ temp, err := r.createTemp(dir)
79+ if err != nil {
80+ return err
81+ }
82+ tempPath := temp.Name()
83+ closed := false
84+ cleanup := func(cause error) error {
85+ if !closed {
86+ if err := r.closeFile(temp); err != nil {
87+ cause = errors.Join(cause, err)
88+ }
89+ closed = true
90+ }
91+ if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
92+ cause = errors.Join(cause, err)
93+ } else if err == nil {
94+ cause = errors.Join(cause, r.syncDirectory(dir))
95+ }
96+ return cause
97+ }
98+ if err := temp.Chmod(0o600); err != nil {
99+ return cleanup(err)
100+ }
101+ if err := r.writeFile(temp, data); err != nil {
102+ return cleanup(err)
103+ }
104+ if err := r.syncFile(temp); err != nil {
105+ return cleanup(err)
106+ }
107+ if err := r.closeFile(temp); err != nil {
108+ closed = true
109+ return cleanup(err)
110+ }
111+ closed = true
112+ if err := r.installImmutable(tempPath, path); err != nil {
113+ if errors.Is(err, os.ErrExist) {
114+ existing, readErr := readBounded(path)
115+ if readErr != nil {
116+ return cleanup(readErr)
117+ }
118+ if verifyErr := verifier(existing); verifyErr != nil {
119+ return cleanup(verifyErr)
120+ }
121+ return cleanup(nil)
122+ }
123+ return cleanup(err)
124+ }
125+ // Removing the linked temporary entry before the directory sync lets one
126+ // sync persist both the immutable install and its consumed temporary file.
127+ return cleanup(nil)
128+}
129+
130+// writeMutable is used only for HEAD, the sole authoritative pointer allowed
131+// to advance. The old or fully synced new HEAD is therefore always recoverable.
132+func (r *Repository) writeMutable(path string, data []byte) error {
133+ dir := filepath.Dir(path)
134+ if err := r.ensurePrivateDirectory(dir); err != nil {
135+ return err
136+ }
137+ temp, err := r.createTemp(dir)
138+ if err != nil {
139+ return err
140+ }
141+ tempPath := temp.Name()
142+ closed := false
143+ cleanup := func(cause error) error {
144+ if !closed {
145+ if err := r.closeFile(temp); err != nil {
146+ cause = errors.Join(cause, err)
147+ }
148+ closed = true
149+ }
150+ if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
151+ cause = errors.Join(cause, err)
152+ } else if err == nil {
153+ cause = errors.Join(cause, r.syncDirectory(dir))
154+ }
155+ return cause
156+ }
157+ if err := temp.Chmod(0o600); err != nil {
158+ return cleanup(err)
159+ }
160+ if err := r.writeFile(temp, data); err != nil {
161+ return cleanup(err)
162+ }
163+ if err := r.syncFile(temp); err != nil {
164+ return cleanup(err)
165+ }
166+ if err := r.closeFile(temp); err != nil {
167+ closed = true
168+ return cleanup(err)
169+ }
170+ closed = true
171+ if err := r.rename(tempPath, path); err != nil {
172+ return cleanup(err)
173+ }
174+ if err := r.syncDirectory(dir); err != nil {
175+ return cleanup(err)
176+ }
177+ // rename consumed tempPath. Its directory sync above persists both rename
178+ // and the absence of the temporary entry.
179+ return nil
180+}
181+
182+func (r *Repository) createTemp(dir string) (*os.File, error) {
183+ if r.hooks.createTemp != nil {
184+ if err := r.hooks.createTemp(dir); err != nil {
185+ return nil, err
186+ }
187+ }
188+ return os.CreateTemp(dir, ".tmp-")
189+}
190+func (r *Repository) writeFile(f *os.File, data []byte) error {
191+ if r.hooks.writeTemp != nil {
192+ if err := r.hooks.writeTemp(f.Name()); err != nil {
193+ return err
194+ }
195+ }
196+ _, err := f.Write(data)
197+ return err
198+}
199+func (r *Repository) syncFile(f *os.File) error {
200+ if r.hooks.syncFile != nil {
201+ if err := r.hooks.syncFile(f.Name()); err != nil {
202+ return err
203+ }
204+ }
205+ return f.Sync()
206+}
207+func (r *Repository) closeFile(f *os.File) error {
208+ var injected error
209+ if r.hooks.closeFile != nil {
210+ injected = r.hooks.closeFile(f.Name())
211+ }
212+ closeErr := f.Close()
213+ if injected != nil {
214+ return injected
215+ }
216+ return closeErr
217+}
218+func (r *Repository) installImmutable(oldPath, newPath string) error {
219+ if r.hooks.installImmutable != nil {
220+ if err := r.hooks.installImmutable(newPath); err != nil {
221+ return err
222+ }
223+ }
224+ return os.Link(oldPath, newPath)
225+}
226+func (r *Repository) rename(oldPath, newPath string) error {
227+ if r.hooks.rename != nil {
228+ if err := r.hooks.rename(newPath); err != nil {
229+ return err
230+ }
231+ }
232+ return os.Rename(oldPath, newPath)
233+}
234+func (r *Repository) remove(path string) error {
235+ if r.hooks.remove != nil {
236+ if err := r.hooks.remove(path); err != nil {
237+ return err
238+ }
239+ }
240+ return os.Remove(path)
241+}
242+func (r *Repository) syncDirectory(dir string) error {
243+ if r.hooks.syncDirectory != nil {
244+ if err := r.hooks.syncDirectory(dir); err != nil {
245+ return err
246+ }
247+ }
248+ return syncDirectory(dir)
249+}
250+func syncDirectory(dir string) error {
251+ f, err := os.Open(dir)
252+ if err != nil {
253+ return err
254+ }
255+ if err := f.Sync(); err != nil {
256+ _ = f.Close()
257+ return err
258+ }
259+ return f.Close()
260+}
261+
262+func readOptionalBounded(path string) ([]byte, bool, error) {
263+ data, err := readBounded(path)
264+ if errors.Is(err, os.ErrNotExist) {
265+ return nil, false, nil
266+ }
267+ return data, err == nil, err
268+}
269+
270+// readBounded opens the final component with O_NOFOLLOW, validates the opened
271+// descriptor, then reads a hard bounded amount. It deliberately never trusts
272+// path metadata obtained before opening the descriptor.
273+func readBounded(path string) ([]byte, error) {
274+ fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
275+ if err != nil {
276+ return nil, err
277+ }
278+ f := os.NewFile(uintptr(fd), path)
279+ if f == nil {
280+ _ = syscall.Close(fd)
281+ return nil, fmt.Errorf("open snapshot file")
282+ }
283+ var stat syscall.Stat_t
284+ if err := syscall.Fstat(fd, &stat); err != nil {
285+ _ = f.Close()
286+ return nil, err
287+ }
288+ if stat.Mode&syscall.S_IFMT != syscall.S_IFREG {
289+ _ = f.Close()
290+ return nil, fmt.Errorf("not a regular file")
291+ }
292+ if int(stat.Uid) != os.Geteuid() {
293+ _ = f.Close()
294+ return nil, fmt.Errorf("snapshot file is not owned by effective uid")
295+ }
296+ if stat.Mode&0o077 != 0 {
297+ _ = f.Close()
298+ return nil, fmt.Errorf("snapshot file has unsafe permissions")
299+ }
300+ if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) {
301+ _ = f.Close()
302+ return nil, fmt.Errorf("snapshot file too large")
303+ }
304+ data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1))
305+ closeErr := f.Close()
306+ if readErr != nil {
307+ return nil, readErr
308+ }
309+ if closeErr != nil {
310+ return nil, closeErr
311+ }
312+ if len(data) > maxRepositoryRead {
313+ return nil, fmt.Errorf("snapshot file too large")
314+ }
315+ return data, nil
316+}
317+
318+func equalBytes(a, b []byte) bool {
319+ if len(a) != len(b) {
320+ return false
321+ }
322+ for i := range a {
323+ if a[i] != b[i] {
324+ return false
325+ }
326+ }
327+ return true
328+}
A internal/adapters/snapshot/atomic_test.go +35 -0
@@ -0,0 +1,35 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {
10+ repo := NewRepository(privateDir(t))
11+ dir := filepath.Join(repo.dir, "immutable")
12+ if err := repo.ensurePrivateDirectory(dir); err != nil {
13+ t.Fatal(err)
14+ }
15+
16+ syncs := 0
17+ repo.hooks.syncDirectory = func(got string) error {
18+ if got == dir {
19+ syncs++
20+ }
21+ return nil
22+ }
23+ data := []byte("immutable")
24+ if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error {
25+ if !equalBytes(existing, data) {
26+ return errors.New("unexpected immutable data")
27+ }
28+ return nil
29+ }); err != nil {
30+ t.Fatal(err)
31+ }
32+ if syncs != 1 {
33+ t.Fatalf("successful immutable directory syncs = %d, want 1", syncs)
34+ }
35+}
@@ -0,0 +1,35 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {
10+ repo := NewRepository(privateDir(t))
11+ dir := filepath.Join(repo.dir, "immutable")
12+ if err := repo.ensurePrivateDirectory(dir); err != nil {
13+ t.Fatal(err)
14+ }
15+
16+ syncs := 0
17+ repo.hooks.syncDirectory = func(got string) error {
18+ if got == dir {
19+ syncs++
20+ }
21+ return nil
22+ }
23+ data := []byte("immutable")
24+ if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error {
25+ if !equalBytes(existing, data) {
26+ return errors.New("unexpected immutable data")
27+ }
28+ return nil
29+ }); err != nil {
30+ t.Fatal(err)
31+ }
32+ if syncs != 1 {
33+ t.Fatalf("successful immutable directory syncs = %d, want 1", syncs)
34+ }
35+}
A internal/adapters/snapshot/load.go +196 -0
@@ -0,0 +1,196 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "encoding/binary"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ codec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+func (r *Repository) List(ctx context.Context) ([]string, error) {
18+ if err := ctx.Err(); err != nil {
19+ return nil, err
20+ }
21+ entries, err := os.ReadDir(filepath.Join(r.dir, repositorySessionsDir))
22+ if errors.Is(err, os.ErrNotExist) {
23+ return nil, nil
24+ }
25+ if err != nil {
26+ return nil, fmt.Errorf("read snapshot sessions: %w", err)
27+ }
28+ out := make([]string, 0, len(entries))
29+ for _, entry := range entries {
30+ if err := ctx.Err(); err != nil {
31+ return nil, err
32+ }
33+ if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
34+ continue
35+ }
36+ numbers, err := r.generationNumbers(entry.Name())
37+ if err != nil {
38+ continue
39+ }
40+ for _, number := range numbers {
41+ data, err := readBounded(r.manifestPath(entry.Name(), number))
42+ if err != nil {
43+ continue
44+ }
45+ manifest, err := codec.UnmarshalManifest(data)
46+ if err != nil || sessionKey(manifest.Name) != entry.Name() {
47+ continue
48+ }
49+ generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number)
50+ if err == nil {
51+ out = append(out, generation.Name)
52+ break
53+ }
54+ }
55+ }
56+ sort.Strings(out)
57+ return out, nil
58+}
59+
60+func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
61+ if err := ctx.Err(); err != nil {
62+ return ports.SnapshotGeneration{}, err
63+ }
64+ key := sessionKey(name)
65+ preferred, preferredDigest, err := r.readHead(key)
66+ if err != nil && !errors.Is(err, os.ErrNotExist) {
67+ preferred = 0
68+ }
69+ candidates, err := r.generationNumbers(key)
70+ if err != nil {
71+ return ports.SnapshotGeneration{}, err
72+ }
73+ if preferred != 0 {
74+ candidates = append([]uint64{preferred}, removeGeneration(candidates, preferred)...)
75+ }
76+ skipped := false
77+ for _, generation := range candidates {
78+ if err := ctx.Err(); err != nil {
79+ return ports.SnapshotGeneration{}, err
80+ }
81+ got, err := r.loadGeneration(ctx, name, key, generation)
82+ if err != nil || (generation == preferred && sha256.Sum256(got.Manifest) != preferredDigest) {
83+ skipped = true
84+ continue
85+ }
86+ got.Fallback = skipped || (preferred != 0 && generation != preferred)
87+ return got, nil
88+ }
89+ return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
90+}
91+
92+func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
93+ generation, digest, err := r.readHead(key)
94+ if err == nil {
95+ got, err := r.loadGeneration(ctx, name, key, generation)
96+ if err == nil && sha256.Sum256(got.Manifest) == digest {
97+ return generation, got.Manifest, nil
98+ }
99+ }
100+ candidates, err := r.generationNumbers(key)
101+ if err != nil {
102+ return 0, nil, err
103+ }
104+ for _, candidate := range candidates {
105+ if got, err := r.loadGeneration(ctx, name, key, candidate); err == nil {
106+ return candidate, got.Manifest, nil
107+ }
108+ }
109+ return 0, nil, nil
110+}
111+
112+func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
113+ data, err := readBounded(r.manifestPath(key, generation))
114+ if err != nil {
115+ return ports.SnapshotGeneration{}, err
116+ }
117+ manifest, err := codec.UnmarshalManifest(data)
118+ if err != nil || manifest.Name != name || manifest.Generation != generation {
119+ return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest")
120+ }
121+ refs := manifestRefs(manifest)
122+ if refs == nil || !withinGenerationBudget(len(data), refs) {
123+ return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large")
124+ }
125+ objects := make(map[ports.SnapshotDigest][]byte, len(refs))
126+ for digest, ref := range refs {
127+ if err := ctx.Err(); err != nil {
128+ return ports.SnapshotGeneration{}, err
129+ }
130+ object, err := readBounded(r.objectPath(key, digest))
131+ if err != nil {
132+ return ports.SnapshotGeneration{}, err
133+ }
134+ if sha256.Sum256(object) != digest || !validObject(object, ref) {
135+ return ports.SnapshotGeneration{}, fmt.Errorf("invalid object")
136+ }
137+ // readBounded returns a fresh caller-owned buffer, so returning it directly
138+ // avoids a second allocation without retaining it in the repository.
139+ objects[digest] = object
140+ }
141+ return ports.SnapshotGeneration{Name: manifest.Name, Generation: generation, Manifest: data, Objects: objects}, nil
142+}
143+
144+func (r *Repository) generationNumbers(key string) ([]uint64, error) {
145+ entries, err := os.ReadDir(filepath.Join(r.sessionPath(key), repositoryGenerations))
146+ if errors.Is(err, os.ErrNotExist) {
147+ return nil, nil
148+ }
149+ if err != nil {
150+ return nil, err
151+ }
152+ numbers := make([]uint64, 0, len(entries))
153+ for _, entry := range entries {
154+ if entry.IsDir() {
155+ continue
156+ }
157+ if generation, ok := parseGenerationFilename(entry.Name()); ok {
158+ numbers = append(numbers, generation)
159+ }
160+ }
161+ sort.Slice(numbers, func(i, j int) bool { return numbers[i] > numbers[j] })
162+ return numbers, nil
163+}
164+func removeGeneration(numbers []uint64, generation uint64) []uint64 {
165+ out := numbers[:0]
166+ for _, n := range numbers {
167+ if n != generation {
168+ out = append(out, n)
169+ }
170+ }
171+ return out
172+}
173+
174+func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
175+ out := make([]byte, 4+8+sha256.Size)
176+ copy(out, "VEVH")
177+ binary.BigEndian.PutUint64(out[4:12], generation)
178+ copy(out[12:], digest[:])
179+ return out
180+}
181+func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {
182+ data, err := readBounded(r.headPath(key))
183+ if err != nil {
184+ return 0, ports.SnapshotDigest{}, err
185+ }
186+ if len(data) != 4+8+sha256.Size || string(data[:4]) != "VEVH" {
187+ return 0, ports.SnapshotDigest{}, fmt.Errorf("invalid HEAD")
188+ }
189+ generation := binary.BigEndian.Uint64(data[4:12])
190+ var digest ports.SnapshotDigest
191+ copy(digest[:], data[12:])
192+ if generation == 0 {
193+ return 0, ports.SnapshotDigest{}, fmt.Errorf("invalid HEAD")
194+ }
195+ return generation, digest, nil
196+}
@@ -0,0 +1,196 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "encoding/binary"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ codec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+func (r *Repository) List(ctx context.Context) ([]string, error) {
18+ if err := ctx.Err(); err != nil {
19+ return nil, err
20+ }
21+ entries, err := os.ReadDir(filepath.Join(r.dir, repositorySessionsDir))
22+ if errors.Is(err, os.ErrNotExist) {
23+ return nil, nil
24+ }
25+ if err != nil {
26+ return nil, fmt.Errorf("read snapshot sessions: %w", err)
27+ }
28+ out := make([]string, 0, len(entries))
29+ for _, entry := range entries {
30+ if err := ctx.Err(); err != nil {
31+ return nil, err
32+ }
33+ if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
34+ continue
35+ }
36+ numbers, err := r.generationNumbers(entry.Name())
37+ if err != nil {
38+ continue
39+ }
40+ for _, number := range numbers {
41+ data, err := readBounded(r.manifestPath(entry.Name(), number))
42+ if err != nil {
43+ continue
44+ }
45+ manifest, err := codec.UnmarshalManifest(data)
46+ if err != nil || sessionKey(manifest.Name) != entry.Name() {
47+ continue
48+ }
49+ generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number)
50+ if err == nil {
51+ out = append(out, generation.Name)
52+ break
53+ }
54+ }
55+ }
56+ sort.Strings(out)
57+ return out, nil
58+}
59+
60+func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
61+ if err := ctx.Err(); err != nil {
62+ return ports.SnapshotGeneration{}, err
63+ }
64+ key := sessionKey(name)
65+ preferred, preferredDigest, err := r.readHead(key)
66+ if err != nil && !errors.Is(err, os.ErrNotExist) {
67+ preferred = 0
68+ }
69+ candidates, err := r.generationNumbers(key)
70+ if err != nil {
71+ return ports.SnapshotGeneration{}, err
72+ }
73+ if preferred != 0 {
74+ candidates = append([]uint64{preferred}, removeGeneration(candidates, preferred)...)
75+ }
76+ skipped := false
77+ for _, generation := range candidates {
78+ if err := ctx.Err(); err != nil {
79+ return ports.SnapshotGeneration{}, err
80+ }
81+ got, err := r.loadGeneration(ctx, name, key, generation)
82+ if err != nil || (generation == preferred && sha256.Sum256(got.Manifest) != preferredDigest) {
83+ skipped = true
84+ continue
85+ }
86+ got.Fallback = skipped || (preferred != 0 && generation != preferred)
87+ return got, nil
88+ }
89+ return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
90+}
91+
92+func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
93+ generation, digest, err := r.readHead(key)
94+ if err == nil {
95+ got, err := r.loadGeneration(ctx, name, key, generation)
96+ if err == nil && sha256.Sum256(got.Manifest) == digest {
97+ return generation, got.Manifest, nil
98+ }
99+ }
100+ candidates, err := r.generationNumbers(key)
101+ if err != nil {
102+ return 0, nil, err
103+ }
104+ for _, candidate := range candidates {
105+ if got, err := r.loadGeneration(ctx, name, key, candidate); err == nil {
106+ return candidate, got.Manifest, nil
107+ }
108+ }
109+ return 0, nil, nil
110+}
111+
112+func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
113+ data, err := readBounded(r.manifestPath(key, generation))
114+ if err != nil {
115+ return ports.SnapshotGeneration{}, err
116+ }
117+ manifest, err := codec.UnmarshalManifest(data)
118+ if err != nil || manifest.Name != name || manifest.Generation != generation {
119+ return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest")
120+ }
121+ refs := manifestRefs(manifest)
122+ if refs == nil || !withinGenerationBudget(len(data), refs) {
123+ return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large")
124+ }
125+ objects := make(map[ports.SnapshotDigest][]byte, len(refs))
126+ for digest, ref := range refs {
127+ if err := ctx.Err(); err != nil {
128+ return ports.SnapshotGeneration{}, err
129+ }
130+ object, err := readBounded(r.objectPath(key, digest))
131+ if err != nil {
132+ return ports.SnapshotGeneration{}, err
133+ }
134+ if sha256.Sum256(object) != digest || !validObject(object, ref) {
135+ return ports.SnapshotGeneration{}, fmt.Errorf("invalid object")
136+ }
137+ // readBounded returns a fresh caller-owned buffer, so returning it directly
138+ // avoids a second allocation without retaining it in the repository.
139+ objects[digest] = object
140+ }
141+ return ports.SnapshotGeneration{Name: manifest.Name, Generation: generation, Manifest: data, Objects: objects}, nil
142+}
143+
144+func (r *Repository) generationNumbers(key string) ([]uint64, error) {
145+ entries, err := os.ReadDir(filepath.Join(r.sessionPath(key), repositoryGenerations))
146+ if errors.Is(err, os.ErrNotExist) {
147+ return nil, nil
148+ }
149+ if err != nil {
150+ return nil, err
151+ }
152+ numbers := make([]uint64, 0, len(entries))
153+ for _, entry := range entries {
154+ if entry.IsDir() {
155+ continue
156+ }
157+ if generation, ok := parseGenerationFilename(entry.Name()); ok {
158+ numbers = append(numbers, generation)
159+ }
160+ }
161+ sort.Slice(numbers, func(i, j int) bool { return numbers[i] > numbers[j] })
162+ return numbers, nil
163+}
164+func removeGeneration(numbers []uint64, generation uint64) []uint64 {
165+ out := numbers[:0]
166+ for _, n := range numbers {
167+ if n != generation {
168+ out = append(out, n)
169+ }
170+ }
171+ return out
172+}
173+
174+func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
175+ out := make([]byte, 4+8+sha256.Size)
176+ copy(out, "VEVH")
177+ binary.BigEndian.PutUint64(out[4:12], generation)
178+ copy(out[12:], digest[:])
179+ return out
180+}
181+func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {
182+ data, err := readBounded(r.headPath(key))
183+ if err != nil {
184+ return 0, ports.SnapshotDigest{}, err
185+ }
186+ if len(data) != 4+8+sha256.Size || string(data[:4]) != "VEVH" {
187+ return 0, ports.SnapshotDigest{}, fmt.Errorf("invalid HEAD")
188+ }
189+ generation := binary.BigEndian.Uint64(data[4:12])
190+ var digest ports.SnapshotDigest
191+ copy(digest[:], data[12:])
192+ if generation == 0 {
193+ return 0, ports.SnapshotDigest{}, fmt.Errorf("invalid HEAD")
194+ }
195+ return generation, digest, nil
196+}
A internal/adapters/snapshot/path.go +54 -0
@@ -0,0 +1,54 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "fmt"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func (r *Repository) sessionPath(key string) string {
15+ return filepath.Join(r.dir, repositorySessionsDir, key)
16+}
17+func (r *Repository) objectPath(key string, digest ports.SnapshotDigest) string {
18+ hexDigest := hex.EncodeToString(digest[:])
19+ return filepath.Join(r.sessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
20+}
21+func (r *Repository) manifestPath(key string, generation uint64) string {
22+ return filepath.Join(r.sessionPath(key), repositoryGenerations, generationFilename(generation))
23+}
24+func (r *Repository) headPath(key string) string {
25+ return filepath.Join(r.sessionPath(key), repositoryHead)
26+}
27+func sessionKey(name string) string {
28+ if safeNameRE.MatchString(name) && name != "." && name != ".." {
29+ return name
30+ }
31+ sum := sha256.Sum256([]byte(name))
32+ return "@" + hex.EncodeToString(sum[:])
33+}
34+func canonicalSessionKey(key string) bool {
35+ if safeNameRE.MatchString(key) && key != "." && key != ".." {
36+ return true
37+ }
38+ if len(key) != 65 || key[0] != '@' {
39+ return false
40+ }
41+ _, err := hex.DecodeString(key[1:])
42+ return err == nil && strings.ToLower(key[1:]) == key[1:]
43+}
44+func generationFilename(generation uint64) string {
45+ return fmt.Sprintf("%0*d.manifest", generationWidth, generation)
46+}
47+func parseGenerationFilename(name string) (uint64, bool) {
48+ if !strings.HasSuffix(name, ".manifest") || len(name) != generationWidth+len(".manifest") {
49+ return 0, false
50+ }
51+ n := strings.TrimSuffix(name, ".manifest")
52+ generation, err := strconv.ParseUint(n, 10, 64)
53+ return generation, err == nil && generationFilename(generation) == name && generation != 0
54+}
@@ -0,0 +1,54 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "fmt"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func (r *Repository) sessionPath(key string) string {
15+ return filepath.Join(r.dir, repositorySessionsDir, key)
16+}
17+func (r *Repository) objectPath(key string, digest ports.SnapshotDigest) string {
18+ hexDigest := hex.EncodeToString(digest[:])
19+ return filepath.Join(r.sessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
20+}
21+func (r *Repository) manifestPath(key string, generation uint64) string {
22+ return filepath.Join(r.sessionPath(key), repositoryGenerations, generationFilename(generation))
23+}
24+func (r *Repository) headPath(key string) string {
25+ return filepath.Join(r.sessionPath(key), repositoryHead)
26+}
27+func sessionKey(name string) string {
28+ if safeNameRE.MatchString(name) && name != "." && name != ".." {
29+ return name
30+ }
31+ sum := sha256.Sum256([]byte(name))
32+ return "@" + hex.EncodeToString(sum[:])
33+}
34+func canonicalSessionKey(key string) bool {
35+ if safeNameRE.MatchString(key) && key != "." && key != ".." {
36+ return true
37+ }
38+ if len(key) != 65 || key[0] != '@' {
39+ return false
40+ }
41+ _, err := hex.DecodeString(key[1:])
42+ return err == nil && strings.ToLower(key[1:]) == key[1:]
43+}
44+func generationFilename(generation uint64) string {
45+ return fmt.Sprintf("%0*d.manifest", generationWidth, generation)
46+}
47+func parseGenerationFilename(name string) (uint64, bool) {
48+ if !strings.HasSuffix(name, ".manifest") || len(name) != generationWidth+len(".manifest") {
49+ return 0, false
50+ }
51+ n := strings.TrimSuffix(name, ".manifest")
52+ generation, err := strconv.ParseUint(n, 10, 64)
53+ return generation, err == nil && generationFilename(generation) == name && generation != 0
54+}
A internal/adapters/snapshot/publish.go +196 -0
@@ -0,0 +1,196 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ codec "github.com/bnema/vev/internal/usecase/snapshot"
10+)
11+
12+func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPublication) error {
13+ if err := ctx.Err(); err != nil {
14+ return err
15+ }
16+ key := sessionKey(publication.Name)
17+ lock := r.sessionLock(key)
18+ lock.Lock()
19+ defer lock.Unlock()
20+
21+ manifest, refs, supplied, err := validatePublication(publication)
22+ if err != nil {
23+ return err
24+ }
25+ _ = manifest
26+ if err := r.ensureSession(key); err != nil {
27+ return err
28+ }
29+
30+ current, currentManifest, err := r.currentGeneration(ctx, publication.Name, key)
31+ if err != nil {
32+ return err
33+ }
34+ if publication.Generation < current || publication.Generation > current+1 {
35+ return fmt.Errorf("snapshot generation %d, current %d: immutable conflict", publication.Generation, current)
36+ }
37+ if publication.Generation == current {
38+ if !equalBytes(currentManifest, publication.Manifest) {
39+ return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation)
40+ }
41+ return nil
42+ }
43+
44+ for digest, ref := range refs {
45+ if err := ctx.Err(); err != nil {
46+ return err
47+ }
48+ path := r.objectPath(key, digest)
49+ exists, err := verifyObjectFile(path, digest, ref)
50+ if err != nil {
51+ return fmt.Errorf("verify object %x: %w", digest, err)
52+ }
53+ if exists {
54+ continue
55+ }
56+ object, ok := supplied[digest]
57+ if !ok {
58+ return fmt.Errorf("missing referenced object %x", digest)
59+ }
60+ if r.hooks.beforeBlobWrite != nil {
61+ if err := r.hooks.beforeBlobWrite(path); err != nil {
62+ return err
63+ }
64+ }
65+ if err := r.writeImmutable(path, object.Data, func(existing []byte) error {
66+ if sha256.Sum256(existing) != digest || !validObject(existing, ref) {
67+ return fmt.Errorf("existing immutable object is invalid")
68+ }
69+ return nil
70+ }); err != nil {
71+ return fmt.Errorf("write object: %w", err)
72+ }
73+ }
74+
75+ manifestPath := r.manifestPath(key, publication.Generation)
76+ existing, exists, err := readOptionalBounded(manifestPath)
77+ if err != nil {
78+ return err
79+ }
80+ if exists {
81+ if !equalBytes(existing, publication.Manifest) {
82+ return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
83+ }
84+ } else {
85+ if r.hooks.beforeManifestWrite != nil {
86+ if err := r.hooks.beforeManifestWrite(manifestPath); err != nil {
87+ return err
88+ }
89+ }
90+ if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error {
91+ if !equalBytes(existing, publication.Manifest) {
92+ return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
93+ }
94+ return nil
95+ }); err != nil {
96+ return fmt.Errorf("write manifest: %w", err)
97+ }
98+ }
99+ if r.hooks.beforeHeadWrite != nil {
100+ if err := r.hooks.beforeHeadWrite(r.headPath(key)); err != nil {
101+ return err
102+ }
103+ }
104+ if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
105+ return fmt.Errorf("write HEAD: %w", err)
106+ }
107+ return nil
108+}
109+func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) {
110+ manifest, err := codec.UnmarshalManifest(p.Manifest)
111+ if err != nil {
112+ return codec.Manifest{}, nil, nil, fmt.Errorf("invalid manifest: %w", err)
113+ }
114+ if manifest.Name != p.Name || manifest.Generation != p.Generation || p.Generation == 0 {
115+ return codec.Manifest{}, nil, nil, fmt.Errorf("manifest name or generation does not match publication")
116+ }
117+ refs := manifestRefs(manifest)
118+ if refs == nil {
119+ return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting manifest references")
120+ }
121+ if !withinGenerationBudget(len(p.Manifest), refs) {
122+ return codec.Manifest{}, nil, nil, fmt.Errorf("snapshot generation too large")
123+ }
124+ objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects))
125+ for _, object := range p.Objects {
126+ if sha256.Sum256(object.Data) != object.Digest {
127+ return codec.Manifest{}, nil, nil, fmt.Errorf("object digest mismatch")
128+ }
129+ ref, used := refs[object.Digest]
130+ if !used {
131+ return codec.Manifest{}, nil, nil, fmt.Errorf("unreferenced object")
132+ }
133+ kind, payload, err := codec.PreflightObject(object.Data)
134+ if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {
135+ return codec.Manifest{}, nil, nil, fmt.Errorf("invalid object envelope")
136+ }
137+ if old, duplicate := objects[object.Digest]; duplicate && !equalBytes(old.Data, object.Data) {
138+ return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting object")
139+ }
140+ objects[object.Digest] = ports.SnapshotObject{Digest: object.Digest, Data: append([]byte(nil), object.Data...)}
141+ }
142+ return manifest, refs, objects, nil
143+}
144+
145+func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool {
146+ if manifestSize < 0 || manifestSize > maxRepositoryRead {
147+ return false
148+ }
149+ total := uint64(manifestSize)
150+ for _, ref := range refs {
151+ if uint64(ref.Size) > uint64(maxRepositoryRead)-total {
152+ return false
153+ }
154+ total += uint64(ref.Size)
155+ }
156+ return true
157+}
158+
159+func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef {
160+ refs := make(map[ports.SnapshotDigest]codec.ObjectRef)
161+ add := func(ref codec.ObjectRef) bool {
162+ if old, ok := refs[ref.Digest]; ok && old != ref {
163+ return false
164+ }
165+ refs[ref.Digest] = ref
166+ return true
167+ }
168+ for _, tab := range manifest.Tabs {
169+ for _, pane := range tab.Panes {
170+ for _, ref := range pane.Sealed {
171+ if !add(ref) {
172+ return nil
173+ }
174+ }
175+ if !add(pane.Tail) || !add(pane.Visible) {
176+ return nil
177+ }
178+ }
179+ }
180+ return refs
181+}
182+func validObject(data []byte, ref codec.ObjectRef) bool {
183+ kind, payload, err := codec.PreflightObject(data)
184+ return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size)
185+}
186+
187+func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {
188+ data, exists, err := readOptionalBounded(path)
189+ if err != nil || !exists {
190+ return exists, err
191+ }
192+ if sha256.Sum256(data) != digest || !validObject(data, ref) {
193+ return true, fmt.Errorf("existing immutable object is invalid")
194+ }
195+ return true, nil
196+}
@@ -0,0 +1,196 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ codec "github.com/bnema/vev/internal/usecase/snapshot"
10+)
11+
12+func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPublication) error {
13+ if err := ctx.Err(); err != nil {
14+ return err
15+ }
16+ key := sessionKey(publication.Name)
17+ lock := r.sessionLock(key)
18+ lock.Lock()
19+ defer lock.Unlock()
20+
21+ manifest, refs, supplied, err := validatePublication(publication)
22+ if err != nil {
23+ return err
24+ }
25+ _ = manifest
26+ if err := r.ensureSession(key); err != nil {
27+ return err
28+ }
29+
30+ current, currentManifest, err := r.currentGeneration(ctx, publication.Name, key)
31+ if err != nil {
32+ return err
33+ }
34+ if publication.Generation < current || publication.Generation > current+1 {
35+ return fmt.Errorf("snapshot generation %d, current %d: immutable conflict", publication.Generation, current)
36+ }
37+ if publication.Generation == current {
38+ if !equalBytes(currentManifest, publication.Manifest) {
39+ return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation)
40+ }
41+ return nil
42+ }
43+
44+ for digest, ref := range refs {
45+ if err := ctx.Err(); err != nil {
46+ return err
47+ }
48+ path := r.objectPath(key, digest)
49+ exists, err := verifyObjectFile(path, digest, ref)
50+ if err != nil {
51+ return fmt.Errorf("verify object %x: %w", digest, err)
52+ }
53+ if exists {
54+ continue
55+ }
56+ object, ok := supplied[digest]
57+ if !ok {
58+ return fmt.Errorf("missing referenced object %x", digest)
59+ }
60+ if r.hooks.beforeBlobWrite != nil {
61+ if err := r.hooks.beforeBlobWrite(path); err != nil {
62+ return err
63+ }
64+ }
65+ if err := r.writeImmutable(path, object.Data, func(existing []byte) error {
66+ if sha256.Sum256(existing) != digest || !validObject(existing, ref) {
67+ return fmt.Errorf("existing immutable object is invalid")
68+ }
69+ return nil
70+ }); err != nil {
71+ return fmt.Errorf("write object: %w", err)
72+ }
73+ }
74+
75+ manifestPath := r.manifestPath(key, publication.Generation)
76+ existing, exists, err := readOptionalBounded(manifestPath)
77+ if err != nil {
78+ return err
79+ }
80+ if exists {
81+ if !equalBytes(existing, publication.Manifest) {
82+ return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
83+ }
84+ } else {
85+ if r.hooks.beforeManifestWrite != nil {
86+ if err := r.hooks.beforeManifestWrite(manifestPath); err != nil {
87+ return err
88+ }
89+ }
90+ if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error {
91+ if !equalBytes(existing, publication.Manifest) {
92+ return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
93+ }
94+ return nil
95+ }); err != nil {
96+ return fmt.Errorf("write manifest: %w", err)
97+ }
98+ }
99+ if r.hooks.beforeHeadWrite != nil {
100+ if err := r.hooks.beforeHeadWrite(r.headPath(key)); err != nil {
101+ return err
102+ }
103+ }
104+ if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
105+ return fmt.Errorf("write HEAD: %w", err)
106+ }
107+ return nil
108+}
109+func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) {
110+ manifest, err := codec.UnmarshalManifest(p.Manifest)
111+ if err != nil {
112+ return codec.Manifest{}, nil, nil, fmt.Errorf("invalid manifest: %w", err)
113+ }
114+ if manifest.Name != p.Name || manifest.Generation != p.Generation || p.Generation == 0 {
115+ return codec.Manifest{}, nil, nil, fmt.Errorf("manifest name or generation does not match publication")
116+ }
117+ refs := manifestRefs(manifest)
118+ if refs == nil {
119+ return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting manifest references")
120+ }
121+ if !withinGenerationBudget(len(p.Manifest), refs) {
122+ return codec.Manifest{}, nil, nil, fmt.Errorf("snapshot generation too large")
123+ }
124+ objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects))
125+ for _, object := range p.Objects {
126+ if sha256.Sum256(object.Data) != object.Digest {
127+ return codec.Manifest{}, nil, nil, fmt.Errorf("object digest mismatch")
128+ }
129+ ref, used := refs[object.Digest]
130+ if !used {
131+ return codec.Manifest{}, nil, nil, fmt.Errorf("unreferenced object")
132+ }
133+ kind, payload, err := codec.PreflightObject(object.Data)
134+ if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {
135+ return codec.Manifest{}, nil, nil, fmt.Errorf("invalid object envelope")
136+ }
137+ if old, duplicate := objects[object.Digest]; duplicate && !equalBytes(old.Data, object.Data) {
138+ return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting object")
139+ }
140+ objects[object.Digest] = ports.SnapshotObject{Digest: object.Digest, Data: append([]byte(nil), object.Data...)}
141+ }
142+ return manifest, refs, objects, nil
143+}
144+
145+func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool {
146+ if manifestSize < 0 || manifestSize > maxRepositoryRead {
147+ return false
148+ }
149+ total := uint64(manifestSize)
150+ for _, ref := range refs {
151+ if uint64(ref.Size) > uint64(maxRepositoryRead)-total {
152+ return false
153+ }
154+ total += uint64(ref.Size)
155+ }
156+ return true
157+}
158+
159+func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef {
160+ refs := make(map[ports.SnapshotDigest]codec.ObjectRef)
161+ add := func(ref codec.ObjectRef) bool {
162+ if old, ok := refs[ref.Digest]; ok && old != ref {
163+ return false
164+ }
165+ refs[ref.Digest] = ref
166+ return true
167+ }
168+ for _, tab := range manifest.Tabs {
169+ for _, pane := range tab.Panes {
170+ for _, ref := range pane.Sealed {
171+ if !add(ref) {
172+ return nil
173+ }
174+ }
175+ if !add(pane.Tail) || !add(pane.Visible) {
176+ return nil
177+ }
178+ }
179+ }
180+ return refs
181+}
182+func validObject(data []byte, ref codec.ObjectRef) bool {
183+ kind, payload, err := codec.PreflightObject(data)
184+ return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size)
185+}
186+
187+func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {
188+ data, exists, err := readOptionalBounded(path)
189+ if err != nil || !exists {
190+ return exists, err
191+ }
192+ if sha256.Sum256(data) != digest || !validObject(data, ref) {
193+ return true, fmt.Errorf("existing immutable object is invalid")
194+ }
195+ return true, nil
196+}
M internal/adapters/snapshot/repository.go +47 -747
@@ -2,23 +2,14 @@ package snapshot
22
33 import (
44 "context"
5- "crypto/sha256"
6- "encoding/binary"
7- "encoding/hex"
85 "errors"
96 "fmt"
10- "io"
117 "os"
128 "path/filepath"
13- "sort"
14- "strconv"
159 "strings"
1610 "sync"
17- "syscall"
1811
1912 "github.com/bnema/vev/internal/ports"
20- codec "github.com/bnema/vev/internal/usecase/snapshot"
21- "github.com/bnema/vev/pkg/safedir"
2213 )
2314
2415 const (
@@ -63,177 +54,9 @@ var _ ports.LegacySnapshotSource = (*Repository)(nil)
6354 // until the first publication, so merely constructing it is side-effect free.
6455 func NewRepository(dir string) *Repository { return &Repository{dir: dir, legacy: NewStore(dir)} }
6556
66-func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPublication) error {
67- if err := ctx.Err(); err != nil {
68- return err
69- }
70- key := sessionKey(publication.Name)
71- lock := r.sessionLock(key)
72- lock.Lock()
73- defer lock.Unlock()
74-
75- manifest, refs, supplied, err := validatePublication(publication)
76- if err != nil {
77- return err
78- }
79- _ = manifest
80- if err := r.ensureSession(key); err != nil {
81- return err
82- }
83-
84- current, currentManifest, err := r.currentGeneration(ctx, publication.Name, key)
85- if err != nil {
86- return err
87- }
88- if publication.Generation < current || publication.Generation > current+1 {
89- return fmt.Errorf("snapshot generation %d, current %d: immutable conflict", publication.Generation, current)
90- }
91- if publication.Generation == current {
92- if !equalBytes(currentManifest, publication.Manifest) {
93- return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation)
94- }
95- return nil
96- }
97-
98- for digest, ref := range refs {
99- if err := ctx.Err(); err != nil {
100- return err
101- }
102- path := r.objectPath(key, digest)
103- exists, err := verifyObjectFile(path, digest, ref)
104- if err != nil {
105- return fmt.Errorf("verify object %x: %w", digest, err)
106- }
107- if exists {
108- continue
109- }
110- object, ok := supplied[digest]
111- if !ok {
112- return fmt.Errorf("missing referenced object %x", digest)
113- }
114- if r.hooks.beforeBlobWrite != nil {
115- if err := r.hooks.beforeBlobWrite(path); err != nil {
116- return err
117- }
118- }
119- if err := r.writeImmutable(path, object.Data, func(existing []byte) error {
120- if sha256.Sum256(existing) != digest || !validObject(existing, ref) {
121- return fmt.Errorf("existing immutable object is invalid")
122- }
123- return nil
124- }); err != nil {
125- return fmt.Errorf("write object: %w", err)
126- }
127- }
128-
129- manifestPath := r.manifestPath(key, publication.Generation)
130- existing, exists, err := readOptionalBounded(manifestPath)
131- if err != nil {
132- return err
133- }
134- if exists {
135- if !equalBytes(existing, publication.Manifest) {
136- return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
137- }
138- } else {
139- if r.hooks.beforeManifestWrite != nil {
140- if err := r.hooks.beforeManifestWrite(manifestPath); err != nil {
141- return err
142- }
143- }
144- if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error {
145- if !equalBytes(existing, publication.Manifest) {
146- return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
147- }
148- return nil
149- }); err != nil {
150- return fmt.Errorf("write manifest: %w", err)
151- }
152- }
153- if r.hooks.beforeHeadWrite != nil {
154- if err := r.hooks.beforeHeadWrite(r.headPath(key)); err != nil {
155- return err
156- }
157- }
158- if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
159- return fmt.Errorf("write HEAD: %w", err)
160- }
161- return nil
162-}
163-
164-func (r *Repository) List(ctx context.Context) ([]string, error) {
165- if err := ctx.Err(); err != nil {
166- return nil, err
167- }
168- entries, err := os.ReadDir(filepath.Join(r.dir, repositorySessionsDir))
169- if errors.Is(err, os.ErrNotExist) {
170- return nil, nil
171- }
172- if err != nil {
173- return nil, fmt.Errorf("read snapshot sessions: %w", err)
174- }
175- out := make([]string, 0, len(entries))
176- for _, entry := range entries {
177- if err := ctx.Err(); err != nil {
178- return nil, err
179- }
180- if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
181- continue
182- }
183- numbers, err := r.generationNumbers(entry.Name())
184- if err != nil {
185- continue
186- }
187- for _, number := range numbers {
188- data, err := readBounded(r.manifestPath(entry.Name(), number))
189- if err != nil {
190- continue
191- }
192- manifest, err := codec.UnmarshalManifest(data)
193- if err != nil || sessionKey(manifest.Name) != entry.Name() {
194- continue
195- }
196- generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number)
197- if err == nil {
198- out = append(out, generation.Name)
199- break
200- }
201- }
202- }
203- sort.Strings(out)
204- return out, nil
205-}
206-
207-func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
208- if err := ctx.Err(); err != nil {
209- return ports.SnapshotGeneration{}, err
210- }
211- key := sessionKey(name)
212- preferred, preferredDigest, err := r.readHead(key)
213- if err != nil && !errors.Is(err, os.ErrNotExist) {
214- preferred = 0
215- }
216- candidates, err := r.generationNumbers(key)
217- if err != nil {
218- return ports.SnapshotGeneration{}, err
219- }
220- if preferred != 0 {
221- candidates = append([]uint64{preferred}, removeGeneration(candidates, preferred)...)
222- }
223- skipped := false
224- for _, generation := range candidates {
225- if err := ctx.Err(); err != nil {
226- return ports.SnapshotGeneration{}, err
227- }
228- got, err := r.loadGeneration(ctx, name, key, generation)
229- if err != nil || (generation == preferred && sha256.Sum256(got.Manifest) != preferredDigest) {
230- skipped = true
231- continue
232- }
233- got.Fallback = skipped || (preferred != 0 && generation != preferred)
234- return got, nil
235- }
236- return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
57+func (r *Repository) sessionLock(key string) *sync.Mutex {
58+ lock, _ := r.locks.LoadOrStore(key, &sync.Mutex{})
59+ return lock.(*sync.Mutex)
23760 }
23861
23962 func (r *Repository) Delete(ctx context.Context, name string) error {
@@ -244,16 +67,20 @@ func (r *Repository) Delete(ctx context.Context, name string) error {
24467 lock := r.sessionLock(key)
24568 lock.Lock()
24669 defer lock.Unlock()
70+
24771 path := r.sessionPath(key)
24872 if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
24973 return nil
25074 } else if err != nil {
251- return fmt.Errorf("stat snapshot session: %w", err)
75+ return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err))
25276 }
25377 if err := os.RemoveAll(path); err != nil {
254- return fmt.Errorf("delete snapshot session: %w", err)
78+ return fmt.Errorf("remove snapshot session %q: %w", key, safeFilesystemError(err))
79+ }
80+ if err := r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir)); err != nil {
81+ return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err))
25582 }
256- return r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir))
83+ return nil
25784 }
25885
25986 // Maintain removes abandoned temporary and quarantine files. Immutable data is
@@ -267,7 +94,7 @@ func (r *Repository) Maintain(ctx context.Context) error {
26794 if errors.Is(err, os.ErrNotExist) {
26895 return nil
26996 }
270- return err
97+ return fmt.Errorf("walk snapshot maintenance path %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
27198 }
27299 if err := ctx.Err(); err != nil {
273100 return err
@@ -276,16 +103,47 @@ func (r *Repository) Maintain(ctx context.Context) error {
276103 return nil
277104 }
278105 base := entry.Name()
279- if strings.HasPrefix(base, ".tmp-") || strings.HasPrefix(base, ".quarantine-") {
280- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
281- return err
282- }
283- return r.syncDirectory(filepath.Dir(path))
106+ if !strings.HasPrefix(base, ".tmp-") && !strings.HasPrefix(base, ".quarantine-") {
107+ return nil
108+ }
109+ if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
110+ return fmt.Errorf("remove snapshot maintenance file %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
111+ }
112+ if err := r.syncDirectory(filepath.Dir(path)); err != nil {
113+ return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
284114 }
285115 return nil
286116 })
287117 }
288118
119+// maintenancePath avoids exposing the repository root while retaining a
120+// path-safe, actionable location in errors.
121+func maintenancePath(root, path string) string {
122+ rel, err := filepath.Rel(root, path)
123+ if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
124+ return filepath.Base(path)
125+ }
126+ return rel
127+}
128+
129+type safePathError struct {
130+ op string
131+ cause error
132+}
133+
134+func (e safePathError) Error() string { return e.op + ": " + e.cause.Error() }
135+func (e safePathError) Unwrap() error { return e.cause }
136+
137+// safeFilesystemError preserves error matching without including a filesystem
138+// path from an os.PathError in terminal-facing error text.
139+func safeFilesystemError(err error) error {
140+ var pathError *os.PathError
141+ if errors.As(err, &pathError) {
142+ return safePathError{op: pathError.Op, cause: pathError.Err}
143+ }
144+ return err
145+}
146+
289147 func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) {
290148 if err := ctx.Err(); err != nil {
291149 return nil, err
@@ -307,561 +165,3 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
307165 }
308166 return r.legacy.Delete(name)
309167 }
310-
311-func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) {
312- manifest, err := codec.UnmarshalManifest(p.Manifest)
313- if err != nil {
314- return codec.Manifest{}, nil, nil, fmt.Errorf("invalid manifest: %w", err)
315- }
316- if manifest.Name != p.Name || manifest.Generation != p.Generation || p.Generation == 0 {
317- return codec.Manifest{}, nil, nil, fmt.Errorf("manifest name or generation does not match publication")
318- }
319- refs := manifestRefs(manifest)
320- if refs == nil {
321- return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting manifest references")
322- }
323- if !withinGenerationBudget(len(p.Manifest), refs) {
324- return codec.Manifest{}, nil, nil, fmt.Errorf("snapshot generation too large")
325- }
326- objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects))
327- for _, object := range p.Objects {
328- if sha256.Sum256(object.Data) != object.Digest {
329- return codec.Manifest{}, nil, nil, fmt.Errorf("object digest mismatch")
330- }
331- ref, used := refs[object.Digest]
332- if !used {
333- return codec.Manifest{}, nil, nil, fmt.Errorf("unreferenced object")
334- }
335- kind, payload, err := codec.PreflightObject(object.Data)
336- if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {
337- return codec.Manifest{}, nil, nil, fmt.Errorf("invalid object envelope")
338- }
339- if old, duplicate := objects[object.Digest]; duplicate && !equalBytes(old.Data, object.Data) {
340- return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting object")
341- }
342- objects[object.Digest] = ports.SnapshotObject{Digest: object.Digest, Data: append([]byte(nil), object.Data...)}
343- }
344- return manifest, refs, objects, nil
345-}
346-
347-func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool {
348- if manifestSize < 0 || manifestSize > maxRepositoryRead {
349- return false
350- }
351- total := uint64(manifestSize)
352- for _, ref := range refs {
353- if uint64(ref.Size) > uint64(maxRepositoryRead)-total {
354- return false
355- }
356- total += uint64(ref.Size)
357- }
358- return true
359-}
360-
361-func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef {
362- refs := make(map[ports.SnapshotDigest]codec.ObjectRef)
363- add := func(ref codec.ObjectRef) bool {
364- if old, ok := refs[ref.Digest]; ok && old != ref {
365- return false
366- }
367- refs[ref.Digest] = ref
368- return true
369- }
370- for _, tab := range manifest.Tabs {
371- for _, pane := range tab.Panes {
372- for _, ref := range pane.Sealed {
373- if !add(ref) {
374- return nil
375- }
376- }
377- if !add(pane.Tail) || !add(pane.Visible) {
378- return nil
379- }
380- }
381- }
382- return refs
383-}
384-
385-func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
386- generation, digest, err := r.readHead(key)
387- if err == nil {
388- got, err := r.loadGeneration(ctx, name, key, generation)
389- if err == nil && sha256.Sum256(got.Manifest) == digest {
390- return generation, got.Manifest, nil
391- }
392- }
393- candidates, err := r.generationNumbers(key)
394- if err != nil {
395- return 0, nil, err
396- }
397- for _, candidate := range candidates {
398- if got, err := r.loadGeneration(ctx, name, key, candidate); err == nil {
399- return candidate, got.Manifest, nil
400- }
401- }
402- return 0, nil, nil
403-}
404-
405-func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
406- data, err := readBounded(r.manifestPath(key, generation))
407- if err != nil {
408- return ports.SnapshotGeneration{}, err
409- }
410- manifest, err := codec.UnmarshalManifest(data)
411- if err != nil || manifest.Name != name || manifest.Generation != generation {
412- return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest")
413- }
414- refs := manifestRefs(manifest)
415- if refs == nil || !withinGenerationBudget(len(data), refs) {
416- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large")
417- }
418- objects := make(map[ports.SnapshotDigest][]byte, len(refs))
419- for digest, ref := range refs {
420- if err := ctx.Err(); err != nil {
421- return ports.SnapshotGeneration{}, err
422- }
423- object, err := readBounded(r.objectPath(key, digest))
424- if err != nil {
425- return ports.SnapshotGeneration{}, err
426- }
427- if sha256.Sum256(object) != digest || !validObject(object, ref) {
428- return ports.SnapshotGeneration{}, fmt.Errorf("invalid object")
429- }
430- objects[digest] = append([]byte(nil), object...)
431- }
432- return ports.SnapshotGeneration{Name: manifest.Name, Generation: generation, Manifest: append([]byte(nil), data...), Objects: objects}, nil
433-}
434-
435-func validObject(data []byte, ref codec.ObjectRef) bool {
436- kind, payload, err := codec.PreflightObject(data)
437- return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size)
438-}
439-
440-func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {
441- data, exists, err := readOptionalBounded(path)
442- if err != nil || !exists {
443- return exists, err
444- }
445- if sha256.Sum256(data) != digest || !validObject(data, ref) {
446- return true, fmt.Errorf("existing immutable object is invalid")
447- }
448- return true, nil
449-}
450-
451-func (r *Repository) ensureSession(key string) error {
452- for _, directory := range []struct {
453- path string
454- phase string
455- }{
456- {r.dir, "repository"},
457- {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
458- {r.sessionPath(key), "session"},
459- {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},
460- {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},
461- } {
462- if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
463- return fmt.Errorf("create snapshot repository directory: %w", err)
464- }
465- }
466- return nil
467-}
468-
469-func (r *Repository) ensurePrivateDirectory(dir string) error {
470- return r.ensurePrivateDirectoryPhase(dir, "snapshot directory")
471-}
472-
473-func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
474- _, err := os.Lstat(dir)
475- created := errors.Is(err, os.ErrNotExist)
476- if err != nil && !created {
477- return err
478- }
479- if created {
480- parent := filepath.Dir(dir)
481- if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {
482- if err := r.ensurePrivateDirectoryPhase(parent, "snapshot directory"); err != nil {
483- return err
484- }
485- } else if err != nil {
486- return err
487- }
488- if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
489- return err
490- }
491- }
492- if err := safedir.EnsurePrivate(dir); err != nil {
493- return err
494- }
495- if created {
496- if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
497- return fmt.Errorf("%s parent directory sync: %w", phase, err)
498- }
499- }
500- return nil
501-}
502-
503-func (r *Repository) sessionPath(key string) string {
504- return filepath.Join(r.dir, repositorySessionsDir, key)
505-}
506-func (r *Repository) objectPath(key string, digest ports.SnapshotDigest) string {
507- hexDigest := hex.EncodeToString(digest[:])
508- return filepath.Join(r.sessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
509-}
510-func (r *Repository) manifestPath(key string, generation uint64) string {
511- return filepath.Join(r.sessionPath(key), repositoryGenerations, generationFilename(generation))
512-}
513-func (r *Repository) headPath(key string) string {
514- return filepath.Join(r.sessionPath(key), repositoryHead)
515-}
516-func (r *Repository) sessionLock(key string) *sync.Mutex {
517- lock, _ := r.locks.LoadOrStore(key, &sync.Mutex{})
518- return lock.(*sync.Mutex)
519-}
520-
521-func sessionKey(name string) string {
522- if safeNameRE.MatchString(name) && name != "." && name != ".." {
523- return name
524- }
525- sum := sha256.Sum256([]byte(name))
526- return "@" + hex.EncodeToString(sum[:])
527-}
528-func canonicalSessionKey(key string) bool {
529- if safeNameRE.MatchString(key) && key != "." && key != ".." {
530- return true
531- }
532- if len(key) != 65 || key[0] != '@' {
533- return false
534- }
535- _, err := hex.DecodeString(key[1:])
536- return err == nil && strings.ToLower(key[1:]) == key[1:]
537-}
538-func generationFilename(generation uint64) string {
539- return fmt.Sprintf("%0*d.manifest", generationWidth, generation)
540-}
541-func parseGenerationFilename(name string) (uint64, bool) {
542- if !strings.HasSuffix(name, ".manifest") || len(name) != generationWidth+len(".manifest") {
543- return 0, false
544- }
545- n := strings.TrimSuffix(name, ".manifest")
546- generation, err := strconv.ParseUint(n, 10, 64)
547- return generation, err == nil && generationFilename(generation) == name && generation != 0
548-}
549-
550-func (r *Repository) generationNumbers(key string) ([]uint64, error) {
551- entries, err := os.ReadDir(filepath.Join(r.sessionPath(key), repositoryGenerations))
552- if errors.Is(err, os.ErrNotExist) {
553- return nil, nil
554- }
555- if err != nil {
556- return nil, err
557- }
558- numbers := make([]uint64, 0, len(entries))
559- for _, entry := range entries {
560- if entry.IsDir() {
561- continue
562- }
563- if generation, ok := parseGenerationFilename(entry.Name()); ok {
564- numbers = append(numbers, generation)
565- }
566- }
567- sort.Slice(numbers, func(i, j int) bool { return numbers[i] > numbers[j] })
568- return numbers, nil
569-}
570-func removeGeneration(numbers []uint64, generation uint64) []uint64 {
571- out := numbers[:0]
572- for _, n := range numbers {
573- if n != generation {
574- out = append(out, n)
575- }
576- }
577- return out
578-}
579-
580-func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
581- out := make([]byte, 4+8+sha256.Size)
582- copy(out, "VEVH")
583- binary.BigEndian.PutUint64(out[4:12], generation)
584- copy(out[12:], digest[:])
585- return out
586-}
587-func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {
588- data, err := readBounded(r.headPath(key))
589- if err != nil {
590- return 0, ports.SnapshotDigest{}, err
591- }
592- if len(data) != 4+8+sha256.Size || string(data[:4]) != "VEVH" {
593- return 0, ports.SnapshotDigest{}, fmt.Errorf("invalid HEAD")
594- }
595- generation := binary.BigEndian.Uint64(data[4:12])
596- var digest ports.SnapshotDigest
597- copy(digest[:], data[12:])
598- if generation == 0 {
599- return 0, ports.SnapshotDigest{}, fmt.Errorf("invalid HEAD")
600- }
601- return generation, digest, nil
602-}
603-
604-// writeImmutable publishes data with link(2), whose EEXIST behavior prevents a
605-// raced target from being overwritten. A competing target is accepted only
606-// after verifier reads it through the same secure descriptor path as Load.
607-func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error {
608- dir := filepath.Dir(path)
609- phase := "object shard"
610- if filepath.Base(dir) == repositoryGenerations {
611- phase = "generation"
612- }
613- if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
614- return err
615- }
616- temp, err := r.createTemp(dir)
617- if err != nil {
618- return err
619- }
620- tempPath := temp.Name()
621- closed := false
622- cleanup := func(cause error) error {
623- if !closed {
624- if err := r.closeFile(temp); err != nil {
625- cause = errors.Join(cause, err)
626- }
627- closed = true
628- }
629- if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
630- cause = errors.Join(cause, err)
631- } else if err == nil {
632- cause = errors.Join(cause, r.syncDirectory(dir))
633- }
634- return cause
635- }
636- if err := temp.Chmod(0o600); err != nil {
637- return cleanup(err)
638- }
639- if err := r.writeFile(temp, data); err != nil {
640- return cleanup(err)
641- }
642- if err := r.syncFile(temp); err != nil {
643- return cleanup(err)
644- }
645- if err := r.closeFile(temp); err != nil {
646- closed = true
647- return cleanup(err)
648- }
649- closed = true
650- if err := r.installImmutable(tempPath, path); err != nil {
651- if errors.Is(err, os.ErrExist) {
652- existing, readErr := readBounded(path)
653- if readErr != nil {
654- return cleanup(readErr)
655- }
656- if verifyErr := verifier(existing); verifyErr != nil {
657- return cleanup(verifyErr)
658- }
659- return cleanup(nil)
660- }
661- return cleanup(err)
662- }
663- if err := r.syncDirectory(dir); err != nil {
664- return cleanup(err)
665- }
666- return cleanup(nil)
667-}
668-
669-// writeMutable is used only for HEAD, the sole authoritative pointer allowed
670-// to advance. The old or fully synced new HEAD is therefore always recoverable.
671-func (r *Repository) writeMutable(path string, data []byte) error {
672- dir := filepath.Dir(path)
673- if err := r.ensurePrivateDirectory(dir); err != nil {
674- return err
675- }
676- temp, err := r.createTemp(dir)
677- if err != nil {
678- return err
679- }
680- tempPath := temp.Name()
681- closed := false
682- cleanup := func(cause error) error {
683- if !closed {
684- if err := r.closeFile(temp); err != nil {
685- cause = errors.Join(cause, err)
686- }
687- closed = true
688- }
689- if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
690- cause = errors.Join(cause, err)
691- } else if err == nil {
692- cause = errors.Join(cause, r.syncDirectory(dir))
693- }
694- return cause
695- }
696- if err := temp.Chmod(0o600); err != nil {
697- return cleanup(err)
698- }
699- if err := r.writeFile(temp, data); err != nil {
700- return cleanup(err)
701- }
702- if err := r.syncFile(temp); err != nil {
703- return cleanup(err)
704- }
705- if err := r.closeFile(temp); err != nil {
706- closed = true
707- return cleanup(err)
708- }
709- closed = true
710- if err := r.rename(tempPath, path); err != nil {
711- return cleanup(err)
712- }
713- if err := r.syncDirectory(dir); err != nil {
714- return cleanup(err)
715- }
716- // rename consumed tempPath. Its directory sync above persists both rename
717- // and the absence of the temporary entry.
718- return nil
719-}
720-
721-func (r *Repository) createTemp(dir string) (*os.File, error) {
722- if r.hooks.createTemp != nil {
723- if err := r.hooks.createTemp(dir); err != nil {
724- return nil, err
725- }
726- }
727- return os.CreateTemp(dir, ".tmp-")
728-}
729-func (r *Repository) writeFile(f *os.File, data []byte) error {
730- if r.hooks.writeTemp != nil {
731- if err := r.hooks.writeTemp(f.Name()); err != nil {
732- return err
733- }
734- }
735- _, err := f.Write(data)
736- return err
737-}
738-func (r *Repository) syncFile(f *os.File) error {
739- if r.hooks.syncFile != nil {
740- if err := r.hooks.syncFile(f.Name()); err != nil {
741- return err
742- }
743- }
744- return f.Sync()
745-}
746-func (r *Repository) closeFile(f *os.File) error {
747- var injected error
748- if r.hooks.closeFile != nil {
749- injected = r.hooks.closeFile(f.Name())
750- }
751- closeErr := f.Close()
752- if injected != nil {
753- return injected
754- }
755- return closeErr
756-}
757-func (r *Repository) installImmutable(oldPath, newPath string) error {
758- if r.hooks.installImmutable != nil {
759- if err := r.hooks.installImmutable(newPath); err != nil {
760- return err
761- }
762- }
763- return os.Link(oldPath, newPath)
764-}
765-func (r *Repository) rename(oldPath, newPath string) error {
766- if r.hooks.rename != nil {
767- if err := r.hooks.rename(newPath); err != nil {
768- return err
769- }
770- }
771- return os.Rename(oldPath, newPath)
772-}
773-func (r *Repository) remove(path string) error {
774- if r.hooks.remove != nil {
775- if err := r.hooks.remove(path); err != nil {
776- return err
777- }
778- }
779- return os.Remove(path)
780-}
781-func (r *Repository) syncDirectory(dir string) error {
782- if r.hooks.syncDirectory != nil {
783- if err := r.hooks.syncDirectory(dir); err != nil {
784- return err
785- }
786- }
787- return syncDirectory(dir)
788-}
789-func syncDirectory(dir string) error {
790- f, err := os.Open(dir)
791- if err != nil {
792- return err
793- }
794- if err := f.Sync(); err != nil {
795- _ = f.Close()
796- return err
797- }
798- return f.Close()
799-}
800-
801-func readOptionalBounded(path string) ([]byte, bool, error) {
802- data, err := readBounded(path)
803- if errors.Is(err, os.ErrNotExist) {
804- return nil, false, nil
805- }
806- return data, err == nil, err
807-}
808-
809-// readBounded opens the final component with O_NOFOLLOW, validates the opened
810-// descriptor, then reads a hard bounded amount. It deliberately never trusts
811-// path metadata obtained before opening the descriptor.
812-func readBounded(path string) ([]byte, error) {
813- fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
814- if err != nil {
815- return nil, err
816- }
817- f := os.NewFile(uintptr(fd), path)
818- if f == nil {
819- _ = syscall.Close(fd)
820- return nil, fmt.Errorf("open snapshot file")
821- }
822- var stat syscall.Stat_t
823- if err := syscall.Fstat(fd, &stat); err != nil {
824- _ = f.Close()
825- return nil, err
826- }
827- if stat.Mode&syscall.S_IFMT != syscall.S_IFREG {
828- _ = f.Close()
829- return nil, fmt.Errorf("not a regular file")
830- }
831- if int(stat.Uid) != os.Geteuid() {
832- _ = f.Close()
833- return nil, fmt.Errorf("snapshot file is not owned by effective uid")
834- }
835- if stat.Mode&0o077 != 0 {
836- _ = f.Close()
837- return nil, fmt.Errorf("snapshot file has unsafe permissions")
838- }
839- if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) {
840- _ = f.Close()
841- return nil, fmt.Errorf("snapshot file too large")
842- }
843- data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1))
844- closeErr := f.Close()
845- if readErr != nil {
846- return nil, readErr
847- }
848- if closeErr != nil {
849- return nil, closeErr
850- }
851- if len(data) > maxRepositoryRead {
852- return nil, fmt.Errorf("snapshot file too large")
853- }
854- return data, nil
855-}
856-
857-func equalBytes(a, b []byte) bool {
858- if len(a) != len(b) {
859- return false
860- }
861- for i := range a {
862- if a[i] != b[i] {
863- return false
864- }
865- }
866- return true
867-}
@@ -2,23 +2,14 @@ package snapshot
2 2
3 import (3 import (
4 "context"4 "context"
5- "crypto/sha256"
6- "encoding/binary"
7- "encoding/hex"
8 "errors"5 "errors"
9 "fmt"6 "fmt"
10- "io"
11 "os"7 "os"
12 "path/filepath"8 "path/filepath"
13- "sort"
14- "strconv"
15 "strings"9 "strings"
16 "sync"10 "sync"
17- "syscall"
18 11
19 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
20- codec "github.com/bnema/vev/internal/usecase/snapshot"
21- "github.com/bnema/vev/pkg/safedir"
22 )13 )
23 14
24 const (15 const (
@@ -63,177 +54,9 @@ var _ ports.LegacySnapshotSource = (*Repository)(nil)
63 // until the first publication, so merely constructing it is side-effect free.54 // until the first publication, so merely constructing it is side-effect free.
64 func NewRepository(dir string) *Repository { return &Repository{dir: dir, legacy: NewStore(dir)} }55 func NewRepository(dir string) *Repository { return &Repository{dir: dir, legacy: NewStore(dir)} }
65 56
66-func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPublication) error {57+func (r *Repository) sessionLock(key string) *sync.Mutex {
67- if err := ctx.Err(); err != nil {58+ lock, _ := r.locks.LoadOrStore(key, &sync.Mutex{})
68- return err59+ return lock.(*sync.Mutex)
69- }
70- key := sessionKey(publication.Name)
71- lock := r.sessionLock(key)
72- lock.Lock()
73- defer lock.Unlock()
74-
75- manifest, refs, supplied, err := validatePublication(publication)
76- if err != nil {
77- return err
78- }
79- _ = manifest
80- if err := r.ensureSession(key); err != nil {
81- return err
82- }
83-
84- current, currentManifest, err := r.currentGeneration(ctx, publication.Name, key)
85- if err != nil {
86- return err
87- }
88- if publication.Generation < current || publication.Generation > current+1 {
89- return fmt.Errorf("snapshot generation %d, current %d: immutable conflict", publication.Generation, current)
90- }
91- if publication.Generation == current {
92- if !equalBytes(currentManifest, publication.Manifest) {
93- return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation)
94- }
95- return nil
96- }
97-
98- for digest, ref := range refs {
99- if err := ctx.Err(); err != nil {
100- return err
101- }
102- path := r.objectPath(key, digest)
103- exists, err := verifyObjectFile(path, digest, ref)
104- if err != nil {
105- return fmt.Errorf("verify object %x: %w", digest, err)
106- }
107- if exists {
108- continue
109- }
110- object, ok := supplied[digest]
111- if !ok {
112- return fmt.Errorf("missing referenced object %x", digest)
113- }
114- if r.hooks.beforeBlobWrite != nil {
115- if err := r.hooks.beforeBlobWrite(path); err != nil {
116- return err
117- }
118- }
119- if err := r.writeImmutable(path, object.Data, func(existing []byte) error {
120- if sha256.Sum256(existing) != digest || !validObject(existing, ref) {
121- return fmt.Errorf("existing immutable object is invalid")
122- }
123- return nil
124- }); err != nil {
125- return fmt.Errorf("write object: %w", err)
126- }
127- }
128-
129- manifestPath := r.manifestPath(key, publication.Generation)
130- existing, exists, err := readOptionalBounded(manifestPath)
131- if err != nil {
132- return err
133- }
134- if exists {
135- if !equalBytes(existing, publication.Manifest) {
136- return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
137- }
138- } else {
139- if r.hooks.beforeManifestWrite != nil {
140- if err := r.hooks.beforeManifestWrite(manifestPath); err != nil {
141- return err
142- }
143- }
144- if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error {
145- if !equalBytes(existing, publication.Manifest) {
146- return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
147- }
148- return nil
149- }); err != nil {
150- return fmt.Errorf("write manifest: %w", err)
151- }
152- }
153- if r.hooks.beforeHeadWrite != nil {
154- if err := r.hooks.beforeHeadWrite(r.headPath(key)); err != nil {
155- return err
156- }
157- }
158- if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
159- return fmt.Errorf("write HEAD: %w", err)
160- }
161- return nil
162-}
163-
164-func (r *Repository) List(ctx context.Context) ([]string, error) {
165- if err := ctx.Err(); err != nil {
166- return nil, err
167- }
168- entries, err := os.ReadDir(filepath.Join(r.dir, repositorySessionsDir))
169- if errors.Is(err, os.ErrNotExist) {
170- return nil, nil
171- }
172- if err != nil {
173- return nil, fmt.Errorf("read snapshot sessions: %w", err)
174- }
175- out := make([]string, 0, len(entries))
176- for _, entry := range entries {
177- if err := ctx.Err(); err != nil {
178- return nil, err
179- }
180- if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
181- continue
182- }
183- numbers, err := r.generationNumbers(entry.Name())
184- if err != nil {
185- continue
186- }
187- for _, number := range numbers {
188- data, err := readBounded(r.manifestPath(entry.Name(), number))
189- if err != nil {
190- continue
191- }
192- manifest, err := codec.UnmarshalManifest(data)
193- if err != nil || sessionKey(manifest.Name) != entry.Name() {
194- continue
195- }
196- generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number)
197- if err == nil {
198- out = append(out, generation.Name)
199- break
200- }
201- }
202- }
203- sort.Strings(out)
204- return out, nil
205-}
206-
207-func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
208- if err := ctx.Err(); err != nil {
209- return ports.SnapshotGeneration{}, err
210- }
211- key := sessionKey(name)
212- preferred, preferredDigest, err := r.readHead(key)
213- if err != nil && !errors.Is(err, os.ErrNotExist) {
214- preferred = 0
215- }
216- candidates, err := r.generationNumbers(key)
217- if err != nil {
218- return ports.SnapshotGeneration{}, err
219- }
220- if preferred != 0 {
221- candidates = append([]uint64{preferred}, removeGeneration(candidates, preferred)...)
222- }
223- skipped := false
224- for _, generation := range candidates {
225- if err := ctx.Err(); err != nil {
226- return ports.SnapshotGeneration{}, err
227- }
228- got, err := r.loadGeneration(ctx, name, key, generation)
229- if err != nil || (generation == preferred && sha256.Sum256(got.Manifest) != preferredDigest) {
230- skipped = true
231- continue
232- }
233- got.Fallback = skipped || (preferred != 0 && generation != preferred)
234- return got, nil
235- }
236- return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
237 }60 }
238 61
239 func (r *Repository) Delete(ctx context.Context, name string) error {62 func (r *Repository) Delete(ctx context.Context, name string) error {
@@ -244,16 +67,20 @@ func (r *Repository) Delete(ctx context.Context, name string) error {
244 lock := r.sessionLock(key)67 lock := r.sessionLock(key)
245 lock.Lock()68 lock.Lock()
246 defer lock.Unlock()69 defer lock.Unlock()
70+
247 path := r.sessionPath(key)71 path := r.sessionPath(key)
248 if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {72 if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
249 return nil73 return nil
250 } else if err != nil {74 } else if err != nil {
251- return fmt.Errorf("stat snapshot session: %w", err)75+ return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err))
252 }76 }
253 if err := os.RemoveAll(path); err != nil {77 if err := os.RemoveAll(path); err != nil {
254- return fmt.Errorf("delete snapshot session: %w", err)78+ return fmt.Errorf("remove snapshot session %q: %w", key, safeFilesystemError(err))
79+ }
80+ if err := r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir)); err != nil {
81+ return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err))
255 }82 }
256- return r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir))83+ return nil
257 }84 }
258 85
259 // Maintain removes abandoned temporary and quarantine files. Immutable data is86 // Maintain removes abandoned temporary and quarantine files. Immutable data is
@@ -267,7 +94,7 @@ func (r *Repository) Maintain(ctx context.Context) error {
267 if errors.Is(err, os.ErrNotExist) {94 if errors.Is(err, os.ErrNotExist) {
268 return nil95 return nil
269 }96 }
270- return err97+ return fmt.Errorf("walk snapshot maintenance path %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
271 }98 }
272 if err := ctx.Err(); err != nil {99 if err := ctx.Err(); err != nil {
273 return err100 return err
@@ -276,16 +103,47 @@ func (r *Repository) Maintain(ctx context.Context) error {
276 return nil103 return nil
277 }104 }
278 base := entry.Name()105 base := entry.Name()
279- if strings.HasPrefix(base, ".tmp-") || strings.HasPrefix(base, ".quarantine-") {106+ if !strings.HasPrefix(base, ".tmp-") && !strings.HasPrefix(base, ".quarantine-") {
280- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {107+ return nil
281- return err108+ }
282- }109+ if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
283- return r.syncDirectory(filepath.Dir(path))110+ return fmt.Errorf("remove snapshot maintenance file %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
111+ }
112+ if err := r.syncDirectory(filepath.Dir(path)); err != nil {
113+ return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
284 }114 }
285 return nil115 return nil
286 })116 })
287 }117 }
288 118
119+// maintenancePath avoids exposing the repository root while retaining a
120+// path-safe, actionable location in errors.
121+func maintenancePath(root, path string) string {
122+ rel, err := filepath.Rel(root, path)
123+ if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
124+ return filepath.Base(path)
125+ }
126+ return rel
127+}
128+
129+type safePathError struct {
130+ op string
131+ cause error
132+}
133+
134+func (e safePathError) Error() string { return e.op + ": " + e.cause.Error() }
135+func (e safePathError) Unwrap() error { return e.cause }
136+
137+// safeFilesystemError preserves error matching without including a filesystem
138+// path from an os.PathError in terminal-facing error text.
139+func safeFilesystemError(err error) error {
140+ var pathError *os.PathError
141+ if errors.As(err, &pathError) {
142+ return safePathError{op: pathError.Op, cause: pathError.Err}
143+ }
144+ return err
145+}
146+
289 func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) {147 func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) {
290 if err := ctx.Err(); err != nil {148 if err := ctx.Err(); err != nil {
291 return nil, err149 return nil, err
@@ -307,561 +165,3 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
307 }165 }
308 return r.legacy.Delete(name)166 return r.legacy.Delete(name)
309 }167 }
310-
311-func validatePublication(p ports.SnapshotPublication) (codec.Manifest, map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) {
312- manifest, err := codec.UnmarshalManifest(p.Manifest)
313- if err != nil {
314- return codec.Manifest{}, nil, nil, fmt.Errorf("invalid manifest: %w", err)
315- }
316- if manifest.Name != p.Name || manifest.Generation != p.Generation || p.Generation == 0 {
317- return codec.Manifest{}, nil, nil, fmt.Errorf("manifest name or generation does not match publication")
318- }
319- refs := manifestRefs(manifest)
320- if refs == nil {
321- return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting manifest references")
322- }
323- if !withinGenerationBudget(len(p.Manifest), refs) {
324- return codec.Manifest{}, nil, nil, fmt.Errorf("snapshot generation too large")
325- }
326- objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects))
327- for _, object := range p.Objects {
328- if sha256.Sum256(object.Data) != object.Digest {
329- return codec.Manifest{}, nil, nil, fmt.Errorf("object digest mismatch")
330- }
331- ref, used := refs[object.Digest]
332- if !used {
333- return codec.Manifest{}, nil, nil, fmt.Errorf("unreferenced object")
334- }
335- kind, payload, err := codec.PreflightObject(object.Data)
336- if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {
337- return codec.Manifest{}, nil, nil, fmt.Errorf("invalid object envelope")
338- }
339- if old, duplicate := objects[object.Digest]; duplicate && !equalBytes(old.Data, object.Data) {
340- return codec.Manifest{}, nil, nil, fmt.Errorf("conflicting object")
341- }
342- objects[object.Digest] = ports.SnapshotObject{Digest: object.Digest, Data: append([]byte(nil), object.Data...)}
343- }
344- return manifest, refs, objects, nil
345-}
346-
347-func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool {
348- if manifestSize < 0 || manifestSize > maxRepositoryRead {
349- return false
350- }
351- total := uint64(manifestSize)
352- for _, ref := range refs {
353- if uint64(ref.Size) > uint64(maxRepositoryRead)-total {
354- return false
355- }
356- total += uint64(ref.Size)
357- }
358- return true
359-}
360-
361-func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef {
362- refs := make(map[ports.SnapshotDigest]codec.ObjectRef)
363- add := func(ref codec.ObjectRef) bool {
364- if old, ok := refs[ref.Digest]; ok && old != ref {
365- return false
366- }
367- refs[ref.Digest] = ref
368- return true
369- }
370- for _, tab := range manifest.Tabs {
371- for _, pane := range tab.Panes {
372- for _, ref := range pane.Sealed {
373- if !add(ref) {
374- return nil
375- }
376- }
377- if !add(pane.Tail) || !add(pane.Visible) {
378- return nil
379- }
380- }
381- }
382- return refs
383-}
384-
385-func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
386- generation, digest, err := r.readHead(key)
387- if err == nil {
388- got, err := r.loadGeneration(ctx, name, key, generation)
389- if err == nil && sha256.Sum256(got.Manifest) == digest {
390- return generation, got.Manifest, nil
391- }
392- }
393- candidates, err := r.generationNumbers(key)
394- if err != nil {
395- return 0, nil, err
396- }
397- for _, candidate := range candidates {
398- if got, err := r.loadGeneration(ctx, name, key, candidate); err == nil {
399- return candidate, got.Manifest, nil
400- }
401- }
402- return 0, nil, nil
403-}
404-
405-func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
406- data, err := readBounded(r.manifestPath(key, generation))
407- if err != nil {
408- return ports.SnapshotGeneration{}, err
409- }
410- manifest, err := codec.UnmarshalManifest(data)
411- if err != nil || manifest.Name != name || manifest.Generation != generation {
412- return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest")
413- }
414- refs := manifestRefs(manifest)
415- if refs == nil || !withinGenerationBudget(len(data), refs) {
416- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large")
417- }
418- objects := make(map[ports.SnapshotDigest][]byte, len(refs))
419- for digest, ref := range refs {
420- if err := ctx.Err(); err != nil {
421- return ports.SnapshotGeneration{}, err
422- }
423- object, err := readBounded(r.objectPath(key, digest))
424- if err != nil {
425- return ports.SnapshotGeneration{}, err
426- }
427- if sha256.Sum256(object) != digest || !validObject(object, ref) {
428- return ports.SnapshotGeneration{}, fmt.Errorf("invalid object")
429- }
430- objects[digest] = append([]byte(nil), object...)
431- }
432- return ports.SnapshotGeneration{Name: manifest.Name, Generation: generation, Manifest: append([]byte(nil), data...), Objects: objects}, nil
433-}
434-
435-func validObject(data []byte, ref codec.ObjectRef) bool {
436- kind, payload, err := codec.PreflightObject(data)
437- return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size)
438-}
439-
440-func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {
441- data, exists, err := readOptionalBounded(path)
442- if err != nil || !exists {
443- return exists, err
444- }
445- if sha256.Sum256(data) != digest || !validObject(data, ref) {
446- return true, fmt.Errorf("existing immutable object is invalid")
447- }
448- return true, nil
449-}
450-
451-func (r *Repository) ensureSession(key string) error {
452- for _, directory := range []struct {
453- path string
454- phase string
455- }{
456- {r.dir, "repository"},
457- {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
458- {r.sessionPath(key), "session"},
459- {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},
460- {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},
461- } {
462- if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
463- return fmt.Errorf("create snapshot repository directory: %w", err)
464- }
465- }
466- return nil
467-}
468-
469-func (r *Repository) ensurePrivateDirectory(dir string) error {
470- return r.ensurePrivateDirectoryPhase(dir, "snapshot directory")
471-}
472-
473-func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
474- _, err := os.Lstat(dir)
475- created := errors.Is(err, os.ErrNotExist)
476- if err != nil && !created {
477- return err
478- }
479- if created {
480- parent := filepath.Dir(dir)
481- if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {
482- if err := r.ensurePrivateDirectoryPhase(parent, "snapshot directory"); err != nil {
483- return err
484- }
485- } else if err != nil {
486- return err
487- }
488- if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
489- return err
490- }
491- }
492- if err := safedir.EnsurePrivate(dir); err != nil {
493- return err
494- }
495- if created {
496- if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
497- return fmt.Errorf("%s parent directory sync: %w", phase, err)
498- }
499- }
500- return nil
501-}
502-
503-func (r *Repository) sessionPath(key string) string {
504- return filepath.Join(r.dir, repositorySessionsDir, key)
505-}
506-func (r *Repository) objectPath(key string, digest ports.SnapshotDigest) string {
507- hexDigest := hex.EncodeToString(digest[:])
508- return filepath.Join(r.sessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
509-}
510-func (r *Repository) manifestPath(key string, generation uint64) string {
511- return filepath.Join(r.sessionPath(key), repositoryGenerations, generationFilename(generation))
512-}
513-func (r *Repository) headPath(key string) string {
514- return filepath.Join(r.sessionPath(key), repositoryHead)
515-}
516-func (r *Repository) sessionLock(key string) *sync.Mutex {
517- lock, _ := r.locks.LoadOrStore(key, &sync.Mutex{})
518- return lock.(*sync.Mutex)
519-}
520-
521-func sessionKey(name string) string {
522- if safeNameRE.MatchString(name) && name != "." && name != ".." {
523- return name
524- }
525- sum := sha256.Sum256([]byte(name))
526- return "@" + hex.EncodeToString(sum[:])
527-}
528-func canonicalSessionKey(key string) bool {
529- if safeNameRE.MatchString(key) && key != "." && key != ".." {
530- return true
531- }
532- if len(key) != 65 || key[0] != '@' {
533- return false
534- }
535- _, err := hex.DecodeString(key[1:])
536- return err == nil && strings.ToLower(key[1:]) == key[1:]
537-}
538-func generationFilename(generation uint64) string {
539- return fmt.Sprintf("%0*d.manifest", generationWidth, generation)
540-}
541-func parseGenerationFilename(name string) (uint64, bool) {
542- if !strings.HasSuffix(name, ".manifest") || len(name) != generationWidth+len(".manifest") {
543- return 0, false
544- }
545- n := strings.TrimSuffix(name, ".manifest")
546- generation, err := strconv.ParseUint(n, 10, 64)
547- return generation, err == nil && generationFilename(generation) == name && generation != 0
548-}
549-
550-func (r *Repository) generationNumbers(key string) ([]uint64, error) {
551- entries, err := os.ReadDir(filepath.Join(r.sessionPath(key), repositoryGenerations))
552- if errors.Is(err, os.ErrNotExist) {
553- return nil, nil
554- }
555- if err != nil {
556- return nil, err
557- }
558- numbers := make([]uint64, 0, len(entries))
559- for _, entry := range entries {
560- if entry.IsDir() {
561- continue
562- }
563- if generation, ok := parseGenerationFilename(entry.Name()); ok {
564- numbers = append(numbers, generation)
565- }
566- }
567- sort.Slice(numbers, func(i, j int) bool { return numbers[i] > numbers[j] })
568- return numbers, nil
569-}
570-func removeGeneration(numbers []uint64, generation uint64) []uint64 {
571- out := numbers[:0]
572- for _, n := range numbers {
573- if n != generation {
574- out = append(out, n)
575- }
576- }
577- return out
578-}
579-
580-func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
581- out := make([]byte, 4+8+sha256.Size)
582- copy(out, "VEVH")
583- binary.BigEndian.PutUint64(out[4:12], generation)
584- copy(out[12:], digest[:])
585- return out
586-}
587-func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {
588- data, err := readBounded(r.headPath(key))
589- if err != nil {
590- return 0, ports.SnapshotDigest{}, err
591- }
592- if len(data) != 4+8+sha256.Size || string(data[:4]) != "VEVH" {
593- return 0, ports.SnapshotDigest{}, fmt.Errorf("invalid HEAD")
594- }
595- generation := binary.BigEndian.Uint64(data[4:12])
596- var digest ports.SnapshotDigest
597- copy(digest[:], data[12:])
598- if generation == 0 {
599- return 0, ports.SnapshotDigest{}, fmt.Errorf("invalid HEAD")
600- }
601- return generation, digest, nil
602-}
603-
604-// writeImmutable publishes data with link(2), whose EEXIST behavior prevents a
605-// raced target from being overwritten. A competing target is accepted only
606-// after verifier reads it through the same secure descriptor path as Load.
607-func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error {
608- dir := filepath.Dir(path)
609- phase := "object shard"
610- if filepath.Base(dir) == repositoryGenerations {
611- phase = "generation"
612- }
613- if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
614- return err
615- }
616- temp, err := r.createTemp(dir)
617- if err != nil {
618- return err
619- }
620- tempPath := temp.Name()
621- closed := false
622- cleanup := func(cause error) error {
623- if !closed {
624- if err := r.closeFile(temp); err != nil {
625- cause = errors.Join(cause, err)
626- }
627- closed = true
628- }
629- if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
630- cause = errors.Join(cause, err)
631- } else if err == nil {
632- cause = errors.Join(cause, r.syncDirectory(dir))
633- }
634- return cause
635- }
636- if err := temp.Chmod(0o600); err != nil {
637- return cleanup(err)
638- }
639- if err := r.writeFile(temp, data); err != nil {
640- return cleanup(err)
641- }
642- if err := r.syncFile(temp); err != nil {
643- return cleanup(err)
644- }
645- if err := r.closeFile(temp); err != nil {
646- closed = true
647- return cleanup(err)
648- }
649- closed = true
650- if err := r.installImmutable(tempPath, path); err != nil {
651- if errors.Is(err, os.ErrExist) {
652- existing, readErr := readBounded(path)
653- if readErr != nil {
654- return cleanup(readErr)
655- }
656- if verifyErr := verifier(existing); verifyErr != nil {
657- return cleanup(verifyErr)
658- }
659- return cleanup(nil)
660- }
661- return cleanup(err)
662- }
663- if err := r.syncDirectory(dir); err != nil {
664- return cleanup(err)
665- }
666- return cleanup(nil)
667-}
668-
669-// writeMutable is used only for HEAD, the sole authoritative pointer allowed
670-// to advance. The old or fully synced new HEAD is therefore always recoverable.
671-func (r *Repository) writeMutable(path string, data []byte) error {
672- dir := filepath.Dir(path)
673- if err := r.ensurePrivateDirectory(dir); err != nil {
674- return err
675- }
676- temp, err := r.createTemp(dir)
677- if err != nil {
678- return err
679- }
680- tempPath := temp.Name()
681- closed := false
682- cleanup := func(cause error) error {
683- if !closed {
684- if err := r.closeFile(temp); err != nil {
685- cause = errors.Join(cause, err)
686- }
687- closed = true
688- }
689- if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
690- cause = errors.Join(cause, err)
691- } else if err == nil {
692- cause = errors.Join(cause, r.syncDirectory(dir))
693- }
694- return cause
695- }
696- if err := temp.Chmod(0o600); err != nil {
697- return cleanup(err)
698- }
699- if err := r.writeFile(temp, data); err != nil {
700- return cleanup(err)
701- }
702- if err := r.syncFile(temp); err != nil {
703- return cleanup(err)
704- }
705- if err := r.closeFile(temp); err != nil {
706- closed = true
707- return cleanup(err)
708- }
709- closed = true
710- if err := r.rename(tempPath, path); err != nil {
711- return cleanup(err)
712- }
713- if err := r.syncDirectory(dir); err != nil {
714- return cleanup(err)
715- }
716- // rename consumed tempPath. Its directory sync above persists both rename
717- // and the absence of the temporary entry.
718- return nil
719-}
720-
721-func (r *Repository) createTemp(dir string) (*os.File, error) {
722- if r.hooks.createTemp != nil {
723- if err := r.hooks.createTemp(dir); err != nil {
724- return nil, err
725- }
726- }
727- return os.CreateTemp(dir, ".tmp-")
728-}
729-func (r *Repository) writeFile(f *os.File, data []byte) error {
730- if r.hooks.writeTemp != nil {
731- if err := r.hooks.writeTemp(f.Name()); err != nil {
732- return err
733- }
734- }
735- _, err := f.Write(data)
736- return err
737-}
738-func (r *Repository) syncFile(f *os.File) error {
739- if r.hooks.syncFile != nil {
740- if err := r.hooks.syncFile(f.Name()); err != nil {
741- return err
742- }
743- }
744- return f.Sync()
745-}
746-func (r *Repository) closeFile(f *os.File) error {
747- var injected error
748- if r.hooks.closeFile != nil {
749- injected = r.hooks.closeFile(f.Name())
750- }
751- closeErr := f.Close()
752- if injected != nil {
753- return injected
754- }
755- return closeErr
756-}
757-func (r *Repository) installImmutable(oldPath, newPath string) error {
758- if r.hooks.installImmutable != nil {
759- if err := r.hooks.installImmutable(newPath); err != nil {
760- return err
761- }
762- }
763- return os.Link(oldPath, newPath)
764-}
765-func (r *Repository) rename(oldPath, newPath string) error {
766- if r.hooks.rename != nil {
767- if err := r.hooks.rename(newPath); err != nil {
768- return err
769- }
770- }
771- return os.Rename(oldPath, newPath)
772-}
773-func (r *Repository) remove(path string) error {
774- if r.hooks.remove != nil {
775- if err := r.hooks.remove(path); err != nil {
776- return err
777- }
778- }
779- return os.Remove(path)
780-}
781-func (r *Repository) syncDirectory(dir string) error {
782- if r.hooks.syncDirectory != nil {
783- if err := r.hooks.syncDirectory(dir); err != nil {
784- return err
785- }
786- }
787- return syncDirectory(dir)
788-}
789-func syncDirectory(dir string) error {
790- f, err := os.Open(dir)
791- if err != nil {
792- return err
793- }
794- if err := f.Sync(); err != nil {
795- _ = f.Close()
796- return err
797- }
798- return f.Close()
799-}
800-
801-func readOptionalBounded(path string) ([]byte, bool, error) {
802- data, err := readBounded(path)
803- if errors.Is(err, os.ErrNotExist) {
804- return nil, false, nil
805- }
806- return data, err == nil, err
807-}
808-
809-// readBounded opens the final component with O_NOFOLLOW, validates the opened
810-// descriptor, then reads a hard bounded amount. It deliberately never trusts
811-// path metadata obtained before opening the descriptor.
812-func readBounded(path string) ([]byte, error) {
813- fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
814- if err != nil {
815- return nil, err
816- }
817- f := os.NewFile(uintptr(fd), path)
818- if f == nil {
819- _ = syscall.Close(fd)
820- return nil, fmt.Errorf("open snapshot file")
821- }
822- var stat syscall.Stat_t
823- if err := syscall.Fstat(fd, &stat); err != nil {
824- _ = f.Close()
825- return nil, err
826- }
827- if stat.Mode&syscall.S_IFMT != syscall.S_IFREG {
828- _ = f.Close()
829- return nil, fmt.Errorf("not a regular file")
830- }
831- if int(stat.Uid) != os.Geteuid() {
832- _ = f.Close()
833- return nil, fmt.Errorf("snapshot file is not owned by effective uid")
834- }
835- if stat.Mode&0o077 != 0 {
836- _ = f.Close()
837- return nil, fmt.Errorf("snapshot file has unsafe permissions")
838- }
839- if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) {
840- _ = f.Close()
841- return nil, fmt.Errorf("snapshot file too large")
842- }
843- data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1))
844- closeErr := f.Close()
845- if readErr != nil {
846- return nil, readErr
847- }
848- if closeErr != nil {
849- return nil, closeErr
850- }
851- if len(data) > maxRepositoryRead {
852- return nil, fmt.Errorf("snapshot file too large")
853- }
854- return data, nil
855-}
856-
857-func equalBytes(a, b []byte) bool {
858- if len(a) != len(b) {
859- return false
860- }
861- for i := range a {
862- if a[i] != b[i] {
863- return false
864- }
865- }
866- return true
867-}
M internal/adapters/snapshot/repository_durability_test.go +14 -147
@@ -34,126 +34,10 @@ func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) {
3434 }
3535 }
3636
37-func TestRepositoryFaultsKeepAnAuthoritativeGeneration(t *testing.T) {
38- stages := []string{"create", "write", "sync-file", "close", "install", "rename", "sync-directory"}
39- for _, stage := range stages {
40- t.Run(stage, func(t *testing.T) {
41- repo := NewRepository(privateDir(t))
42- first := repositoryPublication(t, "named", 1, []byte("one"))
43- if err := repo.Publish(context.Background(), first); err != nil {
44- t.Fatal(err)
45- }
46- second := repositoryPublication(t, "named", 2, []byte("two"))
47- injectPublicationStage(t, repo, second, stage)
48- if err := repo.Publish(context.Background(), second); err == nil {
49- t.Fatal("Publish succeeded with injected failure")
50- }
51- repo.hooks = repositoryHooks{}
52- got, err := repo.Load(context.Background(), "named")
53- if err != nil {
54- t.Fatal(err)
55- }
56- if got.Generation != 1 && got.Generation != 2 {
57- t.Fatalf("authoritative generation = %d, want old or new", got.Generation)
58- }
59- })
60- }
61-}
62-
63-// injectPublicationStage directs failures at the blob, manifest, or HEAD
64-// occurrence of a primitive. This keeps tests deterministic while exercising
65-// every publication primitive rather than just the first blob write.
66-func injectPublicationStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication, stage string) {
67- t.Helper()
68- key := sessionKey(pub.Name)
69- blobDir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest))
70- manifestDir := filepath.Dir(repo.manifestPath(key, pub.Generation))
71- headDir := filepath.Dir(repo.headPath(key))
72- fail := errors.New("injected persistence failure")
73- if stage == "create" {
74- repo.hooks.createTemp = func(dir string) error {
75- if dir == blobDir {
76- return fail
77- }
78- return nil
79- }
80- return
81- }
82- if stage == "write" || stage == "sync-file" || stage == "close" || stage == "install" {
83- // Preinstall blobs so the selected failure is at manifest publication.
84- for _, object := range pub.Objects {
85- ref := manifestReference(t, pub.Manifest, object.Digest)
86- if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {
87- if !equalBytes(data, object.Data) || !validObject(data, ref) {
88- return errors.New("invalid preinstalled object")
89- }
90- return nil
91- }); err != nil {
92- t.Fatal(err)
93- }
94- }
95- repo.hooks.writeTemp = func(path string) error {
96- if stage == "write" && filepath.Dir(path) == manifestDir {
97- return fail
98- }
99- return nil
100- }
101- repo.hooks.syncFile = func(path string) error {
102- if stage == "sync-file" && filepath.Dir(path) == manifestDir {
103- return fail
104- }
105- return nil
106- }
107- repo.hooks.closeFile = func(path string) error {
108- if stage == "close" && filepath.Dir(path) == manifestDir {
109- return fail
110- }
111- return nil
112- }
113- repo.hooks.installImmutable = func(path string) error {
114- if stage == "install" && filepath.Dir(path) == manifestDir {
115- return fail
116- }
117- return nil
118- }
119- return
120- }
121- if stage == "rename" {
122- // Preinstall immutable data and the manifest so Publish reaches HEAD.
123- prepareHeadStage(t, repo, pub)
124- repo.hooks.rename = func(path string) error {
125- if path == repo.headPath(key) {
126- return fail
127- }
128- return nil
129- }
130- return
131- }
132- if stage == "sync-directory" {
133- prepareHeadStage(t, repo, pub)
134- repo.hooks.syncDirectory = func(dir string) error {
135- if dir == headDir {
136- return fail
137- }
138- return nil
139- }
140- }
141-}
142-
14337 func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
14438 t.Helper()
14539 key := sessionKey(pub.Name)
146- for _, object := range pub.Objects {
147- ref := manifestReference(t, pub.Manifest, object.Digest)
148- if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {
149- if !equalBytes(data, object.Data) || !validObject(data, ref) {
150- return errors.New("invalid object")
151- }
152- return nil
153- }); err != nil {
154- t.Fatal(err)
155- }
156- }
40+ prepareObjects(t, repo, pub)
15741 if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
15842 if !equalBytes(data, pub.Manifest) {
15943 return errors.New("invalid manifest")
@@ -198,12 +82,7 @@ func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
19882 if err := repo.ensureSession(key); err != nil {
19983 t.Fatal(err)
20084 }
201- if tc.location == "manifest" {
202- prepareObjects(t, repo, second)
203- }
204- if tc.location == "HEAD" {
205- prepareHeadStage(t, repo, second)
206- }
85+ preparePublicationLocation(t, repo, second, tc.location)
20786 injectBoundary(repo, tc.location, tc.operation, second)
20887 if err := repo.Publish(context.Background(), second); err == nil {
20988 t.Fatal("Publish succeeded with injected persistence failure")
@@ -220,6 +99,18 @@ func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
22099 }
221100 }
222101
102+// preparePublicationLocation advances setup only far enough to target the
103+// requested persistence location in the publication fault matrix.
104+func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.SnapshotPublication, location string) {
105+ t.Helper()
106+ switch location {
107+ case "manifest":
108+ prepareObjects(t, repo, pub)
109+ case "HEAD":
110+ prepareHeadStage(t, repo, pub)
111+ }
112+}
113+
223114 func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {
224115 key := sessionKey(pub.Name)
225116 dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest))
@@ -289,27 +180,3 @@ func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublicatio
289180 }
290181 }
291182 }
292-
293-func TestRepositoryCleanupAndDeletionSyncErrorsSurface(t *testing.T) {
294- repo := NewRepository(privateDir(t))
295- pub := repositoryPublication(t, "named", 1, []byte("state"))
296- cleanupErr := errors.New("cleanup failed")
297- repo.hooks.writeTemp = func(string) error { return errors.New("write failed") }
298- repo.hooks.remove = func(string) error { return cleanupErr }
299- if err := repo.Publish(context.Background(), pub); !errors.Is(err, cleanupErr) {
300- t.Fatalf("Publish error = %v, want cleanup error", err)
301- }
302- repo.hooks = repositoryHooks{}
303- if err := repo.Publish(context.Background(), pub); err != nil {
304- t.Fatal(err)
305- }
306- repo.hooks.syncDirectory = func(dir string) error {
307- if dir == filepath.Join(repo.dir, repositorySessionsDir) {
308- return errors.New("delete sync failed")
309- }
310- return nil
311- }
312- if err := repo.Delete(context.Background(), "named"); err == nil {
313- t.Fatal("Delete succeeded with directory sync failure")
314- }
315-}
@@ -34,126 +34,10 @@ func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) {
34 }34 }
35 }35 }
36 36
37-func TestRepositoryFaultsKeepAnAuthoritativeGeneration(t *testing.T) {
38- stages := []string{"create", "write", "sync-file", "close", "install", "rename", "sync-directory"}
39- for _, stage := range stages {
40- t.Run(stage, func(t *testing.T) {
41- repo := NewRepository(privateDir(t))
42- first := repositoryPublication(t, "named", 1, []byte("one"))
43- if err := repo.Publish(context.Background(), first); err != nil {
44- t.Fatal(err)
45- }
46- second := repositoryPublication(t, "named", 2, []byte("two"))
47- injectPublicationStage(t, repo, second, stage)
48- if err := repo.Publish(context.Background(), second); err == nil {
49- t.Fatal("Publish succeeded with injected failure")
50- }
51- repo.hooks = repositoryHooks{}
52- got, err := repo.Load(context.Background(), "named")
53- if err != nil {
54- t.Fatal(err)
55- }
56- if got.Generation != 1 && got.Generation != 2 {
57- t.Fatalf("authoritative generation = %d, want old or new", got.Generation)
58- }
59- })
60- }
61-}
62-
63-// injectPublicationStage directs failures at the blob, manifest, or HEAD
64-// occurrence of a primitive. This keeps tests deterministic while exercising
65-// every publication primitive rather than just the first blob write.
66-func injectPublicationStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication, stage string) {
67- t.Helper()
68- key := sessionKey(pub.Name)
69- blobDir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest))
70- manifestDir := filepath.Dir(repo.manifestPath(key, pub.Generation))
71- headDir := filepath.Dir(repo.headPath(key))
72- fail := errors.New("injected persistence failure")
73- if stage == "create" {
74- repo.hooks.createTemp = func(dir string) error {
75- if dir == blobDir {
76- return fail
77- }
78- return nil
79- }
80- return
81- }
82- if stage == "write" || stage == "sync-file" || stage == "close" || stage == "install" {
83- // Preinstall blobs so the selected failure is at manifest publication.
84- for _, object := range pub.Objects {
85- ref := manifestReference(t, pub.Manifest, object.Digest)
86- if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {
87- if !equalBytes(data, object.Data) || !validObject(data, ref) {
88- return errors.New("invalid preinstalled object")
89- }
90- return nil
91- }); err != nil {
92- t.Fatal(err)
93- }
94- }
95- repo.hooks.writeTemp = func(path string) error {
96- if stage == "write" && filepath.Dir(path) == manifestDir {
97- return fail
98- }
99- return nil
100- }
101- repo.hooks.syncFile = func(path string) error {
102- if stage == "sync-file" && filepath.Dir(path) == manifestDir {
103- return fail
104- }
105- return nil
106- }
107- repo.hooks.closeFile = func(path string) error {
108- if stage == "close" && filepath.Dir(path) == manifestDir {
109- return fail
110- }
111- return nil
112- }
113- repo.hooks.installImmutable = func(path string) error {
114- if stage == "install" && filepath.Dir(path) == manifestDir {
115- return fail
116- }
117- return nil
118- }
119- return
120- }
121- if stage == "rename" {
122- // Preinstall immutable data and the manifest so Publish reaches HEAD.
123- prepareHeadStage(t, repo, pub)
124- repo.hooks.rename = func(path string) error {
125- if path == repo.headPath(key) {
126- return fail
127- }
128- return nil
129- }
130- return
131- }
132- if stage == "sync-directory" {
133- prepareHeadStage(t, repo, pub)
134- repo.hooks.syncDirectory = func(dir string) error {
135- if dir == headDir {
136- return fail
137- }
138- return nil
139- }
140- }
141-}
142-
143 func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {37 func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
144 t.Helper()38 t.Helper()
145 key := sessionKey(pub.Name)39 key := sessionKey(pub.Name)
146- for _, object := range pub.Objects {40+ prepareObjects(t, repo, pub)
147- ref := manifestReference(t, pub.Manifest, object.Digest)
148- if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {
149- if !equalBytes(data, object.Data) || !validObject(data, ref) {
150- return errors.New("invalid object")
151- }
152- return nil
153- }); err != nil {
154- t.Fatal(err)
155- }
156- }
157 if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {41 if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
158 if !equalBytes(data, pub.Manifest) {42 if !equalBytes(data, pub.Manifest) {
159 return errors.New("invalid manifest")43 return errors.New("invalid manifest")
@@ -198,12 +82,7 @@ func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
198 if err := repo.ensureSession(key); err != nil {82 if err := repo.ensureSession(key); err != nil {
199 t.Fatal(err)83 t.Fatal(err)
200 }84 }
201- if tc.location == "manifest" {85+ preparePublicationLocation(t, repo, second, tc.location)
202- prepareObjects(t, repo, second)
203- }
204- if tc.location == "HEAD" {
205- prepareHeadStage(t, repo, second)
206- }
207 injectBoundary(repo, tc.location, tc.operation, second)86 injectBoundary(repo, tc.location, tc.operation, second)
208 if err := repo.Publish(context.Background(), second); err == nil {87 if err := repo.Publish(context.Background(), second); err == nil {
209 t.Fatal("Publish succeeded with injected persistence failure")88 t.Fatal("Publish succeeded with injected persistence failure")
@@ -220,6 +99,18 @@ func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
220 }99 }
221 }100 }
222 101
102+// preparePublicationLocation advances setup only far enough to target the
103+// requested persistence location in the publication fault matrix.
104+func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.SnapshotPublication, location string) {
105+ t.Helper()
106+ switch location {
107+ case "manifest":
108+ prepareObjects(t, repo, pub)
109+ case "HEAD":
110+ prepareHeadStage(t, repo, pub)
111+ }
112+}
113+
223 func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {114 func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {
224 key := sessionKey(pub.Name)115 key := sessionKey(pub.Name)
225 dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest))116 dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest))
@@ -289,27 +180,3 @@ func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublicatio
289 }180 }
290 }181 }
291 }182 }
292-
293-func TestRepositoryCleanupAndDeletionSyncErrorsSurface(t *testing.T) {
294- repo := NewRepository(privateDir(t))
295- pub := repositoryPublication(t, "named", 1, []byte("state"))
296- cleanupErr := errors.New("cleanup failed")
297- repo.hooks.writeTemp = func(string) error { return errors.New("write failed") }
298- repo.hooks.remove = func(string) error { return cleanupErr }
299- if err := repo.Publish(context.Background(), pub); !errors.Is(err, cleanupErr) {
300- t.Fatalf("Publish error = %v, want cleanup error", err)
301- }
302- repo.hooks = repositoryHooks{}
303- if err := repo.Publish(context.Background(), pub); err != nil {
304- t.Fatal(err)
305- }
306- repo.hooks.syncDirectory = func(dir string) error {
307- if dir == filepath.Join(repo.dir, repositorySessionsDir) {
308- return errors.New("delete sync failed")
309- }
310- return nil
311- }
312- if err := repo.Delete(context.Background(), "named"); err == nil {
313- t.Fatal("Delete succeeded with directory sync failure")
314- }
315-}
A internal/adapters/snapshot/repository_maintenance_test.go +62 -0
@@ -0,0 +1,62 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestSafeFilesystemErrorDoesNotExposePath(t *testing.T) {
13+ cause := errors.New("permission denied")
14+ err := safeFilesystemError(&os.PathError{Op: "remove", Path: "\x1b[31munsafe", Err: cause})
15+ if !errors.Is(err, cause) || strings.Contains(err.Error(), "\x1b[31munsafe") {
16+ t.Fatalf("safe filesystem error = %q", err)
17+ }
18+}
19+
20+func TestRepositoryMaintenanceFilesystemErrorsHaveSafeContext(t *testing.T) {
21+ injected := errors.New("injected filesystem failure")
22+ cases := []struct {
23+ name string
24+ run func(*Repository) error
25+ want string
26+ }{
27+ {
28+ name: "delete sync",
29+ run: func(repo *Repository) error {
30+ publication := repositoryPublication(t, "named", 1, []byte("state"))
31+ if err := repo.Publish(context.Background(), publication); err != nil {
32+ t.Fatal(err)
33+ }
34+ repo.hooks.syncDirectory = func(string) error { return injected }
35+ return repo.Delete(context.Background(), "named")
36+ },
37+ want: `sync deleted snapshot session directory "named"`,
38+ },
39+ {
40+ name: "maintain remove",
41+ run: func(repo *Repository) error {
42+ if err := os.Mkdir(repo.dir, 0o700); err != nil {
43+ t.Fatal(err)
44+ }
45+ if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-stale"), []byte("stale"), 0o600); err != nil {
46+ t.Fatal(err)
47+ }
48+ repo.hooks.remove = func(string) error { return injected }
49+ return repo.Maintain(context.Background())
50+ },
51+ want: `remove snapshot maintenance file ".tmp-stale"`,
52+ },
53+ }
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ err := tc.run(NewRepository(privateDir(t)))
57+ if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.want) {
58+ t.Fatalf("error = %v, want context %q and injected error", err, tc.want)
59+ }
60+ })
61+ }
62+}
@@ -0,0 +1,62 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestSafeFilesystemErrorDoesNotExposePath(t *testing.T) {
13+ cause := errors.New("permission denied")
14+ err := safeFilesystemError(&os.PathError{Op: "remove", Path: "\x1b[31munsafe", Err: cause})
15+ if !errors.Is(err, cause) || strings.Contains(err.Error(), "\x1b[31munsafe") {
16+ t.Fatalf("safe filesystem error = %q", err)
17+ }
18+}
19+
20+func TestRepositoryMaintenanceFilesystemErrorsHaveSafeContext(t *testing.T) {
21+ injected := errors.New("injected filesystem failure")
22+ cases := []struct {
23+ name string
24+ run func(*Repository) error
25+ want string
26+ }{
27+ {
28+ name: "delete sync",
29+ run: func(repo *Repository) error {
30+ publication := repositoryPublication(t, "named", 1, []byte("state"))
31+ if err := repo.Publish(context.Background(), publication); err != nil {
32+ t.Fatal(err)
33+ }
34+ repo.hooks.syncDirectory = func(string) error { return injected }
35+ return repo.Delete(context.Background(), "named")
36+ },
37+ want: `sync deleted snapshot session directory "named"`,
38+ },
39+ {
40+ name: "maintain remove",
41+ run: func(repo *Repository) error {
42+ if err := os.Mkdir(repo.dir, 0o700); err != nil {
43+ t.Fatal(err)
44+ }
45+ if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-stale"), []byte("stale"), 0o600); err != nil {
46+ t.Fatal(err)
47+ }
48+ repo.hooks.remove = func(string) error { return injected }
49+ return repo.Maintain(context.Background())
50+ },
51+ want: `remove snapshot maintenance file ".tmp-stale"`,
52+ },
53+ }
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ err := tc.run(NewRepository(privateDir(t)))
57+ if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.want) {
58+ t.Fatalf("error = %v, want context %q and injected error", err, tc.want)
59+ }
60+ })
61+ }
62+}
M internal/adapters/snapshot/store.go +3 -11
@@ -72,7 +72,7 @@ func (s *Store) Write(name string, data []byte) error {
7272 return fmt.Errorf("rename snapshot: %w", err)
7373 }
7474 keepTmp = true
75- if err := syncDir(s.dir); err != nil {
75+ if err := syncDirectory(s.dir); err != nil {
7676 return fmt.Errorf("sync snapshot dir: %w", err)
7777 }
7878 return nil
@@ -101,7 +101,7 @@ func (s *Store) Load() ([]ports.SnapshotBlob, error) {
101101 if entry.IsDir() || !strings.HasSuffix(base, ".snap") {
102102 continue
103103 }
104- data, err := readSnapshotFileBounded(path)
104+ data, err := readBounded(path)
105105 if err != nil {
106106 s.log.Warn("read snapshot", "path", path, "err", err)
107107 continue
@@ -124,16 +124,12 @@ func (s *Store) Delete(name string) error {
124124 if err != nil {
125125 return fmt.Errorf("delete snapshot: %w", err)
126126 }
127- if err := syncDir(s.dir); err != nil {
127+ if err := syncDirectory(s.dir); err != nil {
128128 return fmt.Errorf("sync snapshot dir: %w", err)
129129 }
130130 return nil
131131 }
132132
133-func readSnapshotFileBounded(path string) ([]byte, error) {
134- return readBounded(path)
135-}
136-
137133 func filenameForName(name string) string {
138134 if safeNameRE.MatchString(name) {
139135 return name + ".snap"
@@ -141,7 +137,3 @@ func filenameForName(name string) string {
141137 sum := sha256.Sum256([]byte(name))
142138 return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
143139 }
144-
145-func syncDir(dir string) error {
146- return syncDirectory(dir)
147-}
@@ -72,7 +72,7 @@ func (s *Store) Write(name string, data []byte) error {
72 return fmt.Errorf("rename snapshot: %w", err)72 return fmt.Errorf("rename snapshot: %w", err)
73 }73 }
74 keepTmp = true74 keepTmp = true
75- if err := syncDir(s.dir); err != nil {75+ if err := syncDirectory(s.dir); err != nil {
76 return fmt.Errorf("sync snapshot dir: %w", err)76 return fmt.Errorf("sync snapshot dir: %w", err)
77 }77 }
78 return nil78 return nil
@@ -101,7 +101,7 @@ func (s *Store) Load() ([]ports.SnapshotBlob, error) {
101 if entry.IsDir() || !strings.HasSuffix(base, ".snap") {101 if entry.IsDir() || !strings.HasSuffix(base, ".snap") {
102 continue102 continue
103 }103 }
104- data, err := readSnapshotFileBounded(path)104+ data, err := readBounded(path)
105 if err != nil {105 if err != nil {
106 s.log.Warn("read snapshot", "path", path, "err", err)106 s.log.Warn("read snapshot", "path", path, "err", err)
107 continue107 continue
@@ -124,16 +124,12 @@ func (s *Store) Delete(name string) error {
124 if err != nil {124 if err != nil {
125 return fmt.Errorf("delete snapshot: %w", err)125 return fmt.Errorf("delete snapshot: %w", err)
126 }126 }
127- if err := syncDir(s.dir); err != nil {127+ if err := syncDirectory(s.dir); err != nil {
128 return fmt.Errorf("sync snapshot dir: %w", err)128 return fmt.Errorf("sync snapshot dir: %w", err)
129 }129 }
130 return nil130 return nil
131 }131 }
132 132
133-func readSnapshotFileBounded(path string) ([]byte, error) {
134- return readBounded(path)
135-}
136-
137 func filenameForName(name string) string {133 func filenameForName(name string) string {
138 if safeNameRE.MatchString(name) {134 if safeNameRE.MatchString(name) {
139 return name + ".snap"135 return name + ".snap"
@@ -141,7 +137,3 @@ func filenameForName(name string) string {
141 sum := sha256.Sum256([]byte(name))137 sum := sha256.Sum256([]byte(name))
142 return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"138 return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
143 }139 }
144-
145-func syncDir(dir string) error {
146- return syncDirectory(dir)
147-}

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