diff --git a/base64-rs/README.md b/base64-rs/README.md index 087c01e..9a40278 100644 --- a/base64-rs/README.md +++ b/base64-rs/README.md @@ -48,7 +48,7 @@ Long options accept unambiguous abbreviations (`--dec`), short options cluster ( ## Behaviour -Verified against GNU coreutils 9.7 with a 153-case differential test covering encoding at every wrap width, decode round-trips, malformed padding, truncated input, garbage handling, all the usage errors and file operands. Output, exit codes and error messages match, including the fiddly parts: +Verified against GNU coreutils 9.7 with a 154-case differential test ([tests/parity.py](tests/parity.py)) covering encoding at every wrap width, decode round-trips, malformed padding, truncated input, garbage handling, all the usage errors and file operands. Output, exit codes and error messages match, including the fiddly parts: - **Partial output on failure.** Bytes are emitted as soon as enough bits are available, so a stream that goes bad halfway still writes the valid prefix before the error — same as coreutils. - **Unpadded input is accepted** when the encoding is canonical, i.e. the leftover bits are zero. `aGVsbG8` decodes to `hello`; `aGV` is rejected because `V` carries stray bits. @@ -64,6 +64,15 @@ Verified against GNU coreutils 9.7 with a 153-case differential test covering en | `--help` omits GNU's support URLs | This isn't GNU coreutils; pointing at their bug tracker would be wrong. | | Error messages quote with `'ASCII'` | GNU uses `'…'` in a UTF-8 locale. Windows consoles are frequently cp437/cp1252, where those bytes render as mojibake. | +## Running the tests + +```bash +cargo build --release +python3 tests/parity.py +``` + +Linux-only — it diffs against a real GNU `base64`, so it can't run on the Windows box this tool targets. Note that many distributions now ship **uutils** coreutils as `/usr/bin/base64`; it disagrees with GNU on most malformed-input cases, so the script hunts for a genuine GNU build and refuses to run against anything else. On Ubuntu with `coreutils-from-uutils` installed, the GNU one is `gnubase64`. + ## Notes - Roughly 2× slower than GNU on large inputs (100 MB encodes in ~0.35s vs ~0.18s). GNU uses hand-tuned routines; this is plain portable Rust and fast enough that the difference is invisible for normal use. diff --git a/base64-rs/tests/parity.py b/base64-rs/tests/parity.py new file mode 100644 index 0000000..5e8bf9e --- /dev/null +++ b/base64-rs/tests/parity.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Differential test: this base64 against GNU coreutils base64. + +Runs both binaries over the same inputs and compares stdout, stderr and +exit code. Linux-only — it needs a real GNU base64 to diff against, so it +cannot run on the Windows machine the tool is actually built for. + + cargo build --release && python3 tests/parity.py + +Note that many distributions now ship uutils coreutils as /usr/bin/base64, +which differs from GNU on malformed input. This script looks for a genuine +GNU build and refuses to run against anything else. +""" + +import os +import random +import shutil +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MINE = os.path.join(ROOT, "target", "release", "base64") + +# Differences that are intentional; see README.md. +EXPECTED_DIFF = { + # We skip '\r' alongside '\n' so CRLF base64 files work on Windows. + "decode CRLF wrapped", + # Our --help omits GNU's own support URLs. + "help", + "help after other flags", +} + + +def find_gnu(): + """Locate a genuine GNU base64, ignoring uutils drop-in replacements.""" + for name in ("gnubase64", "base64", "gbase64"): + path = shutil.which(name) + if not path: + continue + try: + banner = subprocess.run( + [path, "--version"], capture_output=True, text=True, timeout=10 + ).stdout + except OSError: + continue + if "GNU coreutils" in banner: + return path, banner.splitlines()[0] + return None, None + + +GNU, GNU_BANNER = find_gnu() + + +def norm(b, gnu_name): + """Strip argv[0] and locale noise so the two are comparable.""" + b = b.replace(gnu_name.encode(), b"base64") + b = b.replace(os.path.basename(gnu_name).encode(), b"base64") + # GNU quotes with U+2018/2019 outside the C locale; we always use ASCII. + b = b.replace("‘".encode(), b"'").replace("’".encode(), b"'") + return b + + +def run(exe, args, data): + # C locale: GNU otherwise uses en_GB spelling and Unicode quotes, neither + # of which a Windows console would produce. + env = dict(os.environ, LC_ALL="C") + p = subprocess.run([exe] + args, input=data, capture_output=True, env=env) + return p.returncode, norm(p.stdout, GNU), norm(p.stderr, GNU) + + +results = [] + + +def t(name, args, data=b""): + mine = run(MINE, args, data) + gnu = run(GNU, args, data) + ok = mine == gnu + if not ok: + tag = "DIFF(expected)" if name in EXPECTED_DIFF else "FAIL" + print(f"[{tag}] {name} args={args}") + if mine[0] != gnu[0]: + print(f" exit: mine={mine[0]} gnu={gnu[0]}") + if mine[1] != gnu[1]: + print(f" out : mine={mine[1][:90]!r}") + print(f" gnu ={gnu[1][:90]!r}") + if mine[2] != gnu[2]: + print(f" err : mine={mine[2][:120]!r}") + print(f" gnu ={gnu[2][:120]!r}") + results.append((name, ok or name in EXPECTED_DIFF)) + + +def main(): + random.seed(7) + big = bytes(random.getrandbits(8) for _ in range(200_000)) + + # ── encoding ────────────────────────────────────────────────────── + t("encode empty", [], b"") + for n in (1, 2, 3, 4, 5, 56, 57, 58, 76, 1000): + t(f"encode {n} bytes", [], (bytes(range(256)) * 10)[:n]) + t("encode all byte values", [], bytes(range(256))) + t("encode 200KB binary", [], big) + sample = b"hello world, this is a longer sample string for wrapping" + for w in ("0", "1", "2", "3", "4", "5", "76", "77", "1000"): + t(f"encode wrap {w}", ["-w", w], sample) + t(f"encode wrap {w} joined", [f"-w{w}"], sample) + t(f"encode wrap {w} long", ["--wrap=" + w], big[:5000]) + + # ── decoding ────────────────────────────────────────────────────── + for n in (0, 1, 2, 3, 4, 5, 100, 1000): + enc = subprocess.run([GNU], input=big[:n], capture_output=True).stdout + t(f"decode roundtrip {n}", ["-d"], enc) + t("decode 200KB roundtrip", ["-d"], + subprocess.run([GNU], input=big, capture_output=True).stdout) + t("decode unwrapped", ["-d"], b"aGVsbG8gd29ybGQ=") + t("decode no trailing newline", ["-d"], b"aGVsbG8=") + t("decode with newlines", ["-d"], b"aGVs\nbG8=\n") + t("decode CRLF wrapped", ["-d"], b"aGVs\r\nbG8=\r\n") + t("decode empty", ["-d"], b"") + t("decode only newline", ["-d"], b"\n") + t("decode spaces garbage", ["-d"], b"aGVs bG8=") + t("decode spaces garbage -i", ["-di"], b"aGVs bG8=") + t("decode junk", ["-d"], b"aG!!Vs*bG8=") + t("decode junk -i", ["-di"], b"aG!!Vs*bG8=") + t("decode junk only -i", ["-di"], b"!!!!") + t("decode nul bytes -i", ["-di"], b"aGVs\x00bG8=") + t("decode high bytes", ["-d"], b"aGVs\xffbG8=") + t("decode high bytes -i", ["-di"], b"aGVs\xffbG8=") + t("decode urlsafe chars", ["-d"], b"_-_-") + + # Unpadded tails are legal only when the leftover bits are zero. + for s in (b"a", b"aA", b"aQ", b"ag", b"aG", b"aGA", b"aGE", b"aGV", + b"aB", b"aC", b"aGB", b"aGC", b"aGVs", b"aGVsb", b"aGVsbG", + b"aGVsbG8"): + t(f"decode tail {s.decode()}", ["-d"], s) + + # Padding placement, and streams concatenated after padding. + for s in (b"=", b"====", b"=aGVsbG8=", b"aG=sbG8=", b"aGVsbG8===", + b"aA==", b"aQ==", b"aG==", b"aGA=", b"aGV=", b"aA=", b"aA=x", + b"aG=x", b"aGA=x", b"aA==x", b"aA==aGVs", b"aGVsbG8=x", + b"aGVsbG8=aGVs", b"aGVsbG8=\naGVs", b"aGVsbG8=aGVsbG8="): + t(f"decode pad {s.decode()}", ["-d"], s) + + # Every prefix of a known-good encoding, strict and lenient. + enc = subprocess.run([GNU], input=b"hello world!", capture_output=True).stdout.strip() + for i in range(1, len(enc) + 1): + t(f"decode prefix {i}", ["-d"], enc[:i]) + t(f"decode prefix {i} -i", ["-di"], enc[:i]) + + # ── options and usage errors ────────────────────────────────────── + t("help", ["--help"]) + t("help after other flags", ["--decode", "--help"]) + t("abbrev --dec", ["--dec"], b"aGVsbG8=") + t("abbrev --ign", ["--ign", "-d"], b"aGV s bG8=") + t("abbrev --w", ["--w", "4"], b"hello world") + t("bare --", ["--"], b"hello") + t("cluster -di", ["-di"], b"aG!Vs bG8=") + t("cluster -dw0", ["-dw0"], b"aGVsbG8=") + t("wrap missing arg", ["-w"]) + t("wrap bad value", ["-w", "abc"]) + t("wrap negative", ["-w", "-5"]) + t("unknown short", ["-z"]) + t("unknown long", ["--nope"]) + t("ambiguous long", ["--i"]) + t("extra operand", ["a.txt", "b.txt"]) + t("missing file", ["/nonexistent/nope.txt"]) + t("directory as file", ["/tmp"]) + t("dash is stdin", ["-"], b"hello") + t("double dash then dash", ["--", "-"], b"hello") + + # ── file operands ───────────────────────────────────────────────── + with tempfile.TemporaryDirectory() as tmp: + raw = os.path.join(tmp, "sample.bin") + with open(raw, "wb") as fh: + fh.write(big[:9999]) + t("encode from file", [raw]) + t("encode from file wrap 0", ["-w0", raw]) + + encoded = os.path.join(tmp, "sample.b64") + with open(encoded, "wb") as fh: + fh.write(subprocess.run([GNU], input=big[:9999], capture_output=True).stdout) + t("decode from file", ["-d", encoded]) + + passed = sum(1 for _, ok in results if ok) + print(f"\n{passed}/{len(results)} cases match GNU base64") + failed = [n for n, ok in results if not ok] + if failed: + print("failing:", ", ".join(failed)) + return 1 + print("all parity checks passed") + return 0 + + +if __name__ == "__main__": + if not os.path.exists(MINE): + sys.exit(f"build it first: cargo build --release (looked for {MINE})") + if GNU is None: + sys.exit( + "no GNU coreutils base64 found. Distributions increasingly ship " + "uutils as /usr/bin/base64, which differs on malformed input; " + "install GNU coreutils to run this comparison." + ) + print(f"comparing {MINE}\n against {GNU} ({GNU_BANNER})\n") + sys.exit(main())