Skip to content

feat: ask whether a value is a stable key under case folding (#619) - #634

Merged
raeq merged 2 commits into
mainfrom
feat/619-fold-stability
Aug 27, 2026
Merged

feat: ask whether a value is a stable key under case folding (#619)#634
raeq merged 2 commits into
mainfrom
feat/619-fold-stability

Conversation

@raeq

@raeq raeq commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Closes #619.

is_case_fold_stable(x) answers fold_case(x) == x.to_lowercase(). A false says some
other string folds to the same value, so a table keyed on this one can collide —
groß/gross is the pair node-tar's PathReservations guard missed in CVE-2026-23950,
ſtraße/straße and file/file are the same shape.

Six surfaces: Rust (api::is_case_fold_stable, DisarmStr), Python (function, Text
method, disarm.normalization), Node (isCaseFoldStable), Ruby (Disarm.case_fold_stable?),
the C ABI and Java/Kotlin. Parity is clear on all four tracked surfaces.

A fact, not an accusation

The issue is right that naming matters more than usual here, and the consequence runs
further than the name. groß is an ordinary German word and file an ordinary ligature,
so the predicate reads true for ordinary text, stays out of has_anomalies, and stays
out of DETECTOR_PANEL in the CVE suite.

That last one is not a stylistic choice. Panel membership forces a claim on every row
whose probe fires, and the probe for CVE-2023-46695 is ×2000 — genuinely
fold-unstable, and nothing whatever to do with the NFKC cost that row is about. So it is
listed as a named detector and asserted per-row, exactly as is_suspicious_hostname
already is.

Not a precomputed table, because a per-character table is wrong for Greek

The obvious implementation is a PHF set, and it disagrees with the whole-string answer on
ordinary Greek:

'ΟΔΟΣ'  fold='οδοσ'  lower='οδος'   whole-string: unstable   per-char table: stable

ΟΔΟΣ is Greek for "street". str::to_lowercase applies the Final_Sigma context rule and
case folding has no context rule at all, so a table under-reports every Greek word ending
in sigma. U+03A3 is the only code point in Unicode whose lowercase depends on its
neighbours
— asserted by enumerating all 1.1M code points in three positions rather than
by assertion, so a future Unicode version that adds a second one fails the test instead of
silently under-reporting.

So: ASCII short-circuits, everything else scans the folding table in place with no
allocation, and the exact string comparison is paid only by text containing a capital
sigma. On that path it is fold_case, so it cannot drift from it.

No count is pinned, deliberately

The issue puts the class at 2,345 code points and a comment on it at 297. Measured
against this tree: 325.

Both smaller figures are artifacts of which interpreter ran them. Three Unicode versions
are in play at once — disarm's case_folding.tsv is 16.0, rustc 1.94's to_lowercase
is 17.0, CPython 3.13 is 15.1 — so any asserted count here would be a drift gate
anchored to the drifting thing.

What is pinned instead is the invariant that survives a bump in either direction: wherever
disarm's answer and the naive Python one-liner disagree, one of the two has no case mapping
for that code point at all. There is no code point where both know a mapping and they still
disagree; that would be a defect rather than a data gap.

str.casefold() remains the wrong comparison basis and is now pinned as such — it performs
the very transform under test, so a predicate written against it is true everywhere. That
is the substitution #617 already made once.

Measured limit: the issue's pairing is half wrong

#619 pairs CVE-2026-23950 with CVE-2019-19844 as two rows turning on one precondition.
That half does not hold. Nine of the ten members of the .upper() collision class are
fold-unstable, but this row's probe uses the tenthU+0131 DOTLESS I, which folds
and lowercases to itself and collides through .upper() instead. The predicate is
silent on it, and the row keeps is_confusable as its only detector. Pinned by
test_fold_stability_does_not_report_this_row.

İstanbul is stable too, which is the counter-intuitive one: U+0130 is the textbook
case-mapping oddity, but both sides expand it to i + U+0307, so it is a perfectly good
key.

The CVE matrix

CVE-2026-23950 becomes Neutralized + detected, and its Detected by column stops
reading . It leaves TestDetectionHasNoSuperset.UNDETECTED_IN_SCOPE by a route that
class had ruled out: the standing claim is that each remaining row needs a comparison
between two strings, and for the collision itself that is still true — nothing here can say
groß.txt collides with gross.txt (that is #620). What was wrong is treating the
comparison as the only reportable part. The precondition is a single-string property, and
it is the part a reservation table actually needs.

Boy Scout: an ungated column

TestDocsMatrixDrift checked the disposition wording and the CVSS score and nothing
about the function names beside them
— so a typo, a stale name after a rename, or a
detector added to the registry and not to the page all passed. Since this PR edits that
column, test_documented_entry_points_match_the_registry now derives both right-hand
columns from the registry. Verified it bites: a one-letter change to the new cell fails it.

Verification

Gate Result
cargo test 993 passed
cargo test --lib -- --ignored + --test exhaustive_transliterate -- --ignored 26 passed (Tier 3)
pytest 4,876 passed, 6 skipped
pytest -m formal 14 passed
scripts/run_doc_tests.py 33 doc pages
scripts/check_doc_rust_examples.py 55 Rust doc blocks
node vitest 186 passed
ruby rspec 217 examples, 0 failures
java + kotlin ./gradlew test BUILD SUCCESSFUL
clippy, both feature sets clean
scripts/perf_lint.sh — core + all four binding glues clean
mkdocs build --strict, audit_language_consistency.py, mypy, ruff clean

Each binding was built and run against the in-repo core under the CI [patch.crates-io]
redirect, not just compile-checked.

🤖 Generated with Claude Code

`is_case_fold_stable(x)` answers `fold_case(x) == x.to_lowercase()`. A `false` says
some OTHER string folds to the same value, so a table keyed on this one can collide:
`groß`/`gross` is the pair node-tar's PathReservations guard missed in CVE-2026-23950,
`ſtraße`/`straße` and `file`/`file` are the same shape. Six surfaces: Rust (`api::` and
`DisarmStr`), Python (function, `Text` method, `disarm.normalization`), Node
(`isCaseFoldStable`), Ruby (`case_fold_stable?`), the C ABI and Java/Kotlin.

A FACT, NOT AN ACCUSATION, and the naming follows from that. `groß` is an ordinary
German word and `file` an ordinary ligature, so the predicate reads true for ordinary
text and is deliberately outside `has_anomalies` AND outside the CVE detector panel.
Panel membership would have forced a claim on CVE-2023-46695 as well, whose probe is
`ẛ`×2000 — true, and nothing to do with the DoS that row is about. It is asserted
per-row instead, the way `is_suspicious_hostname` already is.

NOT A PRECOMPUTED TABLE, because a per-character table is wrong for Greek. `ΟΔΟΣ`
("street") lowercases to `οδος` and folds to `οδοσ`, yet `Σ` agrees with itself in
isolation, so a table under-reports every Greek word ending in sigma. U+03A3 is the
only code point in Unicode whose lowercase depends on its neighbours — asserted by
enumerating all 1.1M in three positions, not by assertion — so the implementation
short-circuits ASCII, scans the folding table in place, and pays for the exact string
comparison only when a capital sigma is present. It cannot drift from `fold_case`
because on that path it *is* `fold_case`.

NO COUNT IS PINNED, deliberately. The issue puts the class at 2,345 code points and a
comment on it at 297; measured against this tree it is 325. Both smaller figures are
artifacts of which CPython ran them — disarm's CaseFolding table is Unicode 16.0,
rustc 1.94's `to_lowercase` is 17.0, CPython 3.13 is 15.1. Any asserted count would be
a drift gate anchored to the drifting thing. What is pinned instead is the invariant
that survives a bump either way: wherever disarm and the Python one-liner disagree,
one of them has no case mapping for that code point at all.

MEASURED LIMIT, correcting how the issue is framed. It pairs this with CVE-2019-19844
as two rows turning on one precondition. That half does not hold: nine of the ten
members of the `.upper()` collision class are fold-unstable, but this row's probe uses
the tenth, U+0131 DOTLESS I, which folds AND lowercases to itself and collides through
`.upper()` instead. The predicate is silent on it and the row keeps `is_confusable`
alone. `İstanbul` is stable too, which is the counter-intuitive one — both sides expand
U+0130 the same way.

CVE-2026-23950's *Detected by* column stops reading `—`, by a route
`TestDetectionHasNoSuperset` had ruled out. The collision itself is still a property of
a pair of names and no single-string predicate can report it (#620); what moved is the
precondition, which turned out to be a single-string property after all.

Also closes an ungated column while touching it: the docs matrix drift test checked the
disposition and the CVSS score and nothing about the function names beside them, so a
typo or a post-rename stale name passed. `test_documented_entry_points_match_the_registry`
now derives both right-hand columns from the registry; verified it fails on a one-letter
change.

Verified: 993 Rust incl. every Tier-3 exhaustive gate, 4,876 pytest, 14 formal, 33 doc
pages, 55 Rust doc blocks, node 186, ruby 217, java+kotlin gradle, clippy on both
feature sets, perf_lint on the core and all four binding glues, mkdocs --strict, parity
clear on all four surfaces.

Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-5
Copilot AI lite review requested due to automatic review settings August 27, 2026 11:55
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

📄 Docs preview: https://b17ae62c.disarm-docs.pages.dev

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new Rust public API documentation currently misstates the “no mapping” behavior, and the core implementation can avoid an unnecessary second scan of text for U+03A3.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a cross-language predicate to ask whether a string is a stable identity key under case folding (fold_case(text) == text.to_lowercase()), and wires it into the CVE validation matrix/documentation so CVE-2026-23950 becomes “Neutralized + detected” without folding this fact into anomaly claims.

Changes:

  • Implement is_case_fold_stable in the Rust core with an ASCII fast path, allocation-free per-char scan, and a whole-string fallback for Greek final sigma behavior.
  • Expose the predicate consistently across Rust (api + DisarmStr), Python (function + Text method + normalization), Node, Ruby, Java/Kotlin, and the C ABI (including the committed disarm.h contract).
  • Expand tests and drift gates: CVE vector assertions, docs table column parsing, and parity mappings across bindings.
File summaries
File Description
tests/test_cve_vectors.py Updates CVE assertions and adds a docs drift gate to validate documented entry points vs registry.
tests/test_case_folding.py Adds Python-level tests covering fold stability semantics, sigma behavior, and boundary/surrogate behavior.
tests/test_api_stability.py Adds the new Python API symbol to the public API stability list.
tests/api_pure_rust.rs Adds a basic Rust API usage check for api::is_case_fold_stable.
tests/api_idioms.rs Adds DisarmStr::is_case_fold_stable() idiom coverage.
src/py/case_fold.rs Exposes _is_case_fold_stable as a PyO3 function.
src/lib.rs Registers the new Python binding function in the module init.
src/case_fold.rs Implements is_case_fold_stable_impl and adds Rust unit/property/exhaustive (ignored) tests.
src/api/text.rs Adds the public Rust API function and documentation for api::is_case_fold_stable.
src/api/mod.rs Adds DisarmStr::is_case_fold_stable() extension method.
scripts/parity.py Extends parity mapping for the new Ruby predicate surface.
python/disarm/normalization.py Re-exports is_case_fold_stable under disarm.normalization.
python/disarm/_text.pyi Adds Text.is_case_fold_stable() type stub.
python/disarm/_text.py Implements Text.is_case_fold_stable() calling into the boundary layer.
python/disarm/_core.pyi Adds the core stub for _is_case_fold_stable.
python/disarm/_boundary.pyi Re-exports _is_case_fold_stable at the boundary layer (stubs).
python/disarm/_api.py Adds public Python is_case_fold_stable() wrapper and docstring.
python/disarm/init.py Exports is_case_fold_stable from the package top level.
generated/parity.yaml Adds the operation entry to the generated parity list.
docs/security/cve-validation.md Updates the CVE matrix row and narrative for the new detector behavior.
docs/ruby/api.md Documents Disarm.case_fold_stable?.
docs/node/api.md Documents isCaseFoldStable.
docs/api/predicates.md Adds the Python predicate docs page entry.
CHANGELOG.md Adds release notes describing the new predicate, rationale, and CVE matrix impact.
bindings/ruby/spec/surrogate_spec.rb Adds the new predicate to Ruby surrogate/boundary coverage.
bindings/ruby/spec/disarm_spec.rb Adds Ruby tests covering stable/unstable examples incl. Greek sigma.
bindings/ruby/lib/disarm.rb Adds the Ruby public wrapper case_fold_stable?.
bindings/ruby/ext/disarm/src/lib.rs Adds the Ruby native entry point _is_case_fold_stable?.
bindings/node/src/lib.rs Adds the N-API exported is_case_fold_stable.
bindings/node/index.ts Adds the JS wrapper isCaseFoldStable with documentation.
bindings/node/test/disarm.test.mjs Adds Node tests for stable/unstable examples incl. Greek sigma.
bindings/java/rust/src/lib.rs Adds the JNI exported isCaseFoldStable implementation.
bindings/java/disarm-kotlin/src/test/kotlin/dev/disarm/kotlin/DisarmKtTest.kt Adds Kotlin tests for the new extension function.
bindings/java/disarm-kotlin/src/main/kotlin/dev/disarm/kotlin/Disarm.kt Adds Kotlin String.isCaseFoldStable() extension + docs.
bindings/java/disarm-java/src/test/java/dev/disarm/DisarmCoverageTest.java Adds Java tests for Disarm.isCaseFoldStable.
bindings/java/disarm-java/src/main/java/dev/disarm/internal/Native.java Adds the native declaration for isCaseFoldStable.
bindings/java/disarm-java/src/main/java/dev/disarm/Disarm.java Adds the public Java API wrapper + Javadoc.
bindings/cabi/src/lib.rs Adds the exported C ABI function disarm_is_case_fold_stable.
bindings/cabi/disarm.h Updates the committed C header with the new API contract.
Review details
  • Files reviewed: 38/39 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/api/text.rs Outdated
Comment thread src/case_fold.rs
…already running

Both from Copilot review on #634.

The docstring said "a code point one of them has no mapping for reads false",
which mis-states the contract in the direction that matters: the answer turns on
whether the two RESULTS differ. A code point neither the folding table nor
`to_lowercase` has a mapping for is left alone by both, so it agrees with itself
and reads true. Only the asymmetric case — one side maps it, the other does not —
reads false. A caller reading the old sentence would expect every code point
outside disarm's table to be flagged.

`text.contains('\u{03A3}')` was a second linear scan over a string the
per-character loop had just walked, and it ran on the success path, which is the
common one. The loop now notes the sigma as it passes, so the predicate is one
pass rather than two with no behaviour change: the early `return false` skipped
the `contains` call before and skips the flag now.

Also repaired a comment naming `ascii_is_always_stable`, a test renamed to
`ascii_folds_and_lowercases_identically` before the branch was pushed.

Verified: 993 Rust incl. the three Tier-3 exhaustive gates, 4,876 pytest, clippy
on both feature sets, perf_lint clean.

Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-5
@raeq

raeq commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Both Copilot findings were right and are fixed in c3dbf6f.

The contract sentence. "A code point one of them has no mapping for reads false" was wrong in the direction that matters. The answer turns on whether the two results differ: a code point neither the folding table nor to_lowercase maps is left alone by both, agrees with itself, and reads true. Only the asymmetric case reads false. A caller reading the old sentence would have expected every code point outside disarm's table to be flagged, which is most of Unicode.

The second scan. text.contains('\u{03A3}') walked a string the per-character loop had just walked, on the success path, which is the common one. The loop now sets the flag as it passes, so the predicate is one pass. Behaviour is unchanged: the early return false skipped the contains call before and skips the flag now, and exhaustive_agrees_with_the_definition (every code point, Tier 3) still passes.

Also repaired a comment naming ascii_is_always_stable, a test renamed to ascii_folds_and_lowercases_identically before the branch was pushed.

Re-verified: 993 Rust including the three Tier-3 exhaustive gates, 4,876 pytest, clippy on both feature sets, perf_lint.sh clean.

@raeq
raeq merged commit 393f33c into main Aug 27, 2026
29 checks passed
@raeq
raeq deleted the feat/619-fold-stability branch August 27, 2026 12:56
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.

No way to ask whether a string is a stable key under case folding (CVE-2019-19844, CVE-2026-23950)

2 participants