Skip to main content

safe_decode/
utf16.rs

1//! UTF-16 decoding, with the NUL policy in the function name.
2//!
3//! Four policies exist in the wild and they disagree on the same bytes. Collapsing them
4//! into one function with a flag would let a caller pick the wrong one silently, so each
5//! is its own named function and there is no default.
6
7use alloc::string::String;
8use alloc::vec::Vec;
9
10/// A decoded UTF-16 string together with what decoding it cost.
11///
12/// `text` is always well-formed UTF-8 — every unpaired surrogate half is replaced with
13/// U+FFFD — so a caller may ignore the rest. The remaining fields say what the replacement
14/// concealed, because a decode that lost information should be able to report it rather
15/// than hand back a plausible-looking string.
16#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct DecodedUtf16 {
19    /// The decoded text. Unpaired surrogate halves appear as U+FFFD.
20    pub text: String,
21    /// How many unpaired surrogate halves were replaced with U+FFFD in `text`.
22    pub unpaired_surrogates: usize,
23    /// The input had an odd length, so a trailing byte could not form a code unit and was
24    /// dropped. Reported for the input as a whole, whatever the NUL policy — an
25    /// odd-length UTF-16 field is a structural anomaly worth surfacing even when the
26    /// dropped byte fell past a terminator.
27    pub dangling_byte: bool,
28}
29
30impl DecodedUtf16 {
31    /// Whether anything was lost: an unpaired surrogate, a dangling byte, or both.
32    #[must_use]
33    pub fn is_lossy(&self) -> bool {
34        self.dangling_byte || self.unpaired_surrogates > 0
35    }
36}
37
38/// Split `bytes` into UTF-16 code units, dropping an odd trailing byte. `to_unit` is the
39/// endianness: `u16::from_le_bytes` or `u16::from_be_bytes`.
40fn units(bytes: &[u8], to_unit: fn([u8; 2]) -> u16) -> Vec<u16> {
41    bytes
42        .chunks_exact(2)
43        // `chunks_exact(2)` yields nothing but 2-byte chunks, so this always converts.
44        .filter_map(|chunk| <[u8; 2]>::try_from(chunk).ok())
45        .map(to_unit)
46        .collect()
47}
48
49/// Whether `bytes` has an odd length, leaving a byte that cannot form a code unit.
50fn has_dangling_byte(bytes: &[u8]) -> bool {
51    bytes.len() % 2 == 1
52}
53
54/// Decode code units to text, replacing each unpaired surrogate half with U+FFFD and
55/// counting how many were replaced.
56fn decode(units: &[u16], dangling_byte: bool) -> DecodedUtf16 {
57    let mut text = String::with_capacity(units.len());
58    let mut unpaired_surrogates = 0;
59    for unit in core::char::decode_utf16(units.iter().copied()) {
60        if let Ok(ch) = unit {
61            text.push(ch);
62        } else {
63            text.push(char::REPLACEMENT_CHARACTER);
64            unpaired_surrogates += 1;
65        }
66    }
67    DecodedUtf16 {
68        text,
69        unpaired_surrogates,
70        dangling_byte,
71    }
72}
73
74fn keep_nuls(bytes: &[u8], to_unit: fn([u8; 2]) -> u16) -> DecodedUtf16 {
75    decode(&units(bytes, to_unit), has_dangling_byte(bytes))
76}
77
78fn until_nul(bytes: &[u8], to_unit: fn([u8; 2]) -> u16) -> DecodedUtf16 {
79    let mut units = units(bytes, to_unit);
80    if let Some(nul) = units.iter().position(|&u| u == 0) {
81        units.truncate(nul);
82    }
83    decode(&units, has_dangling_byte(bytes))
84}
85
86fn trim_end_nuls(bytes: &[u8], to_unit: fn([u8; 2]) -> u16) -> DecodedUtf16 {
87    let mut units = units(bytes, to_unit);
88    let end = units
89        .iter()
90        .rposition(|&u| u != 0)
91        .map_or(0, |last| last + 1);
92    units.truncate(end);
93    decode(&units, has_dangling_byte(bytes))
94}
95
96fn split_on_nul(bytes: &[u8], to_unit: fn([u8; 2]) -> u16) -> Vec<DecodedUtf16> {
97    let units = units(bytes, to_unit);
98    let mut segments: Vec<DecodedUtf16> = units
99        .split(|&u| u == 0)
100        .map(|segment| decode(segment, false))
101        .collect();
102    // `slice::split` always yields at least one segment, so the dangling byte — which sits
103    // at the very end of the input — always has a segment to be attributed to.
104    if has_dangling_byte(bytes) {
105        if let Some(last) = segments.last_mut() {
106            last.dangling_byte = true;
107        }
108    }
109    segments
110}
111
112/// Decode the whole slice as UTF-16LE, keeping NUL code units as U+0000 characters.
113///
114/// No NUL is treated as a terminator. Use this when the field length is authoritative and
115/// the bytes are exactly the string.
116#[must_use]
117pub fn decode_utf16le_keep_nuls(bytes: &[u8]) -> DecodedUtf16 {
118    keep_nuls(bytes, u16::from_le_bytes)
119}
120
121/// Decode UTF-16LE up to the first NUL code unit, which terminates the string.
122///
123/// Everything at and after the first NUL is discarded, including further strings. Use this
124/// for a NUL-terminated field inside a larger buffer.
125#[must_use]
126pub fn decode_utf16le_until_nul(bytes: &[u8]) -> DecodedUtf16 {
127    until_nul(bytes, u16::from_le_bytes)
128}
129
130/// Decode the whole slice as UTF-16LE, then strip NUL code units from the end only.
131///
132/// Interior NULs are kept as U+0000. Use this for a fixed-width, NUL-padded field.
133#[must_use]
134pub fn decode_utf16le_trim_end_nuls(bytes: &[u8]) -> DecodedUtf16 {
135    trim_end_nuls(bytes, u16::from_le_bytes)
136}
137
138/// Split a UTF-16LE slice on NUL code units and decode every segment.
139///
140/// The split is structural and total: `n` NULs yield `n + 1` segments, empty ones
141/// included, and an empty input yields one empty segment. A trailing NUL therefore
142/// produces a trailing empty segment. Callers that want a terminator convention — such as
143/// "stop at the first empty segment" — apply it themselves, where the knowledge of that
144/// convention lives.
145#[must_use]
146pub fn split_utf16le_on_nul(bytes: &[u8]) -> Vec<DecodedUtf16> {
147    split_on_nul(bytes, u16::from_le_bytes)
148}
149
150/// Decode the whole slice as UTF-16BE, keeping NUL code units as U+0000 characters.
151///
152/// The big-endian twin of [`decode_utf16le_keep_nuls`].
153#[must_use]
154pub fn decode_utf16be_keep_nuls(bytes: &[u8]) -> DecodedUtf16 {
155    keep_nuls(bytes, u16::from_be_bytes)
156}
157
158/// Decode UTF-16BE up to the first NUL code unit, which terminates the string.
159///
160/// The big-endian twin of [`decode_utf16le_until_nul`].
161#[must_use]
162pub fn decode_utf16be_until_nul(bytes: &[u8]) -> DecodedUtf16 {
163    until_nul(bytes, u16::from_be_bytes)
164}
165
166/// Decode the whole slice as UTF-16BE, then strip NUL code units from the end only.
167///
168/// The big-endian twin of [`decode_utf16le_trim_end_nuls`].
169#[must_use]
170pub fn decode_utf16be_trim_end_nuls(bytes: &[u8]) -> DecodedUtf16 {
171    trim_end_nuls(bytes, u16::from_be_bytes)
172}
173
174/// Split a UTF-16BE slice on NUL code units and decode every segment.
175///
176/// The big-endian twin of [`split_utf16le_on_nul`].
177#[must_use]
178pub fn split_utf16be_on_nul(bytes: &[u8]) -> Vec<DecodedUtf16> {
179    split_on_nul(bytes, u16::from_be_bytes)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::{
185        decode_utf16be_keep_nuls, decode_utf16be_trim_end_nuls, decode_utf16be_until_nul,
186        decode_utf16le_keep_nuls, decode_utf16le_trim_end_nuls, decode_utf16le_until_nul,
187        split_utf16be_on_nul, split_utf16le_on_nul,
188    };
189    use alloc::string::String;
190    use alloc::vec::Vec;
191
192    /// UTF-16LE for 'A', NUL, 'B', NUL — an interior NUL *and* a trailing NUL, so all four
193    /// policies must disagree. This one fixture is the whole reason the family exists.
194    const FOUR_WAY_LE: &[u8] = &[0x41, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00, 0x00];
195    /// The same four code units, big-endian.
196    const FOUR_WAY_BE: &[u8] = &[0x00, 0x41, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00];
197
198    fn texts(parts: &[super::DecodedUtf16]) -> Vec<String> {
199        parts.iter().map(|d| d.text.clone()).collect()
200    }
201
202    // ---- the four policies disagree on identical bytes -------------------------------
203
204    #[test]
205    fn keep_nuls_keeps_every_nul_as_u0000() {
206        assert_eq!(decode_utf16le_keep_nuls(FOUR_WAY_LE).text, "A\0B\0");
207    }
208
209    #[test]
210    fn until_nul_stops_dead_at_the_first_nul() {
211        assert_eq!(decode_utf16le_until_nul(FOUR_WAY_LE).text, "A");
212    }
213
214    #[test]
215    fn trim_end_nuls_keeps_interior_nuls_and_drops_only_trailing_ones() {
216        assert_eq!(decode_utf16le_trim_end_nuls(FOUR_WAY_LE).text, "A\0B");
217    }
218
219    #[test]
220    fn split_on_nul_yields_every_segment_including_the_trailing_empty() {
221        assert_eq!(texts(&split_utf16le_on_nul(FOUR_WAY_LE)), ["A", "B", ""]);
222    }
223
224    #[test]
225    fn the_four_policies_produce_four_different_answers() {
226        let keep = decode_utf16le_keep_nuls(FOUR_WAY_LE).text;
227        let until = decode_utf16le_until_nul(FOUR_WAY_LE).text;
228        let trim = decode_utf16le_trim_end_nuls(FOUR_WAY_LE).text;
229        let split = texts(&split_utf16le_on_nul(FOUR_WAY_LE)).join("|");
230        let all = [keep.as_str(), until.as_str(), trim.as_str(), split.as_str()];
231        for (i, a) in all.iter().enumerate() {
232            for b in all.iter().skip(i + 1) {
233                assert_ne!(a, b, "two policies collapsed onto the same answer");
234            }
235        }
236    }
237
238    // ---- the big-endian twins agree with their little-endian counterparts ------------
239
240    #[test]
241    fn big_endian_twins_match_the_little_endian_family() {
242        assert_eq!(
243            decode_utf16be_keep_nuls(FOUR_WAY_BE).text,
244            decode_utf16le_keep_nuls(FOUR_WAY_LE).text
245        );
246        assert_eq!(
247            decode_utf16be_until_nul(FOUR_WAY_BE).text,
248            decode_utf16le_until_nul(FOUR_WAY_LE).text
249        );
250        assert_eq!(
251            decode_utf16be_trim_end_nuls(FOUR_WAY_BE).text,
252            decode_utf16le_trim_end_nuls(FOUR_WAY_LE).text
253        );
254        assert_eq!(
255            texts(&split_utf16be_on_nul(FOUR_WAY_BE)),
256            texts(&split_utf16le_on_nul(FOUR_WAY_LE))
257        );
258    }
259
260    #[test]
261    fn endianness_actually_changes_the_result() {
262        // Reading LE bytes as BE must not silently produce the same string.
263        assert_ne!(
264            decode_utf16be_keep_nuls(&[0x41, 0x00]).text,
265            decode_utf16le_keep_nuls(&[0x41, 0x00]).text
266        );
267    }
268
269    // ---- lossiness is reported, never hidden -----------------------------------------
270
271    #[test]
272    fn well_formed_surrogate_pair_is_not_lossy() {
273        // U+1F600 GRINNING FACE = D83D DE00.
274        let d = decode_utf16le_keep_nuls(&[0x3D, 0xD8, 0x00, 0xDE]);
275        assert_eq!(d.text, "\u{1F600}");
276        assert_eq!(d.unpaired_surrogates, 0);
277        assert!(!d.dangling_byte);
278        assert!(!d.is_lossy());
279    }
280
281    #[test]
282    fn lone_high_surrogate_is_replaced_and_counted() {
283        let d = decode_utf16le_keep_nuls(&[0x00, 0xD8]);
284        assert_eq!(d.text, "\u{FFFD}");
285        assert_eq!(d.unpaired_surrogates, 1);
286        assert!(!d.dangling_byte);
287        assert!(d.is_lossy());
288    }
289
290    #[test]
291    fn lone_low_surrogate_is_replaced_and_counted() {
292        let d = decode_utf16le_keep_nuls(&[0x00, 0xDC]);
293        assert_eq!(d.text, "\u{FFFD}");
294        assert_eq!(d.unpaired_surrogates, 1);
295        assert!(d.is_lossy());
296    }
297
298    #[test]
299    fn unpaired_surrogate_before_a_valid_pair_counts_once() {
300        // High, high, low — the first high is unpaired, the second pairs with the low.
301        let d = decode_utf16le_keep_nuls(&[0x00, 0xD8, 0x3D, 0xD8, 0x00, 0xDE]);
302        assert_eq!(d.text, "\u{FFFD}\u{1F600}");
303        assert_eq!(d.unpaired_surrogates, 1);
304        assert!(d.is_lossy());
305    }
306
307    #[test]
308    fn odd_length_input_drops_the_trailing_byte_and_says_so() {
309        let d = decode_utf16le_keep_nuls(&[0x41, 0x00, 0x42]);
310        assert_eq!(d.text, "A");
311        assert!(d.dangling_byte);
312        assert_eq!(d.unpaired_surrogates, 0);
313        assert!(d.is_lossy());
314    }
315
316    #[test]
317    fn a_single_byte_decodes_to_nothing_but_reports_the_dangling_byte() {
318        let d = decode_utf16be_keep_nuls(&[0x41]);
319        assert_eq!(d.text, "");
320        assert!(d.dangling_byte);
321        assert!(d.is_lossy());
322    }
323
324    #[test]
325    fn every_policy_reports_the_dangling_byte() {
326        let odd: &[u8] = &[0x41, 0x00, 0x00, 0x00, 0x42];
327        assert!(decode_utf16le_keep_nuls(odd).dangling_byte);
328        assert!(decode_utf16le_until_nul(odd).dangling_byte);
329        assert!(decode_utf16le_trim_end_nuls(odd).dangling_byte);
330        assert!(decode_utf16be_keep_nuls(odd).dangling_byte);
331        assert!(decode_utf16be_until_nul(odd).dangling_byte);
332        assert!(decode_utf16be_trim_end_nuls(odd).dangling_byte);
333    }
334
335    #[test]
336    fn split_reports_the_dangling_byte_on_the_segment_that_lost_it() {
337        let parts = split_utf16le_on_nul(&[0x41, 0x00, 0x00, 0x00, 0x42]);
338        assert_eq!(texts(&parts), ["A", ""]);
339        assert!(!parts[0].dangling_byte);
340        assert!(parts[1].dangling_byte);
341        assert!(parts[1].is_lossy());
342    }
343
344    #[test]
345    fn split_counts_unpaired_surrogates_per_segment() {
346        // "A" NUL <lone high surrogate>
347        let parts = split_utf16le_on_nul(&[0x41, 0x00, 0x00, 0x00, 0x00, 0xD8]);
348        assert_eq!(texts(&parts), ["A", "\u{FFFD}"]);
349        assert_eq!(parts[0].unpaired_surrogates, 0);
350        assert!(!parts[0].is_lossy());
351        assert_eq!(parts[1].unpaired_surrogates, 1);
352        assert!(parts[1].is_lossy());
353    }
354
355    // ---- boundary inputs --------------------------------------------------------------
356
357    #[test]
358    fn empty_input_decodes_to_empty_and_is_not_lossy() {
359        for d in [
360            decode_utf16le_keep_nuls(&[]),
361            decode_utf16le_until_nul(&[]),
362            decode_utf16le_trim_end_nuls(&[]),
363            decode_utf16be_keep_nuls(&[]),
364            decode_utf16be_until_nul(&[]),
365            decode_utf16be_trim_end_nuls(&[]),
366        ] {
367            assert_eq!(d.text, "");
368            assert!(!d.is_lossy());
369        }
370    }
371
372    #[test]
373    fn empty_input_splits_into_one_empty_segment() {
374        let parts = split_utf16le_on_nul(&[]);
375        assert_eq!(texts(&parts), [""]);
376        assert!(!parts[0].is_lossy());
377    }
378
379    #[test]
380    fn all_nuls_are_handled_by_each_policy() {
381        let nuls: &[u8] = &[0x00; 6];
382        assert_eq!(decode_utf16le_keep_nuls(nuls).text, "\0\0\0");
383        assert_eq!(decode_utf16le_until_nul(nuls).text, "");
384        assert_eq!(decode_utf16le_trim_end_nuls(nuls).text, "");
385        assert_eq!(texts(&split_utf16le_on_nul(nuls)), ["", "", "", ""]);
386    }
387
388    #[test]
389    fn until_nul_returns_the_whole_string_when_no_nul_is_present() {
390        let d = decode_utf16le_until_nul(&[0x41, 0x00, 0x42, 0x00]);
391        assert_eq!(d.text, "AB");
392        assert!(!d.is_lossy());
393    }
394
395    #[test]
396    fn trim_end_nuls_leaves_a_string_without_padding_untouched() {
397        assert_eq!(
398            decode_utf16le_trim_end_nuls(&[0x41, 0x00, 0x42, 0x00]).text,
399            "AB"
400        );
401    }
402
403    #[test]
404    fn decodes_a_realistic_nul_padded_path_field() {
405        // "C:\ok" padded to a fixed 8-code-unit field with trailing NULs.
406        let mut field: Vec<u8> = Vec::new();
407        for u in "C:\\ok".encode_utf16() {
408            field.extend_from_slice(&u.to_le_bytes());
409        }
410        field.resize(16, 0);
411        assert_eq!(decode_utf16le_trim_end_nuls(&field).text, "C:\\ok");
412        assert_eq!(decode_utf16le_until_nul(&field).text, "C:\\ok");
413        assert_eq!(decode_utf16le_keep_nuls(&field).text, "C:\\ok\0\0\0");
414    }
415}