@@ -8,6 +8,7 @@ use regex::Regex;
88use serde:: de:: DeserializeOwned ;
99use serde:: Serialize ;
1010use std:: {
11+ borrow:: Cow ,
1112 fs:: { self , File } ,
1213 path:: { Path , PathBuf } ,
1314 process:: Command ,
@@ -245,26 +246,166 @@ where
245246 Ok ( ( ) )
246247}
247248
249+ /// Reads `file_path`, decodes it using the detected text encoding and
250+ /// deserializes the resulting JSON into `T`.
251+ ///
252+ /// Supports UTF-8, UTF-16LE, UTF-16BE, UTF-32LE and UTF-32BE, each with or
253+ /// without a BOM - the 10 encodings a JSON file produced by an arbitrary
254+ /// editor or tool can realistically use. Any BOM is consumed while decoding and
255+ /// never reaches serde_json, which would fail if the json payload contains BOM prefix.
248256pub fn json_read_from_file < T > ( file_path : & Path ) -> Result < T >
249257where
250258 T : DeserializeOwned ,
251259{
252- // Read the whole file to bytes so we can transparently skip an optional
253- // UTF-8 BOM (EF BB BF). serde_json does not strip a BOM and would otherwise
254- // fail the parse with "expected value at line 1 column 1" for any file
255- // produced by editors / tools that default to BOM-prefixed UTF-8 (e.g.
256- // Windows PowerShell 5.1's `Set-Content -Encoding UTF8`, Notepad, VS Code's
257- // "UTF-8 with BOM").
258260 let bytes = fs:: read ( file_path) ?;
259- let payload = match bytes. as_slice ( ) {
260- [ 0xEF , 0xBB , 0xBF , rest @ ..] => rest,
261- rest => rest,
262- } ;
263- let obj: T = serde_json:: from_slice ( payload) ?;
261+ let text = decode_text ( & bytes)
262+ . map_err ( |detail| Error :: DecodeFile ( path_to_string ( file_path) , detail) ) ?;
263+ let obj: T = serde_json:: from_str ( & text) ?;
264264
265265 Ok ( obj)
266266}
267267
268+ /// The Unicode transformation format of the detected encoding.
269+ #[ derive( Clone , Copy ) ]
270+ enum TextEncoding {
271+ /// UTF-8 (and plain ASCII) - 1 byte per code unit.
272+ Utf8 ,
273+ /// UTF-16 - 2 bytes per code unit.
274+ Utf16 ,
275+ /// UTF-32 - 4 bytes per code unit.
276+ Utf32 ,
277+ }
278+
279+ /// The text encoding detected from the leading bytes of a file.
280+ struct DetectedEncoding {
281+ /// The Unicode transformation format.
282+ text_encoding : TextEncoding ,
283+ /// True for big endian, false for little endian. Not meaningful for UTF-8.
284+ big_endian : bool ,
285+ /// Length of the BOM in bytes, 0 when the file has no BOM.
286+ bom_len : usize ,
287+ }
288+
289+ impl DetectedEncoding {
290+ const fn new ( text_encoding : TextEncoding , big_endian : bool , bom_len : usize ) -> Self {
291+ Self {
292+ text_encoding,
293+ big_endian,
294+ bom_len,
295+ }
296+ }
297+ }
298+
299+ /// Detects the text encoding of `bytes`.
300+ ///
301+ /// A BOM is a Unicode construct rather than a JSON one, so BOM detection here is
302+ /// format agnostic. The BOM-less fallback, however, assumes the document starts
303+ /// with an ASCII character - true for JSON, XML and most text config formats.
304+ ///
305+ /// Wider BOMs must be tested first: the UTF-32LE BOM (FF FE 00 00) starts with
306+ /// the UTF-16LE BOM (FF FE), so a shortest-first scan would mis-detect a
307+ /// UTF-32LE file as UTF-16LE.
308+ fn detect_text_encoding ( bytes : & [ u8 ] ) -> DetectedEncoding {
309+ if bytes. starts_with ( & [ 0x00 , 0x00 , 0xFE , 0xFF ] ) {
310+ DetectedEncoding :: new ( TextEncoding :: Utf32 , true , 4 ) // UTF-32BE with BOM
311+ } else if bytes. starts_with ( & [ 0xFF , 0xFE , 0x00 , 0x00 ] ) {
312+ DetectedEncoding :: new ( TextEncoding :: Utf32 , false , 4 ) // UTF-32LE with BOM
313+ } else if bytes. starts_with ( & [ 0xEF , 0xBB , 0xBF ] ) {
314+ DetectedEncoding :: new ( TextEncoding :: Utf8 , false , 3 ) // UTF-8 with BOM
315+ } else if bytes. starts_with ( & [ 0xFE , 0xFF ] ) {
316+ DetectedEncoding :: new ( TextEncoding :: Utf16 , true , 2 ) // UTF-16BE with BOM
317+ } else if bytes. starts_with ( & [ 0xFF , 0xFE ] ) {
318+ DetectedEncoding :: new ( TextEncoding :: Utf16 , false , 2 ) // UTF-16LE with BOM
319+ } else {
320+ // No BOM. The document is expected to start with an ASCII character
321+ // (for JSON that is `[`, `{`, `"`, a digit or whitespace), so the NUL
322+ // padding around the first code unit identifies both the width and the
323+ // byte order. The 4-byte patterns are checked first because they are a
324+ // superset of the 2-byte ones.
325+ let is_nul = |i : usize | bytes. get ( i) == Some ( & 0x00 ) ;
326+ let is_text = |i : usize | matches ! ( bytes. get( i) , Some ( b) if * b != 0x00 ) ;
327+
328+ if is_nul ( 0 ) && is_nul ( 1 ) && is_nul ( 2 ) && is_text ( 3 ) {
329+ DetectedEncoding :: new ( TextEncoding :: Utf32 , true , 0 ) // 00 00 00 xx -> UTF-32BE
330+ } else if is_text ( 0 ) && is_nul ( 1 ) && is_nul ( 2 ) && is_nul ( 3 ) {
331+ DetectedEncoding :: new ( TextEncoding :: Utf32 , false , 0 ) // xx 00 00 00 -> UTF-32LE
332+ } else if is_nul ( 0 ) && is_text ( 1 ) {
333+ DetectedEncoding :: new ( TextEncoding :: Utf16 , true , 0 ) // 00 xx -> UTF-16BE
334+ } else if is_text ( 0 ) && is_nul ( 1 ) {
335+ DetectedEncoding :: new ( TextEncoding :: Utf16 , false , 0 ) // xx 00 -> UTF-16LE
336+ } else {
337+ DetectedEncoding :: new ( TextEncoding :: Utf8 , false , 0 ) // anything else, including plain ASCII / UTF-8
338+ }
339+ }
340+ }
341+
342+ /// Decodes `bytes` into UTF-8 text using the encoding detected by
343+ /// [`detect_text_encoding`], skipping the BOM when present.
344+ /// UTF-8 input is borrowed as-is, so the common case does not allocate.
345+ ///
346+ /// On failure it returns only a description of what made the content
347+ /// undecodable; the caller attaches the source of the bytes.
348+ fn decode_text ( bytes : & [ u8 ] ) -> std:: result:: Result < Cow < ' _ , str > , String > {
349+ let encoding = detect_text_encoding ( bytes) ;
350+ let big_endian = encoding. big_endian ;
351+ let payload = & bytes[ encoding. bom_len ..] ;
352+
353+ match encoding. text_encoding {
354+ TextEncoding :: Utf8 => std:: str:: from_utf8 ( payload)
355+ . map ( Cow :: Borrowed )
356+ . map_err ( |e| format ! ( "content is not valid UTF-8: {e}" ) ) ,
357+ TextEncoding :: Utf16 => {
358+ if !payload. len ( ) . is_multiple_of ( 2 ) {
359+ return Err ( format ! (
360+ "UTF-16 content is truncated: {} bytes is not a whole number of 16-bit code units" ,
361+ payload. len( )
362+ ) ) ;
363+ }
364+
365+ let code_units = payload. chunks_exact ( 2 ) . map ( |chunk| {
366+ let unit = [ chunk[ 0 ] , chunk[ 1 ] ] ;
367+ if big_endian {
368+ u16:: from_be_bytes ( unit)
369+ } else {
370+ u16:: from_le_bytes ( unit)
371+ }
372+ } ) ;
373+
374+ // `decode_utf16` pairs surrogates, so astral-plane characters
375+ // (emoji) are reassembled correctly; a lone surrogate is rejected
376+ // rather than silently replaced.
377+ char:: decode_utf16 ( code_units)
378+ . collect :: < std:: result:: Result < String , _ > > ( )
379+ . map ( Cow :: Owned )
380+ . map_err ( |e| format ! ( "UTF-16 content has an unpaired surrogate: {e}" ) )
381+ }
382+ TextEncoding :: Utf32 => {
383+ if !payload. len ( ) . is_multiple_of ( 4 ) {
384+ return Err ( format ! (
385+ "UTF-32 content is truncated: {} bytes is not a whole number of 32-bit code units" ,
386+ payload. len( )
387+ ) ) ;
388+ }
389+
390+ payload
391+ . chunks_exact ( 4 )
392+ . map ( |chunk| {
393+ let unit = [ chunk[ 0 ] , chunk[ 1 ] , chunk[ 2 ] , chunk[ 3 ] ] ;
394+ let scalar = if big_endian {
395+ u32:: from_be_bytes ( unit)
396+ } else {
397+ u32:: from_le_bytes ( unit)
398+ } ;
399+ char:: from_u32 ( scalar) . ok_or_else ( || {
400+ format ! ( "UTF-32 content has an invalid scalar value: {scalar:#010X}" )
401+ } )
402+ } )
403+ . collect :: < std:: result:: Result < String , String > > ( )
404+ . map ( Cow :: Owned )
405+ }
406+ }
407+ }
408+
268409pub fn json_clone < T > ( obj : & T ) -> Result < T >
269410where
270411 T : Serialize + DeserializeOwned ,
@@ -644,6 +785,156 @@ mod tests {
644785 _ = fs:: remove_dir_all ( & temp_test_path) ;
645786 }
646787
788+ #[ test]
789+ fn json_read_from_file_supports_all_ten_encodings_test ( ) {
790+ // Latin-1 accent + CJK + an astral-plane emoji (a surrogate pair in
791+ // UTF-16) so multi-byte decoding and surrogate pairing are exercised,
792+ // not just the ASCII fast path.
793+ const NON_ASCII_MESSAGE : & str = "caf\u{00e9} \u{6d4b} \u{8bd5} \u{1F600} " ;
794+
795+ #[ derive( Serialize , Deserialize , PartialEq , Debug ) ]
796+ struct EncodingTestStruct {
797+ name : String ,
798+ code : i32 ,
799+ message : String ,
800+ enabled : bool ,
801+ }
802+
803+ #[ derive( Clone , Copy ) ]
804+ enum Encoding {
805+ Utf8 ,
806+ Utf16Le ,
807+ Utf16Be ,
808+ Utf32Le ,
809+ Utf32Be ,
810+ }
811+
812+ /// Encodes `text` into the raw bytes written to each test file.
813+ fn encode ( text : & str , encoding : Encoding , with_bom : bool ) -> Vec < u8 > {
814+ let mut bytes = Vec :: new ( ) ;
815+
816+ if with_bom {
817+ bytes. extend_from_slice ( match encoding {
818+ Encoding :: Utf8 => & [ 0xEF , 0xBB , 0xBF ] [ ..] ,
819+ Encoding :: Utf16Le => & [ 0xFF , 0xFE ] [ ..] ,
820+ Encoding :: Utf16Be => & [ 0xFE , 0xFF ] [ ..] ,
821+ Encoding :: Utf32Le => & [ 0xFF , 0xFE , 0x00 , 0x00 ] [ ..] ,
822+ Encoding :: Utf32Be => & [ 0x00 , 0x00 , 0xFE , 0xFF ] [ ..] ,
823+ } ) ;
824+ }
825+
826+ match encoding {
827+ Encoding :: Utf8 => bytes. extend_from_slice ( text. as_bytes ( ) ) ,
828+ Encoding :: Utf16Le => {
829+ for unit in text. encode_utf16 ( ) {
830+ bytes. extend_from_slice ( & unit. to_le_bytes ( ) ) ;
831+ }
832+ }
833+ Encoding :: Utf16Be => {
834+ for unit in text. encode_utf16 ( ) {
835+ bytes. extend_from_slice ( & unit. to_be_bytes ( ) ) ;
836+ }
837+ }
838+ Encoding :: Utf32Le => {
839+ for ch in text. chars ( ) {
840+ bytes. extend_from_slice ( & ( ch as u32 ) . to_le_bytes ( ) ) ;
841+ }
842+ }
843+ Encoding :: Utf32Be => {
844+ for ch in text. chars ( ) {
845+ bytes. extend_from_slice ( & ( ch as u32 ) . to_be_bytes ( ) ) ;
846+ }
847+ }
848+ }
849+
850+ bytes
851+ }
852+
853+ let mut temp_test_path = env:: temp_dir ( ) ;
854+ temp_test_path. push ( "json_read_from_file_supports_all_ten_encodings_test" ) ;
855+ // clean up and ignore the clean up errors
856+ _ = fs:: remove_dir_all ( & temp_test_path) ;
857+ super :: try_create_folder ( & temp_test_path) . unwrap ( ) ;
858+
859+ let json = format ! (
860+ r#"{{"name":"EncodingTest","code":7,"message":"{NON_ASCII_MESSAGE}","enabled":true}}"#
861+ ) ;
862+ let expected = EncodingTestStruct {
863+ name : "EncodingTest" . to_string ( ) ,
864+ code : 7 ,
865+ message : NON_ASCII_MESSAGE . to_string ( ) ,
866+ enabled : true ,
867+ } ;
868+
869+ // The same JSON document written 10 times, byte-for-byte encoded
870+ // differently. The UTF-32LE-with-BOM case is the ambiguous one: its BOM
871+ // starts with the UTF-16LE BOM.
872+ let combinations = [
873+ ( "utf8_bom.json" , Encoding :: Utf8 , true ) ,
874+ ( "utf8_no_bom.json" , Encoding :: Utf8 , false ) ,
875+ ( "utf16le_bom.json" , Encoding :: Utf16Le , true ) ,
876+ ( "utf16le_no_bom.json" , Encoding :: Utf16Le , false ) ,
877+ ( "utf16be_bom.json" , Encoding :: Utf16Be , true ) ,
878+ ( "utf16be_no_bom.json" , Encoding :: Utf16Be , false ) ,
879+ ( "utf32le_bom.json" , Encoding :: Utf32Le , true ) ,
880+ ( "utf32le_no_bom.json" , Encoding :: Utf32Le , false ) ,
881+ ( "utf32be_bom.json" , Encoding :: Utf32Be , true ) ,
882+ ( "utf32be_no_bom.json" , Encoding :: Utf32Be , false ) ,
883+ ] ;
884+
885+ for ( file_name, encoding, with_bom) in combinations {
886+ let file_path = temp_test_path. join ( file_name) ;
887+ fs:: write ( & file_path, encode ( & json, encoding, with_bom) ) . unwrap ( ) ;
888+
889+ let actual = super :: json_read_from_file :: < EncodingTestStruct > ( & file_path)
890+ . unwrap_or_else ( |e| panic ! ( "{file_name}: {e}" ) ) ;
891+
892+ assert_eq ! ( expected, actual, "{file_name}: decoded payload differs" ) ;
893+ }
894+
895+ // Odd byte count cannot be a whole number of UTF-16 code units.
896+ let truncated = temp_test_path. join ( "truncated_utf16.json" ) ;
897+ fs:: write ( & truncated, [ 0x7B , 0x00 , 0x22 ] ) . unwrap ( ) ;
898+ let error = super :: json_read_from_file :: < EncodingTestStruct > ( & truncated)
899+ . unwrap_err ( )
900+ . to_string ( ) ;
901+ assert ! ( error. contains( "truncated" ) , "{error}" ) ;
902+
903+ // 0x0011_0000 is one past the highest Unicode scalar value.
904+ let bad_scalar = temp_test_path. join ( "bad_utf32_scalar.json" ) ;
905+ fs:: write (
906+ & bad_scalar,
907+ [ 0x7B , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x11 , 0x00 ] ,
908+ )
909+ . unwrap ( ) ;
910+ let error = super :: json_read_from_file :: < EncodingTestStruct > ( & bad_scalar)
911+ . unwrap_err ( )
912+ . to_string ( ) ;
913+ assert ! ( error. contains( "invalid scalar value" ) , "{error}" ) ;
914+
915+ _ = fs:: remove_dir_all ( & temp_test_path) ;
916+ }
917+
918+ #[ test]
919+ fn detect_text_encoding_short_input_test ( ) {
920+ use super :: TextEncoding ;
921+
922+ /// True when `bytes` is detected as plain UTF-8 with no BOM.
923+ fn is_utf8_no_bom ( bytes : & [ u8 ] ) -> bool {
924+ let detected = super :: detect_text_encoding ( bytes) ;
925+ matches ! ( detected. text_encoding, TextEncoding :: Utf8 )
926+ && !detected. big_endian
927+ && detected. bom_len == 0
928+ }
929+
930+ // 1. An empty input is UTF-8 with no BOM.
931+ assert ! ( is_utf8_no_bom( & [ ] ) , "empty input" ) ;
932+ // 2. A single ASCII byte is UTF-8 with no BOM.
933+ assert ! ( is_utf8_no_bom( & [ 0 ] ) , "single NUL byte" ) ;
934+ // 3. Two bytes that are invalid
935+ assert ! ( is_utf8_no_bom( & [ 1 , 2 ] ) , "two invalid bytes" ) ;
936+ }
937+
647938 #[ test]
648939 fn path_to_string_test ( ) {
649940 let path = "path_to_string_test" ;
0 commit comments