|
1 | 1 | package corpus |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "os" |
5 | 6 | "path/filepath" |
6 | 7 | "testing" |
| 8 | + |
| 9 | + "github.com/jeduden/mdsmith/internal/bytelimit" |
7 | 10 | ) |
8 | 11 |
|
9 | 12 | func TestCollect_HappyPath(t *testing.T) { |
@@ -207,6 +210,144 @@ func TestCollect_ErrorPath(t *testing.T) { |
207 | 210 | } |
208 | 211 | } |
209 | 212 |
|
| 213 | +// TestCollect_OversizedFile_SkippedNotFatal guards against an unbounded |
| 214 | +// os.ReadFile on a corpus source: collectFile ingests markdown from |
| 215 | +// cloned third-party repositories, which are untrusted input |
| 216 | +// (docs/development/high-performance-go.md — "os.ReadFile on huge |
| 217 | +// inputs: one giant alloc, all resident"). A file over the shared |
| 218 | +// bytelimit.DefaultMaxInputBytes cap must be skipped rather than read |
| 219 | +// into memory in full — and, since a real source repository can contain |
| 220 | +// one large file among many good ones (a big CHANGELOG, a vendored |
| 221 | +// spec), skipping it must not abort collection of the rest of that |
| 222 | +// source, or of sources collected *earlier* in the same run (Collect's |
| 223 | +// loop over cfg.Sources returns on the first error, discarding every |
| 224 | +// record gathered so far — so this uses two sources, not one, to prove |
| 225 | +// the first source's record survives a later source's oversized file). |
| 226 | +func TestCollect_OversizedFile_SkippedNotFatal(t *testing.T) { |
| 227 | + t.Parallel() |
| 228 | + |
| 229 | + const prose = "# Title\n\nword word word word word word\n" |
| 230 | + |
| 231 | + goodRoot := filepath.Join(t.TempDir(), "good") |
| 232 | + mustMkdirAll(t, goodRoot) |
| 233 | + mustWriteFile(t, filepath.Join(goodRoot, "early.md"), []byte(prose)) |
| 234 | + |
| 235 | + mixedRoot := filepath.Join(t.TempDir(), "mixed") |
| 236 | + mustMkdirAll(t, mixedRoot) |
| 237 | + oversized := bytes.Repeat([]byte("a "), int(bytelimit.DefaultMaxInputBytes)/2+1) |
| 238 | + mustWriteFile(t, filepath.Join(mixedRoot, "huge.md"), oversized) |
| 239 | + mustWriteFile(t, filepath.Join(mixedRoot, "normal.md"), []byte(prose)) |
| 240 | + |
| 241 | + cfg := &Config{ |
| 242 | + CollectedAt: "2026-02-16", |
| 243 | + MinWords: 1, |
| 244 | + MinChars: 1, |
| 245 | + LicenseAllowlist: []string{"MIT"}, |
| 246 | + Sources: []SourceConfig{ |
| 247 | + { |
| 248 | + Name: "early", |
| 249 | + Repository: "github.com/acme/early", |
| 250 | + Root: goodRoot, |
| 251 | + CommitSHA: "abc123", |
| 252 | + License: "MIT", |
| 253 | + }, |
| 254 | + { |
| 255 | + Name: "mixed", |
| 256 | + Repository: "github.com/acme/mixed", |
| 257 | + Root: mixedRoot, |
| 258 | + CommitSHA: "def456", |
| 259 | + License: "MIT", |
| 260 | + }, |
| 261 | + }, |
| 262 | + } |
| 263 | + |
| 264 | + records, err := Collect(cfg, t.TempDir()) |
| 265 | + if err != nil { |
| 266 | + t.Fatalf("Collect: unexpected error, oversized file should be skipped: %v", err) |
| 267 | + } |
| 268 | + if len(records) != 2 { |
| 269 | + t.Fatalf("record count = %d, want 2 (early.md from the first source, "+ |
| 270 | + "normal.md from the second; huge.md must be skipped)", len(records)) |
| 271 | + } |
| 272 | + paths := make([]string, len(records)) |
| 273 | + for i, r := range records { |
| 274 | + paths[i] = r.Source + "/" + r.Path |
| 275 | + } |
| 276 | + if paths[0] != "early/early.md" || paths[1] != "mixed/normal.md" { |
| 277 | + t.Fatalf("records = %v, want [early/early.md mixed/normal.md]", paths) |
| 278 | + } |
| 279 | +} |
| 280 | + |
| 281 | +// mustMkdirAll creates dir and all parents, failing the test on error. |
| 282 | +func mustMkdirAll(t *testing.T, dir string) { |
| 283 | + t.Helper() |
| 284 | + if err := os.MkdirAll(dir, 0o755); err != nil { |
| 285 | + t.Fatalf("mkdir %s: %v", dir, err) |
| 286 | + } |
| 287 | +} |
| 288 | + |
| 289 | +// mustWriteFile writes content to path, failing the test on error. |
| 290 | +func mustWriteFile(t *testing.T, path string, content []byte) { |
| 291 | + t.Helper() |
| 292 | + if err := os.WriteFile(path, content, 0o644); err != nil { |
| 293 | + t.Fatalf("write %s: %v", path, err) |
| 294 | + } |
| 295 | +} |
| 296 | + |
| 297 | +// TestCollectFile_StatError_SkippedNotFatal covers collectFile's os.Stat |
| 298 | +// error branch directly: a file that vanishes between WalkDir listing it |
| 299 | +// and the Stat call inside collectFile (or any other stat failure) must |
| 300 | +// be skipped, not treated as fatal — the same reasoning as the oversized- |
| 301 | +// file case above. |
| 302 | +func TestCollectFile_StatError_SkippedNotFatal(t *testing.T) { |
| 303 | + t.Parallel() |
| 304 | + |
| 305 | + root := t.TempDir() |
| 306 | + missing := filepath.Join(root, "gone.md") |
| 307 | + |
| 308 | + cfg := &Config{MinWords: 1, MinChars: 1} |
| 309 | + record, keep, err := collectFile(cfg, SourceConfig{Name: "seed"}, missing, "gone.md", root) |
| 310 | + if err != nil { |
| 311 | + t.Fatalf("collectFile: unexpected error for a stat failure: %v", err) |
| 312 | + } |
| 313 | + if keep { |
| 314 | + t.Fatal("collectFile: keep = true, want false for a stat failure") |
| 315 | + } |
| 316 | + if record != (Record{}) { |
| 317 | + t.Fatalf("collectFile: record = %+v, want zero value", record) |
| 318 | + } |
| 319 | +} |
| 320 | + |
| 321 | +// TestCollectFile_ReadError_SkippedNotFatal covers collectFile's fallback |
| 322 | +// bytelimit.ReadFileLimited error branch directly: a path that passes the |
| 323 | +// Stat-based size pre-check but then fails to read must be skipped, not |
| 324 | +// treated as fatal — the same reasoning as the stat-failure and |
| 325 | +// oversized-file cases above. A directory Stats successfully (size 0, |
| 326 | +// under the cap) but fails to Read as a file ("is a directory"), |
| 327 | +// deterministically reaching this branch regardless of the running |
| 328 | +// user's privileges (unlike a permission-bit test, which root ignores). |
| 329 | +func TestCollectFile_ReadError_SkippedNotFatal(t *testing.T) { |
| 330 | + t.Parallel() |
| 331 | + |
| 332 | + root := t.TempDir() |
| 333 | + notAFile := filepath.Join(root, "not-a-file.md") |
| 334 | + if err := os.Mkdir(notAFile, 0o755); err != nil { |
| 335 | + t.Fatalf("mkdir: %v", err) |
| 336 | + } |
| 337 | + |
| 338 | + cfg := &Config{MinWords: 1, MinChars: 1} |
| 339 | + record, keep, err := collectFile(cfg, SourceConfig{Name: "seed"}, notAFile, "not-a-file.md", root) |
| 340 | + if err != nil { |
| 341 | + t.Fatalf("collectFile: unexpected error reading a directory as a file: %v", err) |
| 342 | + } |
| 343 | + if keep { |
| 344 | + t.Fatal("collectFile: keep = true, want false when the read fails") |
| 345 | + } |
| 346 | + if record != (Record{}) { |
| 347 | + t.Fatalf("collectFile: record = %+v, want zero value", record) |
| 348 | + } |
| 349 | +} |
| 350 | + |
210 | 351 | // --- reportProgress --- |
211 | 352 |
|
212 | 353 | // TestReportProgress pins all three branches: nil cfg is a no-op, |
|
0 commit comments