Byte-to-text transforms that name what they do to NUL. Fourteen hand-rolled UTF-16 decoders across one codebase, four different NUL policies, and every one of them called decode_utf16le. This crate ends that: the policy is in the function name, so choosing the wrong one is a decision rather than an accident.
use safe_decode::{
decode_utf16le_keep_nuls, decode_utf16le_trim_end_nuls, decode_utf16le_until_nul,
split_utf16le_on_nul,
};
// UTF-16LE 'A', NUL, 'B', NUL — the same bytes, four correct answers.
let bytes = b"A\0\0\0B\0\0\0";
assert_eq!(decode_utf16le_keep_nuls(bytes).text, "A\0B\0"); // NUL is a character
assert_eq!(decode_utf16le_until_nul(bytes).text, "A"); // NUL terminates
assert_eq!(decode_utf16le_trim_end_nuls(bytes).text, "A\0B"); // NUL is padding
let parts = split_utf16le_on_nul(bytes); // NUL separates
assert_eq!(parts.iter().map(|d| d.text.as_str()).collect::<Vec<_>>(), ["A", "B", ""]);[dependencies]
safe-decode = "0.1"Every decode returns a DecodedUtf16, not a bare String. text is always well-formed UTF-8, so a caller may ignore the rest — but the rest is what a forensic report needs, because "the field decoded" and "the field decoded faithfully" are different claims.
use safe_decode::decode_utf16le_keep_nuls;
let d = decode_utf16le_keep_nuls(&[0x41, 0x00, 0x00, 0xD8, 0x42]);
assert_eq!(d.text, "A\u{FFFD}");
assert_eq!(d.unpaired_surrogates, 1); // a lone high surrogate, replaced
assert!(d.dangling_byte); // odd length: a byte could not form a code unit
assert!(d.is_lossy());| Function | Behaviour |
|---|---|
rot13 |
Rotate ASCII letters by thirteen; everything else, including non-ASCII, unchanged. |
decode_utf16le_keep_nuls · decode_utf16be_keep_nuls |
Decode the whole slice; NUL code units become U+0000 characters. |
decode_utf16le_until_nul · decode_utf16be_until_nul |
Stop at the first NUL; discard it and everything after. |
decode_utf16le_trim_end_nuls · decode_utf16be_trim_end_nuls |
Decode all, then strip trailing NULs only; interior NULs kept. |
split_utf16le_on_nul · split_utf16be_on_nul |
Split on NUL and decode every segment, empty ones included. |
to_hex_lower · to_hex_upper |
Two hex digits per byte, no separator. |
- Fuzzed. A
cargo-fuzztarget per public function drives arbitrary byte slices; a panic is a build failure, andcargo fuzz checkruns in CI. - Panic-free by lint and by construction.
unwrap_used,expect_usedandindexing_slicingaredeny, andunsafe_codeisforbid. Code units come fromchunks_exactthrough a fallible array conversion rather than an index; truncation goes throughVec::truncaterather than a range slice; the hex digit is masked to a nibble. There is no construct in the crate that can panic on any input. - 100% line and function coverage, gated in CI.
- Zero dependencies,
#![no_std]plusalloc, MSRV 1.75 verified in CI — nothing here raises a consumer's floor.
A function belongs in this crate only if it is panic-free, allocating, format-agnostic, and free of domain knowledge. alloc is the line between this crate and safe-read, its no_std-without-allocator sibling for fixed-width integer reads.
Deliberately absent: anything that needs to know what the bytes mean. REG_MULTI_SZ's double-NUL terminator and MRUListEx's 0xFFFFFFFF sentinel are Windows registry conventions, not properties of an encoding, so they live with the crate that owns that knowledge. split_utf16le_on_nul gives that caller the structural half; the convention stays where the fact is. See docs/decisions/.
Privacy Policy · Terms of Service · © 2026 Security Ronin Ltd