detached d8d4bf6

fix(snapshot): decode dirents with aligned storage

commit
d8d4bf61ad0c8184bfc4701c8f94b31d044ed440
author
brice · · 2026-07-22 05:55 UTC
parent
fb7c7c4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +78 -9
M internal/adapters/snapshot/maintenance_cursor.go +30 -9
@@ -150,18 +150,16 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
150150 if err != nil {
151151 return nil, false, maintenanceDirectoryError("read maintenance directory", err)
152152 }
153+ if count < 0 || count > len(buffer) {
154+ return nil, false, syscall.EIO
155+ }
153156 if count == 0 {
154157 return entries, true, nil
155158 }
156159 for data := buffer[:count]; len(data) != 0 && (len(entries) < limit || drainMaintenanceDirentBatch()); {
157- if len(data) < int(unsafe.Offsetof(syscall.Dirent{}.Name)) {
158- return nil, false, syscall.EIO
159- }
160- record := (*syscall.Dirent)(unsafe.Pointer(&data[0]))
161- reclen := int(record.Reclen)
162- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
163- if reclen < nameOffset || reclen > len(data) {
164- return nil, false, syscall.EIO
160+ record, nameBytes, reclen, decodeErr := decodeMaintenanceDirent(data)
161+ if decodeErr != nil {
162+ return nil, false, decodeErr
165163 }
166164 data = data[reclen:]
167165 // Advance past every raw record, including dot and disappeared entries.
@@ -170,7 +168,6 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
170168 return nil, false, maintenanceDirectoryError("tell maintenance directory", cookieErr)
171169 }
172170 cursor.offset = offset
173- nameBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
174171 if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {
175172 nameBytes = nameBytes[:end]
176173 }
@@ -204,6 +201,30 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
204201 return entries, false, nil
205202 }
206203
204+// decodeMaintenanceDirent copies a bounded raw record into aligned storage
205+// before reading its fields. syscall.ReadDirent writes to a byte buffer, whose
206+// start and record boundaries are not guaranteed to satisfy syscall.Dirent's
207+// alignment requirements on Darwin.
208+func decodeMaintenanceDirent(data []byte) (*syscall.Dirent, []byte, int, error) {
209+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
210+ if len(data) < nameOffset {
211+ return nil, nil, 0, syscall.EIO
212+ }
213+
214+ var record syscall.Dirent
215+ header := unsafe.Slice((*byte)(unsafe.Pointer(&record)), nameOffset)
216+ copy(header, data[:nameOffset])
217+ reclen := int(record.Reclen)
218+ if reclen < nameOffset || reclen > len(data) || reclen > int(unsafe.Sizeof(record)) {
219+ return nil, nil, 0, syscall.EIO
220+ }
221+
222+ recordBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record)), reclen)
223+ copy(recordBytes, data[:reclen])
224+ name := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
225+ return &record, name, reclen, nil
226+}
227+
207228 func (r *Repository) requeueMaintenanceEntries(dir, purpose string, entries []maintenanceDirEntry) {
208229 if len(entries) == 0 {
209230 return
@@ -150,18 +150,16 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
150 if err != nil {150 if err != nil {
151 return nil, false, maintenanceDirectoryError("read maintenance directory", err)151 return nil, false, maintenanceDirectoryError("read maintenance directory", err)
152 }152 }
153+ if count < 0 || count > len(buffer) {
154+ return nil, false, syscall.EIO
155+ }
153 if count == 0 {156 if count == 0 {
154 return entries, true, nil157 return entries, true, nil
155 }158 }
156 for data := buffer[:count]; len(data) != 0 && (len(entries) < limit || drainMaintenanceDirentBatch()); {159 for data := buffer[:count]; len(data) != 0 && (len(entries) < limit || drainMaintenanceDirentBatch()); {
157- if len(data) < int(unsafe.Offsetof(syscall.Dirent{}.Name)) {160+ record, nameBytes, reclen, decodeErr := decodeMaintenanceDirent(data)
158- return nil, false, syscall.EIO161+ if decodeErr != nil {
159- }162+ return nil, false, decodeErr
160- record := (*syscall.Dirent)(unsafe.Pointer(&data[0]))
161- reclen := int(record.Reclen)
162- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
163- if reclen < nameOffset || reclen > len(data) {
164- return nil, false, syscall.EIO
165 }163 }
166 data = data[reclen:]164 data = data[reclen:]
167 // Advance past every raw record, including dot and disappeared entries.165 // Advance past every raw record, including dot and disappeared entries.
@@ -170,7 +168,6 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
170 return nil, false, maintenanceDirectoryError("tell maintenance directory", cookieErr)168 return nil, false, maintenanceDirectoryError("tell maintenance directory", cookieErr)
171 }169 }
172 cursor.offset = offset170 cursor.offset = offset
173- nameBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
174 if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {171 if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {
175 nameBytes = nameBytes[:end]172 nameBytes = nameBytes[:end]
176 }173 }
@@ -204,6 +201,30 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
204 return entries, false, nil201 return entries, false, nil
205 }202 }
206 203
204+// decodeMaintenanceDirent copies a bounded raw record into aligned storage
205+// before reading its fields. syscall.ReadDirent writes to a byte buffer, whose
206+// start and record boundaries are not guaranteed to satisfy syscall.Dirent's
207+// alignment requirements on Darwin.
208+func decodeMaintenanceDirent(data []byte) (*syscall.Dirent, []byte, int, error) {
209+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
210+ if len(data) < nameOffset {
211+ return nil, nil, 0, syscall.EIO
212+ }
213+
214+ var record syscall.Dirent
215+ header := unsafe.Slice((*byte)(unsafe.Pointer(&record)), nameOffset)
216+ copy(header, data[:nameOffset])
217+ reclen := int(record.Reclen)
218+ if reclen < nameOffset || reclen > len(data) || reclen > int(unsafe.Sizeof(record)) {
219+ return nil, nil, 0, syscall.EIO
220+ }
221+
222+ recordBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record)), reclen)
223+ copy(recordBytes, data[:reclen])
224+ name := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
225+ return &record, name, reclen, nil
226+}
227+
207 func (r *Repository) requeueMaintenanceEntries(dir, purpose string, entries []maintenanceDirEntry) {228 func (r *Repository) requeueMaintenanceEntries(dir, purpose string, entries []maintenanceDirEntry) {
208 if len(entries) == 0 {229 if len(entries) == 0 {
209 return230 return
M internal/adapters/snapshot/maintenance_test.go +48 -0
@@ -96,6 +96,54 @@ func TestReadMaintenanceDirentRejectsRecordShorterThanNameOffset(t *testing.T) {
9696 }
9797 }
9898
99+func TestDecodeMaintenanceDirentBoundsRecords(t *testing.T) {
100+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
101+ reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
102+ valid := make([]byte, nameOffset+len("entry")+1)
103+ binary.NativeEndian.PutUint16(valid[reclenOffset:], uint16(len(valid)))
104+ copy(valid[nameOffset:], "entry")
105+
106+ tests := []struct {
107+ name string
108+ data []byte
109+ wantName string
110+ wantErr bool
111+ }{
112+ {name: "truncated header", data: make([]byte, nameOffset-1), wantErr: true},
113+ {name: "short record", data: append([]byte(nil), valid...), wantErr: true},
114+ {name: "truncated record", data: valid[:nameOffset], wantErr: true},
115+ {name: "record exceeds aligned storage", data: make([]byte, int(unsafe.Sizeof(syscall.Dirent{}))+1), wantErr: true},
116+ {name: "valid", data: valid, wantName: "entry"},
117+ }
118+ binary.NativeEndian.PutUint16(tests[1].data[reclenOffset:], uint16(nameOffset-1))
119+ binary.NativeEndian.PutUint16(tests[2].data[reclenOffset:], uint16(len(valid)))
120+ binary.NativeEndian.PutUint16(tests[3].data[reclenOffset:], uint16(len(tests[3].data)))
121+
122+ for _, tc := range tests {
123+ t.Run(tc.name, func(t *testing.T) {
124+ record, name, reclen, err := decodeMaintenanceDirent(tc.data)
125+ if tc.wantErr {
126+ if !errors.Is(err, syscall.EIO) {
127+ t.Fatalf("decodeMaintenanceDirent() error = %v, want EIO", err)
128+ }
129+ return
130+ }
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ if record == nil {
135+ t.Fatal("decodeMaintenanceDirent() record = nil")
136+ }
137+ if reclen != len(valid) {
138+ t.Fatalf("decodeMaintenanceDirent() reclen = %d, want %d", reclen, len(valid))
139+ }
140+ if got := string(name[:len(tc.wantName)]); got != tc.wantName {
141+ t.Fatalf("decodeMaintenanceDirent() name = %q, want %q", got, tc.wantName)
142+ }
143+ })
144+ }
145+}
146+
99147 type fakeMaintenanceDirectory struct {
100148 data []byte
101149 seekOffset int64
@@ -96,6 +96,54 @@ func TestReadMaintenanceDirentRejectsRecordShorterThanNameOffset(t *testing.T) {
96 }96 }
97 }97 }
98 98
99+func TestDecodeMaintenanceDirentBoundsRecords(t *testing.T) {
100+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
101+ reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
102+ valid := make([]byte, nameOffset+len("entry")+1)
103+ binary.NativeEndian.PutUint16(valid[reclenOffset:], uint16(len(valid)))
104+ copy(valid[nameOffset:], "entry")
105+
106+ tests := []struct {
107+ name string
108+ data []byte
109+ wantName string
110+ wantErr bool
111+ }{
112+ {name: "truncated header", data: make([]byte, nameOffset-1), wantErr: true},
113+ {name: "short record", data: append([]byte(nil), valid...), wantErr: true},
114+ {name: "truncated record", data: valid[:nameOffset], wantErr: true},
115+ {name: "record exceeds aligned storage", data: make([]byte, int(unsafe.Sizeof(syscall.Dirent{}))+1), wantErr: true},
116+ {name: "valid", data: valid, wantName: "entry"},
117+ }
118+ binary.NativeEndian.PutUint16(tests[1].data[reclenOffset:], uint16(nameOffset-1))
119+ binary.NativeEndian.PutUint16(tests[2].data[reclenOffset:], uint16(len(valid)))
120+ binary.NativeEndian.PutUint16(tests[3].data[reclenOffset:], uint16(len(tests[3].data)))
121+
122+ for _, tc := range tests {
123+ t.Run(tc.name, func(t *testing.T) {
124+ record, name, reclen, err := decodeMaintenanceDirent(tc.data)
125+ if tc.wantErr {
126+ if !errors.Is(err, syscall.EIO) {
127+ t.Fatalf("decodeMaintenanceDirent() error = %v, want EIO", err)
128+ }
129+ return
130+ }
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ if record == nil {
135+ t.Fatal("decodeMaintenanceDirent() record = nil")
136+ }
137+ if reclen != len(valid) {
138+ t.Fatalf("decodeMaintenanceDirent() reclen = %d, want %d", reclen, len(valid))
139+ }
140+ if got := string(name[:len(tc.wantName)]); got != tc.wantName {
141+ t.Fatalf("decodeMaintenanceDirent() name = %q, want %q", got, tc.wantName)
142+ }
143+ })
144+ }
145+}
146+
99 type fakeMaintenanceDirectory struct {147 type fakeMaintenanceDirectory struct {
100 data []byte148 data []byte
101 seekOffset int64149 seekOffset int64

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