diff --git a/fuzz/fuzz_targets/bytes.rs b/fuzz/fuzz_targets/bytes.rs index 18a77654..13498ad2 100644 --- a/fuzz/fuzz_targets/bytes.rs +++ b/fuzz/fuzz_targets/bytes.rs @@ -7,5 +7,8 @@ use mock::MockTime; fuzz_target!(|data: (MockTime, &[u8])| { let (time, format) = data; - strftime::bytes::strftime(&time, format); + // Give each fuzzer input a 16kb buffer to write to. + let mut buf = vec![0u8; 16 * 1024].into_boxed_slice(); + let _ignored = strftime::buffered::strftime(&time, format, &mut buf[..]); + let _ignored = strftime::io::strftime(&time, format, &mut &mut buf[..]); }); diff --git a/fuzz/fuzz_targets/mock.rs b/fuzz/fuzz_targets/mock.rs index cf9f636e..24ed1dc3 100644 --- a/fuzz/fuzz_targets/mock.rs +++ b/fuzz/fuzz_targets/mock.rs @@ -8,13 +8,6 @@ macro_rules! create_mock_time { $($field_name: $field_type),*, } - impl<'a> MockTime<'a> { - #[allow(clippy::too_many_arguments)] - fn new($($field_name: $field_type),*) -> Self { - Self { $($field_name),* } - } - } - impl<'a> Time for MockTime<'a> { $(fn $field_name(&self) -> $field_type { self.$field_name })* } diff --git a/fuzz/fuzz_targets/string.rs b/fuzz/fuzz_targets/string.rs index e8d215fc..e1c09489 100644 --- a/fuzz/fuzz_targets/string.rs +++ b/fuzz/fuzz_targets/string.rs @@ -2,10 +2,41 @@ mod mock; +use core::fmt; use libfuzzer_sys::fuzz_target; use mock::MockTime; +struct LimitedBuf<'a> { + buf: &'a mut [u8], + pos: usize, +} + +impl<'a> fmt::Write for LimitedBuf<'a> { + fn write_str(&mut self, s: &str) -> fmt::Result { + let bytes = s.as_bytes(); + let remaining_buf = &mut self.buf[self.pos..]; + + if remaining_buf.len() < bytes.len() { + // Signal that buffer was too small. + return Err(fmt::Error); + } + + remaining_buf[..bytes.len()].copy_from_slice(bytes); + self.pos += bytes.len(); + + Ok(()) + } +} + fuzz_target!(|data: (MockTime, &str)| { let (time, format) = data; - strftime::string::strftime(&time, format); + // Give each fuzzer input a 16kb buffer to write to. + let mut buf = vec![0u8; 16 * 1024].into_boxed_slice(); + + let mut writer = LimitedBuf { + buf: &mut buf[..], + pos: 0, + }; + + let _ignored = strftime::fmt::strftime(&time, format, &mut writer); }); diff --git a/src/format/mod.rs b/src/format/mod.rs index c9ad68a7..cf5f83c0 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -846,6 +846,11 @@ impl<'t, 'f, T: CheckedTime> TimeFormatter<'t, 'f, T> { Some(&b'#') => { flags.set(Flag::ChangeCase); } + Some(byte) if !byte.is_ascii() => { + // We've found a multi-byte UTF-8 character sequence. + // All specifiers must be ASCII-only, so this is invalid. + return Ok(None); + } _ => break, } cursor.next(); @@ -930,15 +935,24 @@ impl<'t, 'f, T: CheckedTime> TimeFormatter<'t, 'f, T> { (b'z', Spec::TimeZoneOffsetHourMinute), ]); - match cursor.next() { - Some(x) => match POSSIBLE_SPECS.binary_search_by_key(&x, |&(c, _)| c) { - #[expect( - clippy::indexing_slicing, - reason = "index is returned from binary search" - )] - Ok(index) => Some(POSSIBLE_SPECS[index].1), - Err(_) => None, - }, + match cursor.remaining().first() { + Some(x) if !x.is_ascii() => { + // We've found a multi-byte UTF-8 character sequence. + // All specifiers must be ASCII-only, so this is invalid. + return Ok(None); + } + Some(x) => { + cursor.next(); + + match POSSIBLE_SPECS.binary_search_by_key(&x, |(c, _)| c) { + #[expect( + clippy::indexing_slicing, + reason = "index is returned from binary search" + )] + Ok(index) => Some(POSSIBLE_SPECS[index].1), + Err(_) => None, + } + } None => return Err(Error::InvalidFormatString), } } else if cursor.read_optional_tag(b"z") { diff --git a/src/tests/format.rs b/src/tests/format.rs index a988cc50..7a7a693a 100644 --- a/src/tests/format.rs +++ b/src/tests/format.rs @@ -647,13 +647,15 @@ fn test_format_tabulation() { fn test_format_percent() { let times = [MockTime::default()]; - check_all(×, "'%%'", &["'%'"]); - check_all(×, "'%1%'", &["'%'"]); - check_all(×, "'%6%'", &["' %'"]); - check_all(×, "'%-_#^6%'", &["'%'"]); - check_all(×, "'%-0^6%'", &["'%'"]); - check_all(×, "'%0_#6%'", &["' %'"]); - check_all(×, "'%_06%'", &["'00000%'"]); + check_all(×, "'%%'", &["'%'"]); + check_all(×, "'%%Q'", &["'%Q'"]); + check_all(×, "'%%%%%%%Q'", &["'%%%%Q'"]); + check_all(×, "'%1%'", &["'%'"]); + check_all(×, "'%6%'", &["' %'"]); + check_all(×, "'%-_#^6%'", &["'%'"]); + check_all(×, "'%-0^6%'", &["'%'"]); + check_all(×, "'%0_#6%'", &["' %'"]); + check_all(×, "'%_06%'", &["'00000%'"]); } #[test] @@ -906,3 +908,23 @@ fn test_chrono_pr_966_week_numbers() { ], ); } + +#[test] +#[cfg(feature = "alloc")] +fn test_multibyte_utf8_characters_after_percent_treated_as_literal() { + use alloc::string::String; + + let time = MockTime::new(1970, 1, 1, 0, 0, 0, 0, 4, 1, 0, false, 0, ""); + + for format in [ + "\u{1e}%\u{02c9}0", + "%\u{076f}\u{2}", + "%\u{01af}", + "%10\u{94}\u{6}", + "%100000\u{94}\u{6}", + ] { + let mut buf = String::new(); + crate::fmt::strftime(&time, format, &mut buf).unwrap(); + assert_eq!(buf, format); + } +}