nativeaudio

audio playback for Go
Log | Files | Refs | README | LICENSE

fuzz_test.go (5426B)


      1 package test
      2 
      3 import (
      4 	"io"
      5 	"os"
      6 	"path/filepath"
      7 	"testing"
      8 	"time"
      9 
     10 	"git.sr.ht/~jackmordaunt/nativeaudio"
     11 )
     12 
     13 // checkFormat asserts that a decode which reported success produced a
     14 // self-consistent result. A decoder is free to reject anything it does
     15 // not like, but if it claims to have decoded something then the format
     16 // has to describe the PCM it handed back.
     17 func checkFormat(t *testing.T, pcm []byte, f nativeaudio.Format) {
     18 	t.Helper()
     19 	if f.BytesPerSample != 2 {
     20 		t.Errorf("decode succeeded with BytesPerSample %d, want 2", f.BytesPerSample)
     21 	}
     22 	if f.Channels < 1 {
     23 		t.Errorf("decode succeeded with %d channels", f.Channels)
     24 	}
     25 	if f.SampleRate < 1 {
     26 		t.Errorf("decode succeeded with sample rate %d", f.SampleRate)
     27 	}
     28 	if f.Channels < 1 || f.BytesPerSample < 1 {
     29 		return
     30 	}
     31 	if frame := f.Channels * f.BytesPerSample; len(pcm)%frame != 0 {
     32 		t.Errorf("decode produced %d bytes, not a whole number of %d-byte frames", len(pcm), frame)
     33 	}
     34 }
     35 
     36 // fuzzDecoder returns a Decoder on a short budget, closed when the fuzz
     37 // target finishes.
     38 //
     39 // Fuzzing needs every input to finish quickly. Malformed audio can make a
     40 // decoder grind for far longer than the harness tolerates before it
     41 // declares a worker hung, so the budget here is much tighter than the
     42 // default a normal caller gets.
     43 func fuzzDecoder(f *testing.F) *nativeaudio.Decoder {
     44 	f.Helper()
     45 	d, err := nativeaudio.New(nativeaudio.WithLimits(nativeaudio.Limits{
     46 		MaxDuration: 250 * time.Millisecond,
     47 		MaxBytes:    32 << 20,
     48 	}))
     49 	if err != nil {
     50 		f.Fatalf("creating decoder: %v", err)
     51 	}
     52 	f.Cleanup(func() { d.Close() })
     53 	return d
     54 }
     55 
     56 // seed adds the corpus shared by the fuzz targets: real files, a known
     57 // corrupt one, generated PCM, and the degenerate shapes that tend to
     58 // find off-by-one handling in header parsers.
     59 func seed(f *testing.F) {
     60 	f.Add(compressed)
     61 	f.Add(corrupt)
     62 	f.Add(uncompressed[:4096]) // a slice: seeds want to be small
     63 	f.Add(silentWAV(44100, 2, 1))
     64 	f.Add(silentWAV(8000, 1, 1))
     65 	f.Add([]byte{})
     66 	f.Add([]byte("RIFF"))
     67 	f.Add([]byte("RIFF\x00\x00\x00\x00WAVEfmt "))
     68 	f.Add([]byte("\x00\x00\x00\x20ftypM4A "))
     69 }
     70 
     71 // FuzzDecode feeds arbitrary bytes to the platform decoder.
     72 //
     73 // This is the package's main untrusted-input surface: callers hand it
     74 // files they did not create, and on Windows and macOS those bytes reach
     75 // the operating system's own decoder through hand-written bindings. The
     76 // property under test is that decoding either fails or returns a
     77 // coherent result, and never panics or corrupts memory.
     78 func FuzzDecode(f *testing.F) {
     79 	seed(f)
     80 
     81 	d := fuzzDecoder(f)
     82 
     83 	f.Fuzz(func(t *testing.T, data []byte) {
     84 		pcm, format, err := d.Decode(data)
     85 		if err != nil {
     86 			return
     87 		}
     88 		checkFormat(t, pcm, format)
     89 	})
     90 }
     91 
     92 // FuzzStream feeds arbitrary bytes to the incremental decoder, which
     93 // has its own bookkeeping around sample lifetime and buffer locking
     94 // that the buffered path does not exercise on the same schedule.
     95 func FuzzStream(f *testing.F) {
     96 	seed(f)
     97 
     98 	d := fuzzDecoder(f)
     99 
    100 	f.Fuzz(func(t *testing.T, data []byte) {
    101 		s, err := d.Stream(data)
    102 		if err != nil {
    103 			return
    104 		}
    105 		defer s.Close()
    106 
    107 		format := s.Format()
    108 		pcm, err := io.ReadAll(s)
    109 		if err != nil {
    110 			return
    111 		}
    112 		checkFormat(t, pcm, format)
    113 	})
    114 }
    115 
    116 // FuzzStreamAbandoned closes streams part-way through, which is the
    117 // path that leaves decoder state and, where ffmpeg is the backend, a
    118 // live subprocess to clean up.
    119 func FuzzStreamAbandoned(f *testing.F) {
    120 	seed(f)
    121 
    122 	d := fuzzDecoder(f)
    123 
    124 	f.Fuzz(func(t *testing.T, data []byte) {
    125 		s, err := d.Stream(data)
    126 		if err != nil {
    127 			return
    128 		}
    129 		if _, err := io.CopyN(io.Discard, s, 64); err != nil && err != io.EOF {
    130 			// A read error is an acceptable outcome for junk input.
    131 			_ = s.Close()
    132 			return
    133 		}
    134 		if err := s.Close(); err != nil {
    135 			t.Errorf("closing abandoned stream: %v", err)
    136 		}
    137 	})
    138 }
    139 
    140 // TestPathologicalDecodeIsBounded pins down the fix for a decode that
    141 // used to be unrecoverable.
    142 //
    143 // testdata/pathological.m4a is a mutated copy of the real fixture, found by
    144 // FuzzDecode. It opens cleanly and reports a plausible format, then
    145 // Media Foundation decodes it indefinitely: a few hundred kilobytes of
    146 // PCM at a time, never finishing and never failing. Every individual
    147 // read completes, so no per-read timeout catches it. Left alone it runs
    148 // for hours.
    149 //
    150 // Limits are what bound it, so the decode fails instead of running
    151 // forever.
    152 func TestPathologicalDecodeIsBounded(t *testing.T) {
    153 	in, err := os.ReadFile(filepath.Join("testdata", "pathological.m4a"))
    154 	if err != nil {
    155 		t.Fatalf("reading fixture: %v", err)
    156 	}
    157 
    158 	const budget = 5 * time.Second
    159 	d, err := nativeaudio.New(nativeaudio.WithLimits(nativeaudio.Limits{MaxDuration: budget}))
    160 	if err != nil {
    161 		t.Fatalf("creating decoder: %v", err)
    162 	}
    163 	defer d.Close()
    164 
    165 	start := time.Now()
    166 	_, _, err = d.Decode(in)
    167 	elapsed := time.Since(start)
    168 
    169 	if err == nil {
    170 		t.Fatalf("decode of a pathological file succeeded after %v", elapsed)
    171 	}
    172 	// Two things can end it: the configured budget, checked between
    173 	// reads, or a single read stalling past the backend's own timeout.
    174 	// Which one wins depends on where the decoder gets stuck, and the
    175 	// guarantee being pinned down here is termination, not the route.
    176 	if max := budget + 30*time.Second; elapsed > max {
    177 		t.Errorf("decode took %v, want under %v", elapsed, max)
    178 	}
    179 	t.Logf("bounded after %v: %v", elapsed.Round(time.Millisecond), err)
    180 }