Skip to content

Commit 1aba2d9

Browse files
committed
fix(corpus): also skip (not abort) on stat failure and TOCTOU read races
Review found the stat-based size pre-check protected only the common case: a file that grows past the cap between the Stat and the subsequent bytelimit.ReadFileLimited call still returned a hard error, reintroducing the exact whole-build-abort bug the pre-check was meant to fix, just for a narrower race window. Both the Stat error branch and the fallback ReadFileLimited error branch now skip-and-report instead of failing collectFile's caller. Added direct collectFile unit tests for both branches (a vanished path, and a directory passed as a file, which reliably fails at Read without depending on file-permission bits that root ignores) and rewrote the oversized-file test to use two Sources, proving an earlier source's records survive a later source's bad file — Collect's per-source loop returns on first error, discarding every prior record, which a single-source test can't exercise. Also: correct a stale comment in internal/linkgraph/linkgraph.go describing ColumnOfOffset's old O(column) backward scan (it's now O(log lines) via binary search), and add an integration-level test driving pkg/goldmark/parser's full HTML block Open() path with mixed-case tags — the existing equivalence/upstream-parity harness and TestHTMLBlock_AllSevenTypes only ever exercise lowercase tags, so a wiring mistake in the new tag-lookup helpers could otherwise only be caught by their own unit tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UCh2j2oeSw6UgNsmpjRonL
1 parent 7f6d8ef commit 1aba2d9

4 files changed

Lines changed: 162 additions & 36 deletions

File tree

internal/corpus/collect.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -176,16 +176,19 @@ func collectFile(
176176
return Record{}, false, nil
177177
}
178178

179-
// Skip files over the shared byte-limit cap rather than failing the
180-
// whole source: collectFromRoot's caller aborts the entire walk (and
181-
// every record already collected from every source before it, since
182-
// Collect returns on the first error) on any error from this
183-
// function. A cloned third-party repository can legitimately contain
184-
// one oversized file (a large CHANGELOG, a vendored spec) without
185-
// that file being reason to discard the rest of the corpus build.
179+
// Skip a file this function cannot read — oversized, vanished, or
180+
// permission-denied — rather than failing the whole source:
181+
// collectFromRoot's caller aborts the entire walk (and every record
182+
// already collected from every source before it, since Collect
183+
// returns on the first error) on any error from this function. A
184+
// cloned third-party repository can legitimately contain one
185+
// oversized or racy file (a large CHANGELOG, a vendored spec, a file
186+
// removed mid-walk) without that file being reason to discard the
187+
// rest of the corpus build.
186188
info, err := os.Stat(fullPath)
187189
if err != nil {
188-
return Record{}, false, fmt.Errorf("stat file %s: %w", fullPath, err)
190+
reportProgress(cfg, fmt.Sprintf("skipping %s: %v", relPath, err))
191+
return Record{}, false, nil
189192
}
190193
if info.Size() > bytelimit.DefaultMaxInputBytes {
191194
reportProgress(cfg, fmt.Sprintf(
@@ -194,9 +197,14 @@ func collectFile(
194197
return Record{}, false, nil
195198
}
196199

200+
// bytelimit.ReadFileLimited re-checks the size on the actual read,
201+
// so a file that grows past the cap in the window between the Stat
202+
// above and this call (or otherwise becomes unreadable) is caught
203+
// here too — skipped the same way, not treated as fatal.
197204
content, err := bytelimit.ReadFileLimited(fullPath, bytelimit.DefaultMaxInputBytes)
198205
if err != nil {
199-
return Record{}, false, fmt.Errorf("read file %s: %w", fullPath, err)
206+
reportProgress(cfg, fmt.Sprintf("skipping %s: %v", relPath, err))
207+
return Record{}, false, nil
200208
}
201209
raw := normalizeContent(string(content))
202210
words := countWords(raw)

internal/corpus/collect_test.go

Lines changed: 109 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -219,46 +219,132 @@ func TestCollect_ErrorPath(t *testing.T) {
219219
// into memory in full — and, since a real source repository can contain
220220
// one large file among many good ones (a big CHANGELOG, a vendored
221221
// spec), skipping it must not abort collection of the rest of that
222-
// source or of sources collected earlier in the same run.
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).
223226
func TestCollect_OversizedFile_SkippedNotFatal(t *testing.T) {
224227
t.Parallel()
225228

226-
root := filepath.Join(t.TempDir(), "docs")
227-
if err := os.MkdirAll(root, 0o755); err != nil {
228-
t.Fatalf("mkdir: %v", err)
229-
}
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)
230237
oversized := bytes.Repeat([]byte("a "), int(bytelimit.DefaultMaxInputBytes)/2+1)
231-
if err := os.WriteFile(filepath.Join(root, "huge.md"), oversized, 0o644); err != nil {
232-
t.Fatalf("write oversized markdown: %v", err)
233-
}
234-
normalContent := []byte("# Title\n\nword word word word word word\n")
235-
if err := os.WriteFile(filepath.Join(root, "normal.md"), normalContent, 0o644); err != nil {
236-
t.Fatalf("write normal markdown: %v", err)
237-
}
238+
mustWriteFile(t, filepath.Join(mixedRoot, "huge.md"), oversized)
239+
mustWriteFile(t, filepath.Join(mixedRoot, "normal.md"), []byte(prose))
238240

239241
cfg := &Config{
240242
CollectedAt: "2026-02-16",
241243
MinWords: 1,
242244
MinChars: 1,
243245
LicenseAllowlist: []string{"MIT"},
244-
Sources: []SourceConfig{{
245-
Name: "seed",
246-
Repository: "github.com/acme/seed",
247-
Root: root,
248-
CommitSHA: "abc123",
249-
License: "MIT",
250-
}},
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+
},
251262
}
252263

253264
records, err := Collect(cfg, t.TempDir())
254265
if err != nil {
255266
t.Fatalf("Collect: unexpected error, oversized file should be skipped: %v", err)
256267
}
257-
if len(records) != 1 {
258-
t.Fatalf("record count = %d, want 1 (only normal.md; huge.md must be skipped)", len(records))
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")
259345
}
260-
if records[0].Path != "normal.md" {
261-
t.Fatalf("Path = %q, want normal.md", records[0].Path)
346+
if record != (Record{}) {
347+
t.Fatalf("collectFile: record = %+v, want zero value", record)
262348
}
263349
}
264350

internal/linkgraph/linkgraph.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,10 +251,10 @@ func linkPosition(f *lint.File, n ast.Node) (int, int) {
251251
if offset < 0 {
252252
return 1, 1
253253
}
254-
// f.ColumnOfOffset scans backward from offset to the previous
255-
// newline, so it's O(column) per call instead of the O(offset)
256-
// forward scan a hand-rolled version would do — meaningful for
257-
// `mdsmith list backlinks` which can call this many times per file.
254+
// f.ColumnOfOffset binary-searches the cached newline index, so
255+
// it's O(log lines) per call instead of the O(column) backward scan
256+
// a hand-rolled version would do — meaningful for `mdsmith list
257+
// backlinks` which can call this many times per file.
258258
return f.LineOfOffset(offset), f.ColumnOfOffset(offset)
259259
}
260260

pkg/goldmark/parser/html_blocks_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,38 @@ func TestHTMLBlock_AllSevenTypes(t *testing.T) {
5656
}
5757
}
5858

59+
// TestHTMLBlock_TagCaseInsensitive drives the full Open() path (not just
60+
// the tagInAllowedSet/isRawTextTag unit tests) with mixed-case tag names,
61+
// so a wiring mistake in html_block.go's tag lookups (e.g. swapping which
62+
// helper a call site uses) would be caught here even though
63+
// TestHTMLBlock_AllSevenTypes only exercises lowercase tags.
64+
func TestHTMLBlock_TagCaseInsensitive(t *testing.T) {
65+
cases := []struct {
66+
name string
67+
src string
68+
kind ast.NodeKind
69+
}{
70+
{"type1-script-mixed-case", "<Script>alert('x')</Script>\n", ast.KindHTMLBlock},
71+
{"type6-block-tag-uppercase", "<DIV>\nblock\n</DIV>\n", ast.KindHTMLBlock},
72+
{"type7-tag-mixed-case", "<A href=\"x\">\n\n", ast.KindHTMLBlock},
73+
}
74+
for _, tc := range cases {
75+
t.Run(tc.name, func(t *testing.T) {
76+
root := parseWithDefaults(tc.src)
77+
found := false
78+
_ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
79+
if entering && n.Kind() == tc.kind {
80+
found = true
81+
}
82+
return ast.WalkContinue, nil
83+
})
84+
if !found {
85+
t.Errorf("expected %v for %q", tc.kind, tc.src)
86+
}
87+
})
88+
}
89+
}
90+
5991
func TestRawHTML_InlineTags(t *testing.T) {
6092
cases := []struct {
6193
name string

0 commit comments

Comments
 (0)