Follow @Openwall on Twitter for new release announcements and other news
[<prev] [next>] [<thread-prev] [thread-next>] [day] [month] [year] [list]
Message-ID: <20260904164533.GZ23438@brightrain.aerifal.cx>
Date: Fri, 4 Sep 2026 12:45:33 -0400
From: Rich Felker <dalias@...c.org>
To: musl@...ts.openwall.com
Subject: Re: Collation implementation is functional

On Thu, Sep 03, 2026 at 12:22:22PM -0400, Rich Felker wrote:
> Runtime side:
> 
> Both basic proof of concept (iteration of collation elements from an
> input string) and a working strxfrm (transformation of string to a
> null-terminated binary sort key) are working, and have been
> smoke-tested manually for basic coverage:
> 
> - Straightforward one-to-one mappings
> - Matching prefix contexts (middle dot following L)
> - Non-matching prefix contexts (middle dot not following L)
> - One-to-many mappings
> - Two-to-one mappings
> - Reordering mappings (Thai vowel)
> - Auto-generated ideographic radical-stroke-order weights
> - Implicit weights
> - Reversed-order secondary weights
> 
> This list will make the basis for an automated smoke test. There are
> also Unicode-provided test vectors which have not yet been tested, but
> these do not assert specific behavior generating keys; they just
> assert the correct ordering of a long list of short string fragments.

Yesterday I started testing against the Unicode test vectors, and
after fixing a few small bugs and version skew issues between tests
and data, there's only one real issue.

Apparently I'd overlooked (UTR#35 doesn't mention this; it's only
covered in the original UTR#10) that UCA has discontiguous contraction
matches. This is a costly and cursed requirement but necessary because
of how canonicalization -- specifically, canonical ordering of
combining marks -- works.

In a sequence A B C, where B and C are combining marks, it's possible
that, due purely to arbitrary choice that was made in how to number
the combining classes, the normalization is A C B.

But if A+B collate as a unit, this means the addition of the mark C
would suddenly break the sorting of A B by reordering between them.



As such, UCA specifies a discontiguous matching of contractions. When
A is a prefix of a possible contraction, and the next character C does
not match but is a "non-starter" (character which participates in
canonical reordering), the search for a match must look ahead,
consuming any characters that contribute to a match until a blocker
is encountered. These consumed characters are then logically removed
from the input.

Described that way, this is (pardon the pun) a non-starter: there is
no way to remove characters out of an immutable input stream with no
place for a copy. However, fortunately that impossibility is an
artifact of the description, and not fundamental.



Let's look at the general case, much worse than anything practical,
where we have a contraction ABCDEFG involving 6 combining marks
attached to A, and an input which, after normalization, looks like:

A............B...........CD.....E.F..G......
01111111111112222222333334444444555556666660

Here the dots could be arbitrary nonmatching characters; the numbers
below them are their canonical combining classes. It's not important
that A have ccc=0; it could itself have been a non-starter.

The matching process, when the next character does not match but A is
a prefix, looks forward to find B, C, D, and E. But F can't be
included, because there's an intervening character in the same ccc.
Thus, we match the contraction ABCDE, advancing past the A and
pre-consuming B, C, D, and E.

So, how do we represent the state after this, to be able to continue
without storing all the offsets of characters pre-consumed?

We've already advanced past A as if it were a single-character match,
so no special accounting for it is needed. For the rest, we use the
fact that there are only a fixed number of ccc's (bounded by 255;
practically a lot less), and that we can only consume contiguous spans
at the beginning of each ccc in the normalized order: B, CD, and E. So
the state after the above ABCDE match is:

ccc=1: 0 preconsumed
ccc=2: 1 preconsumed
ccc=3: 0 preconsumed
ccc=4: 2 preconsumed
ccc=5: 1 preconsumed
ccc=6: 0 preconsumed

Now, upon continuing to consume input, we just need to know the ccc of
each character we read from the input sequence. If there is a nonzero
preconsumed counter for that ccc, we drop the character and decrement
the preconsumed counter, repeating this process until we get to a
character that's not preconsumed. If we reach the next starter
(ccc=0), it's guaranteed that all of the preconsumed counts have
dropped back to 0.

