use crate::character_wizard_domain::Character;
const PREFIX: &str = "cw1:";
const MAX_CODE_BYTES: usize = 256 * 1024 + PREFIX.len();
const MAX_JSON_BYTES: usize = 192 * 1024;
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
pub fn encode(character: &Character) -> Result<String, String> {
let source = serde_json::to_vec(character).map_err(|error| error.to_string())?;
if source.len() > MAX_JSON_BYTES {
return Err(format!(
"character JSON exceeds the {} KiB share limit",
MAX_JSON_BYTES / 1024
));
}
let mut output = String::with_capacity(PREFIX.len() + source.len().div_ceil(3) * 4);
output.push_str(PREFIX);
for chunk in source.chunks(3) {
let first = chunk[0];
let second = chunk.get(1).copied().unwrap_or(0);
let third = chunk.get(2).copied().unwrap_or(0);
output.push(char::from(ALPHABET[usize::from(first >> 2)]));
output.push(char::from(
ALPHABET[usize::from(((first & 0x03) << 4) | (second >> 4))],
));
if chunk.len() > 1 {
output.push(char::from(
ALPHABET[usize::from(((second & 0x0f) << 2) | (third >> 6))],
));
}
if chunk.len() > 2 {
output.push(char::from(ALPHABET[usize::from(third & 0x3f)]));
}
}
Ok(output)
}
pub fn decode(code: &str) -> Result<Character, String> {
let code = code.trim();
if code.len() > MAX_CODE_BYTES {
return Err(format!(
"share code exceeds the {} KiB input limit",
(MAX_CODE_BYTES - PREFIX.len()) / 1024
));
}
let payload = code.strip_prefix(PREFIX).ok_or_else(|| {
if code.starts_with("cw") {
"unsupported character share-code version".to_owned()
} else {
"character share code must start with cw1:".to_owned()
}
})?;
if payload.is_empty() {
return Err("character share code has an empty payload".to_owned());
}
let source = decode_base64url(payload)?;
if source.len() > MAX_JSON_BYTES {
return Err(format!(
"decoded character exceeds the {} KiB share limit",
MAX_JSON_BYTES / 1024
));
}
let source = std::str::from_utf8(&source)
.map_err(|_| "character share payload is not UTF-8 JSON".to_owned())?;
Character::from_json(source).map_err(|error| format!("invalid shared character: {error}"))
}
fn decode_base64url(payload: &str) -> Result<Vec<u8>, String> {
if payload.len() % 4 == 1 {
return Err("character share payload has an invalid length".to_owned());
}
let values = payload
.bytes()
.map(|byte| {
ALPHABET
.iter()
.position(|value| *value == byte)
.and_then(|value| u8::try_from(value).ok())
.ok_or_else(|| "character share payload is not valid base64url".to_owned())
})
.collect::<Result<Vec<_>, _>>()?;
let mut output = Vec::with_capacity(payload.len() / 4 * 3 + 2);
for chunk in values.chunks(4) {
output.push((chunk[0] << 2) | (chunk[1] >> 4));
if chunk.len() > 2 {
output.push((chunk[1] << 4) | (chunk[2] >> 2));
}
if chunk.len() > 3 {
output.push((chunk[2] << 6) | chunk[3]);
}
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::{MAX_CODE_BYTES, decode, encode};
use crate::character_wizard_domain::Character;
#[test]
fn share_code_round_trips_the_canonical_character() {
let character = Character::from_json(include_str!("../fixtures/complete-character.json"))
.expect("character fixture");
let code = encode(&character).expect("encode character");
assert!(code.starts_with("cw1:"));
assert!(!code.contains('='));
assert_eq!(decode(&code), Ok(character));
}
#[test]
fn rejects_untrusted_versions_alphabets_lengths_and_sizes() {
assert_eq!(
decode("cw2:AAAA").expect_err("unsupported version"),
"unsupported character share-code version"
);
assert_eq!(
decode("cw1:AA=A").expect_err("padding is forbidden"),
"character share payload is not valid base64url"
);
assert_eq!(
decode("cw1:A").expect_err("invalid payload length"),
"character share payload has an invalid length"
);
let oversized = format!("cw1:{}", "A".repeat(MAX_CODE_BYTES));
assert!(
decode(&oversized)
.expect_err("oversized")
.contains("input limit")
);
}
}