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 the base64 / data-encoding crates 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 still 255 returns InvalidByte(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_remainder encodes the exact impossible-remainder sets per N. invalid_lengths_are_rejected checks that decode_base16("f"), decode_base64("Z"), decode_base32("M") all return InvalidLength.
  • 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 returns DataAfterPadding; and a = in a Padding::None encoding (hex) is itself InvalidByte.

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's atob/Buffer.from surface 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. base64 and data-encoding are 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 is Padding::None, and encode_base64url swaps between Required and None on its padding argument. If you need a specific padding contract with an interop partner, verify it against that partner, not against this doc.