Skip to content

Commit 60b5930

Browse files
read and deserialize json UTF-8, UTF-16, UTF-32, BOM, LE/BE support (#386)
* read and deserialize json file support all encoding types regarding UTF-8, UTF-16, UTF-32, BOM, LE/BE * spell fixes * spelling fixes * ut test update * review comments * unit test --------- Co-authored-by: Zhidong Peng <zpeng@microsoft.com>
1 parent abf2fa3 commit 60b5930

2 files changed

Lines changed: 305 additions & 11 deletions

File tree

proxy_agent_shared/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ pub enum Error {
5757
#[error("Parse datetime string error: {0}")]
5858
ParseDateTimeStringError(String),
5959

60+
#[error("Failed to decode file '{0}': {1}")]
61+
DecodeFile(String, String),
62+
6063
#[error(
6164
"Failed to get proxy agent aggregate status (server error: {0}; status file error: {1})"
6265
)]

proxy_agent_shared/src/misc_helpers.rs

Lines changed: 302 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use regex::Regex;
88
use serde::de::DeserializeOwned;
99
use serde::Serialize;
1010
use 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.
248256
pub fn json_read_from_file<T>(file_path: &Path) -> Result<T>
249257
where
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+
268409
pub fn json_clone<T>(obj: &T) -> Result<T>
269410
where
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

Comments
 (0)