Repository navigation
feat: refuse an iterator that no init call set up - #264
Merged
Merged
Conversation
Every segment call checked the iterator through a validator per rule set, called out of line, which cost a grapheme break about 2.7 ns (16%) and a line break about 6.9 ns (28%) on the bench. The check now lives in segment.c and is inlined into each call (MUNI_ALWAYS_INLINE), with a switch on the rule set that checks only that set's fields, and line's anywhere setting only the grapheme state it keeps. The flags' bytes are read from the caller's iterator rather than from the copy just made, so no load waits on the copy's stores. Every field is still checked before it is read. The largest values the rule sets write are named in segmenter.h and tied to each rule set's enumeration by a static_assert. The check now costs a grapheme break about 1.9 ns (11%) and a line break about 4.9 ns (20%), and a script run about 1.3 ns (4%), all of it executed instructions: perf shows no added branch or cache miss.
siracozmen01
commented
Nov 4, 2026
siracozmen01
left a comment
Contributor
Author
There was a problem hiding this comment.
Review at d121e69. No changes needed. Read, not run. CI had just started when I looked, so this approval assumes it passes.
- Every field is checked before it is used, in its rule set's arm, and every refusal reaches the shared exit. I compared
muniSegmenter(src/segmenter.h:136-161) and its rule structs field by field withLoadandRulesValid(src/segment.c):- Every call site does
if (!Load(...)) return muni_errorInvalid;(or the default value in the two getters) before any read of the copy. - Top level:
kind(an unknown kind falls to the switch'sdefault: return false),phase, the four flag bytes andmore, the cursor'spendingCount,positionagainstlength,textNULL with a length, and the waiting and hold cross-checks.complex.nextis checked against its length whencomplex.textis set. - Grapheme: all four fields. Word and sentence: every field, including
overrideCountagainstMAX_OVERRIDESand a NULL table. Line:lineSettings >> 3,actual, and then, by strictness, either the grapheme view (anywhere) orprevious,before,lead,number,holdKindand the three flags.Sotismuni_lbXx(line_rules.h:47), so the<= muni_lbHhbounds take it. - Two line fields are not bounded:
previousCodePointandbeforeCodePoint. Every use is a property lookup (IsEastAsian,IsWide,Category,muniLookupEmoji, line_rules.h:215-449), and the generated lookups return 0 for a code point at or past 0x10FFFE before indexing (e.g. east_asian_width.c:115-118). So any value stays inside the tables, and leaving them unchecked is safe. - The script iterator:
Load(src/script_runs.c) checks the cookie, the four flag bytes, the cursor,bracketCount <= BRACKET_DEPTH,firstScriptand every bracket's script againstmuniScriptTagCountorNO_SCRIPT, and no candidate bit past the last script, with at least one in a determined run.
- Every call site does
- There is no check-then-use gap from reading the flags in the caller's iterator. Within
Load, nothing writes the caller's iterator between thememcpyand the byte reads, so each byte read is the byte copied. Everything afterLoadreads the stack copy, not the caller's iterator, including after the complex breaker's callback, during which the phase is Busy and other calls refuse. Only a write from another thread could make them differ, and the headers already rule that out. In the scriptLoad, each flag's byte is checked before the copy'sboolis read:waitingis checked before it is passed tomuniCursorValid, which checksmoreFollows' byte first. - The static_asserts'
(int)casts are right. Each compares an enumerator of a private anonymous enum with one ofsegmenter.h's. The values are 2 and 4, so the casts change nothing, and they only keep an enum-compare warning away under-Werror.
feat is right at 1.x: a call that was undefined behaviour now refuses with a result the headers document, the ABI is unchanged, and results for an initialized iterator are the same.
Merged
This was referenced Nov 4, 2026
siracozmen01
added a commit
that referenced
this pull request
Nov 4, 2026
…267) ## 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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The family conventions now say that opaque state only the library writes is checked by its cookie and by every index, slot and length in it being in bounds, so that no bit pattern makes a call read outside the state (Rawframe-Project/.github#119).
muniScriptIteratorandmuniSegmentIteratorcarried no cookie, and their calls trusted the counts and flags in caller memory. A script iterator of 0x41 bytes mademuniScriptIterator_NextRuncopy 65 bytes into a 4-byte window on the stack, and 0x02 or 0x04 bytes made both iterators load aboolthat was neither 0 nor 1.Change
bool. It also checks the two cross-field facts the assertions downstream rely on: a waiting cursor holds less than a code point, and a hold lies before the cursor. The cookie only turns away an iterator no init call set up; the checks after it keep any other bit pattern from reaching outside the state too. They run once per call.muni_errorInvalid, the result each header already gives for a bad argument, and left as it was;muniSegmentIterator_GetLineBreakStrictnessandmuniSegmentIterator_GetWritingSystemgive their defaults for it, as for NULL. Each header now names it, and says that a copy of an initialized iterator is one too. The pointers an iterator keeps (the text, the overrides, the caller's breaker) cannot be checked: an iterator forged there with the cookie kept stays outside the contract, asdocs/internals/overview.mdnow says.static_assertbesidemuniSegmenter, like the script state's, keeps the iterator from growing a wholeuint64_tmore than the state needs. A first version put a 32-bit cookie in front of each state, which grew both iterators by 8 bytes; the segmenter's copies in and out of caller memory then passed 128 bytes and becamememcpycalls, which cost a grapheme break about 8 ns.feat, so the next release is 1.1.0. The ABI is unchanged.src/segmenter.hand tied to the rule set's own enumeration by astatic_assert.src/script_state.hso that the new fuzz target can reach its fields.docs/internals/segmentation.mddescribes the checks and where the cookie lives, and no longer asks for!when the iterator grows, which 1.x forbids.Who sees it: a caller that passed an iterator no init call set up, which was undefined behavior, now gets
muni_errorInvalid. Every result for an initialized iterator is the same; no result of the results version changes.Verification
test_segmentandtest_scriptfill an iterator with 0x00 (zeroed, as a static one is), 0x41, 0x02 and 0x04 and pass it to every call that takes one back: each refuses it and changes nothing in it. On main the 0x41 script fill is a stack buffer overflow under AddressSanitizer and the 0x02 and 0x04 fills stop UBSan on aboolload.fuzz_iterators, a new fuzz target, sets up each kind of iterator over a fixed text, answers a few calls, then sets bytes of its state from the input, everywhere but the pointers and the lengths and counts that go with them, and makes every call again: none may read outside the state or the text or trip an assertion. 300 seconds of it ran 7,227,223 inputs with no finding.tools/check.sh changedandtools/check.sh prpass: the generated tables, the HarfBuzz and AddressSanitizer presets with every suite, clang-tidy on every source in Release and Debug, and ten seconds of each fuzz target, the new one included.Speed, Release builds with clang 20, best of 15 passes over a text of 1 Mi code points, the two builds run eight times each in turn and the best of each kept. Every call here reports one run or boundary, the most calls a text can need, so the difference is the check's whole cost per call:
muniScriptIterator_NextRun, alternating Latin and GreekmuniSegmentIterator_NextBreak, grapheme clusters of ASCIImuniSegmentIterator_NextBreak, lines of "a " repeatedThe medians of the eight runs differ by the same: +0.6, +2.0 and +5.4 ns.
perf statover the whole bench counts the same branch misses (302 thousand) and L1 misses (1.5 million) for both builds and 29% more instructions, so the cost is the instructions of the check, which a line call runs more of: the cursor's checks and the line rules' classes, flags, number state and hold kind. The first version of this change, which called a validator per rule set out of line, cost +2.7 ns (16%) and +6.9 ns (28%) on the same two calls. On ordinary prose the cost is small: lineNextBreakover the English preamble of the Universal Declaration of Human Rights, repeated to 1 MiB (168,979 breaks, one per 6.2 bytes), takes 107.2 ns per break on main and 108.2 ns with this change at best, +1.0 ns (1%), and 109.6 against 112.3 ns at the median, +2.6 ns (2%), over the same eight alternating runs.Closes #263