This isn't quite the complete story though. because it might occur
that we have another lookahead while there are already preconsumed
characters. Let's say XYZ is also a contraction, and the above dots
are filled in partly as:

A....X.......B...........CDY....EZF..G......
01111111111112222222333334444444555556666660

When we reach X and begin lookahead, B is skipped because ccc=2 has 1
preconsumed, but since this is lookahead, we can't decrement that
counter. Instead it needs to be left alone while skipping ahead. Upon
reaching ccc=4, there are 2 preconsumed characters, so C and D are
skipped. Now Y is the first character of a new ccc, so it can be
consumed for the contraction, incrementing the preconsumed count for
ccc=4 to 3. Lookahead continues to ccc=5 where E is skipped (1
character was already preconsumed) and now Z is the first character of
a new ccc. It also gets added to the contraction match and the
preconsumed count gets incremented.

The final state has:

ccc=1: 0 preconsumed
ccc=2: 1 preconsumed
ccc=3: 0 preconsumed
ccc=4: 3 preconsumed
ccc=5: 2 preconsumed
ccc=6: 0 preconsumed



I've written up the following pseudocode for the above process:


Non-lookahead input reads:

if nskip[ccc] is nonzero, decremenet nskip[ccc], discard character,
and start over reading the next character


Lookahead process:

if contraction is incomplete
and next character does not match
and next character is a non-starter:

place next character pending

make a copy of nfd iterator state, and enter a loop advancing it
forward:

- end loop if we hit a non-starter
- if new ccc, skip nskip[ccc]
- if we hit a match, consume it and increment nskip[ccc]
- if not a match, skip past all further chars in same ccc

after stopping, the temp iterator copy can be discarded. we only need
to preserve nskip[] array.




Implementation considerations:

The NFD iterator implementation already has ccc values available to
it, but drops them from the return value to give clean wchar_t
codepoint values. Switching to return a combined ccc+codepoint value,
like what's used internally, is not hard; the caller can extract/strip
the ccc as needed.

Having a 255-element array of size_t for nskip[] is somewhat costly,
mainly in terms of overhead to initialize in the common case where it
will never be touched.

The decomposition table already compresses ccc values to account for
the fact that most of the space is unused. I recall there being around
56 actually used. An array of bytes in .rodata mapping ccc's to an
index in a much smaller array of size_t would have very low .text and
.rodata size and drop this runtime cost by 80%.



Performance considerations:

How bad does this all perform? In principle we can end up iterating
over the same very long span of characters many times in successive
lookehead operations. However, the sort of interleaved ABC/XYZ
situation in the example above can only arise if you have contractions
beginning with non-starters. You would think that wouldn't happen, but
there's exactly one place in the root data where it does: contractions
for 0F71 0F72 and 0F71 0F80. This means you can achieve quadratic(*)
time with an input consisting of n copies of 0F71 and n copies of
0F72, in any order.

Unfortunately, this is essential; it's not a consequence of
implementation choices. The only ways to avoid it are by not
attempting to collate monstrously long strings with potentially
malicious contents, or by only using locales without such bad
contractions. The fact that they appear at all here is probably due to
an error encoding the characters in Unicode; 0F71 behaves like a
subjoined letter not a vowel marker, and likely should have been
encoded with ccc=0. Since the root data does not even give correct
dictionary order for Tibetan to begin with, it may be a good idea to
just drop these contractions for locales that are not tailored for it.

On the bright side, exposure of collation interfaces to low-trust data
is usually limited to things like file listings, where NAME_MAX sets a
very low limit on the number of characters that can participate in
pathological canonical ordering shenanigans. Since the affected
characters are 3-byte UTF-8, the n going into quadratic(?) time cost
is at most 85 here.

(*) It may actually be higher-order, because the NFD iteration process
is complex and quasi-nonlinear. For a given iteration, the number of
times going over the same character is bounded by the number of ccc's,
making it technically O(n). But when resetting a clone of the
iterator, I'm not sure, and it's making my head hurt trying to think
about it.

Powered by blists - more mailing lists

Confused about mailing lists and their use? Read about mailing lists on Wikipedia and check out these guidelines on proper formatting of your messages.