363 lines
14 KiB
Python
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())
|