detached 962bc0c

refactor(snapshot): tighten repository validation

commit
962bc0ce5668448a181d1d4bc90ab588b0c6f23d
author
brice · · 2026-07-22 04:49 UTC
parent
0edf864
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +60 -33
M internal/adapters/snapshot/atomic.go +2 -2
@@ -72,7 +72,7 @@ func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
7272 created = true
7373 }
7474 parentCloseErr := r.closeDescriptor(parent, "close snapshot parent directory")
75- if mkdirErr != nil && mkdirErr != syscall.EEXIST {
75+ if mkdirErr != nil && !errors.Is(mkdirErr, syscall.EEXIST) {
7676 err = mkdirErr
7777 joinCloseError(&err, "close snapshot parent directory", parentCloseErr)
7878 return err
@@ -326,7 +326,7 @@ func (r *Repository) remove(path string) (err error) {
326326 defer func() {
327327 joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))
328328 }()
329- if err := syscall.Unlinkat(fd, name); err != syscall.EISDIR {
329+ if err := syscall.Unlinkat(fd, name); !errors.Is(err, syscall.EISDIR) {
330330 return err
331331 }
332332 namePtr, err := syscall.BytePtrFromString(name)
@@ -72,7 +72,7 @@ func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
72 created = true72 created = true
73 }73 }
74 parentCloseErr := r.closeDescriptor(parent, "close snapshot parent directory")74 parentCloseErr := r.closeDescriptor(parent, "close snapshot parent directory")
75- if mkdirErr != nil && mkdirErr != syscall.EEXIST {75+ if mkdirErr != nil && !errors.Is(mkdirErr, syscall.EEXIST) {
76 err = mkdirErr76 err = mkdirErr
77 joinCloseError(&err, "close snapshot parent directory", parentCloseErr)77 joinCloseError(&err, "close snapshot parent directory", parentCloseErr)
78 return err78 return err
@@ -326,7 +326,7 @@ func (r *Repository) remove(path string) (err error) {
326 defer func() {326 defer func() {
327 joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))327 joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))
328 }()328 }()
329- if err := syscall.Unlinkat(fd, name); err != syscall.EISDIR {329+ if err := syscall.Unlinkat(fd, name); !errors.Is(err, syscall.EISDIR) {
330 return err330 return err
331 }331 }
332 namePtr, err := syscall.BytePtrFromString(name)332 namePtr, err := syscall.BytePtrFromString(name)
M internal/adapters/snapshot/legacy.go +10 -2
@@ -38,6 +38,14 @@ func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) {
3838 return r.openDirectory(path)
3939 }
4040
41+// readLegacyImportCandidate reports whether an untrusted root entry can be
42+// safely imported. Legacy scanning skips entries that disappear or fail secure
43+// descriptor validation so one hostile .snap file does not block other imports.
44+func (r *Repository) readLegacyImportCandidate(path string) ([]byte, bool) {
45+ data, err := r.readBounded(path)
46+ return data, err == nil
47+}
48+
4149 // LoadLegacy reads only pre-incremental root .snap files. It is deliberately
4250 // isolated from repository reads so incremental directories are never traversed.
4351 func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot, err error) {
@@ -87,8 +95,8 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
8795 return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)
8896 }
8997 path := filepath.Join(r.dir, entry.Name())
90- data, dataErr := r.readBounded(path)
91- if dataErr != nil {
98+ data, importable := r.readLegacyImportCandidate(path)
99+ if !importable {
92100 continue
93101 }
94102 if len(data) > maxLegacySnapshotBytes-total {
@@ -38,6 +38,14 @@ func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) {
38 return r.openDirectory(path)38 return r.openDirectory(path)
39 }39 }
40 40
41+// readLegacyImportCandidate reports whether an untrusted root entry can be
42+// safely imported. Legacy scanning skips entries that disappear or fail secure
43+// descriptor validation so one hostile .snap file does not block other imports.
44+func (r *Repository) readLegacyImportCandidate(path string) ([]byte, bool) {
45+ data, err := r.readBounded(path)
46+ return data, err == nil
47+}
48+
41 // LoadLegacy reads only pre-incremental root .snap files. It is deliberately49 // LoadLegacy reads only pre-incremental root .snap files. It is deliberately
42 // isolated from repository reads so incremental directories are never traversed.50 // isolated from repository reads so incremental directories are never traversed.
43 func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot, err error) {51 func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot, err error) {
@@ -87,8 +95,8 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot
87 return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)95 return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)
88 }96 }
89 path := filepath.Join(r.dir, entry.Name())97 path := filepath.Join(r.dir, entry.Name())
90- data, dataErr := r.readBounded(path)98+ data, importable := r.readLegacyImportCandidate(path)
91- if dataErr != nil {99+ if !importable {
92 continue100 continue
93 }101 }
94 if len(data) > maxLegacySnapshotBytes-total {102 if len(data) > maxLegacySnapshotBytes-total {
M internal/adapters/snapshot/maintenance_cursor.go +3 -2
@@ -156,13 +156,14 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
156156 }
157157 record := (*syscall.Dirent)(unsafe.Pointer(&data[0]))
158158 reclen := int(record.Reclen)
159- if reclen <= 0 || reclen > len(data) {
159+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
160+ if reclen < nameOffset || reclen > len(data) {
160161 return nil, false, syscall.EIO
161162 }
162163 data = data[reclen:]
163164 // Advance past every raw record, including dot and disappeared entries.
164165 cursor.offset = record.Off
165- nameBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-int(unsafe.Offsetof(syscall.Dirent{}.Name)))
166+ nameBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
166167 if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {
167168 nameBytes = nameBytes[:end]
168169 }
@@ -156,13 +156,14 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
156 }156 }
157 record := (*syscall.Dirent)(unsafe.Pointer(&data[0]))157 record := (*syscall.Dirent)(unsafe.Pointer(&data[0]))
158 reclen := int(record.Reclen)158 reclen := int(record.Reclen)
159- if reclen <= 0 || reclen > len(data) {159+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
160+ if reclen < nameOffset || reclen > len(data) {
160 return nil, false, syscall.EIO161 return nil, false, syscall.EIO
161 }162 }
162 data = data[reclen:]163 data = data[reclen:]
163 // Advance past every raw record, including dot and disappeared entries.164 // Advance past every raw record, including dot and disappeared entries.
164 cursor.offset = record.Off165 cursor.offset = record.Off
165- nameBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-int(unsafe.Offsetof(syscall.Dirent{}.Name)))166+ nameBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
166 if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {167 if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {
167 nameBytes = nameBytes[:end]168 nameBytes = nameBytes[:end]
168 }169 }
M internal/adapters/snapshot/maintenance_gc.go +11 -21
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "os"
99 "path/filepath"
10+ "sort"
1011 "strings"
1112
1213 "github.com/bnema/vev/internal/ports"
@@ -226,20 +227,21 @@ func maintenanceManifestRefs(manifest codec.Manifest) (map[ports.SnapshotDigest]
226227 return refs, true
227228 }
228229
229-func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 {
230+// completeGenerationsDescending returns only complete generations in canonical
231+// newest-first order for both manifest and object retention.
232+func completeGenerationsDescending(marked map[uint64]manifestMaintenance) []uint64 {
230233 complete := make([]uint64, 0, len(marked))
231234 for generation, item := range marked {
232235 if item.complete {
233236 complete = append(complete, generation)
234237 }
235238 }
236- for i := range complete {
237- for j := i + 1; j < len(complete); j++ {
238- if complete[j] > complete[i] {
239- complete[i], complete[j] = complete[j], complete[i]
240- }
241- }
242- }
239+ sort.Slice(complete, func(i, j int) bool { return complete[i] > complete[j] })
240+ return complete
241+}
242+
243+func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 {
244+ complete := completeGenerationsDescending(marked)
243245 if len(complete) <= 2 {
244246 return nil
245247 }
@@ -354,19 +356,7 @@ func retainedReferences(marked map[uint64]manifestMaintenance, conservative bool
354356 }
355357 return references
356358 }
357- complete := make([]uint64, 0, len(marked))
358- for generation, item := range marked {
359- if item.complete {
360- complete = append(complete, generation)
361- }
362- }
363- for i := range complete {
364- for j := i + 1; j < len(complete); j++ {
365- if complete[j] > complete[i] {
366- complete[i], complete[j] = complete[j], complete[i]
367- }
368- }
369- }
359+ complete := completeGenerationsDescending(marked)
370360 keep := make(map[uint64]bool, 2)
371361 for _, generation := range complete {
372362 if len(keep) == 2 {
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10+ "sort"
10 "strings"11 "strings"
11 12
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
@@ -226,20 +227,21 @@ func maintenanceManifestRefs(manifest codec.Manifest) (map[ports.SnapshotDigest]
226 return refs, true227 return refs, true
227 }228 }
228 229
229-func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 {230+// completeGenerationsDescending returns only complete generations in canonical
231+// newest-first order for both manifest and object retention.
232+func completeGenerationsDescending(marked map[uint64]manifestMaintenance) []uint64 {
230 complete := make([]uint64, 0, len(marked))233 complete := make([]uint64, 0, len(marked))
231 for generation, item := range marked {234 for generation, item := range marked {
232 if item.complete {235 if item.complete {
233 complete = append(complete, generation)236 complete = append(complete, generation)
234 }237 }
235 }238 }
236- for i := range complete {239+ sort.Slice(complete, func(i, j int) bool { return complete[i] > complete[j] })
237- for j := i + 1; j < len(complete); j++ {240+ return complete
238- if complete[j] > complete[i] {241+}
239- complete[i], complete[j] = complete[j], complete[i]242+
240- }243+func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 {
241- }244+ complete := completeGenerationsDescending(marked)
242- }
243 if len(complete) <= 2 {245 if len(complete) <= 2 {
244 return nil246 return nil
245 }247 }
@@ -354,19 +356,7 @@ func retainedReferences(marked map[uint64]manifestMaintenance, conservative bool
354 }356 }
355 return references357 return references
356 }358 }
357- complete := make([]uint64, 0, len(marked))359+ complete := completeGenerationsDescending(marked)
358- for generation, item := range marked {
359- if item.complete {
360- complete = append(complete, generation)
361- }
362- }
363- for i := range complete {
364- for j := i + 1; j < len(complete); j++ {
365- if complete[j] > complete[i] {
366- complete[i], complete[j] = complete[j], complete[i]
367- }
368- }
369- }
370 keep := make(map[uint64]bool, 2)360 keep := make(map[uint64]bool, 2)
371 for _, generation := range complete {361 for _, generation := range complete {
372 if len(keep) == 2 {362 if len(keep) == 2 {
M internal/adapters/snapshot/maintenance_test.go +27 -2
@@ -3,12 +3,15 @@ package snapshot
33 import (
44 "context"
55 "crypto/sha256"
6+ "encoding/binary"
67 "errors"
78 "fmt"
89 "os"
910 "path/filepath"
1011 "strings"
12+ "syscall"
1113 "testing"
14+ "unsafe"
1215
1316 "github.com/bnema/vev/internal/ports"
1417 )
@@ -77,15 +80,37 @@ func TestReadMaintenanceDirentPreservesCursorAndCloseFailuresWithoutPaths(t *tes
7780 }
7881 }
7982
83+func TestReadMaintenanceDirentRejectsRecordShorterThanNameOffset(t *testing.T) {
84+ repo := NewRepository(privateDir(t))
85+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
86+ recordData := make([]byte, nameOffset)
87+ reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
88+ binary.NativeEndian.PutUint16(recordData[reclenOffset:], 1)
89+
90+ repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
91+ return fakeMaintenanceDirectory{data: recordData}, nil
92+ }
93+ _, _, err := repo.readMaintenanceDirent("unused", 1, &maintenanceCursor{})
94+ if !errors.Is(err, syscall.EIO) {
95+ t.Fatalf("readMaintenanceDirent error = %v, want EIO", err)
96+ }
97+}
98+
8099 type fakeMaintenanceDirectory struct {
100+ data []byte
81101 seekErr error
82102 readErr error
83103 closeErr error
84104 }
85105
86106 func (d fakeMaintenanceDirectory) Seek(int64, int) (int64, error) { return 0, d.seekErr }
87-func (d fakeMaintenanceDirectory) ReadDirent([]byte) (int, error) { return 0, d.readErr }
88-func (d fakeMaintenanceDirectory) Close() error { return d.closeErr }
107+func (d fakeMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
108+ if d.readErr != nil {
109+ return 0, d.readErr
110+ }
111+ return copy(buffer, d.data), nil
112+}
113+func (d fakeMaintenanceDirectory) Close() error { return d.closeErr }
89114
90115 func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) {
91116 repo := NewRepository(privateDir(t))
@@ -3,12 +3,15 @@ package snapshot
3 import (3 import (
4 "context"4 "context"
5 "crypto/sha256"5 "crypto/sha256"
6+ "encoding/binary"
6 "errors"7 "errors"
7 "fmt"8 "fmt"
8 "os"9 "os"
9 "path/filepath"10 "path/filepath"
10 "strings"11 "strings"
12+ "syscall"
11 "testing"13 "testing"
14+ "unsafe"
12 15
13 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
14 )17 )
@@ -77,15 +80,37 @@ func TestReadMaintenanceDirentPreservesCursorAndCloseFailuresWithoutPaths(t *tes
77 }80 }
78 }81 }
79 82
83+func TestReadMaintenanceDirentRejectsRecordShorterThanNameOffset(t *testing.T) {
84+ repo := NewRepository(privateDir(t))
85+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
86+ recordData := make([]byte, nameOffset)
87+ reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
88+ binary.NativeEndian.PutUint16(recordData[reclenOffset:], 1)
89+
90+ repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
91+ return fakeMaintenanceDirectory{data: recordData}, nil
92+ }
93+ _, _, err := repo.readMaintenanceDirent("unused", 1, &maintenanceCursor{})
94+ if !errors.Is(err, syscall.EIO) {
95+ t.Fatalf("readMaintenanceDirent error = %v, want EIO", err)
96+ }
97+}
98+
80 type fakeMaintenanceDirectory struct {99 type fakeMaintenanceDirectory struct {
100+ data []byte
81 seekErr error101 seekErr error
82 readErr error102 readErr error
83 closeErr error103 closeErr error
84 }104 }
85 105
86 func (d fakeMaintenanceDirectory) Seek(int64, int) (int64, error) { return 0, d.seekErr }106 func (d fakeMaintenanceDirectory) Seek(int64, int) (int64, error) { return 0, d.seekErr }
87-func (d fakeMaintenanceDirectory) ReadDirent([]byte) (int, error) { return 0, d.readErr }107+func (d fakeMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
88-func (d fakeMaintenanceDirectory) Close() error { return d.closeErr }108+ if d.readErr != nil {
109+ return 0, d.readErr
110+ }
111+ return copy(buffer, d.data), nil
112+}
113+func (d fakeMaintenanceDirectory) Close() error { return d.closeErr }
89 114
90 func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) {115 func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) {
91 repo := NewRepository(privateDir(t))116 repo := NewRepository(privateDir(t))
M internal/adapters/snapshot/path_security_test.go +3 -1
@@ -35,7 +35,9 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
3535 t.Fatal("Load succeeded through symlinked repository component")
3636 }
3737 // Maintenance must reject the replacement rather than following it.
38- _ = repo.Maintain(context.Background())
38+ if err := repo.Maintain(context.Background()); err == nil {
39+ t.Fatal("Maintain succeeded through symlinked repository component")
40+ }
3941 got, err := os.ReadFile(guard)
4042 if err != nil || string(got) != "guard" {
4143 t.Fatalf("outside guard = %q, %v", got, err)
@@ -35,7 +35,9 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
35 t.Fatal("Load succeeded through symlinked repository component")35 t.Fatal("Load succeeded through symlinked repository component")
36 }36 }
37 // Maintenance must reject the replacement rather than following it.37 // Maintenance must reject the replacement rather than following it.
38- _ = repo.Maintain(context.Background())38+ if err := repo.Maintain(context.Background()); err == nil {
39+ t.Fatal("Maintain succeeded through symlinked repository component")
40+ }
39 got, err := os.ReadFile(guard)41 got, err := os.ReadFile(guard)
40 if err != nil || string(got) != "guard" {42 if err != nil || string(got) != "guard" {
41 t.Fatalf("outside guard = %q, %v", got, err)43 t.Fatalf("outside guard = %q, %v", got, err)
M internal/adapters/snapshot/repository_phase6_test.go +2 -2
@@ -2,6 +2,7 @@ package snapshot
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "os"
78 "path/filepath"
@@ -27,7 +28,6 @@ func TestRepositoryPublishDeleteManySessionsRaceFree(t *testing.T) {
2728
2829 var wg sync.WaitGroup
2930 for _, publication := range publications {
30- publication := publication
3131 wg.Add(2)
3232 go func() {
3333 defer wg.Done()
@@ -73,7 +73,7 @@ func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) {
7373
7474 ctx, cancel := context.WithCancel(context.Background())
7575 cancel()
76- if err := repo.Maintain(ctx); err != context.Canceled {
76+ if err := repo.Maintain(ctx); !errors.Is(err, context.Canceled) {
7777 t.Fatalf("canceled Maintain error = %v, want context canceled", err)
7878 }
7979 locks, epochs = repo.sessionStateCounts()
@@ -2,6 +2,7 @@ package snapshot
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
@@ -27,7 +28,6 @@ func TestRepositoryPublishDeleteManySessionsRaceFree(t *testing.T) {
27 28
28 var wg sync.WaitGroup29 var wg sync.WaitGroup
29 for _, publication := range publications {30 for _, publication := range publications {
30- publication := publication
31 wg.Add(2)31 wg.Add(2)
32 go func() {32 go func() {
33 defer wg.Done()33 defer wg.Done()
@@ -73,7 +73,7 @@ func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) {
73 73
74 ctx, cancel := context.WithCancel(context.Background())74 ctx, cancel := context.WithCancel(context.Background())
75 cancel()75 cancel()
76- if err := repo.Maintain(ctx); err != context.Canceled {76+ if err := repo.Maintain(ctx); !errors.Is(err, context.Canceled) {
77 t.Fatalf("canceled Maintain error = %v, want context canceled", err)77 t.Fatalf("canceled Maintain error = %v, want context canceled", err)
78 }78 }
79 locks, epochs = repo.sessionStateCounts()79 locks, epochs = repo.sessionStateCounts()
M internal/adapters/snapshot/safe_path.go +2 -1
@@ -3,6 +3,7 @@ package snapshot
33 import (
44 "crypto/rand"
55 "encoding/hex"
6+ "errors"
67 "fmt"
78 "os"
89 "path/filepath"
@@ -166,7 +167,7 @@ func (r *Repository) createTempAt(dir string) (temp *os.File, err error) {
166167 }
167168 name := ".tmp-" + hex.EncodeToString(random[:])
168169 tempFD, err := syscall.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
169- if err == syscall.EEXIST {
170+ if errors.Is(err, syscall.EEXIST) {
170171 continue
171172 }
172173 if err != nil {
@@ -3,6 +3,7 @@ package snapshot
3 import (3 import (
4 "crypto/rand"4 "crypto/rand"
5 "encoding/hex"5 "encoding/hex"
6+ "errors"
6 "fmt"7 "fmt"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
@@ -166,7 +167,7 @@ func (r *Repository) createTempAt(dir string) (temp *os.File, err error) {
166 }167 }
167 name := ".tmp-" + hex.EncodeToString(random[:])168 name := ".tmp-" + hex.EncodeToString(random[:])
168 tempFD, err := syscall.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)169 tempFD, err := syscall.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
169- if err == syscall.EEXIST {170+ if errors.Is(err, syscall.EEXIST) {
170 continue171 continue
171 }172 }
172 if err != nil {173 if err != nil {

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 2

Tags 13

↑↓ moveenter openesc close