Fix crash with utf8 truncation

This commit is contained in:
2026-08-18 16:15:48 +00:00
parent db19d08257
commit 4e1cf20800
2 changed files with 47 additions and 3 deletions
+45 -1
View File
@@ -122,7 +122,15 @@ pub fn decode_entities(s: &str) -> String {
while let Some(i) = rest.find('&') { while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]); out.push_str(&rest[..i]);
let tail = &rest[i..]; let tail = &rest[i..];
let Some(end) = tail[..tail.len().min(12)].find(';') else { // Entity names are ASCII, but malformed input is not necessarily so.
// Search the bounded byte window for the ASCII delimiter instead of
// slicing at byte 12, which may fall in the middle of a UTF-8 scalar.
let Some(end) = tail
.as_bytes()
.iter()
.take(12)
.position(|&byte| byte == b';')
else {
out.push('&'); out.push('&');
rest = &tail[1..]; rest = &tail[1..];
continue; continue;
@@ -158,6 +166,20 @@ pub fn decode_entities(s: &str) -> String {
out out
} }
/// Prefix of `s` that fits within `max_bytes` without splitting a UTF-8
/// character.
///
/// This is intended for byte-budgeted logs and excerpts. The returned string
/// may be shorter than the limit by up to three bytes.
pub fn truncate_utf8(s: &str, max_bytes: usize) -> &str {
let mut end = max_bytes.min(s.len());
while !s.is_char_boundary(end) {
end -= 1;
}
// `end` is explicitly adjusted to a character boundary above.
s.get(..end).unwrap_or_default()
}
/// Escape a string for use inside a double-quoted XML attribute. /// Escape a string for use inside a double-quoted XML attribute.
pub fn attr_escape(s: &str) -> String { pub fn attr_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len()); let mut out = String::with_capacity(s.len());
@@ -315,6 +337,28 @@ mod tests {
assert_eq!(decode_entities("&unknown; &"), "&unknown; &"); assert_eq!(decode_entities("&unknown; &"), "&unknown; &");
} }
#[test]
fn entity_decoding_handles_unicode_at_the_scan_boundary() {
// The curly apostrophe begins at byte 11 after `&`. The bounded entity
// scan must leave malformed/non-entity text alone rather than slicing
// through the apostrophe and panicking.
let input = "&abcdefghij rest";
assert_eq!(decode_entities(input), input);
// A semicolon after non-ASCII text is likewise safe and remains
// unchanged because it is not one of the entities we decode.
assert_eq!(decode_entities("&é;"), "&é;");
}
#[test]
fn utf8_truncation_respects_byte_limits_and_character_boundaries() {
assert_eq!(truncate_utf8("abcdef", 4), "abcd");
assert_eq!(truncate_utf8("abcd", 4), "ab");
assert_eq!(truncate_utf8("abcd", 5), "ab");
assert_eq!(truncate_utf8("éclair", 0), "");
assert_eq!(truncate_utf8("éclair", usize::MAX), "éclair");
}
#[test] #[test]
fn escaping_is_the_inverse_that_output_needs() { fn escaping_is_the_inverse_that_output_needs() {
assert_eq!( assert_eq!(
+2 -2
View File
@@ -16,7 +16,7 @@
//! Candidates are judged by *shape*, never by publisher: a string with braces, //! Candidates are judged by *shape*, never by publisher: a string with braces,
//! whitespace or quotes in it cannot resolve, whoever wrote it. //! whitespace or quotes in it cannot resolve, whoever wrote it.
use crate::html::{html_to_text, parse_attrs, tag_end, tag_name}; use crate::html::{html_to_text, parse_attrs, tag_end, tag_name, truncate_utf8};
/// Elements that readability deletes outright, and which a page may nevertheless /// Elements that readability deletes outright, and which a page may nevertheless
/// have wrapped around an image (lightbox triggers, mostly). /// have wrapped around an image (lightbox triggers, mostly).
@@ -181,7 +181,7 @@ pub fn normalize_img_tags(html: &str) -> String {
} }
out.push_str("/>"); out.push_str("/>");
} else { } else {
tracing::debug!(tag = %&raw[..raw.len().min(120)], "dropping unusable img"); tracing::debug!(tag = %truncate_utf8(raw, 120), "dropping unusable img");
} }
} }
_ => out.push_str(raw), _ => out.push_str(raw),