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 ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | "strconv" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "sync" | |
| 9 | 10 | "sync/atomic" | |
| @@ -23,6 +24,15 @@ const ( | |||
| 23 | 24 | accessibilityCaptureEnvValue = "1" | |
| 24 | 25 | accessibilityCaptureKindTree = "tree" | |
| 25 | 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 | 38 | type accessibilityStatsSnapshot struct { | |
| @@ -171,9 +181,13 @@ func (w *accessibilityCaptureWorker) Close() { | |||
| 171 | 181 | } | |
| 172 | 182 | ||
| 173 | 183 | 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 | |
| 177 | 191 | } | |
| 178 | 192 | ||
| 179 | 193 | func accessibilityCaptureEnabled() bool { | |
| @@ -200,6 +214,60 @@ func validateAccessibilityCaptureDir(dir string) error { | |||
| 200 | 214 | 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 | 271 | func newAccessibilityCapture(dir string) (*accessibilityCapture, error) { | |
| 204 | 272 | if err := validateAccessibilityCaptureDir(dir); err != nil { | |
| 205 | 273 | return nil, err | |
| @@ -210,7 +278,18 @@ func newAccessibilityCapture(dir string) (*accessibilityCapture, error) { | |||
| 210 | 278 | if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil { | |
| 211 | 279 | return nil, fmt.Errorf("chmod accessibility capture dir: %w", err) | |
| 212 | 280 | } | |
| 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 | |
| 214 | 293 | } | |
| 215 | 294 | ||
| 216 | 295 | func accessibilityCaptureKindAllowed(kind string) bool { | |
| @@ -229,15 +308,25 @@ func (c *accessibilityCapture) Write(p accessibilityPayload) error { | |||
| 229 | 308 | if !accessibilityCaptureKindAllowed(p.Kind) { | |
| 230 | 309 | return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind) | |
| 231 | 310 | } | |
| 311 | + | payloadBytes := int64(len(p.JSON)) | |
| 232 | 312 | c.mu.Lock() | |
| 233 | 313 | 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) | |
| 236 | 322 | path := filepath.Join(c.dir, name) | |
| 237 | 323 | file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm) | |
| 238 | 324 | if err != nil { | |
| 239 | 325 | return err | |
| 240 | 326 | } | |
| 327 | + | c.seq = next | |
| 328 | + | c.fileCount++ | |
| 329 | + | c.totalBytes += payloadBytes | |
| 241 | 330 | _, writeErr := file.WriteString(p.JSON) | |
| 242 | 331 | chmodErr := file.Chmod(accessibilityCaptureFilePerm) | |
| 243 | 332 | 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.Mutex | 184 | + | mu sync.Mutex |
| 175 | - | dir string | 185 | + | dir string |
| 176 | - | seq uint64 | 186 | + | 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 nil | 214 | 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, err | 273 | 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}, 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 | |||
| 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 err | 325 | 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) | |||
| 90 | 90 | ||
| 91 | 91 | capture, err := newAccessibilityCapture(dir) | |
| 92 | 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 | 95 | got, err := os.ReadFile(preexisting) | |
| 96 | 96 | require.NoError(t, err) | |
| 97 | 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 | 103 | entries, err := os.ReadDir(dir) | |
| 100 | 104 | 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) | |
| 103 | 177 | } | |
| 104 | 178 | ||
| 105 | 179 | 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) { | ||