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 }