Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions FIDELITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ The committed PNGs under `testdata/renders/` back every claim here. Reproduce
them with the commands at the bottom. The measured-vs-Chrome numbers live in
[`bench/REPORT.md`](bench/REPORT.md).

## 2026-10-09 (round 167) — the interrupted 500-page bench run (round 166's own queued work) found a second real crash: a lone CSS Grid item using `grid-row:1 / -1` with no explicit row template panicked on an empty row-offset slice (engine#266, patch)

Round 166's fix shipped, then the still-queued 500-page bench run (launched before that fix, to measure the round-165 rotate fix) was resumed to see it through. It crashed again, on page 406: `https://www.lego.com/`, with `panic: runtime error: index out of range [0] with length 0` in `layout.grid` at `rowY[it.r0]`. Reproduced directly with `cmd/render` against the live URL, independent of the bench harness.

- **Root cause**: `resolveAxisPlacement` (grid item row/column placement) resolves a pair of explicit grid lines (e.g. `grid-row:1 / -1`, the common "span the whole axis" idiom — Tailwind's `row-span-full` compiles to exactly this) by normalising both lines, swapping if needed so `end >= start`, then clamping only the *start* to be non-negative (a track index can't be negative) — without re-checking that the span survived the clamp. With no `grid-template-rows` at all (a grid relying purely on implicit/auto rows, nTracks=-1 so the negative end line resolves against nothing), `1 / -1` normalises to start=0, end=-2, swaps to start=-2, end=0 (a valid, non-empty `[-2,0)` span) — then clamping start alone to 0 left end at 0 too: a degenerate **zero-track span**. CSS Grid's own spec (§8.3) guarantees an item always occupies at least one track per axis; this was a real divergence from it, with a concrete crash consequence: with this as a grid's *only* item (a second, plainly-auto-placed sibling would mask the bug — its own default 1-row span raises `nRows` regardless), `nRows` never rises above 0, so `sizeRows`/`trackOffsets` return empty slices, and indexing `rowY[it.r0]` panics.
- **Fixed**: `resolveAxisPlacement`'s explicit-start-and-end case now re-checks `end <= clampedStart` *after* the clamp and widens the span back to at least 1 rather than leaving it collapsed — the same "span must stay at least 1" invariant the function's other four cases already guarantee unconditionally.
- **Test**: `TestGridRowSpanFullWithNoExplicitRowsDoesNotCrash` (`layout/grid_test.go`) — a single-item grid with no `grid-template-rows`, `grid-row:1 / -1`. Stash-verified against the unfixed code (the exact same discipline round 166 and the round-165 rotate fix both used): it panics with the identical trace without the fix, passes with it.
- **Coverage**: `layout` stayed at 100% (the fixed branch is reached unconditionally whenever this case executes at all — no separate dead branch was introduced).

## 2026-10-07 (round 166) — a script-built DOM tree far deeper than any real page crashed every whole-document tree walk with an unrecoverable stack overflow, not a panic (security audit, patch)

Continuing this session's "audit de sécurité" pass after the rotate-crash fix below: checked for ReDoS and stack-overflow risk from pathological input. Go's `regexp` package is RE2-based (confirmed: every custom pattern in this repo — `svg.go`, `css/supports.go`, `css/selector.go`, `css/container.go`, `css/parse.go` — compiles under `regexp.MustCompile`, never a backtracking engine), so ReDoS is structurally impossible here, same category of finding as round 161's XXE check. Stack-overflow risk was real.
Expand Down
18 changes: 17 additions & 1 deletion layout/grid.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,23 @@ func resolveAxisPlacement(start, end css.GridLine, nTracks int) (start0, span in
if e == s {
e = s + 1
}
return clampNonNeg(s), e - clampNonNeg(s), true
// Clamping s to 0 can undo the e>s gap this far established: e.g.
// `grid-row:1 / -1` with no grid-template-rows (nTracks=-1, so norm
// never resolves the -1 against anything real) gives s=0, e=-2, swaps
// to s=-2, e=0 — already a non-empty [s,e) span — but THEN clamping s
// alone to 0 leaves e=0 too, a degenerate ZERO-track span. A grid
// item always occupies at least one track per axis (CSS Grid §8.3:
// an omitted/unresolvable line implies a span of 1), so re-check
// after the clamp and widen rather than collapse. Confirmed live on
// https://www.lego.com/ (round 167): a lone item using exactly this
// pattern left every row-related slice (sizeRows, trackOffsets)
// empty despite the item being non-empty, panicking grid() with
// "index out of range [0] with length 0" at rowY[it.r0].
cs := clampNonNeg(s)
if e <= cs {
e = cs + 1
}
return cs, e - cs, true
case !start.Auto && !start.Span && end.Span:
s := clampNonNeg(norm(start.N))
return s, maxInt(end.N, 1), true
Expand Down
31 changes: 31 additions & 0 deletions layout/grid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,37 @@ func TestGridRowSpan(t *testing.T) {
assertF(t, "rspan.C.Y", byText["C"].Y, 30) // second row
}

func TestGridRowSpanFullWithNoExplicitRowsDoesNotCrash(t *testing.T) {
// TestGridRowSpanFullNegativeLineReservesOccupancy (below) covers
// `grid-row:1 / -1` with an explicit grid-template-rows, where the
// negative end line resolves against that explicit track count. With NO
// grid-template-rows at all (a grid relying purely on implicit/auto
// rows — common for a single-row "sidebar spans everything" layout),
// resolveAxisPlacement's nTracks argument is -1 (unresolved), and its
// norm() helper then does a bare `n-1` on the negative end line with
// nothing to resolve against: start=1 -> norm=0, end=-1 -> norm=-2, the
// e<s swap makes s=-2 e=0, then clamping s to 0 (a track index can't be
// negative) left the span as `e - clampedS` = 0 - 0 = 0 — a degenerate,
// ZERO-track span. With this as the grid's ONLY item (no sibling to give
// nRows a normal >=1 span of its own — a second item auto-placed at
// row 0 span 1 would mask the bug by raising nRows to 1 regardless),
// nRows never rises above nRowsExplicit(0), so sizeRows/trackOffsets
// return empty slices and grid()'s `rowY[it.r0]` panics with "index out
// of range [0] with length 0". Confirmed live on https://www.lego.com/
// during the 500-page bench corpus (round 167) — a real, reachable
// crash, not a contrived one; reproduced with `cmd/render` against the
// live URL, independent of the bench harness.
src := `<html><body style="margin:0"><div style="display:grid;grid-template-columns:100px">` +
`<div style="grid-row:1 / -1">A</div></div></body></html>`
g := findBox(layoutHTML(t, src, 400), "div")
if len(g.Children) != 1 {
t.Fatalf("want 1 grid item, got %d", len(g.Children))
}
if g.Children[0].H <= 0 {
t.Errorf("item should have a non-zero height, got %v", g.Children[0].H)
}
}

func TestGridRowSpanFullNegativeLineReservesOccupancy(t *testing.T) {
// `grid-row: 1 / -1` (Tailwind's row-span-full) in a 3-row explicit grid
// must span all 3 rows and reserve column 0 in every row, exactly like a
Expand Down
Loading