Skip to content

Commit 4cff3b1

Browse files
committed
review comments
1 parent e89f4d3 commit 4cff3b1

1 file changed

Lines changed: 75 additions & 43 deletions

File tree

proxy_agent_shared/src/misc_helpers.rs

Lines changed: 75 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -258,74 +258,108 @@ where
258258
T: DeserializeOwned,
259259
{
260260
let bytes = fs::read(file_path)?;
261-
let text = decode_json_text(&bytes, file_path)?;
261+
let text = decode_text(&bytes)
262+
.map_err(|detail| Error::DecodeFile(path_to_string(file_path), detail))?;
262263
let obj: T = serde_json::from_str(&text)?;
263264

264265
Ok(obj)
265266
}
266267

267-
/// Detects the text encoding of `bytes` and returns it as
268-
/// (code unit width in bytes, big endian, BOM length in bytes).
269-
/// width: 1 for UTF-8, 2 for UTF-16, 4 for UTF-32
270-
/// big_endian: true for BE, false for LE
271-
/// bom length length of bom
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.
272304
///
273305
/// Wider BOMs must be tested first: the UTF-32LE BOM (FF FE 00 00) starts with
274306
/// the UTF-16LE BOM (FF FE), so a shortest-first scan would mis-detect a
275307
/// UTF-32LE file as UTF-16LE.
276-
fn detect_json_encoding(bytes: &[u8]) -> (usize, bool, usize) {
308+
fn detect_text_encoding(bytes: &[u8]) -> DetectedEncoding {
277309
if bytes.starts_with(&[0x00, 0x00, 0xFE, 0xFF]) {
278-
(4, true, 4) // UTF-32BE with BOM
310+
DetectedEncoding::new(TextEncoding::Utf32, true, 4) // UTF-32BE with BOM
279311
} else if bytes.starts_with(&[0xFF, 0xFE, 0x00, 0x00]) {
280-
(4, false, 4) // UTF-32LE with BOM
312+
DetectedEncoding::new(TextEncoding::Utf32, false, 4) // UTF-32LE with BOM
281313
} else if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
282-
(1, false, 3) // UTF-8 with BOM
314+
DetectedEncoding::new(TextEncoding::Utf8, false, 3) // UTF-8 with BOM
283315
} else if bytes.starts_with(&[0xFE, 0xFF]) {
284-
(2, true, 2) // UTF-16BE with BOM
316+
DetectedEncoding::new(TextEncoding::Utf16, true, 2) // UTF-16BE with BOM
285317
} else if bytes.starts_with(&[0xFF, 0xFE]) {
286-
(2, false, 2) // UTF-16LE with BOM
318+
DetectedEncoding::new(TextEncoding::Utf16, false, 2) // UTF-16LE with BOM
287319
} else {
288-
// No BOM. A JSON document always starts with an ASCII character (`[`,
289-
// `{`, `"`, a digit or whitespace), so the NUL padding around the first
290-
// code unit identifies both the width and the byte order. The 4-byte
291-
// patterns are checked first because they are a superset of the 2-byte
292-
// ones.
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.
293325
let is_nul = |i: usize| bytes.get(i) == Some(&0x00);
294326
let is_text = |i: usize| matches!(bytes.get(i), Some(b) if *b != 0x00);
295327

296328
if is_nul(0) && is_nul(1) && is_nul(2) && is_text(3) {
297-
(4, true, 0) // 00 00 00 xx -> UTF-32BE
329+
DetectedEncoding::new(TextEncoding::Utf32, true, 0) // 00 00 00 xx -> UTF-32BE
298330
} else if is_text(0) && is_nul(1) && is_nul(2) && is_nul(3) {
299-
(4, false, 0) // xx 00 00 00 -> UTF-32LE
331+
DetectedEncoding::new(TextEncoding::Utf32, false, 0) // xx 00 00 00 -> UTF-32LE
300332
} else if is_nul(0) && is_text(1) {
301-
(2, true, 0) // 00 xx -> UTF-16BE
333+
DetectedEncoding::new(TextEncoding::Utf16, true, 0) // 00 xx -> UTF-16BE
302334
} else if is_text(0) && is_nul(1) {
303-
(2, false, 0) // xx 00 -> UTF-16LE
335+
DetectedEncoding::new(TextEncoding::Utf16, false, 0) // xx 00 -> UTF-16LE
304336
} else {
305-
(1, false, 0) // anything else, including plain ASCII / UTF-8
337+
DetectedEncoding::new(TextEncoding::Utf8, false, 0) // anything else, including plain ASCII / UTF-8
306338
}
307339
}
308340
}
309341

310342
/// Decodes `bytes` into UTF-8 text using the encoding detected by
311-
/// [`detect_json_encoding`], skipping the BOM when present.
343+
/// [`detect_text_encoding`], skipping the BOM when present.
312344
/// UTF-8 input is borrowed as-is, so the common case does not allocate.
313-
fn decode_json_text<'a>(bytes: &'a [u8], file_path: &Path) -> Result<Cow<'a, str>> {
314-
let decode_error = |detail: String| Error::DecodeFile(path_to_string(file_path), detail);
315-
316-
let (code_unit_len, big_endian, bom_len) = detect_json_encoding(bytes);
317-
let payload = &bytes[bom_len..];
318-
319-
match code_unit_len {
320-
1 => std::str::from_utf8(payload)
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)
321355
.map(Cow::Borrowed)
322-
.map_err(|e| decode_error(format!("content is not valid UTF-8: {e}"))),
323-
2 => {
356+
.map_err(|e| format!("content is not valid UTF-8: {e}")),
357+
TextEncoding::Utf16 => {
324358
if !payload.len().is_multiple_of(2) {
325-
return Err(decode_error(format!(
359+
return Err(format!(
326360
"UTF-16 content is truncated: {} bytes is not a whole number of 16-bit code units",
327361
payload.len()
328-
)));
362+
));
329363
}
330364

331365
let code_units = payload.chunks_exact(2).map(|chunk| {
@@ -343,14 +377,14 @@ fn decode_json_text<'a>(bytes: &'a [u8], file_path: &Path) -> Result<Cow<'a, str
343377
char::decode_utf16(code_units)
344378
.collect::<std::result::Result<String, _>>()
345379
.map(Cow::Owned)
346-
.map_err(|e| decode_error(format!("UTF-16 content has an unpaired surrogate: {e}")))
380+
.map_err(|e| format!("UTF-16 content has an unpaired surrogate: {e}"))
347381
}
348-
_ => {
382+
TextEncoding::Utf32 => {
349383
if !payload.len().is_multiple_of(4) {
350-
return Err(decode_error(format!(
384+
return Err(format!(
351385
"UTF-32 content is truncated: {} bytes is not a whole number of 32-bit code units",
352386
payload.len()
353-
)));
387+
));
354388
}
355389

356390
payload
@@ -363,12 +397,10 @@ fn decode_json_text<'a>(bytes: &'a [u8], file_path: &Path) -> Result<Cow<'a, str
363397
u32::from_le_bytes(unit)
364398
};
365399
char::from_u32(scalar).ok_or_else(|| {
366-
decode_error(format!(
367-
"UTF-32 content has an invalid scalar value: {scalar:#010X}"
368-
))
400+
format!("UTF-32 content has an invalid scalar value: {scalar:#010X}")
369401
})
370402
})
371-
.collect::<Result<String>>()
403+
.collect::<std::result::Result<String, String>>()
372404
.map(Cow::Owned)
373405
}
374406
}

0 commit comments

Comments
 (0)