Skip to content

fix(segment): refuse an iterator whose SA run starts past the cursor - #267

Merged
siracozmen01 merged 1 commit into
mainfrom
fix/complex-run-start
Nov 4, 2026
Merged

siracozmen01 merged 1 commit into
mainfrom
fix/complex-run-start

Conversation

@siracozmen01

Copy link
Copy Markdown
Contributor

Problem

The iterator check #264 added accepts a segment iterator whose SA run starts after the cursor. muniComplexTrackRun asks the caller's breaker about cursor.offset - complex.start, so the subtraction wraps and the breaker gets an offset outside the run, which segment.h promises never happens. A line iterator with a breaker, over four Thai letters (12 bytes), answers one muniSegmentIterator_NextBreak; with only complex.start changed from 0 to 7, the next call passes the check and calls the breaker with length 12 and from 18446744073709551615. A breaker that reads run[from] reads outside the text. test/fuzz_iterators.c kept the whole run as it was, and its breaker did not check from, so it could not see this.

Change

  • Load in src/segment.c also 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.c now 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.
  • A seed, line-run-start, sets the low byte of a line iterator's run start after one boundary.
  • docs/internals/segmentation.md lists the check and the trapping breaker.

Who sees it: only a caller that changed an iterator's bytes by hand, which then gets muni_errorInvalid where 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

  • The new seed traps the fuzz target built with main's src/segment.c ("deadly signal") and passes with this change.
  • fuzz_iterators with the wider overwrite ran 300 seconds: 7,244,602 inputs, no finding.
  • tools/check.sh pr passes: the generated tables, the HarfBuzz and AddressSanitizer presets with every suite, clang-tidy on every source, and ten seconds of each fuzz target.
  • An audit pass on main 5a2e40a found this by reading the check against each field the calls use; 300 seconds each of fuzz_iterators, fuzz_segment and fuzz_script there found nothing, since the run lay outside what the target changed.

Closes #266

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 siracozmen01 added the bug Something does not work as documented label Nov 4, 2026

@siracozmen01 siracozmen01 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 handed text with length. A new run is cut from cursor.text + cursor.position, and Load already bounds that with position <= length.
  • start: muniComplexTrackRun computes cursor.offset - start only when offset < start + length. With the new start <= offset, relative lies in [0, length), so Ask passes the breaker a from inside the run. start + length wraps only when start > SIZE_MAX - length, and that needs offset >= start too. The wrapped sum is then below length, so offset < start + length is false and the run is dropped, with no read. A real run cannot reach that case: it would need length above 2^63.
  • next: it is compared only (next <= relative before asking, and offset - start == next in muniComplexBreakAt), and Load bounds it by length. A forged value in range gives a different break, never a read outside the run.
  • cursor.offset is 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 before start is now refused.
  • When text is NULL, start and next are never read: muniComplexBreakAt returns muni_complexNone, and muniComplexTrackRun clears 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 40 sets byte 1 of complex.start (offsets 64 to 71), so the start becomes 0x4000, past the cursor.
  • The pair 5e 50 writes into heldOffset. 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 fix with 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.py here, because the local checkout is behind the PR. The docs check covers it in CI.

@siracozmen01
siracozmen01 merged commit c6c66ec into main Nov 4, 2026
24 checks passed
@siracozmen01
siracozmen01 deleted the fix/complex-run-start branch November 4, 2026 07:13
@rawframe-release rawframe-release Bot mentioned this pull request Nov 4, 2026
siracozmen01 pushed a commit that referenced this pull request Nov 4, 2026
🤖 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something does not work as documented

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A forged SA run start reaches the caller's breaker outside the run

1 participant