Repository navigation
fix(segment): refuse an iterator whose SA run starts past the cursor - #267
Conversation
The iterator check accepted a run start past the cursor, and the call then asked the caller's breaker about an offset outside the run, which segment.h promises never happens: the offset cursor.offset minus the start wrapped. Load now refuses such an iterator; an init call never leaves one, since a run starts at the cursor and the offset only grows. fuzz_iterators now changes the run's start and next break, keeping only the breaker, its context, the run's text and its length, and its breaker traps on an offset outside the run. A seed reproduces the case. Closes #266
siracozmen01
left a comment
There was a problem hiding this comment.
Review at b4ecd8a: ready. Read, not run. Every check is still queued, so this approval assumes they pass.
Is every field of the SA path bounded? Yes. I read each field of muniComplexRun against the cursor and the run's length. The complex path reads it in two places only: muniComplexTrackRun in src/complex.c, and muniComplexBreakAt in src/complex.h. The line rules call muniComplexBreakAt from muniLineDecide and the word rules from src/word.c.
text,length,breaker,context: these are the pointer, its length and the caller's function, so they are the init call's and are kept. The breaker is only ever handedtextwithlength. A new run is cut fromcursor.text + cursor.position, andLoadalready bounds that withposition <= length.start:muniComplexTrackRuncomputescursor.offset - startonly whenoffset < start + length. With the newstart <= offset,relativelies in[0, length), soAskpasses the breaker afrominside the run.start + lengthwraps only whenstart > SIZE_MAX - length, and that needsoffset >= starttoo. The wrapped sum is then belowlength, sooffset < start + lengthis false and the run is dropped, with no read. A real run cannot reach that case: it would needlengthabove 2^63.next: it is compared only (next <= relativebefore asking, andoffset - start == nextinmuniComplexBreakAt), andLoadbounds it bylength. A forged value in range gives a different break, never a read outside the run.cursor.offsetis fuzzed too. A value past the run drops the run. A value inside the run only changes which in-run offset the breaker is asked about. A value beforestartis now refused.- When
textis NULL,startandnextare never read:muniComplexBreakAtreturnsmuni_complexNone, andmuniComplexTrackRunclears the run and starts over from the cursor.
Nothing else in the run is left unchecked.
Tests. The seed bytes are 23 5e 41 40 5e 50:
- They select a line iterator with the breaker set, then run six steps.
- On 64-bit, the pair
41 40sets byte 1 ofcomplex.start(offsets 64 to 71), so the start becomes 0x4000, past the cursor. - The pair
5e 50writes intoheldOffset. Unless a hold is open, that is harmless.
On main's Load, this input reaches Ask with a wrapped from, which the new trap in Breaker catches, matching the body's claim. With this change it is refused. The kept[8] slot for the override count is the {0, 0} entry, as kept[5] was before.
Standard.
- The title is conventional. A
fixwith no!is right at 1.x: only a hand-forged iterator sees a difference. - No body line starts at column 0 with a name and
(. - The docs paragraph matches the code and the fuzz target.
- I did not run
docs_check.pyhere, because the local checkout is behind the PR. Thedocscheck covers it in CI.
🤖 I have created a release *beep* *boop* --- ## [1.1.0](v1.0.0...v1.1.0) (2026-11-04) ### Added - Every call that takes a `muniSegmentIterator` or a `muniScriptIterator` back now checks it first, and refuses one no init call set up with `muni_errorInvalid`, leaving it as it was: a zeroed iterator, one of stray bytes, or one whose counts, flags or states are out of range. Such an iterator was undefined behavior before, and a forged script iterator could overflow a buffer on the stack. `muniSegmentIterator_GetLineBreakStrictness` and `muniSegmentIterator_GetWritingSystem` give their defaults for it, as for NULL. A copy of an initialized iterator is accepted. The pointers an iterator keeps (the text, the overrides, the breaker) cannot be checked, so an iterator changed there by hand stays outside the contract ([#264](#264), [#267](#267)). ### Changed - The check costs each iterator call a few nanoseconds: line breaking over English prose takes 1 to 2% longer, and up to 20% where every call reports a boundary two code points after the last. Neither iterator changes size ([#264](#264)). No results change: the Unicode data is still 18.0.0 and `MUNI_RESULTS_VERSION` stays 2, so stored normalized keys, skeletons and verdicts from 1.0.0 stay valid.
Problem
The iterator check #264 added accepts a segment iterator whose SA run starts after the cursor.
muniComplexTrackRunasks the caller's breaker aboutcursor.offset - complex.start, so the subtraction wraps and the breaker gets an offset outside the run, whichsegment.hpromises never happens. A line iterator with a breaker, over four Thai letters (12 bytes), answers onemuniSegmentIterator_NextBreak; with onlycomplex.startchanged from 0 to 7, the next call passes the check and calls the breaker withlength12 andfrom18446744073709551615. A breaker that readsrun[from]reads outside the text.test/fuzz_iterators.ckept the whole run as it was, and its breaker did not checkfrom, so it could not see this.Change
Loadinsrc/segment.calso refuses an iterator whose run starts past the cursor. An init call never leaves one: a run starts at the cursor's offset, and the offset only grows.test/fuzz_iterators.cnow keeps only the breaker, its context, the run's text and its length, the pointer and the length that go with it, so it changes the run's start and next break like any other count. Its breaker traps when asked about an offset outside the run.line-run-start, sets the low byte of a line iterator's run start after one boundary.docs/internals/segmentation.mdlists the check and the trapping breaker.Who sees it: only a caller that changed an iterator's bytes by hand, which then gets
muni_errorInvalidwhere its breaker was called outside the run. Nothing changes for an iterator an init call set up, and no result of the results version changes.Verification
src/segment.c("deadly signal") and passes with this change.fuzz_iteratorswith the wider overwrite ran 300 seconds: 7,244,602 inputs, no finding.tools/check.sh prpasses: the generated tables, the HarfBuzz and AddressSanitizer presets with every suite, clang-tidy on every source, and ten seconds of each fuzz target.fuzz_iterators,fuzz_segmentandfuzz_scriptthere found nothing, since the run lay outside what the target changed.Closes #266