// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Fuzz targets: what the library must do with input nobody wrote. //! //! Almost every length in an Ogg stream is a number the stream itself chose -- //! how many segments a page has, how long each of them is, which logical //! bitstream a page belongs to, whether a packet has finished. 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: //! //! * **Demuxing anything terminates, reports and frees.** Whatever the bytes //! say, the demuxer 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 a page and writing it back is the identity.** A page that parsed //! was checksummed, so re-encoding it has to reproduce its bytes exactly -- //! otherwise a tool that rewrites a stream's framing corrupts it. //! * **Resynchronising accounts for every byte.** Each byte of the input is //! either part of a page that was handed back or one of the bytes `dropped` //! says were thrown away. Nothing may be silently skipped, and no page whose //! checksum is wrong may be handed back. //! * **A round trip is the identity.** For any packets at all, and any page //! size, demuxing what the writer wrote gives the packets back in order. //! //! # Running them //! //! ```console //! $ zig build test # the corpus below, and nothing else //! $ zig build fuzz --fuzz # Zig's fuzzer, following coverage //! $ zig build fuzz-run -- --seconds 60 //! ``` //! //! `tools/fuzz.zig` is a second loop around the same targets, mutating the //! corpus rather than following coverage. Without a fuzzer a fuzz test runs //! only its corpus, so the seeds at the bottom of this file are what //! `zig build test` exercises -- among them the three real streams under //! `src/tests/data`, which gives a mutator somewhere to start other than //! discovering the capture pattern for itself. //! //! **The memory has to be bounded here rather than in the library.** A page can //! ask for 65 kB and a chain of them can ask for a packet without end, which is //! what `Demuxer.Options` is for -- but a fuzzer finds those shapes early and //! often, so every target also runs against `Budget`, which hands out at most //! `budget_bytes` before it starts answering `error.OutOfMemory`. The library //! promises to survive that anyway, 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 ogg = @import("ogg"); const Allocator = std.mem.Allocator; const Smith = std.testing.Smith; const testing = std.testing; const Page = ogg.Page; /// 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 page buffer and a packet of a few hundred kilobytes, and not /// enough for a stream that asks for a packet without end. const budget_bytes = 8 << 20; /// The longest stream a target is handed. The interesting failures are in what /// a page header says, not in how many pages follow it. const max_stream = 64 * 1024; test "fuzz demux: demuxing anything terminates, reports and frees" { try std.testing.fuzz({}, demuxOne, .{ .corpus = &stream_corpus }); } pub fn demuxOne(_: void, smith: *Smith) anyerror!void { var raw: [max_stream]u8 = undefined; const stream = raw[0..smith.slice(&raw)]; var budget: Budget = .init(backing, budget_bytes); const gpa = budget.allocator(); const buffer = gpa.create([Page.max_size]u8) catch return; defer gpa.destroy(buffer); var input: std.Io.Reader = .fixed(stream); var d: ogg.Demuxer = .init(gpa, &input, buffer, .{ .max_packet_size = 1 << 20, .max_streams = 16 }); defer d.deinit(); // The demuxer promises a packet is whole, so every byte of one has to be // readable. Reading them all is what makes a bad slice a failure here rather // than a puzzle somewhere else. var sum: usize = 0; while (d.next() catch return) |packet| { for (packet.data) |byte| sum +%= byte; } std.mem.doNotOptimizeAway(sum); } test "fuzz pages: reading a page and writing it back changes nothing" { try std.testing.fuzz({}, pagesOne, .{ .corpus = &stream_corpus }); } pub fn pagesOne(_: void, smith: *Smith) anyerror!void { var raw: [max_stream]u8 = undefined; const stream = raw[0..smith.slice(&raw)]; var budget: Budget = .init(backing, budget_bytes); const gpa = budget.allocator(); const buffer = gpa.create([Page.max_size]u8) catch return; defer gpa.destroy(buffer); var input: std.Io.Reader = .fixed(stream); var pr: ogg.PageReader = .init(&input, buffer); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); var offset: usize = 0; while (pr.next() catch return) |page| { out.clearRetainingCapacity(); page.write(&out.writer) catch return; try testing.expectEqualSlices(u8, stream[offset..][0..page.size()], out.written()); offset += page.size(); } } test "fuzz resync: resynchronising accounts for every byte" { try std.testing.fuzz({}, resyncOne, .{ .corpus = &stream_corpus }); } pub fn resyncOne(_: void, smith: *Smith) anyerror!void { var raw: [max_stream]u8 = undefined; const stream = raw[0..smith.slice(&raw)]; var budget: Budget = .init(backing, budget_bytes); const gpa = budget.allocator(); const buffer = gpa.create([Page.max_size]u8) catch return; defer gpa.destroy(buffer); var input: std.Io.Reader = .fixed(stream); var pr: ogg.PageReader = .init(&input, buffer); var accounted: u64 = 0; while (pr.resync() catch return) |page| { // Nothing damaged may come back out of a resynchronisation: finding a // page is defined as finding one whose checksum is right. try testing.expectEqual(page.checksum, page.computeChecksum()); accounted += page.size(); } try testing.expectEqual(@as(u64, stream.len), accounted + pr.dropped); } test "fuzz roundtrip: what the writer wrote is what the demuxer reads" { try std.testing.fuzz({}, roundTripOne, .{ .corpus = &packet_corpus }); } pub fn roundTripOne(_: void, smith: *Smith) anyerror!void { var raw: [max_stream]u8 = undefined; const bytes = raw[0..smith.slice(&raw)]; if (bytes.len == 0) return; var budget: Budget = .init(backing, budget_bytes); const gpa = budget.allocator(); // Everything about the shape of the run is read out of the input rather // than asked of `Smith`, so that a mutation of one byte is a mutation of one // decision and a corpus seed is just a byte string. const page_sizes = [_]usize{ 1, 17, 255, 256, 4096, Page.max_body_size }; const target_body_size = page_sizes[bytes[0] % page_sizes.len]; var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var packets: std.ArrayList([]const u8) = .empty; // Every other input opens with a packet longer than any page can hold, // which is the only way to reach the code that continues one across a page // boundary. if (bytes[0] & 1 != 0) { const long = arena.alloc(u8, Page.max_body_size + 1 + bytes.len) catch return; for (long, 0..) |*byte, i| byte.* = bytes[i % bytes.len]; packets.append(arena, long) catch return; } // The rest is split the way Ogg splits a page: a length, then that many // bytes. Multiplying by five lets one byte ask for a packet several // segments long. var rest = bytes[1..]; while (rest.len != 0) { const want = @min(@as(usize, rest[0]) * 5, rest.len - 1); packets.append(arena, rest[1..][0..want]) catch return; rest = rest[1 + want ..]; } // A stream of no packets at all is legal, and `writer.zig` has a test for // it; there is nothing for a round trip to compare. if (packets.items.len == 0) return; var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); { const body = gpa.create([Page.max_body_size]u8) catch return; defer gpa.destroy(body); var w: ogg.StreamWriter = .init(&out.writer, body, .{ .serial_number = 0x0dd0_c0de, .target_body_size = target_body_size, }); for (packets.items[0 .. packets.items.len - 1], 0..) |packet, i| { w.writePacket(packet, @intCast(i)) catch return; } // The last one goes in as the last one, so that the end-of-stream flag // lands on a page with something on it. w.writeLastPacket(packets.items[packets.items.len - 1], @intCast(packets.items.len - 1)) catch return; } const buffer = gpa.create([Page.max_size]u8) catch return; defer gpa.destroy(buffer); var input: std.Io.Reader = .fixed(out.written()); var d: ogg.Demuxer = .init(gpa, &input, buffer, .{}); defer d.deinit(); var index: usize = 0; while (try d.next()) |packet| : (index += 1) { try testing.expect(index < packets.items.len); try testing.expectEqualSlices(u8, packets.items[index], packet.data); try testing.expectEqual(index == 0, packet.first); try testing.expectEqual(index == packets.items.len - 1, packet.last); try testing.expect(!packet.after_hole); } try testing.expectEqual(packets.items.len, index); } /// 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 = "demux", .run = demuxOne, .corpus = &stream_corpus }, .{ .name = "pages", .run = pagesOne, .corpus = &stream_corpus }, .{ .name = "resync", .run = resyncOne, .corpus = &stream_corpus }, .{ .name = "roundtrip", .run = roundTripOne, .corpus = &packet_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; } /// A page header, for the seeds below: `flags`, a granule position, a serial /// number, a sequence number, a zero checksum, and a segment table. The /// checksum is wrong on purpose -- a mutator has to be able to reach a page that /// parses, and the ones that do not are worth running through the reader too. fn header(comptime flags: u8, comptime segments: []const u8) []const u8 { return "OggS\x00" ++ .{flags} ++ @as([8]u8, @splat(0x00)) ++ // granule position "\x01\x00\x00\x00" ++ // serial number @as([4]u8, @splat(0x00)) ++ // sequence number @as([4]u8, @splat(0x00)) ++ // checksum .{segments.len} ++ segments; } /// Streams to mutate: three that a real encoder wrote, and the shapes that are /// hard to reach by mutating one. const stream_corpus = [_][]const u8{ seed(@embedFile("data/tone.oga")), seed(@embedFile("data/tone.ogg")), seed(@embedFile("data/grouped.ogg")), // A chain, which is what concatenating two physical bitstreams makes. seed(@embedFile("data/tone.ogg") ++ @embedFile("data/tone.oga")), seed(""), seed("OggS"), // A page claiming the largest body the format allows, and not carrying it. seed(header(0x02, &@as([255]u8, @splat(255)))), // A nil page with the end-of-stream flag, which is legal and carries // nothing. seed(header(0x04, "")), // A page continuing a packet nothing started. seed(header(0x01, "\xff") ++ @as([255]u8, @splat(0x00))), // The version byte nothing has ever written. seed("OggS\x01" ++ @as([22]u8, @splat(0x00)) ++ "\x00"), }; /// Byte strings the round trip reads packet lengths out of. What matters is the /// shape: one packet of nothing, lengths either side of a segment boundary, and /// a run of tiny ones. const packet_corpus = [_][]const u8{ seed("\x00\x00"), seed("\x01\x33abcdefghijklmnopqrstuvwxyz0123456789012345678901"), seed("\x04\x33" ++ @as([255]u8, @splat('x'))), seed("\x05\x00\x00\x00\x00\x00\x00\x00\x00"), seed("\x03\xff" ++ @as([1275]u8, @splat('y'))), seed("\x02"), }; /// 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; } };