Something went wrong. Try again.
A FLAC encoder and decoder for Zig, written against RFC 9639, for native FLAC streams and FLAC in Ogg
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Fuzz targets: what the library must do with input nobody wrote.//!//! A FLAC stream arrives from outside, and almost every length in it -- the//! size of a metadata block, a block size, a predictor order, a partition//! order, the width of an escaped partition -- is a number the file itself//! chose. That is the shape of input that gets parsers into trouble, so each//! target below states a property that has to hold for *every* input rather//! than for the ones somebody thought to write down://!//! * **Decoding anything terminates, reports and frees.** Whatever the bytes//! say, the decoder finishes, says what was wrong, and gives back what it//! took. It is allowed to refuse; it is not allowed to crash, hang or leak.//! * **Reading metadata and writing it back is the identity.** Where a block//! parses, re-encoding it must reproduce the bytes it came from, or a file//! loses something on being retagged.//! * **Decoding an Ogg stream terminates, reports and frees.** The container//! adds a second set of lengths the file chose -- how many segments a page//! has, how long each is, whether a packet has finished -- on top of the ones//! the codec has, and the mapping's own: how many header packets there are and//! what is in them.//! * **A round trip is lossless.** For any samples at all, and any encoder//! settings, decoding what the encoder wrote gives the samples back, and the//! MD5 the encoder computed matches the audio the decoder produced. This is//! the whole promise of the format, and it is the one worth hunting for a//! counter-example to.//!//! # Running them//!//! ```console//! $ zig build test # the corpus below, and nothing else//! $ zig build fuzz --fuzz # Zig's fuzzer//! $ zig build fuzz --fuzz=1M # bounded//! ```//!//! Without `--fuzz` a fuzz test runs only its corpus, so the seeds at the//! bottom of this file are what `zig build test` exercises. The three example//! files from RFC 9639 are among them, which gives the fuzzer valid streams to//! start mutating rather than making it discover the `fLaC` signature first.//!//! **The memory has to be bounded here rather than in the library.** A//! streaminfo block can ask for blocks of 65535 samples across 8 channels, and//! a metadata block can be 16 megabytes; a fuzzer finds those shapes early and//! often. Every target runs against `Budget`, which hands out at most//! `budget_bytes` before it starts answering `error.OutOfMemory` -- which the//! library promises to survive anyway, since every entry point that allocates//! returns `Allocator.Error`. So the cap keeps the fuzzer inside its machine//! and exercises the allocation-failure paths at the same time.
const builtin = @import("builtin");const std = @import("std");const flac = @import("flac");
const Allocator = std.mem.Allocator;const Smith = std.testing.Smith;const testing = std.testing;
/// What `Budget` hands out, and what a leak is detected by.pub var backing: Allocator = if (builtin.is_test) testing.allocator else undefined;
/// Enough for a decoder's buffers at any block size the format allows, and not/// enough for a metadata block that claims sixteen megabytes.const budget_bytes = 8 << 20;
/// The longest stream a target is handed. The interesting failures are in what/// a frame header says, not in how many frames follow it, and a fuzzer left to/// choose the length spends its time making the file longer.const max_stream = 64 * 1024;
/// The most samples a round trip is asked to carry.const max_samples = 4096;
test "fuzz decode: decoding anything terminates, reports and frees" { try std.testing.fuzz({}, decodeOne, .{ .corpus = &stream_corpus });}
pub fn decodeOne(_: void, smith: *Smith) anyerror!void { var buffer: [max_stream]u8 = undefined; const stream = buffer[0..smith.slice(&buffer)];
var budget: Budget = .init(backing, budget_bytes); const gpa = budget.allocator();
var input: std.Io.Reader = .fixed(stream); var d: flac.Decoder = flac.Decoder.init(gpa, &input, .{}) catch return; defer d.deinit();
while (d.next() catch return) |_| {} d.finish() catch return;}
test "fuzz metadata: reading a block and writing it back changes nothing" { try std.testing.fuzz({}, metadataOne, .{ .corpus = &stream_corpus });}
pub fn metadataOne(_: void, smith: *Smith) anyerror!void { var buffer: [max_stream]u8 = undefined; const stream = buffer[0..smith.slice(&buffer)];
var budget: Budget = .init(backing, budget_bytes); const gpa = budget.allocator();
var input: std.Io.Reader = .fixed(stream); const blocks = flac.metadata.readAll(gpa, &input) catch return; defer flac.metadata.freeBlocks(gpa, blocks);
// Everything up to the first frame came from these blocks, so writing them // back has to reproduce it byte for byte. var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); flac.metadata.writeAll(&out.writer, blocks) catch return; try testing.expectEqualSlices(u8, stream[0..out.written().len], out.written());}
test "fuzz ogg: decoding an Ogg FLAC stream terminates, reports and frees" { try std.testing.fuzz({}, oggOne, .{ .corpus = &ogg_corpus });}
pub fn oggOne(_: void, smith: *Smith) anyerror!void { var buffer: [max_stream]u8 = undefined; const stream = buffer[0..smith.slice(&buffer)];
var budget: Budget = .init(backing, budget_bytes); const gpa = budget.allocator();
const pages = gpa.create([flac.ogg.page_buffer_size]u8) catch return; defer gpa.destroy(pages);
var input: std.Io.Reader = .fixed(stream); var d: flac.ogg.Decoder = flac.ogg.Decoder.init(gpa, &input, pages, .{ .demuxer = .{ .max_packet_size = 1 << 20, .max_streams = 16 }, }) catch return; defer d.deinit();
while (d.next() catch return) |_| {} d.finish() catch return;}
test "fuzz roundtrip: what the encoder wrote is what the decoder reads" { try std.testing.fuzz({}, roundTripOne, .{ .corpus = &sample_corpus });}
pub fn roundTripOne(_: void, smith: *Smith) anyerror!void { var budget: Budget = .init(backing, budget_bytes); const gpa = budget.allocator();
const channels = smith.valueRangeAtMost(u8, 1, flac.limits.max_channels); const bits_per_sample = smith.valueRangeAtMost(u8, 4, flac.limits.max_bits_per_sample); const level: flac.Level = @fromBackingInt(@intCast(smith.valueRangeAtMost(u4, 0, 8))); const block_size = smith.valueRangeAtMost(u32, flac.limits.min_block_size, 512);
// Samples are drawn a byte at a time and stretched to the configured // depth, so that a mutation of one input byte is a mutation of one sample. var raw: [max_samples]u8 = undefined; const count = smith.slice(&raw) / channels; if (count == 0) return;
const samples = gpa.alloc(i32, count * channels) catch return; defer gpa.free(samples); for (samples, 0..) |*sample, i| { const byte: i32 = @as(i32, raw[i]) - 128; sample.* = if (bits_per_sample >= 8) byte * (@as(i32, 1) << @intCast(bits_per_sample - 8)) else byte >> @intCast(8 - bits_per_sample); }
const config: flac.Config = .{ .sample_rate = 44100, .channels = channels, .bits_per_sample = bits_per_sample, };
var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); { var e: flac.Encoder = flac.Encoder.init(gpa, &out.writer, config, .{ .level = level, .block_size = block_size, .padding = 0, }) catch return; defer e.deinit(); e.writeInterleaved(samples) catch return; e.finish() catch return;
var patched: std.Io.Writer = .fixed(out.written()[flac.streaminfo_body_offset..]); e.streaminfo.write(&patched) catch return; }
var input: std.Io.Reader = .fixed(out.written()); var d: flac.Decoder = try .init(gpa, &input, .{}); defer d.deinit();
var at: usize = 0; while (try d.next()) |frame| { for (0..frame.header.block_size) |sample| { for (frame.channels) |channel| { try testing.expectEqual(samples[at], channel[sample]); at += 1; } } } try testing.expectEqual(samples.len, at); // Which also checks the encoder's MD5 against the audio that came back. try d.finish();}
/// The targets above, so that `tools/fuzz.zig` can drive the same ones the/// test suite does.pub const Target = struct { name: []const u8, run: *const fn (void, *Smith) anyerror!void, corpus: []const []const u8,};
pub const targets = [_]Target{ .{ .name = "decode", .run = decodeOne, .corpus = &stream_corpus }, .{ .name = "ogg", .run = oggOne, .corpus = &ogg_corpus }, .{ .name = "metadata", .run = metadataOne, .corpus = &stream_corpus }, .{ .name = "roundtrip", .run = roundTripOne, .corpus = &sample_corpus },};
/// `seed` is what `Smith.slice` reads: a little-endian `u32` length and then/// that many bytes.fn seed(comptime bytes: []const u8) []const u8 { return std.mem.toBytes(@as(u32, bytes.len))[0..] ++ bytes;}
/// Valid streams to mutate, and the shapes that are hard to reach by mutating/// one.const stream_corpus = [_][]const u8{ seed(@embedFile("rfc9639/example1.flac")), seed(@embedFile("rfc9639/example2.flac")), seed(@embedFile("rfc9639/example3.flac")), seed(""), seed("fLaC"), // A streaminfo block that promises the largest blocks the format allows, // across the most channels, and then stops. seed("fLaC\x80\x00\x00\x22\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00" ++ "\x0a\xc4\x43\xf0\x00\x00\x00\x00" ++ @as([16]u8, @splat(0x00))), // A metadata block claiming sixteen megabytes it does not have. seed("fLaC\x84\xff\xff\xff"), // The forbidden block type, which exists so that a metadata block header // can never look like a frame sync code. seed("fLaC\xff\x00\x00\x00"),};
/// Ogg FLAC streams to mutate, and the shapes of the mapping that are hard to/// reach by mutating one: an identification packet that is not one, a truncated/// one, and a page that is not a FLAC stream at all.const ogg_corpus = [_][]const u8{ seed(@embedFile("ogg/tone.oga")), // Two of them chained, which is a legal physical bitstream and means the // decoder has to stop at the end of the first. seed(@embedFile("ogg/tone.oga") ++ @embedFile("ogg/tone.oga")), seed(""), seed("OggS"), // The first 79 bytes, which is the identification page and nothing after // it. seed(@embedFile("ogg/tone.oga")[0..79]), // The identification page with its mapping version bumped past what any // 1.x decoder promises to read. seed(@embedFile("ogg/tone.oga")[0..33] ++ "\x02" ++ @embedFile("ogg/tone.oga")[34..79]),};
/// Sample runs to mutate. `Smith.slice` reads these as the byte string the/// round trip stretches into samples, so what matters is the shape: silence,/// a ramp, alternating extremes, and noise.const sample_corpus = [_][]const u8{ seed(&@as([64]u8, @splat(0x80))), seed(&@as([64]u8, @bitCast(@as([32][2]u8, @splat("\x00\xff".*))))), seed("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"), seed("\x00\x40\x60\x70\x78\x7c\x7e\x7f\x7e\x7c\x78\x70\x60\x40\x00\x40"), seed(""), seed("\x01"),};
/// An allocator that stops after `budget_bytes`, so that a stream promising/// more memory than the machine has is refused rather than obeyed.const Budget = struct { parent: Allocator, left: usize,
fn init(parent: Allocator, cap: usize) Budget { return .{ .parent = parent, .left = cap }; }
fn allocator(self: *Budget) Allocator { return .{ .ptr = self, .vtable = &.{ .alloc = alloc, .resize = resize, .remap = remap, .free = free, } }; }
fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ra: usize) ?[*]u8 { const self: *Budget = @ptrCast(@alignCast(ctx)); if (len > self.left) return null; const got = self.parent.vtable.alloc(self.parent.ptr, len, alignment, ra) orelse return null; self.left -= len; return got; }
fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ra: usize) bool { const self: *Budget = @ptrCast(@alignCast(ctx)); if (new_len > memory.len and new_len - memory.len > self.left) return false; if (!self.parent.vtable.resize(self.parent.ptr, memory, alignment, new_len, ra)) return false; self.charge(memory.len, new_len); return true; }
fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ra: usize) ?[*]u8 { const self: *Budget = @ptrCast(@alignCast(ctx)); if (new_len > memory.len and new_len - memory.len > self.left) return null; const got = self.parent.vtable.remap(self.parent.ptr, memory, alignment, new_len, ra) orelse return null; self.charge(memory.len, new_len); return got; }
fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ra: usize) void { const self: *Budget = @ptrCast(@alignCast(ctx)); self.parent.vtable.free(self.parent.ptr, memory, alignment, ra); self.left += memory.len; }
fn charge(self: *Budget, was: usize, now: usize) void { if (now > was) self.left -= now - was else self.left += was - now; }};