Fanbase

png/png-boundary

version 1.0

Same as png, with values taken from the edges of what the format allows

Format
Portable Network Graphics (png)
Authors
The Fandango Fuzzer Team
License
Apache-2.0
Fandango
>=1.3
Extensions
png
Media type
image/png
Specification
https://www.w3.org/TR/png-3/
Its files should decode
always
In the registry
specs/png/png-boundary/png-boundary.fan

Use it

fanbase install png-boundary
fandango fuzz -F png-boundary -n 10

How it does

accepts 91%, covers 22.4%, 71/s. 1000 inputs, seed 1.

ParserAcceptsVersion
ffmpeg91%ffmpeg version 7.1.5-0+deb13u1
imagemagick88%Version: ImageMagick 7.1.1-43 Q16 x86_64 22550
libpng-cov88%libpng 1.6.59
pillow89%12.3.0
pngcheck28%PNGcheck version 3.0.3 of 25 April 2021

The spec says its files decode always: not as expected.

libpng-cov

lines reached
22.4% of 8119
real files reach
16.1%
only the generated files
722 lines
only the real files
206 lines
11.1% after 1 inputs, 22.4% after 1000real files: 16.1%

Cite it

@software{fanbase_png_png_boundary_1_0,
  author  = {{The Fandango Fuzzer Team}},
  title   = {Portable Network Graphics (png/png-boundary), a Fandango input specification},
  version = {1.0},
  url     = {https://github.com/fandango-fuzzer/fanbase},
  note    = {Fanbase registry, specs/png/png-boundary/png-boundary.fan, sha256 dd20d92429bd},
}

Source

Source (247 lines)
# PNG, same layout as png.fan but with values taken from the edges: 1x1 and
# prime sizes, all bit depths, gamma 0 and 0xFFFFFFFF, empty and 79 character
# keywords, a leap second in tIME, 1 and 256 palette entries, empty and split
# IDAT chunks.
#
# TODO:
# * Replace constructor functions with grammar rules + constraints
# * Try not to depend on `random`; let Fandango do this
# * Make this an extension of `png.fan` (once it has a better structure) rather than a separate spec

import zlib
import struct
import random

# geometry handed from IHDR to IDAT within one file
_geom = {}
_SAMPLES = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}   # channels per colour type

def _chunk(typ, data):
    """len(4 BE) | type | data | CRC-32(type+data) (4 BE)."""
    body = typ + data
    return (struct.pack('>I', len(data)) + body
            + struct.pack('>I', zlib.crc32(body) & 0xffffffff))

_DIMS = [1, 2, 3, 5, 7, 8, 13, 16, 17, 31, 32]        # incl. 1 & primes
_ZLEVEL = [0, 1, 6, 9]

def _pick_bd(ct):
    if ct == 0:                       # grayscale: every legal depth
        return random.choice([1, 2, 4, 8, 16])
    if ct == 3:                       # indexed: fixed 8 so palette idx 0 valid
        return 8
    return random.choice([8, 16])     # colour / alpha types: 8 or 16

def mk_ihdr(ct):
    bd = _pick_bd(ct)
    w = random.choice(_DIMS)
    h = random.choice(_DIMS)
    _geom.clear()
    _geom.update(w=w, h=h, bd=bd, ct=ct, samples=_SAMPLES[ct])
    data = struct.pack('>IIBBBBB', w, h, bd, ct,
                       0,   # compression method 0 (only legal value)
                       0,   # filter method 0 (only legal value)
                       0)   # interlace 0 (Adam7 raw-size intentionally avoided)
    return _chunk(b'IHDR', data)

def mk_idat_section():
    """Real zlib stream of an all-zero (filter 0) image, split 1..4 IDATs."""
    w, h, bd, s = _geom['w'], _geom['h'], _geom['bd'], _geom['samples']
    rowbytes = (w * s * bd + 7) // 8
    raw = (b'\x00' + b'\x00' * rowbytes) * h        # filter byte 0 + zero pixels
    comp = zlib.compress(raw, random.choice(_ZLEVEL))
    n = random.randint(1, 4)                          # faithful multi-IDAT
    if n == 1 or len(comp) < 4:
        parts = [comp]
    else:
        cuts = sorted(random.sample(range(1, len(comp)), n - 1))
        bounds = [0] + cuts + [len(comp)]
        parts = [comp[bounds[i]:bounds[i + 1]] for i in range(len(bounds) - 1)]
    # occasionally emit a spec-legal zero-length trailing IDAT
    if random.random() < 0.25:
        parts.append(b'')
    return b''.join(_chunk(b'IDAT', p) for p in parts)

def mk_plte(n_entries):
    data = bytes(random.randrange(256) for _ in range(n_entries * 3))
    return _chunk(b'PLTE', data)

