summaryrefslogtreecommitdiff
path: root/tools/gen_fuzz_corpus.py
blob: 87d11ae01cae49249b12018be44f5323bf567e9b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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())