From 4e1cf208004d24de87a5354283abcc331d740741 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Tue, 18 Aug 2026 16:15:48 +0000 Subject: [PATCH] Fix crash with utf8 truncation --- src/html.rs | 46 ++++++++++++++++++++++++++++++++++++++++- src/images/normalize.rs | 4 ++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/html.rs b/src/html.rs index 2c5ce84..afdc467 100644 --- a/src/html.rs +++ b/src/html.rs @@ -122,7 +122,15 @@ pub fn decode_entities(s: &str) -> String { while let Some(i) = rest.find('&') { out.push_str(&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('&'); rest = &tail[1..]; continue; @@ -158,6 +166,20 @@ pub fn decode_entities(s: &str) -> String { 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. pub fn attr_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -315,6 +337,28 @@ mod tests { 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("ab’cd", 4), "ab"); + assert_eq!(truncate_utf8("ab’cd", 5), "ab’"); + assert_eq!(truncate_utf8("éclair", 0), ""); + assert_eq!(truncate_utf8("éclair", usize::MAX), "éclair"); + } + #[test] fn escaping_is_the_inverse_that_output_needs() { assert_eq!( diff --git a/src/images/normalize.rs b/src/images/normalize.rs index 687d8fd..b47dc3e 100644 --- a/src/images/normalize.rs +++ b/src/images/normalize.rs @@ -16,7 +16,7 @@ //! Candidates are judged by *shape*, never by publisher: a string with braces, //! 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 /// have wrapped around an image (lightbox triggers, mostly). @@ -181,7 +181,7 @@ pub fn normalize_img_tags(html: &str) -> String { } out.push_str("/>"); } 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),