base64 and hex
rusty-js-basen is Cruft's base64, base64url, base32, and hex codec, the encoder and decoder behind btoa/atob, Buffer.from, and toString('hex'). One bit-regrouper serves all four, and its strict decoder rejects unknown characters, bad lengths, and non-canonical input rather than guessing.
base64 and hex are the workhorse codecs behind btoa/atob, Buffer.from(str, 'base64'), and buf.toString('hex'): a value that goes in comes back out, and garbage input is rejected rather than silently reinterpreted. Node leans on mature, fuzzed C++ and, on npm, on crates like base64 and data-encoding. Cruft does not use those. It regroups the bits itself, in a crate called rusty-js-basen.
Alpha, not production-ready.rusty-js-basen(Cruft 0.0.10) stands in for thebase64/data-encodingcrates behind base64, base64url, base32, and hex. It has not been externally audited and has near-zero interop testing. Do not feed it adversarial input in a setting where a wrong answer costs you.
One bit-regrouper serving four codecs
rusty-js-basen collapses base64, base32, and hex into a single operation: read the input as a stream of 8-bit cells, regroup into N-bit symbols, and project each symbol through an alphabet. Base64 is N=6, base32 is N=5, hex is N=4. That is the entire idea, and it is literally one encode/decode pair on an Encoding struct parameterized by symbol_bits, alphabet, padding, and case_insensitive_decode.
The named encodings are just const instances: BASE16_LOWER, BASE32, BASE64, BASE64URL, each with a public wrapper (encode_base64, decode_base64, encode_base64url(input, padding), decode_base16, and so on). There is no separate hex code path to audit against the base64 code path. That is the payoff of the collapse: one decoder to get right.
What the decoder enforces
The property that matters is that invalid input is refused, not silently truncated into plausible-looking bytes. The decoder enforces:
- Unknown bytes are rejected. The decoder builds a 256-entry lookup table, initializes it to
255, and any input byte whose slot is still255returnsInvalidByte(b). No unknown character is ever quietly skipped. - Bad lengths are rejected. A base16/base32/base64 stream of a length that could not have been produced by encoding is
InvalidLength, not a truncated guess.invalid_symbol_remainderencodes the exact impossible-remainder sets per N.invalid_lengths_are_rejectedchecks thatdecode_base16("f"),decode_base64("Z"),decode_base32("M")all returnInvalidLength. - Non-zero trailing bits are rejected. After decoding, leftover bits that are not zero return
NonZeroTrailingBits. This is the check that separates a strict decoder from a lax one: two different base64 strings cannot both decode to the same bytes, because the non-canonical one is refused rather than accepted. - Data after padding is rejected. Once a
=is seen, any further symbol returnsDataAfterPadding; and a=in aPadding::Noneencoding (hex) is itselfInvalidByte.
The RFC 4648 Section 10 vectors round-trip both directions for hex, base32, and base64 (rfc4648_section_10_encode_vectors, rfc4648_section_10_decode_vectors), case-insensitive decode for hex/base32 is covered, and base64url's -_ projection round-trips. The strict decoder behaviors above are real code paths, and the happy path is vector-checked.
Strict rejection can disagree with Node
Node's Buffer.from(str, 'base64') and browser atob are lenient: they skip characters they do not recognize, tolerate missing padding, and do not complain about trailing bits. rusty-js-basen's core decoder is a strict RFC 4648 decoder. That is a defensible, arguably safer choice, but it is a behavioral difference, and its interop is unmeasured: there is no test in this crate pinning rusty-js-basen's decode outcomes against Node's or against the base64 / data-encoding crates on the same lenient inputs. Those crates, plus Node's C++, have years of fuzzing and real-world corpus behind exactly these edge cases. This crate has the RFC vectors and a handful of negative tests. Do not assume byte-for-byte parity with whatever produced your input.
Memory safety and panics
There is no unsafe in this crate, and no unwrap/expect/panic!/todo! reachable from decode input. The decoder returns Result<Vec<u8>, BaseNError> for every rejection rather than panicking. The bit accumulator is a u32 and symbol_bits is bounded below 8 by validate, so the shifts cannot overflow the accumulator. This holds by inspection of the whole (small, single-file) source.
The encode-path panic that once lurked
There was one real robustness concern on the encode side. The public encode_* wrappers project a symbol index into self.alphabet, and an earlier version used a panic-shaped path that a future const-alphabet change could arm: memory-safe and unreachable from JS input, but a latent panic. It is now removed. Every public encoder ends in .unwrap_or_default(), so a would-be-invalid Encoding yields an empty String rather than a panic. The same sweep added compile-checked fuzz scaffolds for the decoder surfaces (basen_decoders among them).
The shape of that fix is worth noting: unwrap_or_default() converts a latent panic into a silent empty string on an internally-inconsistent encoding. For the shipped const encodings validate always passes, so this branch is unreachable; it is a defense against future refactors, not a behavior you should ever observe.
Limitations
- The strict decoder is real; interop parity is not measured. The rejection paths (
InvalidByte,InvalidLength,NonZeroTrailingBits,DataAfterPadding) are real code with negative-test coverage. Whether Cruft'satob/Buffer.fromsurface behaves like Node's lenient decoder on malformed input is untested and, given the strict core, probably differs. - Test coverage is thin. The RFC 4648 vectors, a case-projection test, a base64url test, and three invalid-length checks. That is the whole suite. There is no corpus, no differential test against the crates it replaces, and the fuzz scaffolds are scaffolds, not a fuzzing campaign with logged coverage.
- The maturity baseline wins on hardening.
base64anddata-encodingare widely deployed, fuzzed, and battle-tested across the ecosystem. This crate is a single small file at Cruft 0.0.10. It is easier to audit precisely because it is small, but small is not the same as proven. - Padding is per-encoding and partly implicit. Base64 is
Padding::Optional, hex isPadding::None, andencode_base64urlswaps betweenRequiredandNoneon itspaddingargument. If you need a specific padding contract with an interop partner, verify it against that partner, not against this doc.