//! Base64, written here because nothing in the tree had it and two surfaces want it: nampower's //! `EncryptPassword`, whose argument name is literally `encryptedPasswordBase64String`, and //! ClassicAPI's `C_EncodingUtil.EncodeBase64`/`DecodeBase64`. //! //! Standard alphabet with `=` padding (RFC 4648 §4), which is what both of those mean by base64 — //! not the URL-safe variant, and not the unpadded form. Decoding is strict about length and //! alphabet but **tolerant of embedded whitespace**, because a blob that has been through a //! SavedVariables file, a chat line or a forum post has usually been wrapped. /// RFC 4648 §4's alphabet, in value order. const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; /// Encode `bytes`, padded to a multiple of four with `=`. pub fn base64_encode(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { // The three input bytes as one 24-bit group, missing bytes reading zero. let b = [ u32::from(chunk[0]), u32::from(chunk.get(1).copied().unwrap_or(0)), u32::from(chunk.get(2).copied().unwrap_or(0)), ]; let group = b[0] << 16 | b[1] << 8 | b[2]; for i in 0..4 { // A sextet that exists only because of a zero-filled byte is padding, not data. if i <= chunk.len() { out.push(char::from( ALPHABET[(group >> (18 - 6 * i) & 0x3f) as usize], )); } else { out.push('='); } } } out } /// One character's value, or `None` for anything outside the alphabet. fn value(c: u8) -> Option { let v = match c { b'A'..=b'Z' => c - b'A', b'a'..=b'z' => c - b'a' + 26, b'0'..=b'9' => c - b'0' + 52, b'+' => 62, b'/' => 63, _ => return None, }; Some(u32::from(v)) } /// Decode `text`, or `None` for anything that is not a well-formed padded base64 string. /// /// Whitespace anywhere is skipped first (see the module note). After that the body must be a /// multiple of four with at most two trailing `=`, every other character in the alphabet, and the /// unused low bits of a padded group zero — a non-zero tail means the string was truncated or /// re-encoded, and accepting it would silently drop data. pub fn base64_decode(text: &str) -> Option> { let body: Vec = text.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); if !body.len().is_multiple_of(4) { return None; } let pad = body.iter().rev().take_while(|&&b| b == b'=').count(); if pad > 2 { return None; } let data = &body[..body.len() - pad]; let mut out = Vec::with_capacity(data.len() / 4 * 3); for chunk in data.chunks(4) { let mut group = 0u32; for (i, &c) in chunk.iter().enumerate() { group |= value(c)? << (18 - 6 * i); } // One sextet alone encodes no whole byte: `AAAA=` style groups of 1 are malformed. let bytes = match chunk.len() { 4 => 3, 3 => 2, 2 => 1, _ => return None, }; for i in 0..bytes { out.push((group >> (16 - 8 * i)) as u8); } // The bits below the last whole byte must be zero in a canonical encoding. let used = 8 * bytes; if used < 24 && group & ((1 << (24 - used)) - 1) != 0 { return None; } } Some(out) } #[cfg(test)] mod tests { use super::*; /// RFC 4648 §10's own vectors, which is the point of using them: they pin the padding at /// every remainder, and padding is where a hand-written codec goes wrong. #[test] fn the_rfc_vectors_round_trip() { for (plain, encoded) in [ ("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy"), ] { assert_eq!(base64_encode(plain.as_bytes()), encoded, "encode {plain:?}"); assert_eq!( base64_decode(encoded).as_deref(), Some(plain.as_bytes()), "decode {encoded:?}" ); } } /// Every byte value, so the `+`/`/` end of the alphabet is exercised rather than assumed. #[test] fn all_256_bytes_round_trip() { let all: Vec = (0..=255u8).collect(); let text = base64_encode(&all); assert!(text.contains('+') && text.contains('/'), "got {text}"); assert_eq!(base64_decode(&text).as_deref(), Some(all.as_slice())); } /// Whitespace is skipped, because a blob that has been through a SavedVariables file or a /// forum post has usually been wrapped. #[test] fn wrapped_text_decodes_and_malformed_text_does_not() { assert_eq!( base64_decode("Zm9v\n YmFy").as_deref(), Some(&b"foobar"[..]) ); for bad in [ "Zm9vYmF", // not a multiple of four "Zm9v!mFy", // outside the alphabet "Zg===", // three pads "Zm9vYg=A", // data after the pad "Zh==", // a non-zero tail under the padding: truncated or re-encoded "Zm9vY===", // one data sextet in the group encodes no whole byte ] { assert!(base64_decode(bad).is_none(), "{bad:?} must not decode"); } } }