detached 1f4a728

fix(cef): bound accessibility capture retention

commit
1f4a72853379309bf80ad149d0f3b416bf5eba87
author
brice · · 2026-07-29 09:37 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
39cda6d
signature
unsigned
2 files · +172 -9
M internal/infrastructure/cef/accessibility_capture.go +95 -6
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "os"
66 "path/filepath"
7+ "strconv"
78 "strings"
89 "sync"
910 "sync/atomic"
@@ -23,6 +24,15 @@ const (
2324 accessibilityCaptureEnvValue = "1"
2425 accessibilityCaptureKindTree = "tree"
2526 accessibilityCaptureKindLocation = "location"
27+
28+ // Per-WebView capture budgets for the durable .../a11y-capture/webview-%d
29+ // namespace. Sized to cover an ~80s measurement campaign with headroom for
30+ // frequent tree+location updates, while capping unbounded growth when the
31+ // same WebView-ID directory is reused across sessions. Existing strict
32+ // capture files count toward both budgets; writes reject before create
33+ // when either limit would be exceeded. Tests may inject smaller limits.
34+ accessibilityCaptureDefaultMaxFiles = 10_000
35+ accessibilityCaptureDefaultMaxBytes = 512 << 20 // 512 MiB
2636 )
2737
2838 type accessibilityStatsSnapshot struct {
@@ -171,9 +181,13 @@ func (w *accessibilityCaptureWorker) Close() {
171181 }
172182
173183 type accessibilityCapture struct {
174- mu sync.Mutex
175- dir string
176- seq uint64
184+ mu sync.Mutex
185+ dir string
186+ seq uint64
187+ fileCount int
188+ totalBytes int64
189+ maxFiles int
190+ maxBytes int64
177191 }
178192
179193 func accessibilityCaptureEnabled() bool {
@@ -200,6 +214,60 @@ func validateAccessibilityCaptureDir(dir string) error {
200214 return nil
201215 }
202216
217+// parseAccessibilityCaptureName accepts only strict numbered capture names
218+// produced by Write: NNNNNN-(tree|location).json with a non-zero sequence.
219+func parseAccessibilityCaptureName(name string) (seq uint64, ok bool) {
220+ if !strings.HasSuffix(name, ".json") {
221+ return 0, false
222+ }
223+ base := strings.TrimSuffix(name, ".json")
224+ seqPart, kind, cut := strings.Cut(base, "-")
225+ if !cut || len(seqPart) != 6 || !accessibilityCaptureKindAllowed(kind) {
226+ return 0, false
227+ }
228+ for _, r := range seqPart {
229+ if r < '0' || r > '9' {
230+ return 0, false
231+ }
232+ }
233+ n, err := strconv.ParseUint(seqPart, 10, 64)
234+ if err != nil || n == 0 {
235+ return 0, false
236+ }
237+ return n, true
238+}
239+
240+// scanAccessibilityCaptureDir accounts only strict regular capture files.
241+// Symlinks and non-regular entries are ignored (never followed). Gaps are
242+// allowed; the returned sequence is the maximum numbered name present.
243+func scanAccessibilityCaptureDir(dir string) (maxSeq uint64, fileCount int, totalBytes int64, err error) {
244+ entries, err := os.ReadDir(dir)
245+ if err != nil {
246+ return 0, 0, 0, fmt.Errorf("read accessibility capture dir: %w", err)
247+ }
248+ for _, entry := range entries {
249+ name := entry.Name()
250+ seq, ok := parseAccessibilityCaptureName(name)
251+ if !ok {
252+ continue
253+ }
254+ path := filepath.Join(dir, name)
255+ info, lstatErr := os.Lstat(path)
256+ if lstatErr != nil {
257+ return 0, 0, 0, fmt.Errorf("lstat accessibility capture %s: %w", name, lstatErr)
258+ }
259+ if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
260+ continue
261+ }
262+ fileCount++
263+ totalBytes += info.Size()
264+ if seq > maxSeq {
265+ maxSeq = seq
266+ }
267+ }
268+ return maxSeq, fileCount, totalBytes, nil
269+}
270+
203271 func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
204272 if err := validateAccessibilityCaptureDir(dir); err != nil {
205273 return nil, err
@@ -210,7 +278,18 @@ func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
210278 if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil {
211279 return nil, fmt.Errorf("chmod accessibility capture dir: %w", err)
212280 }
213- return &accessibilityCapture{dir: dir}, nil
281+ maxSeq, fileCount, totalBytes, err := scanAccessibilityCaptureDir(dir)
282+ if err != nil {
283+ return nil, err
284+ }
285+ return &accessibilityCapture{
286+ dir: dir,
287+ seq: maxSeq,
288+ fileCount: fileCount,
289+ totalBytes: totalBytes,
290+ maxFiles: accessibilityCaptureDefaultMaxFiles,
291+ maxBytes: accessibilityCaptureDefaultMaxBytes,
292+ }, nil
214293 }
215294
216295 func accessibilityCaptureKindAllowed(kind string) bool {
@@ -229,15 +308,25 @@ func (c *accessibilityCapture) Write(p accessibilityPayload) error {
229308 if !accessibilityCaptureKindAllowed(p.Kind) {
230309 return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind)
231310 }
311+ payloadBytes := int64(len(p.JSON))
232312 c.mu.Lock()
233313 defer c.mu.Unlock()
234- c.seq++
235- name := fmt.Sprintf("%06d-%s.json", c.seq, p.Kind)
314+ if c.fileCount >= c.maxFiles {
315+ return fmt.Errorf("accessibility capture file budget exceeded (%d files)", c.maxFiles)
316+ }
317+ if c.totalBytes+payloadBytes > c.maxBytes {
318+ return fmt.Errorf("accessibility capture byte budget exceeded (%d bytes)", c.maxBytes)
319+ }
320+ next := c.seq + 1
321+ name := fmt.Sprintf("%06d-%s.json", next, p.Kind)
236322 path := filepath.Join(c.dir, name)
237323 file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm)
238324 if err != nil {
239325 return err
240326 }
327+ c.seq = next
328+ c.fileCount++
329+ c.totalBytes += payloadBytes
241330 _, writeErr := file.WriteString(p.JSON)
242331 chmodErr := file.Chmod(accessibilityCaptureFilePerm)
243332 closeErr := file.Close()
@@ -4,6 +4,7 @@ import (
4 "fmt"4 "fmt"
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7+ "strconv"
7 "strings"8 "strings"
8 "sync"9 "sync"
9 "sync/atomic"10 "sync/atomic"
@@ -23,6 +24,15 @@ const (
23 accessibilityCaptureEnvValue = "1"24 accessibilityCaptureEnvValue = "1"
24 accessibilityCaptureKindTree = "tree"25 accessibilityCaptureKindTree = "tree"
25 accessibilityCaptureKindLocation = "location"26 accessibilityCaptureKindLocation = "location"
27+
28+ // Per-WebView capture budgets for the durable .../a11y-capture/webview-%d
29+ // namespace. Sized to cover an ~80s measurement campaign with headroom for
30+ // frequent tree+location updates, while capping unbounded growth when the
31+ // same WebView-ID directory is reused across sessions. Existing strict
32+ // capture files count toward both budgets; writes reject before create
33+ // when either limit would be exceeded. Tests may inject smaller limits.
34+ accessibilityCaptureDefaultMaxFiles = 10_000
35+ accessibilityCaptureDefaultMaxBytes = 512 << 20 // 512 MiB
26 )36 )
27 37
28 type accessibilityStatsSnapshot struct {38 type accessibilityStatsSnapshot struct {
@@ -171,9 +181,13 @@ func (w *accessibilityCaptureWorker) Close() {
171 }181 }
172 182
173 type accessibilityCapture struct {183 type accessibilityCapture struct {
174- mu sync.Mutex184+ mu sync.Mutex
175- dir string185+ dir string
176- seq uint64186+ seq uint64
187+ fileCount int
188+ totalBytes int64
189+ maxFiles int
190+ maxBytes int64
177 }191 }
178 192
179 func accessibilityCaptureEnabled() bool {193 func accessibilityCaptureEnabled() bool {
@@ -200,6 +214,60 @@ func validateAccessibilityCaptureDir(dir string) error {
200 return nil214 return nil
201 }215 }
202 216
217+// parseAccessibilityCaptureName accepts only strict numbered capture names
218+// produced by Write: NNNNNN-(tree|location).json with a non-zero sequence.
219+func parseAccessibilityCaptureName(name string) (seq uint64, ok bool) {
220+ if !strings.HasSuffix(name, ".json") {
221+ return 0, false
222+ }
223+ base := strings.TrimSuffix(name, ".json")
224+ seqPart, kind, cut := strings.Cut(base, "-")
225+ if !cut || len(seqPart) != 6 || !accessibilityCaptureKindAllowed(kind) {
226+ return 0, false
227+ }
228+ for _, r := range seqPart {
229+ if r < '0' || r > '9' {
230+ return 0, false
231+ }
232+ }
233+ n, err := strconv.ParseUint(seqPart, 10, 64)
234+ if err != nil || n == 0 {
235+ return 0, false
236+ }
237+ return n, true
238+}
239+
240+// scanAccessibilityCaptureDir accounts only strict regular capture files.
241+// Symlinks and non-regular entries are ignored (never followed). Gaps are
242+// allowed; the returned sequence is the maximum numbered name present.
243+func scanAccessibilityCaptureDir(dir string) (maxSeq uint64, fileCount int, totalBytes int64, err error) {
244+ entries, err := os.ReadDir(dir)
245+ if err != nil {
246+ return 0, 0, 0, fmt.Errorf("read accessibility capture dir: %w", err)
247+ }
248+ for _, entry := range entries {
249+ name := entry.Name()
250+ seq, ok := parseAccessibilityCaptureName(name)
251+ if !ok {
252+ continue
253+ }
254+ path := filepath.Join(dir, name)
255+ info, lstatErr := os.Lstat(path)
256+ if lstatErr != nil {
257+ return 0, 0, 0, fmt.Errorf("lstat accessibility capture %s: %w", name, lstatErr)
258+ }
259+ if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
260+ continue
261+ }
262+ fileCount++
263+ totalBytes += info.Size()
264+ if seq > maxSeq {
265+ maxSeq = seq
266+ }
267+ }
268+ return maxSeq, fileCount, totalBytes, nil
269+}
270+
203 func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {271 func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
204 if err := validateAccessibilityCaptureDir(dir); err != nil {272 if err := validateAccessibilityCaptureDir(dir); err != nil {
205 return nil, err273 return nil, err
@@ -210,7 +278,18 @@ func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
210 if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil {278 if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil {
211 return nil, fmt.Errorf("chmod accessibility capture dir: %w", err)279 return nil, fmt.Errorf("chmod accessibility capture dir: %w", err)
212 }280 }
213- return &accessibilityCapture{dir: dir}, nil281+ maxSeq, fileCount, totalBytes, err := scanAccessibilityCaptureDir(dir)
282+ if err != nil {
283+ return nil, err
284+ }
285+ return &accessibilityCapture{
286+ dir: dir,
287+ seq: maxSeq,
288+ fileCount: fileCount,
289+ totalBytes: totalBytes,
290+ maxFiles: accessibilityCaptureDefaultMaxFiles,
291+ maxBytes: accessibilityCaptureDefaultMaxBytes,
292+ }, nil
214 }293 }
215 294
216 func accessibilityCaptureKindAllowed(kind string) bool {295 func accessibilityCaptureKindAllowed(kind string) bool {
@@ -229,15 +308,25 @@ func (c *accessibilityCapture) Write(p accessibilityPayload) error {
229 if !accessibilityCaptureKindAllowed(p.Kind) {308 if !accessibilityCaptureKindAllowed(p.Kind) {
230 return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind)309 return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind)
231 }310 }
311+ payloadBytes := int64(len(p.JSON))
232 c.mu.Lock()312 c.mu.Lock()
233 defer c.mu.Unlock()313 defer c.mu.Unlock()
234- c.seq++314+ if c.fileCount >= c.maxFiles {
235- name := fmt.Sprintf("%06d-%s.json", c.seq, p.Kind)315+ return fmt.Errorf("accessibility capture file budget exceeded (%d files)", c.maxFiles)
316+ }
317+ if c.totalBytes+payloadBytes > c.maxBytes {
318+ return fmt.Errorf("accessibility capture byte budget exceeded (%d bytes)", c.maxBytes)
319+ }
320+ next := c.seq + 1
321+ name := fmt.Sprintf("%06d-%s.json", next, p.Kind)
236 path := filepath.Join(c.dir, name)322 path := filepath.Join(c.dir, name)
237 file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm)323 file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm)
238 if err != nil {324 if err != nil {
239 return err325 return err
240 }326 }
327+ c.seq = next
328+ c.fileCount++
329+ c.totalBytes += payloadBytes
241 _, writeErr := file.WriteString(p.JSON)330 _, writeErr := file.WriteString(p.JSON)
242 chmodErr := file.Chmod(accessibilityCaptureFilePerm)331 chmodErr := file.Chmod(accessibilityCaptureFilePerm)
243 closeErr := file.Close()332 closeErr := file.Close()
M internal/infrastructure/cef/accessibility_capture_test.go +77 -3
@@ -90,16 +90,90 @@ func TestAccessibilityCapture_NeverOverwritesExistingNumberedFile(t *testing.T)
9090
9191 capture, err := newAccessibilityCapture(dir)
9292 require.NoError(t, err)
93- require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"overwrite":true}`}))
93+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"append":true}`}))
9494
9595 got, err := os.ReadFile(preexisting)
9696 require.NoError(t, err)
9797 assert.Equal(t, original, string(got))
9898
99+ appended, err := os.ReadFile(filepath.Join(dir, "000002-tree.json"))
100+ require.NoError(t, err)
101+ assert.JSONEq(t, `{"append":true}`, string(appended))
102+
99103 entries, err := os.ReadDir(dir)
100104 require.NoError(t, err)
101- require.Len(t, entries, 1)
102- assert.Equal(t, "000001-tree.json", entries[0].Name())
105+ require.Len(t, entries, 2)
106+}
107+
108+func TestAccessibilityCapture_ReusedDirInitializesMaxSequence(t *testing.T) {
109+ dir := t.TempDir()
110+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"a":1}`), 0o600))
111+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000003-location.json"), []byte(`{"b":2}`), 0o600))
112+ require.NoError(t, os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o600))
113+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000002-other.json"), []byte(`{}`), 0o600))
114+
115+ capture, err := newAccessibilityCapture(dir)
116+ require.NoError(t, err)
117+ require.Equal(t, uint64(3), capture.seq)
118+ require.Equal(t, 2, capture.fileCount)
119+ require.Equal(t, int64(len(`{"a":1}`)+len(`{"b":2}`)), capture.totalBytes)
120+
121+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
122+ _, err = os.Stat(filepath.Join(dir, "000004-tree.json"))
123+ require.NoError(t, err)
124+ _, err = os.Stat(filepath.Join(dir, "000002-tree.json"))
125+ require.Error(t, err)
126+}
127+
128+func TestAccessibilityCapture_BudgetsIncludeExistingAndRejectBeforeCreate(t *testing.T) {
129+ dir := t.TempDir()
130+ existing := []byte(`{"keep":true}`)
131+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), existing, 0o600))
132+
133+ capture, err := newAccessibilityCapture(dir)
134+ require.NoError(t, err)
135+ capture.maxFiles = 2
136+ capture.maxBytes = int64(len(existing) + 8)
137+
138+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"ok":1}`}))
139+ require.Equal(t, 2, capture.fileCount)
140+ require.FileExists(t, filepath.Join(dir, "000002-location.json"))
141+
142+ before, err := os.ReadDir(dir)
143+ require.NoError(t, err)
144+ require.Len(t, before, 2)
145+
146+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `x`}))
147+ afterFiles, err := os.ReadDir(dir)
148+ require.NoError(t, err)
149+ require.Len(t, afterFiles, 2, "file-budget reject must not create a file")
150+ require.Equal(t, uint64(2), capture.seq, "file-budget reject must not advance sequence")
151+
152+ capture.maxFiles = 10
153+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `too-large`}))
154+ afterBytes, err := os.ReadDir(dir)
155+ require.NoError(t, err)
156+ require.Len(t, afterBytes, 2, "byte-budget reject must not create a file")
157+ require.Equal(t, uint64(2), capture.seq, "byte-budget reject must not advance sequence")
158+}
159+
160+func TestAccessibilityCapture_ScanIgnoresSymlinkAndNonRegular(t *testing.T) {
161+ dir := t.TempDir()
162+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"ok":1}`), 0o600))
163+ require.NoError(t, os.Symlink(filepath.Join(dir, "000001-tree.json"), filepath.Join(dir, "000009-tree.json")))
164+ require.NoError(t, os.Mkdir(filepath.Join(dir, "000008-location.json"), 0o700))
165+
166+ capture, err := newAccessibilityCapture(dir)
167+ require.NoError(t, err)
168+ require.Equal(t, uint64(1), capture.seq)
169+ require.Equal(t, 1, capture.fileCount)
170+ require.Equal(t, int64(len(`{"ok":1}`)), capture.totalBytes)
171+
172+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"n":2}`}))
173+ require.FileExists(t, filepath.Join(dir, "000002-location.json"))
174+ info, err := os.Lstat(filepath.Join(dir, "000009-tree.json"))
175+ require.NoError(t, err)
176+ assert.NotZero(t, info.Mode()&os.ModeSymlink)
103177 }
104178
105179 func TestAccessibilityCapture_Namespace0700AndChmodExisting(t *testing.T) {
@@ -90,16 +90,90 @@ func TestAccessibilityCapture_NeverOverwritesExistingNumberedFile(t *testing.T)
90 90
91 capture, err := newAccessibilityCapture(dir)91 capture, err := newAccessibilityCapture(dir)
92 require.NoError(t, err)92 require.NoError(t, err)
93- require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"overwrite":true}`}))93+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"append":true}`}))
94 94
95 got, err := os.ReadFile(preexisting)95 got, err := os.ReadFile(preexisting)
96 require.NoError(t, err)96 require.NoError(t, err)
97 assert.Equal(t, original, string(got))97 assert.Equal(t, original, string(got))
98 98
99+ appended, err := os.ReadFile(filepath.Join(dir, "000002-tree.json"))
100+ require.NoError(t, err)
101+ assert.JSONEq(t, `{"append":true}`, string(appended))
102+
99 entries, err := os.ReadDir(dir)103 entries, err := os.ReadDir(dir)
100 require.NoError(t, err)104 require.NoError(t, err)
101- require.Len(t, entries, 1)105+ require.Len(t, entries, 2)
102- assert.Equal(t, "000001-tree.json", entries[0].Name())106+}
107+
108+func TestAccessibilityCapture_ReusedDirInitializesMaxSequence(t *testing.T) {
109+ dir := t.TempDir()
110+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"a":1}`), 0o600))
111+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000003-location.json"), []byte(`{"b":2}`), 0o600))
112+ require.NoError(t, os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o600))
113+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000002-other.json"), []byte(`{}`), 0o600))
114+
115+ capture, err := newAccessibilityCapture(dir)
116+ require.NoError(t, err)
117+ require.Equal(t, uint64(3), capture.seq)
118+ require.Equal(t, 2, capture.fileCount)
119+ require.Equal(t, int64(len(`{"a":1}`)+len(`{"b":2}`)), capture.totalBytes)
120+
121+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
122+ _, err = os.Stat(filepath.Join(dir, "000004-tree.json"))
123+ require.NoError(t, err)
124+ _, err = os.Stat(filepath.Join(dir, "000002-tree.json"))
125+ require.Error(t, err)
126+}
127+
128+func TestAccessibilityCapture_BudgetsIncludeExistingAndRejectBeforeCreate(t *testing.T) {
129+ dir := t.TempDir()
130+ existing := []byte(`{"keep":true}`)
131+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), existing, 0o600))
132+
133+ capture, err := newAccessibilityCapture(dir)
134+ require.NoError(t, err)
135+ capture.maxFiles = 2
136+ capture.maxBytes = int64(len(existing) + 8)
137+
138+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"ok":1}`}))
139+ require.Equal(t, 2, capture.fileCount)
140+ require.FileExists(t, filepath.Join(dir, "000002-location.json"))
141+
142+ before, err := os.ReadDir(dir)
143+ require.NoError(t, err)
144+ require.Len(t, before, 2)
145+
146+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `x`}))
147+ afterFiles, err := os.ReadDir(dir)
148+ require.NoError(t, err)
149+ require.Len(t, afterFiles, 2, "file-budget reject must not create a file")
150+ require.Equal(t, uint64(2), capture.seq, "file-budget reject must not advance sequence")
151+
152+ capture.maxFiles = 10
153+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `too-large`}))
154+ afterBytes, err := os.ReadDir(dir)
155+ require.NoError(t, err)
156+ require.Len(t, afterBytes, 2, "byte-budget reject must not create a file")
157+ require.Equal(t, uint64(2), capture.seq, "byte-budget reject must not advance sequence")
158+}
159+
160+func TestAccessibilityCapture_ScanIgnoresSymlinkAndNonRegular(t *testing.T) {
161+ dir := t.TempDir()
162+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"ok":1}`), 0o600))
163+ require.NoError(t, os.Symlink(filepath.Join(dir, "000001-tree.json"), filepath.Join(dir, "000009-tree.json")))
164+ require.NoError(t, os.Mkdir(filepath.Join(dir, "000008-location.json"), 0o700))
165+
166+ capture, err := newAccessibilityCapture(dir)
167+ require.NoError(t, err)
168+ require.Equal(t, uint64(1), capture.seq)
169+ require.Equal(t, 1, capture.fileCount)
170+ require.Equal(t, int64(len(`{"ok":1}`)), capture.totalBytes)
171+
172+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"n":2}`}))
173+ require.FileExists(t, filepath.Join(dir, "000002-location.json"))
174+ info, err := os.Lstat(filepath.Join(dir, "000009-tree.json"))
175+ require.NoError(t, err)
176+ assert.NotZero(t, info.Mode()&os.ModeSymlink)
103 }177 }
104 178
105 func TestAccessibilityCapture_Namespace0700AndChmodExisting(t *testing.T) {179 func TestAccessibilityCapture_Namespace0700AndChmodExisting(t *testing.T) {

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 8

Tags 42

↑↓ moveenter openesc close