use std::convert::Infallible;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct InvalidUtf8;
pub(crate) struct Utf8Buffer {
buffer: Vec<u8>,
}
impl Utf8Buffer {
pub(crate) fn new() -> Self {
Self::with_capacity(16)
}
pub(crate) fn with_capacity(capacity: usize) -> Self {
Self {
buffer: Vec::with_capacity(capacity),
}
}
pub(crate) fn push(&mut self, bytes: &[u8]) {
self.buffer.extend_from_slice(bytes);
}
pub(crate) fn clear(&mut self) {
self.buffer.clear();
}
pub(crate) fn has_pending(&self) -> bool {
!self.buffer.is_empty()
}
pub(crate) fn pending_len(&self) -> usize {
self.buffer.len()
}
pub(crate) fn flush(&mut self) -> String {
if self.buffer.is_empty() {
return String::new();
}
let result = String::from_utf8_lossy(&self.buffer).into_owned();
self.buffer.clear();
result
}
pub(crate) fn take_complete(&mut self) -> Option<String> {
match self.scan(|| Ok::<char, Infallible>(char::REPLACEMENT_CHARACTER)) {
Ok(text) => text,
Err(never) => match never {},
}
}
pub(crate) fn take_complete_strict(&mut self) -> Result<Option<String>, InvalidUtf8> {
self.scan(|| Err(InvalidUtf8))
}
fn scan<E>(&mut self, on_invalid: impl Fn() -> Result<char, E>) -> Result<Option<String>, E> {
if self.buffer.is_empty() {
return Ok(None);
}
let mut out = String::new();
let mut consumed = 0;
loop {
let rest = &self.buffer[consumed..];
if rest.is_empty() {
break;
}
let (valid_up_to, error_len) = match std::str::from_utf8(rest) {
Ok(valid) => {
out.push_str(valid);
consumed += valid.len();
break;
}
Err(e) => (e.valid_up_to(), e.error_len()),
};
out.push_str(&String::from_utf8_lossy(&rest[..valid_up_to]));
consumed += valid_up_to;
match error_len {
None => break,
Some(invalid_len) => {
out.push(on_invalid()?);
consumed += invalid_len;
}
}
}
self.buffer.drain(..consumed);
if out.is_empty() {
Ok(None)
} else {
Ok(Some(out))
}
}
pub(crate) fn flush_strict(&mut self) -> Result<String, InvalidUtf8> {
String::from_utf8(std::mem::take(&mut self.buffer)).map_err(|_| InvalidUtf8)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn push_take(buf: &mut Utf8Buffer, bytes: &[u8]) -> Option<String> {
buf.push(bytes);
buf.take_complete()
}
fn drive_chunks(input: &[u8], chunks: &[&[u8]]) -> String {
let mut buf = Utf8Buffer::new();
let mut out = String::new();
debug_assert_eq!(
chunks.concat(),
input,
"chunks must reassemble the input exactly"
);
for chunk in chunks {
if let Some(text) = push_take(&mut buf, chunk) {
out.push_str(&text);
}
}
out.push_str(&buf.flush());
out
}
fn drive_byte_by_byte(input: &[u8]) -> String {
let chunks: Vec<&[u8]> = input.chunks(1).collect();
drive_chunks(input, &chunks)
}
#[test]
fn test_ascii_is_emitted_immediately() {
let mut buf = Utf8Buffer::new();
assert_eq!(push_take(&mut buf, b"Hi!"), Some("Hi!".to_string()));
assert!(!buf.has_pending());
}
#[test]
fn test_multi_byte_split_across_pushes() {
let mut buf = Utf8Buffer::new();
assert_eq!(push_take(&mut buf, &[0xE4]), None); assert!(buf.has_pending());
assert_eq!(buf.pending_len(), 1);
assert_eq!(push_take(&mut buf, &[0xB8]), None); assert_eq!(buf.pending_len(), 2);
assert_eq!(push_take(&mut buf, &[0x96]), Some("世".to_string())); assert!(!buf.has_pending());
}
#[test]
fn test_complete_prefix_with_incomplete_tail() {
let mut buf = Utf8Buffer::new();
assert_eq!(push_take(&mut buf, &[b'H', 0xE4]), Some("H".to_string()));
assert!(buf.has_pending());
assert_eq!(buf.pending_len(), 1);
}
#[test]
fn test_flush_incomplete_yields_replacement_char() {
let mut buf = Utf8Buffer::new();
buf.push(&[0xE4, 0xB8]);
let flushed = buf.flush();
assert!(flushed.contains('\u{FFFD}')); assert!(!buf.has_pending());
}
#[test]
fn test_flush_empty_is_empty_string() {
let mut buf = Utf8Buffer::new();
assert_eq!(buf.flush(), String::new());
}
#[test]
fn test_clear_discards_pending() {
let mut buf = Utf8Buffer::new();
buf.push(&[0xE4]);
assert!(buf.has_pending());
buf.clear();
assert!(!buf.has_pending());
}
#[test]
fn test_truncated_lead_recovers_on_next_byte() {
let mut buf = Utf8Buffer::new();
assert_eq!(push_take(&mut buf, &[0xE4]), None);
assert_eq!(push_take(&mut buf, b"A"), Some("\u{FFFD}A".to_string()));
assert_eq!(push_take(&mut buf, b"B"), Some("B".to_string()));
assert_eq!(push_take(&mut buf, b"C"), Some("C".to_string()));
assert!(!buf.has_pending());
}
#[test]
fn test_stray_continuation_byte_recovers() {
let mut buf = Utf8Buffer::new();
assert_eq!(push_take(&mut buf, &[0x80]), Some("\u{FFFD}".to_string()));
assert!(!buf.has_pending());
assert_eq!(push_take(&mut buf, b"ok"), Some("ok".to_string()));
}
#[test]
fn test_never_valid_byte_recovers() {
let mut buf = Utf8Buffer::new();
assert_eq!(push_take(&mut buf, &[0xFF]), Some("\u{FFFD}".to_string()));
assert!(!buf.has_pending());
assert_eq!(push_take(&mut buf, b"ok"), Some("ok".to_string()));
}
#[test]
fn test_invalid_lead_bytes_are_not_buffered_as_possible_leads() {
for &lead in &[0xC0u8, 0xC1, 0xF5] {
let mut buf = Utf8Buffer::new();
assert_eq!(
push_take(&mut buf, &[lead]),
Some("\u{FFFD}".to_string()),
"0x{lead:02X} must be rejected immediately"
);
assert!(!buf.has_pending(), "0x{lead:02X} must not stay buffered");
}
}
#[test]
fn test_overlong_encoding_is_rejected() {
let input = [0xE0, 0x80, 0xAF];
let decoded = drive_byte_by_byte(&input);
assert!(!decoded.contains('/'), "overlong form must not decode");
assert_eq!(decoded, String::from_utf8_lossy(&input));
}
#[test]
fn test_surrogate_encoding_is_rejected() {
let input = [0xED, 0xA0, 0x80];
let decoded = drive_byte_by_byte(&input);
assert!(decoded.chars().all(|c| c == '\u{FFFD}'));
assert_eq!(decoded, String::from_utf8_lossy(&input));
}
#[test]
fn test_invalid_bytes_interleaved_with_split_multi_byte_char() {
let mut buf = Utf8Buffer::new();
assert_eq!(push_take(&mut buf, &[0xFF]), Some("\u{FFFD}".to_string()));
assert_eq!(push_take(&mut buf, &[0xE4, 0xB8]), None);
assert_eq!(push_take(&mut buf, &[0x96]), Some("世".to_string()));
assert_eq!(
push_take(&mut buf, &[0x80, b'z']),
Some("\u{FFFD}z".to_string())
);
assert!(!buf.has_pending());
let input = [0xFF, 0xE4, 0xB8, 0x96, 0x80, b'z'];
assert_eq!(drive_byte_by_byte(&input), String::from_utf8_lossy(&input));
}
#[test]
fn test_strict_reports_a_definitively_invalid_byte() {
let mut buf = Utf8Buffer::new();
buf.push(&[b'o', b'k', 0xFF]);
assert_eq!(buf.take_complete_strict(), Err(InvalidUtf8));
assert_eq!(buf.pending_len(), 3);
}
#[test]
fn test_strict_buffers_an_incomplete_tail_then_reports_it_at_flush() {
let mut buf = Utf8Buffer::new();
buf.push(&[b'H', 0xE4]);
assert_eq!(buf.take_complete_strict(), Ok(Some("H".to_string())));
assert!(buf.has_pending());
assert_eq!(buf.flush_strict(), Err(InvalidUtf8));
assert!(!buf.has_pending());
}
#[test]
fn test_strict_passes_valid_utf8_through() {
let mut buf = Utf8Buffer::new();
buf.push("Hi 世界".as_bytes());
assert_eq!(buf.take_complete_strict(), Ok(Some("Hi 世界".to_string())));
assert_eq!(buf.flush_strict(), Ok(String::new()));
}
proptest! {
#[test]
fn prop_byte_at_a_time_matches_lossy(input in prop::collection::vec(any::<u8>(), 0..64)) {
prop_assert_eq!(
drive_byte_by_byte(&input),
String::from_utf8_lossy(&input).into_owned()
);
}
#[test]
fn prop_arbitrary_chunking_matches_lossy(
chunks in prop::collection::vec(prop::collection::vec(any::<u8>(), 0..8), 0..16)
) {
let input: Vec<u8> = chunks.concat();
let chunk_refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
prop_assert_eq!(
drive_chunks(&input, &chunk_refs),
String::from_utf8_lossy(&input).into_owned()
);
}
}
}