def mk_gama():
    g = random.choice([0, 1, 20000, 45454, 45455, 100000, 2147483647, 4294967295])
    return _chunk(b'gAMA', struct.pack('>I', g))

def mk_chrm():
    vals = [random.choice([0, 1, 31270, 32000, 100000, 4294967295]) for _ in range(8)]
    return _chunk(b'cHRM', struct.pack('>8I', *vals))

def mk_srgb():
    return _chunk(b'sRGB', bytes([random.randint(0, 3)]))   # all 4 intents

def mk_phys():
    x = random.choice([0, 1, 2835, 4294967295])
    y = random.choice([0, 1, 2835, 4294967295])
    return _chunk(b'pHYs', struct.pack('>IIB', x, y, random.randint(0, 1)))

def mk_time():
    yr = random.choice([0, 1, 1970, 1995, 2038, 65535])
    return _chunk(b'tIME', struct.pack('>HBBBBB', yr,
                  random.choice([1, 12]), random.choice([1, 28, 31]),
                  random.choice([0, 23]), random.choice([0, 59]),
                  random.choice([0, 59, 60])))           # 60 = leap second

_KEYWORDS = [b'K', b'Title', b'Author', b'Comment', b'Copyright',
             b'Software', b'Disclaimer', b'Warning',
             b'A' * 79, b'X' * 79]                        # 1 and max(79)
def _kw():
    return random.choice(_KEYWORDS)

def mk_text():
    txt = random.choice([b'', b'x', b'hi', b'\xe9\xe0 latin1', b'z' * 200])
    return _chunk(b'tEXt', _kw() + b'\x00' + txt)

def mk_ztxt():
    txt = random.choice([b'', b'compressed text', b'q' * 500])
    return _chunk(b'zTXt', _kw() + b'\x00' + b'\x00' + zlib.compress(txt))

def mk_itxt():
    comp = random.randint(0, 1)
    txt = random.choice(['', 'unicode ☃', 'w' * 300]).encode('utf-8')
    payload = zlib.compress(txt) if comp else txt
    lang = random.choice([b'', b'en', b'en-us'])
    trans = random.choice([b'', 'Título'.encode('utf-8')])
    return _chunk(b'iTXt', _kw() + b'\x00' + bytes([comp, 0])
                  + lang + b'\x00' + trans + b'\x00' + payload)

def mk_iccp():
    prof = random.choice([b'\x00' * 132, bytes(range(60)), b'\xff' * 300])
    return _chunk(b'iCCP', b'ICC' + b'\x00' + b'\x00' + zlib.compress(prof))

def mk_exif():
    if random.random() < 0.5:
        return _chunk(b'eXIf', b'II*\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00')
    return _chunk(b'eXIf', b'MM\x00*\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00')

def mk_splt():
    depth = random.choice([8, 16])
    nent = random.choice([1, 2, 256])
    if depth == 8:
        body = bytes(random.randrange(256) for _ in range(nent * 4)) \
               + b''.join(struct.pack('>H', random.choice([0, 65535])) for _ in range(nent))
        # interleave properly: entry = R G B A freq(2)
        body = b''.join(bytes(random.randrange(256) for _ in range(4))
                        + struct.pack('>H', random.choice([0, 1, 65535])) for _ in range(nent))
    else:
        body = b''.join(struct.pack('>HHHHH',
                        *[random.choice([0, 65535]) for _ in range(5)]) for _ in range(nent))
    return _chunk(b'sPLT', b'pal' + b'\x00' + bytes([depth]) + body)

# colour-type-specific ancillary (sBIT / bKGD / tRNS / hIST)
def _sig_bits(n):
    bd = _geom['bd']
    return bytes(random.randint(1, bd) for _ in range(n))   # 1..bitdepth per channel

def mk_sbit():
    ct = _geom['ct']
    n = {0: 1, 2: 3, 3: 3, 4: 2, 6: 4}[ct]
    if ct == 3:
        return _chunk(b'sBIT', bytes(random.randint(1, 8) for _ in range(3)))
    return _chunk(b'sBIT', _sig_bits(n))

def _grey_val():
    return struct.pack('>H', random.choice([0, 1, 255, 32768, 65535]))

def mk_bkgd():
    ct = _geom['ct']
    if ct in (0, 4):
        return _chunk(b'bKGD', _grey_val())
    if ct in (2, 6):
        return _chunk(b'bKGD', _grey_val() + _grey_val() + _grey_val())
    return _chunk(b'bKGD', bytes([random.choice([0, 128, 255])]))   # indexed

def mk_trns_gray():
    return _chunk(b'tRNS', _grey_val())

def mk_trns_color():
    return _chunk(b'tRNS', _grey_val() + _grey_val() + _grey_val())

