From 5c2030f6a4e235caad6d7e73e37bc51444c08f9e Mon Sep 17 00:00:00 2001 From: Daniel Eades Date: Sun, 12 Jul 2026 12:50:28 +0100 Subject: [PATCH] fix: count code points, not bytes, in StringValidator length checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON Schema (and RFC 8259) define minLength/maxLength as counting Unicode code points, not UTF-8 bytes. StringValidator::is_valid used s.len(), which is a byte count in Rust, so multi-byte strings like "héllo" (5 code points, 6 bytes) were wrongly rejected by a maxLength: 5 constraint, and multi-byte strings could wrongly pass a minLength check. This mirrors the fix already applied to the generated FromStr/TryFrom validation code in type_entry.rs (#776), which this validator was missed by. StringValidator is used at schema-processing time (e.g. matching enum variants against string constraints), so it has the same class of bug independently of the codegen output. Switch both comparisons to chars().count() and add tests covering multi-byte and 4-byte (emoji) code points at exact boundary lengths. --- typify-impl/src/util.rs | 61 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/typify-impl/src/util.rs b/typify-impl/src/util.rs index 54d232b9..7679dc9e 100644 --- a/typify-impl/src/util.rs +++ b/typify-impl/src/util.rs @@ -919,13 +919,15 @@ impl StringValidator { } pub fn is_valid>(&self, s: S) -> bool { + // Per the JSON Schema spec (and RFC 8259), minLength/maxLength count + // Unicode code points, not UTF-8 bytes, so we must not use `len()`. self.max_length .as_ref() - .is_none_or(|max| s.as_ref().len() as u32 <= *max) + .is_none_or(|max| s.as_ref().chars().count() as u32 <= *max) && self .min_length .as_ref() - .is_none_or(|min| s.as_ref().len() as u32 >= *min) + .is_none_or(|min| s.as_ref().chars().count() as u32 >= *min) && self .pattern .as_ref() @@ -1179,6 +1181,61 @@ mod tests { assert!(!ach.is_valid("Abednego")); } + #[test] + fn test_string_validation_multi_byte() { + // minLength/maxLength count Unicode code points, not UTF-8 bytes. + // "héllo" is 5 code points but 6 bytes (the "é" is 2 bytes). + let five = StringValidator::new( + &Name::Unknown, + Some(&StringValidation { + max_length: Some(5), + min_length: Some(5), + pattern: None, + }), + ) + .unwrap(); + assert!(five.is_valid("héllo")); + assert!(five.is_valid("hello")); + // 6 bytes but only 5 code points -- should still be valid. + assert_eq!("héllo".len(), 6); + assert_eq!("héllo".chars().count(), 5); + + // A single 4-byte emoji is one code point, so it satisfies a + // minLength/maxLength of 1 even though it is 4 bytes long. + let one = StringValidator::new( + &Name::Unknown, + Some(&StringValidation { + max_length: Some(1), + min_length: Some(1), + pattern: None, + }), + ) + .unwrap(); + assert!(one.is_valid("🍔")); + assert_eq!("🍔".len(), 4); + assert_eq!("🍔".chars().count(), 1); + // Two code points should now be rejected by maxLength: 1. + assert!(!one.is_valid("🍔🍔")); + + // A string of exactly 8 code points, each multi-byte, should pass + // an exact-length-8 validator even though its byte length is 24. + let eight = StringValidator::new( + &Name::Unknown, + Some(&StringValidation { + max_length: Some(8), + min_length: Some(8), + pattern: None, + }), + ) + .unwrap(); + let emoji8 = "🍔".repeat(8); + assert_eq!(emoji8.len(), 32); + assert_eq!(emoji8.chars().count(), 8); + assert!(eight.is_valid(&emoji8)); + assert!(!eight.is_valid("🍔".repeat(7))); + assert!(!eight.is_valid("🍔".repeat(9))); + } + #[test] fn test_instance_type_ordering() { let null = ReorderedInstanceType::Null;