From 54941bef4f4658db18399354edecb9365dc14a13 Mon Sep 17 00:00:00 2001 From: Pavel Lazureykis Date: Thu, 30 Jul 2026 18:52:26 +0000 Subject: [PATCH] rustdoc: fix ICE when a grapheme cluster joins a Prepend character to `_` `EscapeBodyTextWithWbr` iterates `text.grapheme_indices(true)`, so `i` is the start of a grapheme cluster, but the `_`/`:` word-break arm sliced at `i + 1` -- assuming a cluster containing `_` or `:` is exactly one byte long. UAX#29 GB9b joins a `Prepend`-class character (U+0600-U+0605, U+0D4E, U+111C2, ...) with the character that follows it, so a cluster can start with a multi-byte character and still contain `_`. `i + 1` then lands inside that character and `str` indexing panics. U+0D4E is `XID_Continue`, so this is reachable from an item name rustc accepts, e.g. `pub struct abc_defgh;`, and rustdoc ICEs instead of documenting the crate. Break after the whole cluster (`i + s.len()`) instead. The adjacent CamelCase arm already slices at `i`, which is always a cluster boundary, so it is unaffected. This also stops the `` from being inserted between `_` and a combining mark that trails it, which split a grapheme cluster without panicking. --- src/librustdoc/html/escape.rs | 7 +++++-- src/librustdoc/html/escape/tests.rs | 4 ++++ tests/rustdoc-html/item-name-grapheme-cluster-wbr.rs | 8 ++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 tests/rustdoc-html/item-name-grapheme-cluster-wbr.rs diff --git a/src/librustdoc/html/escape.rs b/src/librustdoc/html/escape.rs index ac9e2f42cc6d0..f94e76b1f021a 100644 --- a/src/librustdoc/html/escape.rs +++ b/src/librustdoc/html/escape.rs @@ -79,9 +79,12 @@ impl fmt::Display for EscapeBodyTextWithWbr<'_> { } else if (s.contains(':') && !next_is_colon()) || (s.contains('_') && !next_is_underscore()) { - EscapeBodyText(&text[last..i + 1]).fmt(fmt)?; + // `i + s.len()`, not `i + 1`: a cluster containing `_` or `:` can still be + // several bytes long, because UAX#29 GB9b joins a `Prepend` character to the + // one that follows it. + EscapeBodyText(&text[last..i + s.len()]).fmt(fmt)?; fmt.write_str("")?; - last = i + 1; + last = i + s.len(); } } if last < text.len() { diff --git a/src/librustdoc/html/escape/tests.rs b/src/librustdoc/html/escape/tests.rs index de702e1606353..247a1e42d18eb 100644 --- a/src/librustdoc/html/escape/tests.rs +++ b/src/librustdoc/html/escape/tests.rs @@ -40,6 +40,10 @@ fn escape_body_text_with_wbr() { assert_eq!(&E("ṼẽçÑñéå").to_string(), "ṼẽçÑñéå"); assert_eq!(&E("V\u{0300}e\u{0300}c\u{0300}D\u{0300}e\u{0300}q\u{0300}u\u{0300}e\u{0300}u\u{0300}e\u{0300}").to_string(), "V\u{0300}e\u{0300}c\u{0300}D\u{0300}e\u{0300}q\u{0300}u\u{0300}e\u{0300}u\u{0300}e\u{0300}"); assert_eq!(&E("LPFNACCESSIBLEOBJECTFROMWINDOW").to_string(), "LPFNACCESSIBLEOBJECTFROMWINDOW"); + // clusters where the `_` or `:` is not the first byte (UAX#29 GB9b `Prepend`) + assert_eq!(&E("abc\u{0D4E}_defgh").to_string(), "abc\u{0D4E}_defgh"); + assert_eq!(&E("abc\u{0605}:defgh").to_string(), "abc\u{0605}:defgh"); + assert_eq!(&E("first_\u{0300}second").to_string(), "first_\u{0300}second"); } // property test #[test] diff --git a/tests/rustdoc-html/item-name-grapheme-cluster-wbr.rs b/tests/rustdoc-html/item-name-grapheme-cluster-wbr.rs new file mode 100644 index 0000000000000..5244f970389ca --- /dev/null +++ b/tests/rustdoc-html/item-name-grapheme-cluster-wbr.rs @@ -0,0 +1,8 @@ +// Regression test for #160231: `` insertion sliced mid-character on an item name whose +// grapheme cluster starts with a `Prepend` character and contains `_`. + +#![crate_name = "foo"] +#![allow(mixed_script_confusables, non_camel_case_types)] + +//@ hasraw foo/index.html 'abcൎ_defgh' +pub struct abcൎ_defgh;