diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-20 01:51:05 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-20 01:51:05 +0200 |
| commit | 42f9331e693f5058fcfcd7aef5a92cabc04c20ee (patch) | |
| tree | f3dab2657eb592ee468e6cebc9b47d9da7e08683 /tools/gen_fuzz_corpus.py | |
| parent | d537f4e154a40075405b0ab2fed14fbe2eeaac81 (diff) | |
test(import): cover parsers, importer and a seeded fuzz corpus; import 100 entries in the UI flow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/gen_fuzz_corpus.py')
| -rw-r--r-- | tools/gen_fuzz_corpus.py | 114 |
1 files changed, 114 insertions, 0 deletions
diff --git a/tools/gen_fuzz_corpus.py b/tools/gen_fuzz_corpus.py new file mode 100644 index 0000000..87d11ae --- /dev/null +++ b/tools/gen_fuzz_corpus.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Generate the seeded fuzz corpus for the import parsers: mutated Aegis JSON and otpauth URIs. +"""Seeded fuzz inputs for the Monkey C import tests (README 17.3). + +Starts from small valid documents (fake data only) and applies random +mutations: truncation, deletion, insertion of random or structural bytes, +byte flips, duplicated spans, deep nesting and long strings. The output is +deterministic for a given seed, so the corpus in git is reproducible: + + gen_fuzz_corpus.py [--seed 4242] [--out test/data/FuzzCorpus.mc] + +Inputs are written as hex, because many aren't valid UTF-8; the tests feed +the bytes to the tokenizer and the Aegis reader, and the valid-UTF-8 ones +to the whole importer as text. +""" + +from __future__ import annotations + +import argparse +import random +from pathlib import Path + +SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" # RFC 4226 test key in Base32, not a real secret +AEGIS_SEED = ( + '{"version": 1, "header": {"slots": null, "params": null}, "db": {"version": 3, "entries": [' + '{"type": "totp", "uuid": "u1", "name": "alice", "issuer": "GitHub", "note": "", "favorite": true, ' + '"icon": null, "info": {"secret": "' + SECRET + '", "algo": "SHA1", "digits": 6, "period": 30}, ' + '"groups": ["g1"]}, ' + '{"type": "hotp", "name": "b\\u00e9b", "issuer": "VPN", "info": {"secret": "' + SECRET + '", ' + '"algo": "SHA256", "digits": 8, "period": 30, "counter": 7}}], ' + '"groups": [{"uuid": "g1", "name": "Work"}]}}' +) +URI_SEEDS = [ + "otpauth://totp/GitHub:alice%40example.com?secret=" + SECRET + "&issuer=GitHub&digits=6", + "otpauth://hotp/VPN:bob?secret=" + SECRET + "&counter=5;algorithm=SHA256", + "otpauth://steam/Steam:carol?secret=" + SECRET, +] +STRUCTURAL = [ + *(b"{", b"}", b"[", b"]", b'"', b":", b",", b"\\", b"\\u", b"\\ud83d"), + *(b"null", b"-", b"1e9", b"%", b"&", b"=", b"?"), +] + + +def mutate(data: bytes, rng: random.Random) -> bytes: + """One to three random mutations of data.""" + out = bytearray(data) + for _ in range(rng.randint(1, 3)): + choice = rng.randrange(7) + at = rng.randrange(len(out) + 1) if out else 0 + if choice == 0 and out: # truncate + del out[at:] + elif choice == 1 and out: # delete a span + del out[at : at + rng.randint(1, 8)] + elif choice == 2: # insert random bytes + out[at:at] = bytes(rng.randrange(256) for _ in range(rng.randint(1, 4))) + elif choice == 3: # insert a structural token + out[at:at] = rng.choice(STRUCTURAL) + elif choice == 4 and out: # flip a byte + pos = rng.randrange(len(out)) + out[pos] ^= 1 << rng.randrange(8) + elif choice == 5 and out: # duplicate a span + span = out[at : at + rng.randint(1, 30)] + out[at:at] = span + else: # deep nesting or a long string + long_string = b'"' + b"A" * rng.randint(520, 1100) + b'"' + out[at:at] = b"[" * rng.randint(10, 30) if rng.random() < 0.5 else long_string + return bytes(out) + + +def corpus(seed: int, count_json: int, count_uri: int) -> tuple[list[bytes], list[bytes]]: + rng = random.Random(seed) + base = AEGIS_SEED.encode() + jsons = [base] + [mutate(base, rng) for _ in range(count_json - 1)] + mutated = [mutate(rng.choice(URI_SEEDS).encode(), rng) for _ in range(count_uri - len(URI_SEEDS))] + uris = [u.encode() for u in URI_SEEDS] + mutated + return jsons, uris + + +def render(jsons: list[bytes], uris: list[bytes], seed: int) -> str: + def table(name: str, items: list[bytes]) -> str: + last = len(items) - 1 + rows = "\n".join( + f' "{item.hex()}"{"" if i == last else ","} // lint: allow-literal' for i, item in enumerate(items) + ) + return f" const {name} = [\n{rows}\n ];\n" + + return ( + "// Author: cirakg & claude ai\n" + "// Summary: Seeded fuzz inputs for the import parsers (generated by tools/gen_fuzz_corpus.py; do not edit).\n" + f"//\n// Seed {seed}. Hex because many inputs are not valid UTF-8. Fake data only.\n\n" + "import Toybox.Lang;\n\n(:test)\nmodule FuzzCorpus {\n" + + table("JSON", jsons) + + "\n" + + table("URIS", uris) + + "}\n" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--seed", type=int, default=4242) + parser.add_argument("--out", type=Path, default=Path("test/data/FuzzCorpus.mc")) + parser.add_argument("--json", type=int, default=50) + parser.add_argument("--uris", type=int, default=50) + args = parser.parse_args(argv) + jsons, uris = corpus(args.seed, args.json, args.uris) + args.out.write_text(render(jsons, uris, args.seed), encoding="utf-8") + print(f"wrote {len(jsons)} JSON and {len(uris)} URI inputs to {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
