detached 48f90b4

fix(snapshot): prevent repository symlink traversal

commit
48f90b4acb9642aa72169d54535440af2d808102
author
brice · · 2026-07-22 04:02 UTC
parent
6de1185
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +384 -62
M internal/adapters/snapshot/atomic.go +120 -22
@@ -7,6 +7,7 @@ import (
77 "os"
88 "path/filepath"
99 "syscall"
10+ "unsafe"
1011
1112 "github.com/bnema/vev/pkg/safedir"
1213 )
@@ -34,27 +35,61 @@ func (r *Repository) ensurePrivateDirectory(dir string) error {
3435 }
3536
3637 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
38+ // The configured root is the trust boundary. Once it is private, every
39+ // descendant is created through a pinned parent descriptor rather than via
40+ // a path that an attacker can replace between checks.
41+ if filepath.Clean(dir) == filepath.Clean(r.dir) {
42+ _, statErr := os.Lstat(dir)
43+ created := errors.Is(statErr, os.ErrNotExist)
44+ if statErr != nil && !created {
45+ return statErr
5046 }
51- if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
47+ if err := safedir.EnsurePrivate(dir); err != nil {
5248 return err
5349 }
50+ if created {
51+ if hook := r.hooks.syncDirectory; hook != nil {
52+ if err := hook(filepath.Dir(dir)); err != nil {
53+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
54+ }
55+ }
56+ if err := syncDirectory(filepath.Dir(dir)); err != nil {
57+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
58+ }
59+ }
60+ return nil
5461 }
55- if err := safedir.EnsurePrivate(dir); err != nil {
62+ if err := r.ensurePrivateDirectoryPhase(filepath.Dir(dir), "snapshot directory"); err != nil {
5663 return err
5764 }
65+ parent, name, err := r.repositoryParent(dir)
66+ if err != nil {
67+ return err
68+ }
69+ created := false
70+ if err := syscall.Mkdirat(parent, name, 0o700); err == nil {
71+ created = true
72+ } else if err != syscall.EEXIST {
73+ _ = syscall.Close(parent)
74+ return err
75+ }
76+ _ = syscall.Close(parent)
77+ opened, err := r.openDirectory(dir)
78+ if err != nil {
79+ return err
80+ }
81+ var st syscall.Stat_t
82+ statErr := syscall.Fstat(int(opened.Fd()), &st)
83+ closeErr := opened.Close()
84+ if statErr != nil {
85+ return statErr
86+ }
87+ if closeErr != nil {
88+ return closeErr
89+ }
90+ if int(st.Uid) != os.Geteuid() || st.Mode&0o777 != 0o700 {
91+ return fmt.Errorf("snapshot directory is not private")
92+ }
5893 if created {
5994 if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
6095 return fmt.Errorf("%s parent directory sync: %w", phase, err)
@@ -111,7 +146,7 @@ func (r *Repository) writeImmutable(path string, data []byte, verifier func([]by
111146 closed = true
112147 if err := r.installImmutable(tempPath, path); err != nil {
113148 if errors.Is(err, os.ErrExist) {
114- existing, readErr := readBounded(path)
149+ existing, readErr := r.readBounded(path)
115150 if readErr != nil {
116151 return cleanup(readErr)
117152 }
@@ -185,7 +220,7 @@ func (r *Repository) createTemp(dir string) (*os.File, error) {
185220 return nil, err
186221 }
187222 }
188- return os.CreateTemp(dir, ".tmp-")
223+ return r.createTempAt(dir)
189224 }
190225 func (r *Repository) writeFile(f *os.File, data []byte) error {
191226 if r.hooks.writeTemp != nil {
@@ -221,7 +256,29 @@ func (r *Repository) installImmutable(oldPath, newPath string) error {
221256 return err
222257 }
223258 }
224- return os.Link(oldPath, newPath)
259+ oldFD, oldName, err := r.repositoryParent(oldPath)
260+ if err != nil {
261+ return err
262+ }
263+ defer syscall.Close(oldFD)
264+ newFD, newName, err := r.repositoryParent(newPath)
265+ if err != nil {
266+ return err
267+ }
268+ defer syscall.Close(newFD)
269+ oldPtr, err := syscall.BytePtrFromString(oldName)
270+ if err != nil {
271+ return err
272+ }
273+ newPtr, err := syscall.BytePtrFromString(newName)
274+ if err != nil {
275+ return err
276+ }
277+ _, _, errno := syscall.Syscall6(syscall.SYS_LINKAT, uintptr(oldFD), uintptr(unsafe.Pointer(oldPtr)), uintptr(newFD), uintptr(unsafe.Pointer(newPtr)), 0, 0)
278+ if errno != 0 {
279+ return errno
280+ }
281+ return nil
225282 }
226283 func (r *Repository) rename(oldPath, newPath string) error {
227284 if r.hooks.rename != nil {
@@ -229,7 +286,17 @@ func (r *Repository) rename(oldPath, newPath string) error {
229286 return err
230287 }
231288 }
232- return os.Rename(oldPath, newPath)
289+ oldFD, oldName, err := r.repositoryParent(oldPath)
290+ if err != nil {
291+ return err
292+ }
293+ defer syscall.Close(oldFD)
294+ newFD, newName, err := r.repositoryParent(newPath)
295+ if err != nil {
296+ return err
297+ }
298+ defer syscall.Close(newFD)
299+ return syscall.Renameat(oldFD, oldName, newFD, newName)
233300 }
234301 func (r *Repository) remove(path string) error {
235302 if r.hooks.remove != nil {
@@ -237,7 +304,23 @@ func (r *Repository) remove(path string) error {
237304 return err
238305 }
239306 }
240- return os.Remove(path)
307+ fd, name, err := r.repositoryParent(path)
308+ if err != nil {
309+ return err
310+ }
311+ defer syscall.Close(fd)
312+ if err := syscall.Unlinkat(fd, name); err != syscall.EISDIR {
313+ return err
314+ }
315+ namePtr, err := syscall.BytePtrFromString(name)
316+ if err != nil {
317+ return err
318+ }
319+ _, _, errno := syscall.Syscall(syscall.SYS_UNLINKAT, uintptr(fd), uintptr(unsafe.Pointer(namePtr)), uintptr(0x200)) // AT_REMOVEDIR
320+ if errno != 0 {
321+ return errno
322+ }
323+ return nil
241324 }
242325 func (r *Repository) syncDirectory(dir string) error {
243326 if r.hooks.syncDirectory != nil {
@@ -259,8 +342,8 @@ func syncDirectory(dir string) error {
259342 return f.Close()
260343 }
261344
262-func readOptionalBounded(path string) ([]byte, bool, error) {
263- data, err := readBounded(path)
345+func (r *Repository) readOptionalBounded(path string) ([]byte, bool, error) {
346+ data, err := r.readBounded(path)
264347 if errors.Is(err, os.ErrNotExist) {
265348 return nil, false, nil
266349 }
@@ -270,6 +353,16 @@ func readOptionalBounded(path string) ([]byte, bool, error) {
270353 // readBounded opens the final component with O_NOFOLLOW, validates the opened
271354 // descriptor, then reads a hard bounded amount. It deliberately never trusts
272355 // path metadata obtained before opening the descriptor.
356+func (r *Repository) readBounded(path string) ([]byte, error) {
357+ f, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0)
358+ if err != nil {
359+ return nil, err
360+ }
361+ return readBoundedFile(f)
362+}
363+
364+// readBounded remains for focused descriptor-hardening tests. Repository code
365+// must use r.readBounded so intermediate components are pinned and no-follow.
273366 func readBounded(path string) ([]byte, error) {
274367 fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
275368 if err != nil {
@@ -280,6 +373,11 @@ func readBounded(path string) ([]byte, error) {
280373 _ = syscall.Close(fd)
281374 return nil, fmt.Errorf("open snapshot file")
282375 }
376+ return readBoundedFile(f)
377+}
378+
379+func readBoundedFile(f *os.File) ([]byte, error) {
380+ fd := int(f.Fd())
283381 var stat syscall.Stat_t
284382 if err := syscall.Fstat(fd, &stat); err != nil {
285383 _ = f.Close()
@@ -7,6 +7,7 @@ import (
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9 "syscall"9 "syscall"
10+ "unsafe"
10 11
11 "github.com/bnema/vev/pkg/safedir"12 "github.com/bnema/vev/pkg/safedir"
12 )13 )
@@ -34,27 +35,61 @@ func (r *Repository) ensurePrivateDirectory(dir string) error {
34 }35 }
35 36
36 func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {37 func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
37- _, err := os.Lstat(dir)38+ // The configured root is the trust boundary. Once it is private, every
38- created := errors.Is(err, os.ErrNotExist)39+ // descendant is created through a pinned parent descriptor rather than via
39- if err != nil && !created {40+ // a path that an attacker can replace between checks.
40- return err41+ if filepath.Clean(dir) == filepath.Clean(r.dir) {
41- }42+ _, statErr := os.Lstat(dir)
42- if created {43+ created := errors.Is(statErr, os.ErrNotExist)
43- parent := filepath.Dir(dir)44+ if statErr != nil && !created {
44- if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {45+ return statErr
45- if err := r.ensurePrivateDirectoryPhase(parent, "snapshot directory"); err != nil {
46- return err
47- }
48- } else if err != nil {
49- return err
50 }46 }
51- if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {47+ if err := safedir.EnsurePrivate(dir); err != nil {
52 return err48 return err
53 }49 }
50+ if created {
51+ if hook := r.hooks.syncDirectory; hook != nil {
52+ if err := hook(filepath.Dir(dir)); err != nil {
53+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
54+ }
55+ }
56+ if err := syncDirectory(filepath.Dir(dir)); err != nil {
57+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
58+ }
59+ }
60+ return nil
54 }61 }
55- if err := safedir.EnsurePrivate(dir); err != nil {62+ if err := r.ensurePrivateDirectoryPhase(filepath.Dir(dir), "snapshot directory"); err != nil {
56 return err63 return err
57 }64 }
65+ parent, name, err := r.repositoryParent(dir)
66+ if err != nil {
67+ return err
68+ }
69+ created := false
70+ if err := syscall.Mkdirat(parent, name, 0o700); err == nil {
71+ created = true
72+ } else if err != syscall.EEXIST {
73+ _ = syscall.Close(parent)
74+ return err
75+ }
76+ _ = syscall.Close(parent)
77+ opened, err := r.openDirectory(dir)
78+ if err != nil {
79+ return err
80+ }
81+ var st syscall.Stat_t
82+ statErr := syscall.Fstat(int(opened.Fd()), &st)
83+ closeErr := opened.Close()
84+ if statErr != nil {
85+ return statErr
86+ }
87+ if closeErr != nil {
88+ return closeErr
89+ }
90+ if int(st.Uid) != os.Geteuid() || st.Mode&0o777 != 0o700 {
91+ return fmt.Errorf("snapshot directory is not private")
92+ }
58 if created {93 if created {
59 if err := r.syncDirectory(filepath.Dir(dir)); err != nil {94 if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
60 return fmt.Errorf("%s parent directory sync: %w", phase, err)95 return fmt.Errorf("%s parent directory sync: %w", phase, err)
@@ -111,7 +146,7 @@ func (r *Repository) writeImmutable(path string, data []byte, verifier func([]by
111 closed = true146 closed = true
112 if err := r.installImmutable(tempPath, path); err != nil {147 if err := r.installImmutable(tempPath, path); err != nil {
113 if errors.Is(err, os.ErrExist) {148 if errors.Is(err, os.ErrExist) {
114- existing, readErr := readBounded(path)149+ existing, readErr := r.readBounded(path)
115 if readErr != nil {150 if readErr != nil {
116 return cleanup(readErr)151 return cleanup(readErr)
117 }152 }
@@ -185,7 +220,7 @@ func (r *Repository) createTemp(dir string) (*os.File, error) {
185 return nil, err220 return nil, err
186 }221 }
187 }222 }
188- return os.CreateTemp(dir, ".tmp-")223+ return r.createTempAt(dir)
189 }224 }
190 func (r *Repository) writeFile(f *os.File, data []byte) error {225 func (r *Repository) writeFile(f *os.File, data []byte) error {
191 if r.hooks.writeTemp != nil {226 if r.hooks.writeTemp != nil {
@@ -221,7 +256,29 @@ func (r *Repository) installImmutable(oldPath, newPath string) error {
221 return err256 return err
222 }257 }
223 }258 }
224- return os.Link(oldPath, newPath)259+ oldFD, oldName, err := r.repositoryParent(oldPath)
260+ if err != nil {
261+ return err
262+ }
263+ defer syscall.Close(oldFD)
264+ newFD, newName, err := r.repositoryParent(newPath)
265+ if err != nil {
266+ return err
267+ }
268+ defer syscall.Close(newFD)
269+ oldPtr, err := syscall.BytePtrFromString(oldName)
270+ if err != nil {
271+ return err
272+ }
273+ newPtr, err := syscall.BytePtrFromString(newName)
274+ if err != nil {
275+ return err
276+ }
277+ _, _, errno := syscall.Syscall6(syscall.SYS_LINKAT, uintptr(oldFD), uintptr(unsafe.Pointer(oldPtr)), uintptr(newFD), uintptr(unsafe.Pointer(newPtr)), 0, 0)
278+ if errno != 0 {
279+ return errno
280+ }
281+ return nil
225 }282 }
226 func (r *Repository) rename(oldPath, newPath string) error {283 func (r *Repository) rename(oldPath, newPath string) error {
227 if r.hooks.rename != nil {284 if r.hooks.rename != nil {
@@ -229,7 +286,17 @@ func (r *Repository) rename(oldPath, newPath string) error {
229 return err286 return err
230 }287 }
231 }288 }
232- return os.Rename(oldPath, newPath)289+ oldFD, oldName, err := r.repositoryParent(oldPath)
290+ if err != nil {
291+ return err
292+ }
293+ defer syscall.Close(oldFD)
294+ newFD, newName, err := r.repositoryParent(newPath)
295+ if err != nil {
296+ return err
297+ }
298+ defer syscall.Close(newFD)
299+ return syscall.Renameat(oldFD, oldName, newFD, newName)
233 }300 }
234 func (r *Repository) remove(path string) error {301 func (r *Repository) remove(path string) error {
235 if r.hooks.remove != nil {302 if r.hooks.remove != nil {
@@ -237,7 +304,23 @@ func (r *Repository) remove(path string) error {
237 return err304 return err
238 }305 }
239 }306 }
240- return os.Remove(path)307+ fd, name, err := r.repositoryParent(path)
308+ if err != nil {
309+ return err
310+ }
311+ defer syscall.Close(fd)
312+ if err := syscall.Unlinkat(fd, name); err != syscall.EISDIR {
313+ return err
314+ }
315+ namePtr, err := syscall.BytePtrFromString(name)
316+ if err != nil {
317+ return err
318+ }
319+ _, _, errno := syscall.Syscall(syscall.SYS_UNLINKAT, uintptr(fd), uintptr(unsafe.Pointer(namePtr)), uintptr(0x200)) // AT_REMOVEDIR
320+ if errno != 0 {
321+ return errno
322+ }
323+ return nil
241 }324 }
242 func (r *Repository) syncDirectory(dir string) error {325 func (r *Repository) syncDirectory(dir string) error {
243 if r.hooks.syncDirectory != nil {326 if r.hooks.syncDirectory != nil {
@@ -259,8 +342,8 @@ func syncDirectory(dir string) error {
259 return f.Close()342 return f.Close()
260 }343 }
261 344
262-func readOptionalBounded(path string) ([]byte, bool, error) {345+func (r *Repository) readOptionalBounded(path string) ([]byte, bool, error) {
263- data, err := readBounded(path)346+ data, err := r.readBounded(path)
264 if errors.Is(err, os.ErrNotExist) {347 if errors.Is(err, os.ErrNotExist) {
265 return nil, false, nil348 return nil, false, nil
266 }349 }
@@ -270,6 +353,16 @@ func readOptionalBounded(path string) ([]byte, bool, error) {
270 // readBounded opens the final component with O_NOFOLLOW, validates the opened353 // readBounded opens the final component with O_NOFOLLOW, validates the opened
271 // descriptor, then reads a hard bounded amount. It deliberately never trusts354 // descriptor, then reads a hard bounded amount. It deliberately never trusts
272 // path metadata obtained before opening the descriptor.355 // path metadata obtained before opening the descriptor.
356+func (r *Repository) readBounded(path string) ([]byte, error) {
357+ f, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0)
358+ if err != nil {
359+ return nil, err
360+ }
361+ return readBoundedFile(f)
362+}
363+
364+// readBounded remains for focused descriptor-hardening tests. Repository code
365+// must use r.readBounded so intermediate components are pinned and no-follow.
273 func readBounded(path string) ([]byte, error) {366 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)367 fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
275 if err != nil {368 if err != nil {
@@ -280,6 +373,11 @@ func readBounded(path string) ([]byte, error) {
280 _ = syscall.Close(fd)373 _ = syscall.Close(fd)
281 return nil, fmt.Errorf("open snapshot file")374 return nil, fmt.Errorf("open snapshot file")
282 }375 }
376+ return readBoundedFile(f)
377+}
378+
379+func readBoundedFile(f *os.File) ([]byte, error) {
380+ fd := int(f.Fd())
283 var stat syscall.Stat_t381 var stat syscall.Stat_t
284 if err := syscall.Fstat(fd, &stat); err != nil {382 if err := syscall.Fstat(fd, &stat); err != nil {
285 _ = f.Close()383 _ = f.Close()
M internal/adapters/snapshot/legacy.go +20 -17
@@ -32,7 +32,7 @@ func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) {
3232 if r.hooks.openLegacyDirectory != nil {
3333 return r.hooks.openLegacyDirectory(path)
3434 }
35- return os.Open(path)
35+ return r.openDirectory(path)
3636 }
3737
3838 // LoadLegacy reads only pre-incremental root .snap files. It is deliberately
@@ -41,9 +41,13 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
4141 if err := ctx.Err(); err != nil {
4242 return nil, err
4343 }
44+ // One budget covers marker recovery and import. Every directory entry costs
45+ // work, including unrelated attacker-created names, so neither phase can be
46+ // made unbounded by a huge legacy root.
47+ budget := maxDirectoryTraversalEntries
4448 // A marker is an already-authorized deletion, not an import candidate. Finish
4549 // it before exposing any legacy blobs so process restart cannot republish one.
46- if err := r.cleanupPendingLegacyDeletes(ctx); err != nil {
50+ if err := r.cleanupPendingLegacyDeletes(ctx, &budget); err != nil {
4751 return nil, err
4852 }
4953 f, err := r.openLegacyDirectory(r.dir)
@@ -68,6 +72,10 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
6872 if err := ctx.Err(); err != nil {
6973 return nil, err
7074 }
75+ if budget == 0 {
76+ return nil, ErrDirectoryTraversalBudget
77+ }
78+ budget--
7179 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") {
7280 continue
7381 }
@@ -76,13 +84,7 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
7684 return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)
7785 }
7886 path := filepath.Join(r.dir, entry.Name())
79- // Check the prospective size before readBounded allocates a buffer.
80- // readBounded repeats the security validation and the actual length is
81- // checked below to close the metadata/read race.
82- if info, statErr := os.Lstat(path); statErr == nil && info.Mode().IsRegular() && info.Size() > int64(maxLegacySnapshotBytes-total) {
83- return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes)
84- }
85- data, dataErr := readBounded(path)
87+ data, dataErr := r.readBounded(path)
8688 if dataErr != nil {
8789 continue
8890 }
@@ -180,8 +182,8 @@ func (r *Repository) clearLegacyDeleteAuthorization(name string) error {
180182 return nil
181183 }
182184
183-func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context) error {
184- markers, err := r.pendingLegacyDeleteMarkers(ctx)
185+func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *int) error {
186+ markers, err := r.pendingLegacyDeleteMarkers(ctx, budget)
185187 if errors.Is(err, os.ErrNotExist) {
186188 return nil
187189 }
@@ -196,7 +198,7 @@ func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context) error {
196198 return nil
197199 }
198200
199-func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers []legacyDeleteMarker, err error) {
201+func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int) (markers []legacyDeleteMarker, err error) {
200202 f, err := r.openLegacyDirectory(r.dir)
201203 if err != nil {
202204 return nil, err
@@ -213,6 +215,10 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers []
213215 if err := ctx.Err(); err != nil {
214216 return nil, err
215217 }
218+ if *budget == 0 {
219+ return nil, ErrDirectoryTraversalBudget
220+ }
221+ *budget--
216222 if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) {
217223 continue
218224 }
@@ -224,10 +230,7 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers []
224230 return nil, fmt.Errorf("invalid legacy deletion marker")
225231 }
226232 path := filepath.Join(r.dir, entry.Name())
227- if info, statErr := os.Lstat(path); statErr != nil || !info.Mode().IsRegular() || info.Size() > maxLegacyDeleteNameBytes {
228- return nil, fmt.Errorf("invalid legacy deletion marker")
229- }
230- data, dataErr := readBounded(path)
233+ data, dataErr := r.readBounded(path)
231234 if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key {
232235 return nil, fmt.Errorf("invalid legacy deletion marker")
233236 }
@@ -300,7 +303,7 @@ func (r *Repository) tombstoned(name string) (bool, error) {
300303 if name == "" {
301304 return false, nil
302305 }
303- data, err := readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name))))
306+ data, err := r.readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name))))
304307 if errors.Is(err, os.ErrNotExist) {
305308 return false, nil
306309 }
@@ -32,7 +32,7 @@ func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) {
32 if r.hooks.openLegacyDirectory != nil {32 if r.hooks.openLegacyDirectory != nil {
33 return r.hooks.openLegacyDirectory(path)33 return r.hooks.openLegacyDirectory(path)
34 }34 }
35- return os.Open(path)35+ return r.openDirectory(path)
36 }36 }
37 37
38 // LoadLegacy reads only pre-incremental root .snap files. It is deliberately38 // LoadLegacy reads only pre-incremental root .snap files. It is deliberately
@@ -41,9 +41,13 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
41 if err := ctx.Err(); err != nil {41 if err := ctx.Err(); err != nil {
42 return nil, err42 return nil, err
43 }43 }
44+ // One budget covers marker recovery and import. Every directory entry costs
45+ // work, including unrelated attacker-created names, so neither phase can be
46+ // made unbounded by a huge legacy root.
47+ budget := maxDirectoryTraversalEntries
44 // A marker is an already-authorized deletion, not an import candidate. Finish48 // A marker is an already-authorized deletion, not an import candidate. Finish
45 // it before exposing any legacy blobs so process restart cannot republish one.49 // it before exposing any legacy blobs so process restart cannot republish one.
46- if err := r.cleanupPendingLegacyDeletes(ctx); err != nil {50+ if err := r.cleanupPendingLegacyDeletes(ctx, &budget); err != nil {
47 return nil, err51 return nil, err
48 }52 }
49 f, err := r.openLegacyDirectory(r.dir)53 f, err := r.openLegacyDirectory(r.dir)
@@ -68,6 +72,10 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
68 if err := ctx.Err(); err != nil {72 if err := ctx.Err(); err != nil {
69 return nil, err73 return nil, err
70 }74 }
75+ if budget == 0 {
76+ return nil, ErrDirectoryTraversalBudget
77+ }
78+ budget--
71 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") {79 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") {
72 continue80 continue
73 }81 }
@@ -76,13 +84,7 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
76 return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)84 return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)
77 }85 }
78 path := filepath.Join(r.dir, entry.Name())86 path := filepath.Join(r.dir, entry.Name())
79- // Check the prospective size before readBounded allocates a buffer.87+ data, dataErr := r.readBounded(path)
80- // readBounded repeats the security validation and the actual length is
81- // checked below to close the metadata/read race.
82- if info, statErr := os.Lstat(path); statErr == nil && info.Mode().IsRegular() && info.Size() > int64(maxLegacySnapshotBytes-total) {
83- return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes)
84- }
85- data, dataErr := readBounded(path)
86 if dataErr != nil {88 if dataErr != nil {
87 continue89 continue
88 }90 }
@@ -180,8 +182,8 @@ func (r *Repository) clearLegacyDeleteAuthorization(name string) error {
180 return nil182 return nil
181 }183 }
182 184
183-func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context) error {185+func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *int) error {
184- markers, err := r.pendingLegacyDeleteMarkers(ctx)186+ markers, err := r.pendingLegacyDeleteMarkers(ctx, budget)
185 if errors.Is(err, os.ErrNotExist) {187 if errors.Is(err, os.ErrNotExist) {
186 return nil188 return nil
187 }189 }
@@ -196,7 +198,7 @@ func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context) error {
196 return nil198 return nil
197 }199 }
198 200
199-func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers []legacyDeleteMarker, err error) {201+func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int) (markers []legacyDeleteMarker, err error) {
200 f, err := r.openLegacyDirectory(r.dir)202 f, err := r.openLegacyDirectory(r.dir)
201 if err != nil {203 if err != nil {
202 return nil, err204 return nil, err
@@ -213,6 +215,10 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers []
213 if err := ctx.Err(); err != nil {215 if err := ctx.Err(); err != nil {
214 return nil, err216 return nil, err
215 }217 }
218+ if *budget == 0 {
219+ return nil, ErrDirectoryTraversalBudget
220+ }
221+ *budget--
216 if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) {222 if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) {
217 continue223 continue
218 }224 }
@@ -224,10 +230,7 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context) (markers []
224 return nil, fmt.Errorf("invalid legacy deletion marker")230 return nil, fmt.Errorf("invalid legacy deletion marker")
225 }231 }
226 path := filepath.Join(r.dir, entry.Name())232 path := filepath.Join(r.dir, entry.Name())
227- if info, statErr := os.Lstat(path); statErr != nil || !info.Mode().IsRegular() || info.Size() > maxLegacyDeleteNameBytes {233+ data, dataErr := r.readBounded(path)
228- return nil, fmt.Errorf("invalid legacy deletion marker")
229- }
230- data, dataErr := readBounded(path)
231 if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key {234 if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key {
232 return nil, fmt.Errorf("invalid legacy deletion marker")235 return nil, fmt.Errorf("invalid legacy deletion marker")
233 }236 }
@@ -300,7 +303,7 @@ func (r *Repository) tombstoned(name string) (bool, error) {
300 if name == "" {303 if name == "" {
301 return false, nil304 return false, nil
302 }305 }
303- data, err := readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name))))306+ data, err := r.readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name))))
304 if errors.Is(err, os.ErrNotExist) {307 if errors.Is(err, os.ErrNotExist) {
305 return false, nil308 return false, nil
306 }309 }
M internal/adapters/snapshot/load.go +8 -8
@@ -32,7 +32,7 @@ func (r *Repository) List(ctx context.Context) ([]string, error) {
3232 budget := maxDirectoryTraversalEntries
3333 out := make([]string, 0)
3434 sessions := filepath.Join(r.dir, repositorySessionsDir)
35- err := walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
35+ err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
3636 if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
3737 return nil
3838 }
@@ -143,7 +143,7 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge
143143 return ports.SnapshotGeneration{}, false, err
144144 }
145145 before = generation
146- data, err := readBounded(r.manifestPath(key, generation))
146+ data, err := r.readBounded(r.manifestPath(key, generation))
147147 if err != nil {
148148 continue
149149 }
@@ -170,7 +170,7 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge
170170 // newest-to-oldest fallback while work remains bounded by budget.
171171 func (r *Repository) nextGeneration(ctx context.Context, key string, before, exclude uint64, budget *int) (uint64, bool, error) {
172172 var newest uint64
173- err := walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
173+ err := r.walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
174174 if entry.IsDir() {
175175 return nil
176176 }
@@ -195,8 +195,8 @@ func (r *Repository) nextGeneration(ctx context.Context, key string, before, exc
195195 // walkDirectory is the common finite directory traversal primitive for reads.
196196 // File.ReadDir maintains a cursor on one descriptor and returns at most one
197197 // small batch, so no full directory enumeration is retained in memory.
198-func walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) {
199- file, err := os.Open(dir)
198+func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) {
199+ file, err := r.openDirectory(dir)
200200 if err != nil {
201201 return err
202202 }
@@ -229,7 +229,7 @@ func walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.D
229229 }
230230
231231 func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
232- data, err := readBounded(r.manifestPath(key, generation))
232+ data, err := r.readBounded(r.manifestPath(key, generation))
233233 if err != nil {
234234 return ports.SnapshotGeneration{}, err
235235 }
@@ -246,7 +246,7 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener
246246 if err := ctx.Err(); err != nil {
247247 return ports.SnapshotGeneration{}, err
248248 }
249- object, err := readBounded(r.objectPath(key, digest))
249+ object, err := r.readBounded(r.objectPath(key, digest))
250250 if err != nil {
251251 return ports.SnapshotGeneration{}, err
252252 }
@@ -266,7 +266,7 @@ func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
266266 return out
267267 }
268268 func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {
269- data, err := readBounded(r.headPath(key))
269+ data, err := r.readBounded(r.headPath(key))
270270 if err != nil {
271271 return 0, ports.SnapshotDigest{}, err
272272 }
@@ -32,7 +32,7 @@ func (r *Repository) List(ctx context.Context) ([]string, error) {
32 budget := maxDirectoryTraversalEntries32 budget := maxDirectoryTraversalEntries
33 out := make([]string, 0)33 out := make([]string, 0)
34 sessions := filepath.Join(r.dir, repositorySessionsDir)34 sessions := filepath.Join(r.dir, repositorySessionsDir)
35- err := walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {35+ err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
36 if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {36 if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
37 return nil37 return nil
38 }38 }
@@ -143,7 +143,7 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge
143 return ports.SnapshotGeneration{}, false, err143 return ports.SnapshotGeneration{}, false, err
144 }144 }
145 before = generation145 before = generation
146- data, err := readBounded(r.manifestPath(key, generation))146+ data, err := r.readBounded(r.manifestPath(key, generation))
147 if err != nil {147 if err != nil {
148 continue148 continue
149 }149 }
@@ -170,7 +170,7 @@ func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budge
170 // newest-to-oldest fallback while work remains bounded by budget.170 // newest-to-oldest fallback while work remains bounded by budget.
171 func (r *Repository) nextGeneration(ctx context.Context, key string, before, exclude uint64, budget *int) (uint64, bool, error) {171 func (r *Repository) nextGeneration(ctx context.Context, key string, before, exclude uint64, budget *int) (uint64, bool, error) {
172 var newest uint64172 var newest uint64
173- err := walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {173+ err := r.walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
174 if entry.IsDir() {174 if entry.IsDir() {
175 return nil175 return nil
176 }176 }
@@ -195,8 +195,8 @@ func (r *Repository) nextGeneration(ctx context.Context, key string, before, exc
195 // walkDirectory is the common finite directory traversal primitive for reads.195 // walkDirectory is the common finite directory traversal primitive for reads.
196 // File.ReadDir maintains a cursor on one descriptor and returns at most one196 // File.ReadDir maintains a cursor on one descriptor and returns at most one
197 // small batch, so no full directory enumeration is retained in memory.197 // small batch, so no full directory enumeration is retained in memory.
198-func walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) {198+func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) {
199- file, err := os.Open(dir)199+ file, err := r.openDirectory(dir)
200 if err != nil {200 if err != nil {
201 return err201 return err
202 }202 }
@@ -229,7 +229,7 @@ func walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.D
229 }229 }
230 230
231 func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {231 func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
232- data, err := readBounded(r.manifestPath(key, generation))232+ data, err := r.readBounded(r.manifestPath(key, generation))
233 if err != nil {233 if err != nil {
234 return ports.SnapshotGeneration{}, err234 return ports.SnapshotGeneration{}, err
235 }235 }
@@ -246,7 +246,7 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener
246 if err := ctx.Err(); err != nil {246 if err := ctx.Err(); err != nil {
247 return ports.SnapshotGeneration{}, err247 return ports.SnapshotGeneration{}, err
248 }248 }
249- object, err := readBounded(r.objectPath(key, digest))249+ object, err := r.readBounded(r.objectPath(key, digest))
250 if err != nil {250 if err != nil {
251 return ports.SnapshotGeneration{}, err251 return ports.SnapshotGeneration{}, err
252 }252 }
@@ -266,7 +266,7 @@ func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
266 return out266 return out
267 }267 }
268 func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {268 func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {
269- data, err := readBounded(r.headPath(key))269+ data, err := r.readBounded(r.headPath(key))
270 if err != nil {270 if err != nil {
271 return 0, ports.SnapshotDigest{}, err271 return 0, ports.SnapshotDigest{}, err
272 }272 }
M internal/adapters/snapshot/maintenance_cursor.go +9 -3
@@ -101,7 +101,7 @@ func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory,
101101 if open := r.hooks.openMaintenanceDirectory; open != nil {
102102 return open(dir)
103103 }
104- file, err := os.Open(dir)
104+ file, err := r.openDirectory(dir)
105105 if err != nil {
106106 return nil, err
107107 }
@@ -170,14 +170,20 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
170170 if name == "." || name == ".." || name == "" {
171171 continue
172172 }
173- info, err := os.Lstat(filepath.Join(dir, name))
173+ stat, err := r.stat(filepath.Join(dir, name))
174174 if errors.Is(err, os.ErrNotExist) {
175175 continue
176176 }
177177 if err != nil {
178+ // A final symlink is not traversed and is treated as a non-directory;
179+ // deletion remains dirfd-relative and therefore unlinks only the link.
180+ if errors.Is(err, syscall.ELOOP) {
181+ entries = append(entries, maintenanceDirEntry{name: name})
182+ continue
183+ }
178184 return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err)
179185 }
180- entries = append(entries, maintenanceDirEntry{name: name, isDir: info.IsDir()})
186+ entries = append(entries, maintenanceDirEntry{name: name, isDir: stat.Mode&syscall.S_IFMT == syscall.S_IFDIR})
181187 }
182188 }
183189 return entries, false, nil
@@ -101,7 +101,7 @@ func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory,
101 if open := r.hooks.openMaintenanceDirectory; open != nil {101 if open := r.hooks.openMaintenanceDirectory; open != nil {
102 return open(dir)102 return open(dir)
103 }103 }
104- file, err := os.Open(dir)104+ file, err := r.openDirectory(dir)
105 if err != nil {105 if err != nil {
106 return nil, err106 return nil, err
107 }107 }
@@ -170,14 +170,20 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
170 if name == "." || name == ".." || name == "" {170 if name == "." || name == ".." || name == "" {
171 continue171 continue
172 }172 }
173- info, err := os.Lstat(filepath.Join(dir, name))173+ stat, err := r.stat(filepath.Join(dir, name))
174 if errors.Is(err, os.ErrNotExist) {174 if errors.Is(err, os.ErrNotExist) {
175 continue175 continue
176 }176 }
177 if err != nil {177 if err != nil {
178+ // A final symlink is not traversed and is treated as a non-directory;
179+ // deletion remains dirfd-relative and therefore unlinks only the link.
180+ if errors.Is(err, syscall.ELOOP) {
181+ entries = append(entries, maintenanceDirEntry{name: name})
182+ continue
183+ }
178 return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err)184 return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err)
179 }185 }
180- entries = append(entries, maintenanceDirEntry{name: name, isDir: info.IsDir()})186+ entries = append(entries, maintenanceDirEntry{name: name, isDir: stat.Mode&syscall.S_IFMT == syscall.S_IFDIR})
181 }187 }
182 }188 }
183 return entries, false, nil189 return entries, false, nil
M internal/adapters/snapshot/maintenance_delete.go +10 -7
@@ -8,6 +8,7 @@ import (
88 "os"
99 "path/filepath"
1010 "strings"
11+ "syscall"
1112 )
1213
1314 // Delete makes a session unavailable by durably moving it out of the canonical
@@ -42,10 +43,12 @@ func (r *Repository) Delete(ctx context.Context, name string) error {
4243 }
4344 return nil
4445 }
45- if _, err := os.Lstat(canonical); errors.Is(err, os.ErrNotExist) {
46+ if dir, err := r.openDirectory(canonical); errors.Is(err, os.ErrNotExist) {
4647 return nil
4748 } else if err != nil {
4849 return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err))
50+ } else if err := dir.Close(); err != nil {
51+ return fmt.Errorf("close snapshot session %q: %w", key, safeFilesystemError(err))
4952 }
5053 quarantine := filepath.Join(sessions, deletingSessionName(key))
5154 if err := ctx.Err(); err != nil {
@@ -77,14 +80,14 @@ func (r *Repository) pendingQuarantine(dir, key string) (bool, error) {
7780 if hook := r.hooks.beforePendingQuarantineCheck; hook != nil {
7881 hook(path)
7982 }
80- info, err := os.Lstat(path)
83+ file, err := r.openDirectory(path)
8184 if errors.Is(err, os.ErrNotExist) {
8285 return false, nil
8386 }
8487 if err != nil {
8588 return false, err
8689 }
87- return info.IsDir(), nil
90+ return true, file.Close()
8891 }
8992
9093 // Maintain reaps a bounded amount of stale state. Continuation handles are
@@ -131,7 +134,7 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang
131134 if !r.consumeQuarantineWork(budget, "stat") {
132135 return changed, false, nil
133136 }
134- info, err := os.Lstat(state.current)
137+ stat, err := r.stat(state.current)
135138 if errors.Is(err, os.ErrNotExist) {
136139 if state.current == state.root {
137140 return changed, true, nil
@@ -139,10 +142,10 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang
139142 state.current = filepath.Dir(state.current)
140143 continue
141144 }
142- if err != nil {
145+ if err != nil && !errors.Is(err, syscall.ELOOP) {
143146 return changed, false, err
144147 }
145- if !info.IsDir() {
148+ if err != nil || stat.Mode&syscall.S_IFMT != syscall.S_IFDIR {
146149 if !r.consumeQuarantineWork(budget, "remove") {
147150 return changed, false, nil
148151 }
@@ -157,7 +160,7 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang
157160 if !r.consumeQuarantineWork(budget, "open") {
158161 return changed, false, nil
159162 }
160- dir, err := os.Open(state.current)
163+ dir, err := r.openDirectory(state.current)
161164 if err != nil {
162165 return changed, false, err
163166 }
@@ -8,6 +8,7 @@ import (
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10 "strings"10 "strings"
11+ "syscall"
11 )12 )
12 13
13 // Delete makes a session unavailable by durably moving it out of the canonical14 // Delete makes a session unavailable by durably moving it out of the canonical
@@ -42,10 +43,12 @@ func (r *Repository) Delete(ctx context.Context, name string) error {
42 }43 }
43 return nil44 return nil
44 }45 }
45- if _, err := os.Lstat(canonical); errors.Is(err, os.ErrNotExist) {46+ if dir, err := r.openDirectory(canonical); errors.Is(err, os.ErrNotExist) {
46 return nil47 return nil
47 } else if err != nil {48 } else if err != nil {
48 return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err))49 return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err))
50+ } else if err := dir.Close(); err != nil {
51+ return fmt.Errorf("close snapshot session %q: %w", key, safeFilesystemError(err))
49 }52 }
50 quarantine := filepath.Join(sessions, deletingSessionName(key))53 quarantine := filepath.Join(sessions, deletingSessionName(key))
51 if err := ctx.Err(); err != nil {54 if err := ctx.Err(); err != nil {
@@ -77,14 +80,14 @@ func (r *Repository) pendingQuarantine(dir, key string) (bool, error) {
77 if hook := r.hooks.beforePendingQuarantineCheck; hook != nil {80 if hook := r.hooks.beforePendingQuarantineCheck; hook != nil {
78 hook(path)81 hook(path)
79 }82 }
80- info, err := os.Lstat(path)83+ file, err := r.openDirectory(path)
81 if errors.Is(err, os.ErrNotExist) {84 if errors.Is(err, os.ErrNotExist) {
82 return false, nil85 return false, nil
83 }86 }
84 if err != nil {87 if err != nil {
85 return false, err88 return false, err
86 }89 }
87- return info.IsDir(), nil90+ return true, file.Close()
88 }91 }
89 92
90 // Maintain reaps a bounded amount of stale state. Continuation handles are93 // Maintain reaps a bounded amount of stale state. Continuation handles are
@@ -131,7 +134,7 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang
131 if !r.consumeQuarantineWork(budget, "stat") {134 if !r.consumeQuarantineWork(budget, "stat") {
132 return changed, false, nil135 return changed, false, nil
133 }136 }
134- info, err := os.Lstat(state.current)137+ stat, err := r.stat(state.current)
135 if errors.Is(err, os.ErrNotExist) {138 if errors.Is(err, os.ErrNotExist) {
136 if state.current == state.root {139 if state.current == state.root {
137 return changed, true, nil140 return changed, true, nil
@@ -139,10 +142,10 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang
139 state.current = filepath.Dir(state.current)142 state.current = filepath.Dir(state.current)
140 continue143 continue
141 }144 }
142- if err != nil {145+ if err != nil && !errors.Is(err, syscall.ELOOP) {
143 return changed, false, err146 return changed, false, err
144 }147 }
145- if !info.IsDir() {148+ if err != nil || stat.Mode&syscall.S_IFMT != syscall.S_IFDIR {
146 if !r.consumeQuarantineWork(budget, "remove") {149 if !r.consumeQuarantineWork(budget, "remove") {
147 return changed, false, nil150 return changed, false, nil
148 }151 }
@@ -157,7 +160,7 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang
157 if !r.consumeQuarantineWork(budget, "open") {160 if !r.consumeQuarantineWork(budget, "open") {
158 return changed, false, nil161 return changed, false, nil
159 }162 }
160- dir, err := os.Open(state.current)163+ dir, err := r.openDirectory(state.current)
161 if err != nil {164 if err != nil {
162 return changed, false, err165 return changed, false, err
163 }166 }
M internal/adapters/snapshot/maintenance_gc.go +2 -2
@@ -119,7 +119,7 @@ func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, e
119119 // itself stable maintenance state: we retain every classified reference until a
120120 // valid publication changes that state, rather than restarting the mark pass.
121121 func (r *Repository) maintenanceToken(key string) (string, bool, error) {
122- data, exists, err := readOptionalBounded(r.headPath(key))
122+ data, exists, err := r.readOptionalBounded(r.headPath(key))
123123 if err != nil {
124124 return "", false, err
125125 }
@@ -167,7 +167,7 @@ func (r *Repository) markSession(ctx context.Context, key string, state *session
167167 if !ok {
168168 continue
169169 }
170- data, err := readBounded(r.manifestPath(key, generation))
170+ data, err := r.readBounded(r.manifestPath(key, generation))
171171 if err != nil {
172172 state.uncertain = true
173173 continue
@@ -119,7 +119,7 @@ func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, e
119 // itself stable maintenance state: we retain every classified reference until a119 // itself stable maintenance state: we retain every classified reference until a
120 // valid publication changes that state, rather than restarting the mark pass.120 // valid publication changes that state, rather than restarting the mark pass.
121 func (r *Repository) maintenanceToken(key string) (string, bool, error) {121 func (r *Repository) maintenanceToken(key string) (string, bool, error) {
122- data, exists, err := readOptionalBounded(r.headPath(key))122+ data, exists, err := r.readOptionalBounded(r.headPath(key))
123 if err != nil {123 if err != nil {
124 return "", false, err124 return "", false, err
125 }125 }
@@ -167,7 +167,7 @@ func (r *Repository) markSession(ctx context.Context, key string, state *session
167 if !ok {167 if !ok {
168 continue168 continue
169 }169 }
170- data, err := readBounded(r.manifestPath(key, generation))170+ data, err := r.readBounded(r.manifestPath(key, generation))
171 if err != nil {171 if err != nil {
172 state.uncertain = true172 state.uncertain = true
173 continue173 continue
A internal/adapters/snapshot/path_security_test.go +59 -0
@@ -0,0 +1,59 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
13+ for _, target := range []string{repositoryGenerations, repositoryObjectsDir} {
14+ t.Run(target, func(t *testing.T) {
15+ repo := NewRepository(privateDir(t))
16+ publication := repositoryPublication(t, "named", 1, []byte("state"))
17+ if err := repo.Publish(context.Background(), publication); err != nil {
18+ t.Fatal(err)
19+ }
20+ key := sessionKey(publication.Name)
21+ inside := filepath.Join(repo.sessionPath(key), target)
22+ outside := t.TempDir()
23+ guard := filepath.Join(outside, "must-not-change")
24+ if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {
25+ t.Fatal(err)
26+ }
27+ if err := os.Rename(inside, inside+"-real"); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.Symlink(outside, inside); err != nil {
31+ t.Fatal(err)
32+ }
33+ if _, err := repo.Load(context.Background(), publication.Name); err == nil {
34+ t.Fatal("Load succeeded through symlinked repository component")
35+ }
36+ // Maintenance must reject the replacement rather than following it.
37+ _ = repo.Maintain(context.Background())
38+ got, err := os.ReadFile(guard)
39+ if err != nil || string(got) != "guard" {
40+ t.Fatalf("outside guard = %q, %v", got, err)
41+ }
42+ })
43+ }
44+}
45+
46+func TestRepositoryLoadLegacyChargesUnrelatedRootEntries(t *testing.T) {
47+ repo := NewRepository(privateDir(t))
48+ if err := os.MkdirAll(repo.dir, 0o700); err != nil {
49+ t.Fatal(err)
50+ }
51+ for i := 0; i <= maxDirectoryTraversalEntries; i++ {
52+ if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("unrelated-%05d", i)), nil, 0o600); err != nil {
53+ t.Fatal(err)
54+ }
55+ }
56+ if _, err := repo.LoadLegacy(context.Background()); !errors.Is(err, ErrDirectoryTraversalBudget) {
57+ t.Fatalf("LoadLegacy error = %v, want traversal budget error", err)
58+ }
59+}
@@ -0,0 +1,59 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
13+ for _, target := range []string{repositoryGenerations, repositoryObjectsDir} {
14+ t.Run(target, func(t *testing.T) {
15+ repo := NewRepository(privateDir(t))
16+ publication := repositoryPublication(t, "named", 1, []byte("state"))
17+ if err := repo.Publish(context.Background(), publication); err != nil {
18+ t.Fatal(err)
19+ }
20+ key := sessionKey(publication.Name)
21+ inside := filepath.Join(repo.sessionPath(key), target)
22+ outside := t.TempDir()
23+ guard := filepath.Join(outside, "must-not-change")
24+ if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {
25+ t.Fatal(err)
26+ }
27+ if err := os.Rename(inside, inside+"-real"); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.Symlink(outside, inside); err != nil {
31+ t.Fatal(err)
32+ }
33+ if _, err := repo.Load(context.Background(), publication.Name); err == nil {
34+ t.Fatal("Load succeeded through symlinked repository component")
35+ }
36+ // Maintenance must reject the replacement rather than following it.
37+ _ = repo.Maintain(context.Background())
38+ got, err := os.ReadFile(guard)
39+ if err != nil || string(got) != "guard" {
40+ t.Fatalf("outside guard = %q, %v", got, err)
41+ }
42+ })
43+ }
44+}
45+
46+func TestRepositoryLoadLegacyChargesUnrelatedRootEntries(t *testing.T) {
47+ repo := NewRepository(privateDir(t))
48+ if err := os.MkdirAll(repo.dir, 0o700); err != nil {
49+ t.Fatal(err)
50+ }
51+ for i := 0; i <= maxDirectoryTraversalEntries; i++ {
52+ if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("unrelated-%05d", i)), nil, 0o600); err != nil {
53+ t.Fatal(err)
54+ }
55+ }
56+ if _, err := repo.LoadLegacy(context.Background()); !errors.Is(err, ErrDirectoryTraversalBudget) {
57+ t.Fatalf("LoadLegacy error = %v, want traversal budget error", err)
58+ }
59+}
M internal/adapters/snapshot/publish.go +11 -3
@@ -33,6 +33,14 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
3333 if err := r.ensureSession(key); err != nil {
3434 return err
3535 }
36+ // The common replay path needs neither a manifest decode nor a reference
37+ // map. Checking the immutable bytes directly also keeps its descriptor
38+ // reads beneath the pinned repository root.
39+ if generation, _, err := r.readHead(key); err == nil && generation == publication.Generation {
40+ if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && equalBytes(current, publication.Manifest) {
41+ return nil
42+ }
43+ }
3644
3745 current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key)
3846 if err != nil {
@@ -98,7 +106,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
98106 }
99107
100108 manifestPath := r.manifestPath(key, publication.Generation)
101- existing, exists, err := readOptionalBounded(manifestPath)
109+ existing, exists, err := r.readOptionalBounded(manifestPath)
102110 if err != nil {
103111 return err
104112 }
@@ -145,7 +153,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
145153 func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {
146154 generation, digest, err := r.readHead(key)
147155 if err == nil {
148- data, readErr := readBounded(r.manifestPath(key, generation))
156+ data, readErr := r.readBounded(r.manifestPath(key, generation))
149157 if readErr == nil && sha256.Sum256(data) == digest {
150158 refs, validateErr := validateManifest(data, name, generation)
151159 if validateErr == nil {
@@ -311,7 +319,7 @@ func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest,
311319 if r.hooks.beforeObjectRead != nil {
312320 r.hooks.beforeObjectRead(path)
313321 }
314- data, exists, err := readOptionalBounded(path)
322+ data, exists, err := r.readOptionalBounded(path)
315323 if err != nil || !exists {
316324 return exists, err
317325 }
@@ -33,6 +33,14 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
33 if err := r.ensureSession(key); err != nil {33 if err := r.ensureSession(key); err != nil {
34 return err34 return err
35 }35 }
36+ // The common replay path needs neither a manifest decode nor a reference
37+ // map. Checking the immutable bytes directly also keeps its descriptor
38+ // reads beneath the pinned repository root.
39+ if generation, _, err := r.readHead(key); err == nil && generation == publication.Generation {
40+ if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && equalBytes(current, publication.Manifest) {
41+ return nil
42+ }
43+ }
36 44
37 current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key)45 current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key)
38 if err != nil {46 if err != nil {
@@ -98,7 +106,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
98 }106 }
99 107
100 manifestPath := r.manifestPath(key, publication.Generation)108 manifestPath := r.manifestPath(key, publication.Generation)
101- existing, exists, err := readOptionalBounded(manifestPath)109+ existing, exists, err := r.readOptionalBounded(manifestPath)
102 if err != nil {110 if err != nil {
103 return err111 return err
104 }112 }
@@ -145,7 +153,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
145 func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {153 func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {
146 generation, digest, err := r.readHead(key)154 generation, digest, err := r.readHead(key)
147 if err == nil {155 if err == nil {
148- data, readErr := readBounded(r.manifestPath(key, generation))156+ data, readErr := r.readBounded(r.manifestPath(key, generation))
149 if readErr == nil && sha256.Sum256(data) == digest {157 if readErr == nil && sha256.Sum256(data) == digest {
150 refs, validateErr := validateManifest(data, name, generation)158 refs, validateErr := validateManifest(data, name, generation)
151 if validateErr == nil {159 if validateErr == nil {
@@ -311,7 +319,7 @@ func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest,
311 if r.hooks.beforeObjectRead != nil {319 if r.hooks.beforeObjectRead != nil {
312 r.hooks.beforeObjectRead(path)320 r.hooks.beforeObjectRead(path)
313 }321 }
314- data, exists, err := readOptionalBounded(path)322+ data, exists, err := r.readOptionalBounded(path)
315 if err != nil || !exists {323 if err != nil || !exists {
316 return exists, err324 return exists, err
317 }325 }
A internal/adapters/snapshot/safe_path.go +145 -0
@@ -0,0 +1,145 @@
1+package snapshot
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "syscall"
11+)
12+
13+// repositoryFD opens a repository-relative path one component at a time. Each
14+// directory is pinned by an O_NOFOLLOW descriptor before its child is opened,
15+// so a concurrent rename or symlink replacement cannot redirect an operation
16+// outside the repository.
17+func (r *Repository) repositoryFD(path string, finalFlags int, mode uint32) (*os.File, error) {
18+ rel, ok := r.repositoryRelative(path)
19+ if !ok {
20+ return nil, fmt.Errorf("snapshot path outside repository")
21+ }
22+ root, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
23+ if err != nil {
24+ return nil, err
25+ }
26+ if rel == "." {
27+ if finalFlags&syscall.O_DIRECTORY == 0 {
28+ _ = syscall.Close(root)
29+ return nil, fmt.Errorf("invalid snapshot root operation")
30+ }
31+ return os.NewFile(uintptr(root), path), nil
32+ }
33+ parts := strings.Split(filepath.Clean(rel), string(filepath.Separator))
34+ fd := root
35+ for i, part := range parts {
36+ if part == "" || part == "." || part == ".." {
37+ _ = syscall.Close(fd)
38+ return nil, fmt.Errorf("invalid snapshot path")
39+ }
40+ flags := syscall.O_RDONLY | syscall.O_CLOEXEC | syscall.O_NOFOLLOW
41+ if i != len(parts)-1 {
42+ flags |= syscall.O_DIRECTORY
43+ } else {
44+ flags = finalFlags | syscall.O_CLOEXEC | syscall.O_NOFOLLOW
45+ }
46+ next, openErr := syscall.Openat(fd, part, flags, mode)
47+ _ = syscall.Close(fd)
48+ if openErr != nil {
49+ return nil, openErr
50+ }
51+ fd = next
52+ }
53+ f := os.NewFile(uintptr(fd), path)
54+ if f == nil {
55+ _ = syscall.Close(fd)
56+ return nil, fmt.Errorf("open snapshot path")
57+ }
58+ return f, nil
59+}
60+
61+func (r *Repository) repositoryParent(path string) (int, string, error) {
62+ rel, ok := r.repositoryRelative(path)
63+ if !ok || rel == "." {
64+ return -1, "", fmt.Errorf("snapshot path outside repository")
65+ }
66+ parts := strings.Split(filepath.Clean(rel), string(filepath.Separator))
67+ if len(parts) == 0 || parts[len(parts)-1] == "." || parts[len(parts)-1] == ".." {
68+ return -1, "", fmt.Errorf("invalid snapshot path")
69+ }
70+ fd, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
71+ if err != nil {
72+ return -1, "", err
73+ }
74+ for _, part := range parts[:len(parts)-1] {
75+ if part == "" || part == "." || part == ".." {
76+ _ = syscall.Close(fd)
77+ return -1, "", fmt.Errorf("invalid snapshot path")
78+ }
79+ next, openErr := syscall.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
80+ _ = syscall.Close(fd)
81+ if openErr != nil {
82+ return -1, "", openErr
83+ }
84+ fd = next
85+ }
86+ return fd, parts[len(parts)-1], nil
87+}
88+
89+func (r *Repository) repositoryRelative(path string) (string, bool) {
90+ if path == r.dir {
91+ return ".", true
92+ }
93+ prefix := r.dir + string(filepath.Separator)
94+ if !strings.HasPrefix(path, prefix) {
95+ return "", false
96+ }
97+ rel := strings.TrimPrefix(path, prefix)
98+ if rel == "" || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
99+ return "", false
100+ }
101+ return rel, true
102+}
103+
104+func (r *Repository) openDirectory(path string) (*os.File, error) {
105+ return r.repositoryFD(path, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
106+}
107+
108+func (r *Repository) stat(path string) (syscall.Stat_t, error) {
109+ file, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0)
110+ if err != nil {
111+ return syscall.Stat_t{}, err
112+ }
113+ defer file.Close()
114+ var st syscall.Stat_t
115+ if err := syscall.Fstat(int(file.Fd()), &st); err != nil {
116+ return syscall.Stat_t{}, err
117+ }
118+ return st, nil
119+}
120+
121+// createTempAt creates an exclusively owned temporary file in an already
122+// validated directory. os.CreateTemp(path) would resolve that path again.
123+func (r *Repository) createTempAt(dir string) (*os.File, error) {
124+ fd, err := r.openDirectory(dir)
125+ if err != nil {
126+ return nil, err
127+ }
128+ defer fd.Close()
129+ for i := 0; i < 100; i++ {
130+ var random [8]byte
131+ if _, err := rand.Read(random[:]); err != nil {
132+ return nil, err
133+ }
134+ name := ".tmp-" + hex.EncodeToString(random[:])
135+ tempFD, err := syscall.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
136+ if err == syscall.EEXIST {
137+ continue
138+ }
139+ if err != nil {
140+ return nil, err
141+ }
142+ return os.NewFile(uintptr(tempFD), filepath.Join(dir, name)), nil
143+ }
144+ return nil, fmt.Errorf("create snapshot temporary file: too many collisions")
145+}
@@ -0,0 +1,145 @@
1+package snapshot
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "syscall"
11+)
12+
13+// repositoryFD opens a repository-relative path one component at a time. Each
14+// directory is pinned by an O_NOFOLLOW descriptor before its child is opened,
15+// so a concurrent rename or symlink replacement cannot redirect an operation
16+// outside the repository.
17+func (r *Repository) repositoryFD(path string, finalFlags int, mode uint32) (*os.File, error) {
18+ rel, ok := r.repositoryRelative(path)
19+ if !ok {
20+ return nil, fmt.Errorf("snapshot path outside repository")
21+ }
22+ root, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
23+ if err != nil {
24+ return nil, err
25+ }
26+ if rel == "." {
27+ if finalFlags&syscall.O_DIRECTORY == 0 {
28+ _ = syscall.Close(root)
29+ return nil, fmt.Errorf("invalid snapshot root operation")
30+ }
31+ return os.NewFile(uintptr(root), path), nil
32+ }
33+ parts := strings.Split(filepath.Clean(rel), string(filepath.Separator))
34+ fd := root
35+ for i, part := range parts {
36+ if part == "" || part == "." || part == ".." {
37+ _ = syscall.Close(fd)
38+ return nil, fmt.Errorf("invalid snapshot path")
39+ }
40+ flags := syscall.O_RDONLY | syscall.O_CLOEXEC | syscall.O_NOFOLLOW
41+ if i != len(parts)-1 {
42+ flags |= syscall.O_DIRECTORY
43+ } else {
44+ flags = finalFlags | syscall.O_CLOEXEC | syscall.O_NOFOLLOW
45+ }
46+ next, openErr := syscall.Openat(fd, part, flags, mode)
47+ _ = syscall.Close(fd)
48+ if openErr != nil {
49+ return nil, openErr
50+ }
51+ fd = next
52+ }
53+ f := os.NewFile(uintptr(fd), path)
54+ if f == nil {
55+ _ = syscall.Close(fd)
56+ return nil, fmt.Errorf("open snapshot path")
57+ }
58+ return f, nil
59+}
60+
61+func (r *Repository) repositoryParent(path string) (int, string, error) {
62+ rel, ok := r.repositoryRelative(path)
63+ if !ok || rel == "." {
64+ return -1, "", fmt.Errorf("snapshot path outside repository")
65+ }
66+ parts := strings.Split(filepath.Clean(rel), string(filepath.Separator))
67+ if len(parts) == 0 || parts[len(parts)-1] == "." || parts[len(parts)-1] == ".." {
68+ return -1, "", fmt.Errorf("invalid snapshot path")
69+ }
70+ fd, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
71+ if err != nil {
72+ return -1, "", err
73+ }
74+ for _, part := range parts[:len(parts)-1] {
75+ if part == "" || part == "." || part == ".." {
76+ _ = syscall.Close(fd)
77+ return -1, "", fmt.Errorf("invalid snapshot path")
78+ }
79+ next, openErr := syscall.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
80+ _ = syscall.Close(fd)
81+ if openErr != nil {
82+ return -1, "", openErr
83+ }
84+ fd = next
85+ }
86+ return fd, parts[len(parts)-1], nil
87+}
88+
89+func (r *Repository) repositoryRelative(path string) (string, bool) {
90+ if path == r.dir {
91+ return ".", true
92+ }
93+ prefix := r.dir + string(filepath.Separator)
94+ if !strings.HasPrefix(path, prefix) {
95+ return "", false
96+ }
97+ rel := strings.TrimPrefix(path, prefix)
98+ if rel == "" || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
99+ return "", false
100+ }
101+ return rel, true
102+}
103+
104+func (r *Repository) openDirectory(path string) (*os.File, error) {
105+ return r.repositoryFD(path, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
106+}
107+
108+func (r *Repository) stat(path string) (syscall.Stat_t, error) {
109+ file, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0)
110+ if err != nil {
111+ return syscall.Stat_t{}, err
112+ }
113+ defer file.Close()
114+ var st syscall.Stat_t
115+ if err := syscall.Fstat(int(file.Fd()), &st); err != nil {
116+ return syscall.Stat_t{}, err
117+ }
118+ return st, nil
119+}
120+
121+// createTempAt creates an exclusively owned temporary file in an already
122+// validated directory. os.CreateTemp(path) would resolve that path again.
123+func (r *Repository) createTempAt(dir string) (*os.File, error) {
124+ fd, err := r.openDirectory(dir)
125+ if err != nil {
126+ return nil, err
127+ }
128+ defer fd.Close()
129+ for i := 0; i < 100; i++ {
130+ var random [8]byte
131+ if _, err := rand.Read(random[:]); err != nil {
132+ return nil, err
133+ }
134+ name := ".tmp-" + hex.EncodeToString(random[:])
135+ tempFD, err := syscall.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
136+ if err == syscall.EEXIST {
137+ continue
138+ }
139+ if err != nil {
140+ return nil, err
141+ }
142+ return os.NewFile(uintptr(tempFD), filepath.Join(dir, name)), nil
143+ }
144+ return nil, fmt.Errorf("create snapshot temporary file: too many collisions")
145+}

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