Skip to content

Commit

Permalink
improve test cases
Browse files Browse the repository at this point in the history
  • Loading branch information
grimerssy committed Jul 28, 2024
1 parent 9c54340 commit dae9e4c
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 68 deletions.
1 change: 1 addition & 0 deletions proptest-regressions/encoding.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 06029ae61fa48f6cbcb638cad0045e9af71868e00ea2143c4af250e886983872 # shrinks to s = "aaaaaaaaaaaaaaaaaaaaaah"
cc 4583eff0f4a78302e7f8caebf55ec13d00c8c03d49286b0610d9695d75736847 # shrinks to s = "2"
145 changes: 92 additions & 53 deletions src/encoding.rs
Original file line number Diff line number Diff line change
@@ -1,57 +1,56 @@
use core::iter;

use crate::Error;

const ENCODING_LENGTH: usize = MAX_UNPADDED_LEN + PADDING_SIZE_LEN;

const MAX_UNPADDED_LEN: usize = 22;

const PADDING_SIZE_LEN: usize = 1;

static ALPHABET: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

static RADIX: u128 = ALPHABET.len() as u128;

const TARGET_LENGTH: usize = 23;

pub fn encode(n: u128) -> [char; TARGET_LENGTH] {
let mut digits = <[char; TARGET_LENGTH]>::default();
pub fn encode(n: u128) -> [char; ENCODING_LENGTH] {
let mut digits = <[char; ENCODING_LENGTH]>::default();
let mut writer = digits.iter_mut();
let unpadded = digits_of(n)
.zip(writer.by_ref())
.map(|(digit, dest)| *dest = digit)
.map(|(digit, w)| *w = digit)
.count();
let padding_len = TARGET_LENGTH - unpadded;
digits_of(u128::MAX - n)
.take(padding_len - 1)
.chain([to_digit(padding_len as u128)])
gen_padding(n, ENCODING_LENGTH - unpadded)
.zip(writer)
.for_each(|(digit, dest)| *dest = digit);
.for_each(|(digit, w)| *w = digit);
digits
}

pub fn decode(digits: &str) -> crate::Result<u128> {
let padding = digits
.chars()
.next_back()
.ok_or(Error::NoPadding)
.and_then(parse_digit)
.and_then(|padding| match padding {
0 => Err(Error::NoPadding),
p => Ok(p as usize),
})?;
if digits.chars().count() != ENCODING_LENGTH {
return Err(Error::InvalidLength);
}
let mut digits = digits.chars();
digits.by_ref().rev().take(padding).last();
digits
.enumerate()
.map(|(i, digit)| {
let digit = parse_digit(digit)?;
RADIX
.checked_pow(i as u32)
.and_then(|value| value.checked_mul(digit))
.ok_or(Error::Overflow)
})
.try_fold(0, |acc, n| {
n.and_then(|n| n.checked_add(acc).ok_or(crate::Error::Overflow))
})
let padding_size = digits.by_ref().rev().take(PADDING_SIZE_LEN);
let padding_size = match parse_number(padding_size) {
Ok(0) | Err(_) => Err(Error::WrongPadding),
Ok(size) => Ok(size as usize),
}?;
let encoding_size = ENCODING_LENGTH
.checked_sub(padding_size)
.unwrap_or_default();
let n = parse_number(digits.by_ref().take(encoding_size))?;
let filler_padding = gen_padding(n, padding_size).take(padding_size - PADDING_SIZE_LEN);
if digits.eq(filler_padding) {
Ok(n)
} else {
Err(Error::WrongPadding)
}
}

fn digits_of(n: u128) -> impl Iterator<Item = char> {
core::iter::successors(Some(n), move |&prev| match prev / RADIX {
iter::successors(Some(n), move |&n| match n / RADIX {
0 => None,
next => Some(next),
n => Some(n),
})
.map(|x| x % RADIX)
.map(to_digit)
Expand All @@ -61,6 +60,40 @@ fn to_digit(digit: u128) -> char {
ALPHABET.as_bytes()[digit as usize] as char
}

fn gen_padding(seed: u128, size: usize) -> impl Iterator<Item = char> {
iter::successors(Some(seed.max(u128::MAX - seed)), |n| Some(n ^ (n << 1)))
.flat_map(digits_of)
.take(size - PADDING_SIZE_LEN)
.chain(encode_padding_size(size))
}

fn encode_padding_size(size: usize) -> [char; PADDING_SIZE_LEN] {
let mut digits = [to_digit(0); PADDING_SIZE_LEN];
let encoded_size = || digits_of(size as u128);
let leading_zeroes = PADDING_SIZE_LEN - encoded_size().count();
digits
.iter_mut()
.skip(leading_zeroes)
.zip(encoded_size())
.for_each(|(w, digit)| *w = digit);
digits
}

fn parse_number(digits: impl Iterator<Item = char>) -> crate::Result<u128> {
digits
.enumerate()
.map(|(i, digit)| {
let digit = parse_digit(digit)?;
RADIX
.checked_pow(i as u32)
.and_then(|value| value.checked_mul(digit))
.ok_or(Error::Overflow)
})
.try_fold(0, |acc, n| {
n.and_then(|n| n.checked_add(acc).ok_or(Error::Overflow))
})
}

fn parse_digit(digit: char) -> crate::Result<u128> {
ALPHABET
.chars()
Expand All @@ -76,44 +109,50 @@ mod tests {
use super::*;

#[test]
fn alphabet_typos() {
fn length_assumptions_are_met() {
assert_eq!(MAX_UNPADDED_LEN, digits_of(u128::MAX).count());
let max_padding_size = ENCODING_LENGTH - digits_of(u128::MIN).count();
assert_eq!(
PADDING_SIZE_LEN,
digits_of(max_padding_size as u128).count()
);
}

#[test]
fn alphabet_is_base58() {
let blacklist = ['I', 'O', 'l', '0'];
let base58 = ('0'..='9')
.chain('A'..='Z')
.chain('a'..='z')
.filter(|c| !blacklist.contains(c))
.collect::<String>();
assert_eq!(ALPHABET, &base58, "should contain valid base58 charset");
}

#[test]
fn check_target_length() {
assert_eq!(TARGET_LENGTH, digits_of(u128::MAX).count() + 1);
assert_eq!(ALPHABET, &base58);
}

#[property_test]
fn encode_decode_identity(n: u128) {
fn encode_is_deterministic(n: u128) {
let encoded = encode_str(n);
let decoded = decode(&encoded);
prop_assert!(decoded.is_ok());
prop_assert_eq!(n, decoded.unwrap());
}

#[property_test]
fn decode_unsanitized(s: String) {
decode(&s).ok();
}

#[test]
fn decode_sanitized() {
let sanitized_string = prop::collection::vec(0..RADIX, SizeRange::default())
.prop_map(|digits| digits.into_iter().map(to_digit).collect::<String>());
proptest!(|(s in sanitized_string)| {
decode(&s).ok();
});
fn decode_is_partial() {
let check = |s: &str| {
let decoded = decode(s);
decoded.is_err() || decoded.is_ok_and(|ok| encode_str(ok) == s)
};
proptest!(|(s in any::<String>())| prop_assert!(check(&s)));
proptest!(|(s in sanitized_string())| prop_assert!(check(&s)));
}

fn encode_str(n: u128) -> String {
encode(n).into_iter().collect()
}

fn sanitized_string() -> impl Strategy<Value = String> {
prop::collection::vec(0..RADIX, SizeRange::default())
.prop_map(|digits| digits.into_iter().map(to_digit).collect())
}
}
37 changes: 24 additions & 13 deletions src/encryption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,30 @@ use aes::{

pub fn encrypt(tag: u64, id: i64, cipher: &Aes128) -> u128 {
let tagged = concat(tag, id);
let mut bytes = tagged.into();
let mut bytes = tagged.to_le_bytes().into();
cipher.encrypt_block(&mut bytes);
u128::from_le_bytes(bytes.into())
}

pub fn decrypt(expected_tag: u64, id: u128, cipher: &Aes128) -> crate::Result<i64> {
let mut bytes = id.to_le_bytes().into();
cipher.decrypt_block(&mut bytes);
match bisect(bytes.into()) {
let tagged = u128::from_le_bytes(bytes.into());
match bisect(tagged) {
(tag, id) if tag == expected_tag => Ok(id),
_ => Err(crate::Error::WrongTag),
}
}

fn concat(tag: u64, id: i64) -> [u8; 16] {
fn concat(tag: u64, id: i64) -> u128 {
let tag = (tag as u128).reverse_bits();
let id = u64::from_le_bytes(id.to_le_bytes()) as u128;
(tag | id).to_le_bytes()
tag | id
}

fn bisect(bytes: [u8; 16]) -> (u64, i64) {
fn bisect(tagged: u128) -> (u64, i64) {
const HIGH_BITS: u128 = !0 << (u128::BITS / 2);
const LOW_BITS: u128 = !0 >> (u128::BITS / 2);
let tagged = u128::from_le_bytes(bytes);
let tag = (tagged & HIGH_BITS).reverse_bits() as u64;
let id = (tagged & LOW_BITS) as u64;
let id = i64::from_le_bytes(id.to_le_bytes());
Expand All @@ -43,19 +43,30 @@ mod tests {
use super::*;

#[property_test]
fn concat_bisect_identity(tag: u64, id: i64) {
fn concat_is_deterministic(tag: u64, id: i64) {
let tagged_bytes = concat(tag, id);
let (extracted_tag, extracted_id) = bisect(tagged_bytes);
prop_assert_eq!(tag, extracted_tag);
prop_assert_eq!(id, extracted_id);
prop_assert_eq!((tag, id), bisect(tagged_bytes));
}

#[property_test]
fn encrypt_decrypt_identity(tag: u64, id: i64, key: [u8; 16]) {
fn bisect_is_deterministic(tagged: u128) {
let (tag, id) = bisect(tagged);
prop_assert_eq!(tagged, concat(tag, id));
}

#[property_test]
fn encrypt_is_deterministic(tag: u64, id: i64, key: [u8; 16]) {
let cipher = Aes128::new(&key.into());
let encrypted = encrypt(tag, id, &cipher);
let decrypted = decrypt(tag, encrypted, &cipher);
prop_assert!(decrypted.is_ok());
prop_assert_eq!(decrypted.unwrap(), id);
prop_assert!(decrypted.is_ok_and(|ok| ok == id));
}

#[property_test]
fn decrypt_is_partial(tag: u64, id: u128, key: [u8; 16]) {
let cipher = Aes128::new(&key.into());
let decrypted = decrypt(tag, id, &cipher);
let encrypted = decrypted.map(|id| encrypt(tag, id, &cipher));
prop_assert!(encrypted.is_err() || encrypted.is_ok_and(|ok| ok == id));
}
}
6 changes: 4 additions & 2 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ pub enum Error {
WrongTag,
UnknownCharacter,
Overflow,
NoPadding,
WrongPadding,
InvalidLength,
}

impl fmt::Display for Error {
Expand All @@ -15,7 +16,8 @@ impl fmt::Display for Error {
Self::WrongTag => f.write_str("id has an invalid tag"),
Self::UnknownCharacter => f.write_str("id contains invalid characters"),
Self::Overflow => f.write_str("id is too long"),
Self::NoPadding => f.write_str("id is in an unexpected format"),
Self::WrongPadding => f.write_str("encoding padding is wrong"),
Self::InvalidLength => f.write_str("id is of invalid length"),
}
}
}
Expand Down

0 comments on commit dae9e4c

Please sign in to comment.