Skip to content

Fix scorer crash on characters whose downcased form is longer - #124

Merged
orien merged 5 commits into
envato:masterfrom
kugaevsky:fix/unicode-downcase-index-shift
Jul 11, 2026
Merged

Fix scorer crash on characters whose downcased form is longer#124
orien merged 5 commits into
envato:masterfrom
kugaevsky:fix/unicode-downcase-index-shift

Conversation

@kugaevsky

Copy link
Copy Markdown
Contributor

Passwords containing "İ" (U+0130) crashed with
NoMethodError: undefined method '<<' for nil in
Scorer#most_guessable_match_sequence. String#downcase expands "İ" to "i" + combining dot above (U+0069 U+0307), so the lowercased password the dictionary and l33t matchers search is longer than the original. Match indices found after the expansion point are shifted past the end of the password, and the scorer indexes matches_by_j out of range.

Add CaseHelpers.downcase_preserving_length, which falls back to per-character downcasing (truncating multi-character mappings to their first character) whenever plain downcasing changes the string length, and use it in the dictionary matcher, the l33t matcher, and DictionaryRanker. Ranking words with the same normalisation also lets user-input words containing such characters match at all.

kugaevsky and others added 5 commits July 10, 2026 16:27
Passwords containing "İ" (U+0130) crashed with
NoMethodError: undefined method '<<' for nil in
Scorer#most_guessable_match_sequence. String#downcase expands "İ" to
"i" + combining dot above (U+0069 U+0307), so the lowercased password
the dictionary and l33t matchers search is longer than the original.
Match indices found after the expansion point are shifted past the end
of the password, and the scorer indexes matches_by_j out of range.

Add CaseHelpers.downcase_preserving_length, which falls back to
per-character downcasing (truncating multi-character mappings to their
first character) whenever plain downcasing changes the string length,
and use it in the dictionary matcher, the l33t matcher, and
DictionaryRanker. Ranking words with the same normalisation also lets
user-input words containing such characters match at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sZ2Mphi8RqjYqqVfwULRj
most_guessable_match_sequence indexes matches into an array sized to the
password length, so it relies on every match's j (and i) being an in-range
position in the original password. That invariant lived only on
CaseHelpers.downcase_preserving_length, not where a maintainer would break
it. Expand the comment at the indexing site to state the precondition, name
the failure mode (NoMethodError on the nil slot — the crash the length-
preserving downcase fixes), and point to the helper that upholds it.

Comment-only change; no behavioural impact.
The İ regression test lived only inside the trie context, so it exercised
just trie_matches — the path whose out-of-range j crashed the scorer. The
hash path had a different, silent pre-fix bug: bounded by the original
password length, it dropped the trailing words entirely rather than
crashing. Nothing tested that path, so a regression there would pass CI.

Replace the trie-only test with a context that runs both paths and:
 - bounds every i/j on both paths,
 - pins the concrete matched words and their original-password indices
   (was a bare `j < length` check that ignored i and the token), and
 - asserts the trie and hash paths produce identical results.

All three fail against the pre-fix code; the equality assertion is what
guards the hash path's silent under-matching.
rank_dictionary (the method the fix changed) had no direct spec — only
rank_dictionaries was tested, with ASCII input — so the ranker's half of
the "ranker and matcher must agree on the normalized key" contract was
covered only transitively via the tester_spec İstanbul case.

Add a .rank_dictionary block: a baseline frequency-order test plus a
length-changing word (İstanbul) asserting the key is the length-preserving
lowercase form ('istanbul') and that the key's character length matches the
original. Both İstanbul assertions fail against the pre-fix ranker (which
keyed under 'i̇stanbul', length 9).
Follows the project convention of referencing the PR with a ([#NN]) link
and a matching link definition in the version section.
@orien

orien commented Jul 11, 2026

Copy link
Copy Markdown
Member

Thank you @kugaevsky! 🙇

@orien
orien merged commit afa3a65 into envato:master Jul 11, 2026
5 checks passed
@orien orien mentioned this pull request Jul 11, 2026
@orien

orien commented Jul 11, 2026

Copy link
Copy Markdown
Member

Released in 2.0.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants