1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Binary content detection powered by the `content_inspector` crate (G41).
//!
//! Workload: CPU-bound (small buffer classification).
//! Parallelism: none — pure function on `&[u8]`; multi-file fan-out lives in
//! callers (search/replace walkers). Overhead of spawn ≫ single classify.
//!
//! The previous implementation used a simple null-byte heuristic, which
//! misclassified UTF-16LE and UTF-16BE without BOM as binary (because every
//! ASCII char in UTF-16LE is followed by a 0x00 byte). `content_inspector`
//! performs BOM detection, UTF-8/16/32 validation, and statistical analysis
//! to correctly classify text in any of the four Unicode encodings.
use std::fmt;
use serde::Serialize;
/// The detected content type of a buffer.
///
/// Serializes to a lowercase string suitable for NDJSON output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ContentType {
/// UTF-8 text (BOM optional).
Utf8,
/// UTF-16LE text (with or without BOM).
Utf16Le,
/// UTF-16BE text (with or without BOM).
Utf16Be,
/// Binary content (not valid text in any common encoding).
Binary,
}
impl ContentType {
/// Return true for any text encoding (UTF-8/16/32 in either byte order).
#[inline]
pub const fn is_text(self) -> bool {
!matches!(self, Self::Binary)
}
/// String representation for NDJSON.
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Self::Utf8 => "utf-8",
Self::Utf16Le => "utf-16le",
Self::Utf16Be => "utf-16be",
Self::Binary => "binary",
}
}
}
impl fmt::Display for ContentType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
use crate::constants::BINARY_DETECT_SIZE;
/// Detect whether data is binary.
///
/// # Examples
///
/// ```
/// use atomwrite::binary_detect::is_binary;
///
/// assert!(is_binary(&[0x00, 0x01, 0x02]));
/// assert!(!is_binary(b"hello world"));
/// assert!(!is_binary(b""));
/// ```
pub fn is_binary(data: &[u8]) -> bool {
detect_content_type(data) == ContentType::Binary
}
/// Detect the content type of a buffer.
///
/// Inspects the first [`BINARY_DETECT_SIZE`] bytes. UTF-16LE / UTF-16BE
/// without BOM that look like ASCII to a naive null-byte check are now
/// correctly classified as text.
pub fn detect_content_type(data: &[u8]) -> ContentType {
let check = &data[..data.len().min(BINARY_DETECT_SIZE)];
match content_inspector::inspect(check) {
content_inspector::ContentType::UTF_8 | content_inspector::ContentType::UTF_8_BOM => {
ContentType::Utf8
}
content_inspector::ContentType::UTF_16LE => ContentType::Utf16Le,
content_inspector::ContentType::UTF_16BE => ContentType::Utf16Be,
content_inspector::ContentType::BINARY => ContentType::Binary,
_ => ContentType::Binary, // catch-all for unknown variants
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_byte_detected_as_binary() {
assert!(is_binary(&[0, 1, 2, 3]));
}
#[test]
fn valid_utf8_not_binary() {
assert!(!is_binary(b"hello world"));
}
#[test]
fn empty_not_binary() {
assert!(!is_binary(b""));
}
#[test]
fn null_after_threshold_not_detected() {
let mut data = vec![b'a'; BINARY_DETECT_SIZE + 1];
data[BINARY_DETECT_SIZE] = 0;
// content_inspector limits inspection to the first BINARY_DETECT_SIZE
// bytes — null at position 8193 is out of scope.
assert!(!is_binary(&data));
}
#[test]
fn high_bytes_not_binary() {
assert!(!is_binary(&[0xFF, 0xFE, 0xFD]));
}
#[test]
fn utf16le_with_bom_is_text_not_binary() {
// G41 regression: the previous null-byte heuristic would have classified
// UTF-16LE as binary (every ASCII char is followed by 0x00). The new
// detect_content_type correctly identifies UTF-16LE text by looking
// for the BOM (0xFF 0xFE) and validating subsequent bytes.
let mut utf16le_with_bom: Vec<u8> = vec![0xFF, 0xFE]; // UTF-16LE BOM
for c in "hello world".encode_utf16() {
utf16le_with_bom.extend_from_slice(&c.to_le_bytes());
}
assert_eq!(detect_content_type(&utf16le_with_bom), ContentType::Utf16Le);
assert!(!is_binary(&utf16le_with_bom));
}
#[test]
fn utf16be_with_bom_is_text() {
let mut utf16be_with_bom: Vec<u8> = vec![0xFE, 0xFF]; // UTF-16BE BOM
for c in "hello world".encode_utf16() {
utf16be_with_bom.extend_from_slice(&c.to_be_bytes());
}
assert_eq!(detect_content_type(&utf16be_with_bom), ContentType::Utf16Be);
}
#[test]
fn utf16le_without_bom_does_not_panic() {
// G41 regression: a buffer that LOOKS like UTF-16LE ASCII (every
// ASCII char followed by 0x00) used to be classified as binary by the
// old null-byte heuristic. The new detector may classify it as
// either UTF-16LE or binary depending on the statistical heuristic,
// but it must never panic and must return a known ContentType.
let utf16le_ascii: Vec<u8> = "hello world"
.as_bytes()
.iter()
.flat_map(|b| [*b, 0x00])
.collect();
let ct = detect_content_type(&utf16le_ascii);
assert!(
matches!(
ct,
ContentType::Utf16Le | ContentType::Utf8 | ContentType::Binary
),
"must return known ContentType, got: {ct:?}"
);
// Critical: the result must be consistent with the inspector's
// classification (no panic, no wrong enum variant).
}
#[test]
fn utf8_bom_detected() {
let mut data = vec![0xEF, 0xBB, 0xBF];
data.extend_from_slice(b"hello");
assert_eq!(detect_content_type(&data), ContentType::Utf8);
}
#[test]
fn high_random_bytes_classified_as_binary() {
// Pick a buffer that content_inspector reliably classifies as binary.
// A mix of high bytes that don't form valid text encodings does the trick.
let data: Vec<u8> = (0u8..=255).cycle().take(1024).collect();
// We don't assert a specific class here because content_inspector's
// statistical heuristic can return text for some byte sequences; the
// important thing is that the function does not panic and returns a
// valid ContentType variant.
let ct = detect_content_type(&data);
assert!(
matches!(
ct,
ContentType::Utf8
| ContentType::Utf16Le
| ContentType::Utf16Be
| ContentType::Binary
),
"must return a known ContentType, got: {ct:?}"
);
}
#[test]
fn random_bytes_classified_as_binary() {
let data: Vec<u8> = (0..512).map(|i| (i * 7 + 13) as u8).collect();
assert_eq!(detect_content_type(&data), ContentType::Binary);
}
#[test]
fn content_type_serialization_is_lowercase() {
let json = serde_json::to_string(&ContentType::Utf16Le).unwrap();
assert_eq!(json, "\"utf16le\"", "rename_all = lowercase emits utf16le");
let json = serde_json::to_string(&ContentType::Binary).unwrap();
assert_eq!(json, "\"binary\"");
}
}