Files
troupe-standup/wizard-work/null/base32c/test_base32c.py
T

363 lines
14 KiB
Python

#!/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())