#!/usr/bin/env python3 """test_base32c.py -- executable evidence for base32c.py (Crockford Base32). Run: python3 test_base32c.py # exit 0 = every invariant holds, 1 = something broke Covers: spec table semantics, exhaustive + random round trips, cross-check between two independent encode/decode algorithms, alias/hyphen/min-length handling, invalid input rejection, check-symbol correctness, exhaustive single-symbol-substitution and adjacent-transposition detection, the documented leading-zero blind spot, and the module CLI end to end. """ from __future__ import annotations import os import random import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) if HERE not in sys.path: sys.path.insert(0, HERE) import base32c # noqa: E402 CASES = [] FAILURES = [] MEASURED = [] def case(fn): CASES.append(fn) return fn def note(line: str) -> None: MEASURED.append(line) def expect_error(exc_types, fn, *args, **kwargs): try: fn(*args, **kwargs) except exc_types: return except Exception as other: # wrong exception type is still a failure raise AssertionError(f"expected {exc_types}, got {type(other).__name__}: {other}") from other raise AssertionError(f"expected {exc_types}, but {fn.__name__}{args!r} returned normally") # --------------------------------------------------------------------------- spec table @case def case_alphabet_matches_spec(): assert base32c.ALPHABET == "0123456789ABCDEFGHJKMNPQRSTVWXYZ" assert len(base32c.ALPHABET) == 32 for excluded in "ILOU": assert excluded not in base32c.ALPHABET, excluded assert base32c.CHECK_ALPHABET == base32c.ALPHABET + "*~$=U" assert len(base32c.CHECK_ALPHABET) == 37 # values 32..36 are the check-only symbols, in spec order assert base32c.CHECK_ALPHABET[32:37] == "*~$=U" @case def case_positional_encoding_is_base32(): # Facts that follow directly from the spec's positional base-32 definition. assert base32c.encode(0) == "0" assert base32c.encode(1) == "1" assert base32c.encode(9) == "9" assert base32c.encode(10) == "A" assert base32c.encode(31) == "Z" assert base32c.encode(32) == "10" assert base32c.encode(33) == "11" assert base32c.encode(1023) == "ZZ" assert base32c.encode(1024) == "100" assert base32c.encode(31 * 32 + 23) == "ZQ" # 5 bits per symbol: length is ceil(bitlen/5), and leading symbol is significant for value in (1, 2, 15, 16, 31, 32, 1024, 2 ** 64, 2 ** 64 - 1): bits = value.bit_length() or 1 assert len(base32c.encode(value)) == -(-bits // 5), value @case def case_decode_aliases_and_hyphens(): assert base32c.decode("o") == 0 assert base32c.decode("O") == 0 assert base32c.decode("i") == 1 assert base32c.decode("l") == 1 assert base32c.decode("L") == 1 assert base32c.decode("zq") == base32c.decode("ZQ") == 31 * 32 + 23 assert base32c.decode("Z-Q") == base32c.decode("ZQ") assert base32c.decode("0-0-0-1") == 1 assert base32c.decode("-1-") == 1 assert base32c.decode("10") == 32 assert base32c.decode("00000000000000000001") == 1 @case def case_encode_rejects_bad_values(): expect_error(ValueError, base32c.encode, -1) expect_error(TypeError, base32c.encode, "10") expect_error(TypeError, base32c.encode, 1.5) expect_error(TypeError, base32c.encode, True) expect_error(ValueError, base32c.encode, 1, -1) expect_error(ValueError, base32c.encode, 1, 0, -1) expect_error(ValueError, base32c.check_symbol, -1) @case def case_decode_rejects_invalid_symbols(): for bad in ("", "-", "U", "u", "a b", "a\nb", "a!", "a=", "a*", "a~", "a$", "a,b", "Z Q"): expect_error(base32c.Base32Error, base32c.decode, bad) expect_error(TypeError, base32c.decode, 12345) # 'U' is legal only in the check position: as a payload symbol it is rejected, # and as a check symbol it is accepted only when it really is the right one. expect_error(base32c.InvalidSymbolError, base32c.decode, "1U") assert base32c.check_symbol(36) == "U" assert base32c.decode_checked("1" + base32c.check_symbol(1)) == 1 @case def case_roundtrip_exhaustive_small(): for value in range(0, 100_001): text = base32c.encode(value) assert base32c.decode(text) == value, value assert base32c.decode_positional(text) == value, value assert base32c.encode_divmod(value) == text, value @case def case_roundtrip_boundaries_and_random(): rng = random.Random(20261023) values = [0, 1] for k in range(0, 601): values += [2 ** k - 1, 2 ** k, 2 ** k + 1] values += [rng.getrandbits(rng.choice([1, 5, 17, 64, 128, 333, 512, 1024])) for _ in range(4000)] for value in values: text = base32c.encode(value) assert base32c.decode(text) == value, value assert base32c.decode_positional(text) == value, value assert base32c.encode_divmod(value) == text, value assert base32c.encode_divmod(value) == base32c.encode(value), value note(f"round-trip values checked: {len(values) + 100_001} (exhaustive 0..100000 + {len(values)} boundary/random)") @case def case_min_length_and_hyphenation(): assert base32c.encode(1, min_length=4) == "0001" assert base32c.encode(1, min_length=1) == "1" assert base32c.encode(2 ** 64, min_length=3) == base32c.encode(2 ** 64) for value in (0, 1, 31, 32, 12345, 2 ** 64 - 1, 2 ** 128 + 7): for min_length in (0, 1, 5, 13, 40): text = base32c.encode(value, min_length=min_length) assert len(text) >= min_length assert base32c.decode(text) == value for group in (1, 2, 4, 5, 8): text = base32c.encode(value, hyphenate=group) parts = text.split("-") if len(base32c.encode(value)) > group: assert len(parts) > 1, (value, group, text) assert all(len(p) == group for p in parts[:-1]), (value, group, text) assert 0 < len(parts[-1]) <= group, (value, group, text) assert text.replace("-", "") == base32c.encode(value) assert base32c.decode(text) == value # hyphenation with a payload shorter than the group size stays intact assert base32c.decode(base32c.encode(3, hyphenate=8)) == 3 assert base32c.encode(0, hyphenate=1) == "0" assert base32c.encode(0, hyphenate=4) == "0" assert base32c.encode(3, hyphenate=8) == "3" # --------------------------------------------------------------------------- check symbol @case def case_check_symbol_values(): assert base32c.check_symbol(0) == "0" assert base32c.check_symbol(31) == "Z" assert base32c.check_symbol(32) == "*" assert base32c.check_symbol(33) == "~" assert base32c.check_symbol(34) == "$" assert base32c.check_symbol(35) == "=" assert base32c.check_symbol(36) == "U" assert base32c.check_symbol(37) == "0" assert base32c.check_symbol(73) == "U" for value in (0, 1, 37, 38, 1234, 2 ** 70 + 5): assert base32c.check_symbol(value) == base32c.CHECK_ALPHABET[value % 37] @case def case_checked_roundtrip_and_rejection(): rng = random.Random(7) for value in [0, 1, 36, 37, 12345, 2 ** 64 - 1] + [rng.getrandbits(96) for _ in range(500)]: for kwargs in ({}, {"min_length": 9}, {"hyphenate": 4}): text = base32c.encode_checked(value, **kwargs) assert text.replace("-", "")[-1] == base32c.check_symbol(value) assert base32c.decode_checked(text) == value assert base32c.verify(text) is True expect_error(base32c.CheckSymbolError, base32c.decode_checked, "1") expect_error(base32c.CheckSymbolError, base32c.decode_checked, "-1-") expect_error(base32c.CheckSymbolError, base32c.decode_checked, "Z0") @case def case_check_symbol_mismatch_detected(): # 1 % 37 == 1, so the correct check symbol for payload '1' is '1'. assert base32c.encode_checked(1) == "11" expect_error(base32c.CheckSymbolError, base32c.decode_checked, "12") expect_error(base32c.CheckSymbolError, base32c.decode_checked, "1Z") expect_error(base32c.CheckSymbolError, base32c.decode_checked, "10") # alias forms of a correct check symbol still verify assert base32c.decode_checked("1I") == 1 assert base32c.decode_checked("1l") == 1 assert base32c.decode_checked("1-1") == 1 # check symbol for a multiple of 37 is '0', so 'Z0' -> payload Z = 31, 31 % 37 = 31 != 0 expect_error(base32c.CheckSymbolError, base32c.decode_checked, "Z0") assert base32c.verify("Z0") is False assert base32c.verify("nonsense!") is False @case def case_all_single_symbol_substitutions_detected(): rng = random.Random(99) values = [0, 1, 31, 32, 1234, 2 ** 32 - 1, 2 ** 64 + 7, 12345678901234567890, 2 ** 128 - 1] values += [rng.getrandbits(80) for _ in range(12)] total = detected = hard_total = hard_detected = 0 for value in values: checked = base32c.encode(value, min_length=8) + base32c.check_symbol(value) for pos in range(len(checked)): in_check = pos == len(checked) - 1 alternatives = base32c.CHECK_ALPHABET if in_check else base32c.ALPHABET for alt in alternatives: if alt == checked[pos]: continue mutated = checked[:pos] + alt + checked[pos + 1 :] total += 1 if alt in base32c.ALPHABET: hard_total += 1 if not base32c.verify(mutated): detected += 1 if alt in base32c.ALPHABET: hard_detected += 1 assert detected == total, f"{total - detected}/{total} single-symbol substitutions slipped through" assert hard_detected == hard_total, f"{hard_total - hard_detected}/{hard_total} valid-symbol substitutions slipped through" note(f"single-symbol substitutions: {detected}/{total} detected ({hard_detected}/{hard_total} with a valid payload symbol)") @case def case_all_adjacent_transpositions_detected(): values = [1, 31, 32, 1234, 2 ** 32 - 1, 2 ** 64 + 7, 12345678901234567890] total = detected = 0 for value in values: checked = base32c.encode(value, min_length=6) + base32c.check_symbol(value) for i in range(len(checked) - 1): if checked[i] == checked[i + 1]: continue # swapping equal symbols is not an edit mutated = checked[:i] + checked[i + 1] + checked[i] + checked[i + 2 :] total += 1 if not base32c.verify(mutated): detected += 1 assert total > 0 assert detected == total, f"{total - detected}/{total} adjacent transpositions slipped through" note(f"adjacent transpositions of distinct symbols: {detected}/{total} detected") @case def case_leading_zero_blind_spot_is_real(): # Documented limitation: a mod-37 check is value-based, so edits that preserve the # value are invisible. Assert the limitation instead of pretending it does not exist. value = 12345 checked = base32c.encode(value, min_length=8) + base32c.check_symbol(value) assert checked[0] == "0" assert base32c.verify(checked[1:]) is True # deleting the leading '0' is undetected assert base32c.verify("0" + checked) is True # inserting a leading '0' is undetected note("blind spot confirmed: leading-'0' deletion/insertion preserves value mod 37 and is undetected") @case def case_deletion_and_insertion_detection_measured(): rng = random.Random(5) payloads = [base32c.encode(rng.getrandbits(64), min_length=10) for _ in range(60)] del_total = del_hit = ins_total = ins_hit = 0 for payload in payloads: value = base32c.decode(payload) checked = payload + base32c.check_symbol(value) for i in range(len(checked)): del_total += 1 if not base32c.verify(checked[:i] + checked[i + 1 :]): del_hit += 1 for i in range(len(checked) + 1): for alt in "07Z": ins_total += 1 if not base32c.verify(checked[:i] + alt + checked[i:]): ins_hit += 1 assert del_hit / del_total > 0.5, del_hit / del_total assert ins_hit / ins_total > 0.5, ins_hit / ins_total note(f"single-symbol deletions: {del_hit}/{del_total} detected ({del_hit / del_total:.1%})") note(f"single-symbol insertions: {ins_hit}/{ins_total} detected ({ins_hit / ins_total:.1%})") # --------------------------------------------------------------------------- CLI @case def case_cli_end_to_end(): module = os.path.join(HERE, "base32c.py") py = sys.executable def run(*args): return subprocess.run([py, module, *args], capture_output=True, text=True, timeout=60) r = run("encode", "12345") assert r.returncode == 0 and r.stdout.strip() == base32c.encode(12345), r payload = r.stdout.strip() r = run("decode", payload) assert r.returncode == 0 and int(r.stdout.strip()) == 12345, r r = run("encode-checked", "12345") assert r.returncode == 0, r checked = r.stdout.strip() assert checked == base32c.encode_checked(12345) r = run("verify", checked) assert r.returncode == 0 and r.stdout.strip() == "OK 12345", r corrupt = checked[:-1] + ("Z" if checked[-1] != "Z" else "Y") r = run("verify", corrupt) assert r.returncode == 2 and r.stdout.strip() == "" and "INVALID" in r.stderr, r r = run("roundtrip", "98765432109876543210") assert r.returncode == 0 and "equal=True" in r.stdout, r r = run("decode", "U") assert r.returncode == 2 and "INVALID" in r.stderr, r note("CLI exercised: encode/decode/encode-checked/verify(ok,bad)/roundtrip all behaved") # --------------------------------------------------------------------------- runner def main() -> int: for fn in CASES: name = fn.__name__ try: fn() except Exception as exc: # noqa: BLE001 - the runner reports every failure FAILURES.append((name, exc)) print(f"FAIL {name}: {type(exc).__name__}: {exc}") else: print(f"PASS {name}") print() for line in MEASURED: print(f"MEASURED {line}") print() print(f"TESTS {len(CASES) - len(FAILURES)}/{len(CASES)} passed, {len(FAILURES)} failed") if FAILURES: print("FAILED CASES: " + ", ".join(name for name, _ in FAILURES)) return 1 print("ALL INVARIANTS HOLD") return 0 if __name__ == "__main__": raise SystemExit(main())