def mk_trns_indexed():
    n = random.choice([1, 128, 256])
    return _chunk(b'tRNS', bytes(random.choice([0, 128, 255]) for _ in range(n)))

def mk_hist():
    return _chunk(b'hIST', b''.join(
        struct.pack('>H', random.choice([0, 1, 65535])) for _ in range(256)))

def mk_iend():
    return _chunk(b'IEND', b'')


# Grammar
<start> ::= <signature> <image>

<signature> ::= rb'[\x00-\xff]+' := b'\x89PNG\r\n\x1a\n'

<image> ::= <png_grayscale> \
          | <png_gray_alpha> \
          | <png_truecolor> \
          | <png_truecolor_alpha> \
          | <png_indexed>

# ancillary that may appear before PLTE (any colour type)
<pre_anc>  ::= <gama> | <chrm> | <srgb> | <iccp>
# ancillary that may appear after PLTE / before IDAT
<post_anc> ::= <phys> | <time> | <text> | <ztxt> | <itxt> | <exif> | <splt>

# grayscale (ct 0): no PLTE; grey tRNS/bKGD/sBIT legal
<png_grayscale> ::= <ihdr_gray> <pre_anc>{0,3} <sbit>? <trns_g>? <bkgd>? \
                    <post_anc>{0,3} <idat_section> <iend>

# grayscale+alpha (ct 4): no PLTE, no tRNS
<png_gray_alpha> ::= <ihdr_ga> <pre_anc>{0,3} <sbit>? <bkgd>? \
                     <post_anc>{0,3} <idat_section> <iend>

# truecolour (ct 2): PLTE optional (suggested palette)
<png_truecolor> ::= <ihdr_tc> <pre_anc>{0,3} <plte_opt>? <sbit>? <trns_c>? \
                    <bkgd>? <post_anc>{0,3} <idat_section> <iend>

# truecolour+alpha (ct 6): PLTE optional, no tRNS
<png_truecolor_alpha> ::= <ihdr_tca> <pre_anc>{0,3} <plte_opt>? <sbit>? \
                          <bkgd>? <post_anc>{0,3} <idat_section> <iend>

# indexed (ct 3): PLTE mandatory; hIST/indexed tRNS/bKGD legal
<png_indexed> ::= <ihdr_idx> <pre_anc>{0,3} <plte_full> <sbit>? <trns_i>? \
                  <bkgd>? <hist>? <post_anc>{0,3} <idat_section> <iend>

# IHDR leaves (one per colour type)
<ihdr_gray> ::= rb'[\x00-\xff]+' := mk_ihdr(0)
<ihdr_ga>   ::= rb'[\x00-\xff]+' := mk_ihdr(4)
<ihdr_tc>   ::= rb'[\x00-\xff]+' := mk_ihdr(2)
<ihdr_tca>  ::= rb'[\x00-\xff]+' := mk_ihdr(6)
<ihdr_idx>  ::= rb'[\x00-\xff]+' := mk_ihdr(3)

# PLTE
<plte_full> ::= rb'[\x00-\xff]+' := mk_plte(256)                 # indexed
<plte_opt>  ::= rb'[\x00-\xff]+' := mk_plte(random.choice([1, 2, 256]))

# IDAT stream + IEND
<idat_section> ::= rb'[\x00-\xff]+' := mk_idat_section()
<iend>         ::= rb'[\x00-\xff]+' := mk_iend()

# ancillary leaves
<gama> ::= rb'[\x00-\xff]+' := mk_gama()
<chrm> ::= rb'[\x00-\xff]+' := mk_chrm()
<srgb> ::= rb'[\x00-\xff]+' := mk_srgb()
<iccp> ::= rb'[\x00-\xff]+' := mk_iccp()
<phys> ::= rb'[\x00-\xff]+' := mk_phys()
<time> ::= rb'[\x00-\xff]+' := mk_time()
<text> ::= rb'[\x00-\xff]+' := mk_text()
<ztxt> ::= rb'[\x00-\xff]+' := mk_ztxt()
<itxt> ::= rb'[\x00-\xff]+' := mk_itxt()
<exif> ::= rb'[\x00-\xff]+' := mk_exif()
<splt> ::= rb'[\x00-\xff]+' := mk_splt()
<sbit> ::= rb'[\x00-\xff]+' := mk_sbit()
<bkgd> ::= rb'[\x00-\xff]+' := mk_bkgd()
<hist> ::= rb'[\x00-\xff]+' := mk_hist()
<trns_g> ::= rb'[\x00-\xff]+' := mk_trns_gray()
<trns_c> ::= rb'[\x00-\xff]+' := mk_trns_color()
<trns_i> ::= rb'[\x00-\xff]+' := mk_trns_indexed()