null: Crockford Base32 codec w/ mod-37 check symbol (16/16 cases, 6/6 mutants killed, 23514-value agreement vs PyPI oracle)
This commit is contained in:
1 parent
c729afef3a
commit
b1b9d1353c
9 files changed
+1094
No files matched your search
@@ -0,0 +1,72 @@
|
||||
# base32c — Crockford Base32 with a check symbol (stdlib-only)
|
||||
|
||||
Lane: Codeforge (null). Built 2026-10-23.
|
||||
|
||||
Spec: https://www.crockford.com/base32.html (Douglas Crockford, 2019-03-04) — the
|
||||
authoritative table was fetched during this build; the quoted rules live in the
|
||||
`base32c.py` docstring (symbol set, I/L/O/U exclusions, i/l→1 and o→0 decode aliases,
|
||||
zero-extension to a multiple of 5 bits, hyphens ignored on decode, check symbol =
|
||||
value mod 37 over the 37-symbol alphabet `0-9 A-Z * ~ $ = U`).
|
||||
|
||||
## Files
|
||||
|
||||
base32c.py codec + argparse CLI (encode, decode, encode-checked, verify, roundtrip)
|
||||
test_base32c.py 16-case evidence suite; exit 0 = all invariants hold, 1 = broken
|
||||
mutation_check.py injects 6 faults into a scratch copy; exit 0 = every fault caught
|
||||
crosscheck_oracle.py agreement run vs the independent PyPI package base32-crockford 0.3.0
|
||||
demo_ids.py real-data demo: SHA-256 -> 128-bit hyphenated public ID + typo sweep
|
||||
base32c-selftest.txt captured run of test_base32c.py
|
||||
base32c-oracle.txt captured run of crosscheck_oracle.py
|
||||
base32c-demo.txt captured run of demo_ids.py
|
||||
|
||||
## Reproduce (each command's own exit code is the verdict)
|
||||
|
||||
python3 test_base32c.py
|
||||
python3 mutation_check.py
|
||||
python3 demo_ids.py
|
||||
/root/.hermes/profiles/null/cache/scratch/b32venv/bin/python3.14 crosscheck_oracle.py
|
||||
|
||||
## Measured results (this build, this machine)
|
||||
|
||||
test_base32c.py 16/16 cases pass, exit 0 [measured]
|
||||
round trips 105,806 values (exhaustive 0..100,000 plus 2**k-1, 2**k, 2**k+1
|
||||
for k=0..600 and 4,000 seeded randoms up to 1024 bits), each
|
||||
checked by three independent decoders/encoders [measured]
|
||||
mutation_check.py 6/6 injected faults killed, control copy passes, exit 0 [measured]
|
||||
crosscheck_oracle.py 23,514 values agree with PyPI base32-crockford 0.3.0 on payload,
|
||||
checked payload, check symbol, hyphenated decode and alias
|
||||
handling; 18/18 junk rejections matched; 10/10 corrupted
|
||||
check symbols rejected by both [measured]
|
||||
single-symbol typos 9,808/9,808 rejected, of which 9,706/9,706 use a legal payload
|
||||
symbol (the interesting case) [measured]
|
||||
adjacent transpositions 27/27 rejected (all distinct-symbol adjacent swaps) [measured]
|
||||
deletions 816/837 rejected (97.5%) [measured]
|
||||
insertions 2,568/2,691 rejected (95.4%) [measured]
|
||||
demo_ids.py GPL-3 sha256 -> id 1SEB-E9EH-7P96-FGZ6-SDQX-V6JV-SAE,
|
||||
round-trip OK, 842/842 typos rejected, exit 0 [measured]
|
||||
|
||||
Why substitution and adjacent-transposition detection are 100% (not a coincidence):
|
||||
substituting the symbol at weight 32**j by a different canonical digit changes the value
|
||||
by d*32**j, 1 <= |d| <= 31; 37 is prime and 32**j is a unit, and |d| < 37, so the change is
|
||||
never 0 mod 37. Adjacent swaps change the value by (a-b)*32**j*31 with a != b, |a-b| <= 31 —
|
||||
also never 0 mod 37. The suite asserts both exhaustively rather than asserting the algebra.
|
||||
|
||||
## Limitations — stated, not hidden
|
||||
|
||||
* The check symbol is value-based, so edits that preserve the value are invisible:
|
||||
deleting a leading `0`, or inserting `0` in front, still verifies. The suite asserts
|
||||
this blind spot on purpose (`case_leading_zero_blind_spot_is_real`).
|
||||
* Single deletions/insertions elsewhere are ~95–98% caught, not 100%; measured above.
|
||||
* `min_length` pads with `0`, which is exactly what creates the leading-zero blind spot —
|
||||
use it for display widths, not for extra integrity.
|
||||
* Behavioural divergence from the PyPI oracle: its `decode(checksum=False)` treats a
|
||||
trailing check-only symbol (`* ~ $ = U`) as a payload digit, so `a=` decodes to a number.
|
||||
`base32c.decode` refuses check-only symbols in payload positions. In validating mode
|
||||
(`decode_checked` / `oracle.decode(..., checksum=True)`) both reject. Not a bug on either
|
||||
side — an ambiguity in the lax mode, recorded in `crosscheck_oracle.py`.
|
||||
|
||||
## Verdict
|
||||
|
||||
VERDICT: Crockford Base32 codec ships with 16/16 invariant cases, 6/6 mutants killed, and
|
||||
23,514-value agreement with an independent PyPI implementation; the one real limitation
|
||||
(leading-zero edits are invisible to a mod-37 check) is asserted in the suite, not hidden.
|
||||
@@ -0,0 +1,15 @@
|
||||
file /usr/share/common-licenses/GPL-3
|
||||
sha256 3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986
|
||||
id value 76362391716521242212964487524419989290
|
||||
public id 1SEB-E9EH-7P96-FGZ6-SDQX-V6JV-SAE
|
||||
round-trip OK (decode_checked == value)
|
||||
typo sweep 842/842 single-symbol typos rejected (100.0%)
|
||||
|
||||
file /root/.hermes/wizard-outputs/null/base32c.py
|
||||
sha256 73499ccf816cf82e849d62eb52ddb1969a636217ee43e380be2afdb152eafc90
|
||||
id value 153243437682121480174590331425285583254
|
||||
public id 3K96-ECZ0-BCZ0-Q897-B2XD-9DVC-CPP
|
||||
round-trip OK (decode_checked == value)
|
||||
typo sweep 842/842 single-symbol typos rejected (100.0%)
|
||||
|
||||
DEMO OK
|
||||
@@ -0,0 +1,10 @@
|
||||
values compared: 23514
|
||||
payload encodings equal: 23514
|
||||
checked encodings equal: 23514
|
||||
check symbols equal: 23514
|
||||
hyphenated decode equal: 23514
|
||||
alias/lowercase equal: 23514
|
||||
junk rejections matched: 18/18 (never-legal strings x 2 implementations)
|
||||
documented divergence: 5/5 (oracle lax-accepts payload+check-only symbol; base32c is strict, both agree in validating mode)
|
||||
corrupt check rejected: 10/10 (5 values x 2 implementations)
|
||||
ORACLE AGREEMENT: full (on every non-divergent behaviour)
|
||||
@@ -0,0 +1,27 @@
|
||||
PASS case_alphabet_matches_spec
|
||||
PASS case_positional_encoding_is_base32
|
||||
PASS case_decode_aliases_and_hyphens
|
||||
PASS case_encode_rejects_bad_values
|
||||
PASS case_decode_rejects_invalid_symbols
|
||||
PASS case_roundtrip_exhaustive_small
|
||||
PASS case_roundtrip_boundaries_and_random
|
||||
PASS case_min_length_and_hyphenation
|
||||
PASS case_check_symbol_values
|
||||
PASS case_checked_roundtrip_and_rejection
|
||||
PASS case_check_symbol_mismatch_detected
|
||||
PASS case_all_single_symbol_substitutions_detected
|
||||
PASS case_all_adjacent_transpositions_detected
|
||||
PASS case_leading_zero_blind_spot_is_real
|
||||
PASS case_deletion_and_insertion_detection_measured
|
||||
PASS case_cli_end_to_end
|
||||
|
||||
MEASURED round-trip values checked: 105806 (exhaustive 0..100000 + 5805 boundary/random)
|
||||
MEASURED single-symbol substitutions: 9808/9808 detected (9706/9706 with a valid payload symbol)
|
||||
MEASURED adjacent transpositions of distinct symbols: 27/27 detected
|
||||
MEASURED blind spot confirmed: leading-'0' deletion/insertion preserves value mod 37 and is undetected
|
||||
MEASURED single-symbol deletions: 816/837 detected (97.5%)
|
||||
MEASURED single-symbol insertions: 2568/2691 detected (95.4%)
|
||||
MEASURED CLI exercised: encode/decode/encode-checked/verify(ok,bad)/roundtrip all behaved
|
||||
|
||||
TESTS 16/16 passed, 0 failed
|
||||
ALL INVARIANTS HOLD
|
||||
@@ -0,0 +1,280 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""crosscheck_oracle.py -- my Crockford Base32 vs an independent third-party one.
|
||||
|
||||
Oracle: PyPI package `base32-crockford` 0.3.0 (site-packages/base32_crockford.py),
|
||||
installed in a scratch venv, not written by me. If my alphabet, positional weight or
|
||||
mod-37 check-symbol ordering were wrong, the two implementations would disagree.
|
||||
|
||||
The main suite (test_base32c.py) deliberately stays dependency-free; this script is the
|
||||
one place that reaches for an outside implementation, so run it with the venv python:
|
||||
|
||||
/root/.hermes/profiles/null/cache/scratch/b32venv/bin/python3.14 crosscheck_oracle.py
|
||||
|
||||
Exit 0 = full agreement, 1 = disagreement (or oracle missing).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
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
|
||||
|
||||
try:
|
||||
import base32_crockford as oracle # type: ignore
|
||||
except ImportError:
|
||||
print("ORACLE MISSING: base32_crockford not importable (run with the scratch venv python)")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rng = random.Random(4242)
|
||||
values = list(range(0, 20_001))
|
||||
values += [2 ** k - 1 for k in range(1, 257)]
|
||||
values += [2 ** k for k in range(0, 257)]
|
||||
values += [rng.getrandbits(rng.choice([8, 32, 64, 128, 256, 512])) for _ in range(3000)]
|
||||
|
||||
payload_checks = checked_checks = check_symbol_checks = hyphen_checks = alias_checks = 0
|
||||
for value in values:
|
||||
mine = base32c.encode(value)
|
||||
theirs = oracle.encode(value)
|
||||
assert mine == theirs, f"payload mismatch for {value}: {mine!r} != {theirs!r}"
|
||||
payload_checks += 1
|
||||
|
||||
mine_checked = base32c.encode_checked(value)
|
||||
theirs_checked = oracle.encode(value, checksum=True)
|
||||
assert mine_checked == theirs_checked, f"checked mismatch for {value}: {mine_checked!r} != {theirs_checked!r}"
|
||||
checked_checks += 1
|
||||
|
||||
assert base32c.check_symbol(value) == theirs_checked[-1], value
|
||||
check_symbol_checks += 1
|
||||
|
||||
assert oracle.decode(mine) == value, value
|
||||
assert oracle.decode(mine_checked, checksum=True) == value, value
|
||||
assert base32c.decode(theirs) == value, value
|
||||
assert base32c.decode_checked(theirs_checked) == value, value
|
||||
|
||||
hyphenated = base32c.encode(value, hyphenate=4)
|
||||
assert oracle.decode(hyphenated) == value, value
|
||||
hyphen_checks += 1
|
||||
|
||||
lowered = mine.lower().replace("l", "1").replace("i", "1").replace("o", "0")
|
||||
assert base32c.decode(lowered) == value == oracle.decode(lowered), value
|
||||
alias_checks += 1
|
||||
|
||||
# Both implementations must reject the same kinds of junk.
|
||||
never_legal = ("", "-", "U", "u", "A U", "a b", "a!", "\n", "Z Q")
|
||||
rejections = 0
|
||||
for junk in never_legal:
|
||||
for module in (base32c, oracle):
|
||||
try:
|
||||
module.decode(junk)
|
||||
except (ValueError, TypeError):
|
||||
rejections += 1
|
||||
else:
|
||||
raise AssertionError(f"{module.__name__} accepted junk {junk!r}")
|
||||
assert rejections == 2 * len(never_legal), rejections
|
||||
|
||||
# Documented divergence: the oracle's lax decode(checksum=False) treats a trailing
|
||||
# check-only symbol as a payload digit, so 'a=' decodes to a number. Mine refuses
|
||||
# '*~$=U' anywhere in a payload. In the validating mode both agree and reject.
|
||||
divergences = 0
|
||||
for tail in "*~$=U":
|
||||
lax = f"a{tail}"
|
||||
assert oracle.decode(lax) >= 0 # oracle: accepted, lax
|
||||
try:
|
||||
base32c.decode(lax)
|
||||
except base32c.InvalidSymbolError:
|
||||
divergences += 1
|
||||
else:
|
||||
raise AssertionError(f"base32c accepted {lax!r} as a payload")
|
||||
for name, call in (
|
||||
("base32c.decode_checked", lambda: base32c.decode_checked(lax)),
|
||||
("oracle.decode(checksum=True)", lambda: oracle.decode(lax, checksum=True)),
|
||||
):
|
||||
try:
|
||||
call()
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"{name} accepted {lax!r}")
|
||||
assert divergences == 5, divergences
|
||||
|
||||
# A corrupted check symbol must be rejected by both.
|
||||
corrupt_rejections = 0
|
||||
for value in (0, 1, 37, 1234, 2 ** 64 - 1):
|
||||
good = base32c.encode_checked(value)
|
||||
bad = good[:-1] + ("Z" if good[-1] != "Z" else "Y")
|
||||
try:
|
||||
base32c.decode_checked(bad)
|
||||
except base32c.Base32Error:
|
||||
corrupt_rejections += 1
|
||||
try:
|
||||
oracle.decode(bad, checksum=True)
|
||||
except ValueError:
|
||||
corrupt_rejections += 1
|
||||
|
||||
print(f"values compared: {len(values)}")
|
||||
print(f"payload encodings equal: {payload_checks}")
|
||||
print(f"checked encodings equal: {checked_checks}")
|
||||
print(f"check symbols equal: {check_symbol_checks}")
|
||||
print(f"hyphenated decode equal: {hyphen_checks}")
|
||||
print(f"alias/lowercase equal: {alias_checks}")
|
||||
print(f"junk rejections matched: {rejections}/{2 * len(never_legal)} (never-legal strings x 2 implementations)")
|
||||
print(f"documented divergence: {divergences}/5 (oracle lax-accepts payload+check-only symbol; base32c is strict, both agree in validating mode)")
|
||||
print(f"corrupt check rejected: {corrupt_rejections}/10 (5 values x 2 implementations)")
|
||||
assert corrupt_rejections == 10, corrupt_rejections
|
||||
print("ORACLE AGREEMENT: full (on every non-divergent behaviour)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""demo_ids.py -- real-data demo: 128-bit human-transcribable IDs with check symbols.
|
||||
|
||||
Takes a real file's SHA-256, folds it to a 128-bit integer, and emits a hyphenated
|
||||
Crockford Base32 ID with a check symbol (transcription-safe: O/0 and I/L/1 confusions
|
||||
are folded, hyphens ignored, one bad symbol caught). Verifies and decodes it back,
|
||||
then proves the check symbol catches every single-symbol typo in the printed ID.
|
||||
|
||||
Run: python3 demo_ids.py [FILE ...]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
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
|
||||
|
||||
DEFAULT_TARGETS = ["/usr/share/common-licenses/GPL-3", os.path.join(HERE, "base32c.py")]
|
||||
|
||||
|
||||
def make_id(path: str) -> dict:
|
||||
digest = hashlib.sha256(open(path, "rb").read()).digest()
|
||||
value = int.from_bytes(digest[:16], "big") # 128-bit id space
|
||||
ident = base32c.encode_checked(value, min_length=26, hyphenate=4)
|
||||
decoded = base32c.decode_checked(ident)
|
||||
typos = tested = caught = 0
|
||||
body = ident.replace("-", "")
|
||||
for pos in range(len(body)):
|
||||
alternatives = base32c.CHECK_ALPHABET if pos == len(body) - 1 else base32c.ALPHABET
|
||||
for alt in alternatives:
|
||||
if alt == body[pos]:
|
||||
continue
|
||||
mutated = body[:pos] + alt + body[pos + 1 :]
|
||||
tested += 1
|
||||
if not base32c.verify(mutated):
|
||||
caught += 1
|
||||
return {
|
||||
"path": path,
|
||||
"sha256": digest.hex(),
|
||||
"value": value,
|
||||
"id": ident,
|
||||
"roundtrip_ok": decoded == value,
|
||||
"typos_tested": tested,
|
||||
"typos_caught": caught,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
targets = argv or DEFAULT_TARGETS
|
||||
all_ok = True
|
||||
for path in targets:
|
||||
if not os.path.isfile(path):
|
||||
print(f"SKIP {path}: not a file")
|
||||
continue
|
||||
row = make_id(path)
|
||||
print(f"file {row['path']}")
|
||||
print(f"sha256 {row['sha256']}")
|
||||
print(f"id value {row['value']}")
|
||||
print(f"public id {row['id']}")
|
||||
print(f"round-trip {'OK' if row['roundtrip_ok'] else 'BROKEN'} (decode_checked == value)")
|
||||
print(
|
||||
f"typo sweep {row['typos_caught']}/{row['typos_tested']} single-symbol typos rejected"
|
||||
f" ({row['typos_caught'] / row['typos_tested']:.1%})"
|
||||
)
|
||||
print()
|
||||
all_ok &= row["roundtrip_ok"] and row["typos_caught"] == row["typos_tested"]
|
||||
print("DEMO OK" if all_ok else "DEMO FAILED")
|
||||
return 0 if all_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mutation_check.py -- does test_base32c.py actually have teeth?
|
||||
|
||||
Copies base32c.py + test_base32c.py into a scratch dir, injects one fault at a time,
|
||||
runs the suite against the mutant, and reports whether the mutant was KILLED
|
||||
(suite exit != 0). A suite that passes everything, including mutants, is worthless.
|
||||
|
||||
Run: python3 mutation_check.py # exit 0 iff the control passes and every mutant dies
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
MODULE = os.path.join(HERE, "base32c.py")
|
||||
TEST = os.path.join(HERE, "test_base32c.py")
|
||||
|
||||
MUTATIONS = [
|
||||
(
|
||||
"alphabet: letter J replaced by excluded I",
|
||||
'"0123456789ABCDEFGHJKMNPQRSTVWXYZ"',
|
||||
'"0123456789ABCDEFGHIKMNPQRSTVWXYZ"',
|
||||
),
|
||||
(
|
||||
"check symbol: modulus 37 -> 36",
|
||||
"return CHECK_ALPHABET[value % 37]",
|
||||
"return CHECK_ALPHABET[value % 36]",
|
||||
),
|
||||
(
|
||||
"check verify: compares raw value instead of value mod 37",
|
||||
"if got != value % 37:",
|
||||
"if got != value:",
|
||||
),
|
||||
(
|
||||
"decode: hyphens no longer stripped",
|
||||
'def decode(text: str) -> int:\n """Decode a symbol string (hyphens ignored, case folded, i/l/o aliases honoured)."""\n if not isinstance(text, str):\n raise TypeError("text must be a str")\n body = text.replace("-", "")',
|
||||
'def decode(text: str) -> int:\n """Decode a symbol string (hyphens ignored, case folded, i/l/o aliases honoured)."""\n if not isinstance(text, str):\n raise TypeError("text must be a str")\n body = text',
|
||||
),
|
||||
(
|
||||
"encode: 5-bit slicing becomes 4-bit slicing",
|
||||
"for i in range(0, len(bits), 5)",
|
||||
"for i in range(0, len(bits), 4)",
|
||||
),
|
||||
(
|
||||
"decode aliases: i/l/o tolerance removed",
|
||||
'for _alias, _target in (("I", "1"), ("L", "1"), ("O", "0")):\n _SYMBOL_VALUE[_alias] = _SYMBOL_VALUE[_target]',
|
||||
'for _alias, _target in (("I", "1"), ("L", "1"), ("O", "0")):\n pass',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def run_suite(directory: str) -> tuple[int, str]:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, os.path.join(directory, "test_base32c.py")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
cwd=directory,
|
||||
)
|
||||
return proc.returncode, (proc.stdout + proc.stderr).strip().splitlines()[-1] if (proc.stdout + proc.stderr).strip() else ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = tempfile.mkdtemp(prefix="base32c-mutants-", dir=os.environ.get("TMPDIR", "/tmp"))
|
||||
failures = []
|
||||
|
||||
control = os.path.join(root, "control")
|
||||
os.makedirs(control)
|
||||
shutil.copy(MODULE, control)
|
||||
shutil.copy(TEST, control)
|
||||
code, tail = run_suite(control)
|
||||
print(f"CONTROL (unmutated copy): exit {code} -- {tail}")
|
||||
if code != 0:
|
||||
failures.append("control copy of the suite failed; results below are meaningless")
|
||||
|
||||
source = open(MODULE, encoding="utf-8").read()
|
||||
killed = 0
|
||||
for index, (label, old, new) in enumerate(MUTATIONS, start=1):
|
||||
if source.count(old) != 1:
|
||||
failures.append(f"mutation {index} anchor matched {source.count(old)} times, expected 1: {label}")
|
||||
print(f"MUTANT {index} ANCHOR-BROKEN: {label}")
|
||||
continue
|
||||
directory = os.path.join(root, f"mutant{index}")
|
||||
os.makedirs(directory)
|
||||
shutil.copy(TEST, directory)
|
||||
with open(os.path.join(directory, "base32c.py"), "w", encoding="utf-8") as handle:
|
||||
handle.write(source.replace(old, new))
|
||||
code, tail = run_suite(directory)
|
||||
status = "KILLED" if code != 0 else "SURVIVED"
|
||||
if code != 0:
|
||||
killed += 1
|
||||
else:
|
||||
failures.append(f"mutant survived: {label}")
|
||||
print(f"MUTANT {index} {status}: {label} [{tail}]")
|
||||
|
||||
print()
|
||||
print(f"MUTANTS {killed}/{len(MUTATIONS)} killed")
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
if failures:
|
||||
for line in failures:
|
||||
print(f"PROBLEM: {line}")
|
||||
return 1
|
||||
print("SUITE HAS TEETH: every injected fault was caught")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user