"""base32c.py -- Crockford Base32 codec with optional check symbol. Stdlib only. Spec (authoritative): https://www.crockford.com/base32.html Douglas Crockford, 2019-03-04. Fetched 2026-10-23. Quoted from the spec, verbatim: "The Base 32 symbol set is a superset of the Base 16 symbol set. We chose a symbol set of 10 digits and 22 letters. We exclude 4 of the 26 letters: I L O U." I Can be confused with 1 L Can be confused with 1 O Can be confused with 0 U Accidental obscenity "When decoding, upper and lower case letters are accepted, and i and l will be treated as 1 and o will be treated as 0. When encoding, only upper case letters are used." "If the bit-length of the number to be encoded is not a multiple of 5 bits, then zero-extend the number to make its bit-length a multiple of 5." "Hyphens (-) can be inserted into symbol strings. This can partition a string into manageable pieces, improving readability by helping to prevent confusion. Hyphens are ignored during decoding." Check symbols, values 32..36: '*' '~' '$' '=' 'U' "The check symbol encodes the number modulo 37, 37 being the least prime number greater than 32." Design notes (why the check symbol has the detection powers the test suite claims): Let the payload encode value V with symbols d_0..d_{k-1} (d_0 most significant), and let the trailing check symbol be C = V mod 37. * Single-symbol substitution. Replacing the symbol at distance j from the right (weight 32**j) by a different canonical symbol changes the value by d * 32**j with 1 <= |d| <= 31. Modulo 37: 32 == -5 is a unit (37 prime, 37 does not divide 32**j) and |d| < 37, so the change is never 0 mod 37. => every single-symbol substitution in a canonically-encoded string is caught. * Adjacent transposition. Swapping neighbours of weights 32**(j+1), 32**j changes the value by (a-b) * 32**j * 31, with a != b, |a-b| <= 31. Neither 32**j, 31, nor (a-b) is 0 mod 37. => every adjacent transposition of two distinct symbols is caught. * Known blind spot (value-based checks are blind to leading zeros). Deleting a leading '0', or inserting '0' at the front, leaves the value -- and therefore the check symbol -- unchanged. Such edits are NOT detected. The test suite asserts this limitation rather than hiding it. """ from __future__ import annotations import argparse import sys __all__ = [ "ALPHABET", "CHECK_ALPHABET", "Base32Error", "InvalidSymbolError", "CheckSymbolError", "encode", "encode_divmod", "decode", "decode_positional", "check_symbol", "encode_checked", "decode_checked", "verify", ] # 32 canonical payload symbols: 0-9 then the 22 letters, I L O U excluded. ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" assert len(ALPHABET) == 32 assert set("ILOU").isdisjoint(ALPHABET) # 37-symbol check alphabet: the payload alphabet plus the 5 check-only symbols. CHECK_ALPHABET = ALPHABET + "*~$=U" assert len(CHECK_ALPHABET) == 37 _SYMBOL_VALUE = {c: i for i, c in enumerate(ALPHABET)} # Decode-only aliases allowed by the spec: i,l -> 1 ; o -> 0 (case folded). for _alias, _target in (("I", "1"), ("L", "1"), ("O", "0")): _SYMBOL_VALUE[_alias] = _SYMBOL_VALUE[_target] _CHECK_VALUE = {c: i for i, c in enumerate(CHECK_ALPHABET)} for _alias, _target in (("I", "1"), ("L", "1"), ("O", "0"), ("U", "U")): _CHECK_VALUE[_alias] = _CHECK_VALUE[_target] class Base32Error(ValueError): """Base class for every rejection this module raises.""" class InvalidSymbolError(Base32Error): """A character is not a legal Crockford Base32 symbol in this position.""" class CheckSymbolError(Base32Error): """The trailing check symbol does not match the payload's value mod 37.""" def _symbol_value(ch: str) -> int: """Value 0..31 of a payload symbol. 'U' is check-only and is rejected here.""" c = ch.upper() v = _SYMBOL_VALUE.get(c) if v is None: raise InvalidSymbolError( f"{ch!r} is not a payload symbol (I L O U are excluded; U is check-only)" ) return v def _check_value(ch: str) -> int: """Value 0..36 of a check symbol (accepts '*~$=U' and the i/l/o aliases).""" v = _CHECK_VALUE.get(ch.upper()) if v is None: raise InvalidSymbolError(f"{ch!r} is not a check symbol") return v def encode(value: int, min_length: int = 0, hyphenate: int = 0) -> str: """Encode a non-negative int. Zero-extends to a multiple of 5 bits. min_length: left-pad with '0' to at least this many symbols. hyphenate: if > 0, group the payload in runs of that many symbols joined by '-'. """ if not isinstance(value, int) or isinstance(value, bool): raise TypeError("value must be an int") if value < 0: raise ValueError("Crockford Base32 encodes non-negative integers only") if min_length < 0: raise ValueError("min_length must be >= 0") if hyphenate < 0: raise ValueError("hyphenate must be >= 0") bits = bin(value)[2:] bits = "0" * (-len(bits) % 5) + bits # zero-extend to a multiple of 5 bits out = "".join(ALPHABET[int(bits[i : i + 5], 2)] for i in range(0, len(bits), 5)) if len(out) < min_length: out = "0" * (min_length - len(out)) + out if hyphenate: out = "-".join( out[i : i + hyphenate] for i in range(0, len(out), hyphenate) ) # last group keeps 1..hyphenate symbols; a single symbol gets no hyphen return out def encode_divmod(value: int) -> str: """Independent encoder: repeated division by 32 instead of 5-bit slicing. Kept in the module so the test suite can cross-check two unrelated algorithms. """ if not isinstance(value, int) or isinstance(value, bool): raise TypeError("value must be an int") if value < 0: raise ValueError("Crockford Base32 encodes non-negative integers only") if value == 0: return "0" digits = [] while value: value, r = divmod(value, 32) digits.append(ALPHABET[r]) return "".join(reversed(digits)) def decode(text: str) -> int: """Decode a symbol string (hyphens ignored, case folded, i/l/o aliases honoured).""" if not isinstance(text, str): raise TypeError("text must be a str") body = text.replace("-", "") if not body: raise InvalidSymbolError("empty symbol string") value = 0 for ch in body: # Horner value = value * 32 + _symbol_value(ch) return value def decode_positional(text: str) -> int: """Independent decoder: explicit positional weights instead of Horner.""" if not isinstance(text, str): raise TypeError("text must be a str") body = text.replace("-", "") if not body: raise InvalidSymbolError("empty symbol string") n = len(body) return sum(_symbol_value(ch) * (32 ** (n - 1 - i)) for i, ch in enumerate(body)) def check_symbol(value: int) -> str: """The check symbol for a value: CHECK_ALPHABET[value % 37].""" if not isinstance(value, int) or isinstance(value, bool): raise TypeError("value must be an int") if value < 0: raise ValueError("check symbol is defined for non-negative integers") return CHECK_ALPHABET[value % 37] def encode_checked(value: int, min_length: int = 0, hyphenate: int = 0) -> str: """Payload plus trailing check symbol. Hyphenation applies to the payload only.""" return encode(value, min_length=min_length, hyphenate=hyphenate) + check_symbol(value) def decode_checked(text: str) -> int: """Decode payload+check symbol; raise CheckSymbolError if the check fails.""" if not isinstance(text, str): raise TypeError("text must be a str") body = text.replace("-", "") if len(body) < 2: raise CheckSymbolError("need at least one payload symbol plus a check symbol") payload, chk = body[:-1], body[-1] value = decode(payload) got = _check_value(chk) if got != value % 37: raise CheckSymbolError( f"check symbol {chk!r} (= {got}) does not match value mod 37 (= {value % 37})" ) return value def verify(text: str) -> bool: """True iff text carries a valid check symbol.""" try: decode_checked(text) except Base32Error: return False return True def _main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(prog="base32c", description="Crockford Base32 codec") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("encode") p.add_argument("value", type=int) p.add_argument("--min-length", type=int, default=0) p.add_argument("--hyphenate", type=int, default=0) p = sub.add_parser("decode") p.add_argument("text") p = sub.add_parser("encode-checked") p.add_argument("value", type=int) p.add_argument("--min-length", type=int, default=0) p.add_argument("--hyphenate", type=int, default=0) p = sub.add_parser("verify") p.add_argument("text") p = sub.add_parser("roundtrip") p.add_argument("value", type=int) args = ap.parse_args(argv) try: if args.cmd == "encode": print(encode(args.value, args.min_length, args.hyphenate)) elif args.cmd == "decode": print(decode(args.text)) elif args.cmd == "encode-checked": print(encode_checked(args.value, args.min_length, args.hyphenate)) elif args.cmd == "verify": value = decode_checked(args.text) print(f"OK {value}") elif args.cmd == "roundtrip": payload = encode(args.value) checked = encode_checked(args.value) back = decode_checked(checked) print(f"payload={payload} checked={checked} decoded={back} equal={back == args.value}") if back != args.value: return 1 except Base32Error as exc: print(f"INVALID: {exc}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(_main())